From ea6934782959e89d167490150f4235951b5dbfbc Mon Sep 17 00:00:00 2001 From: infer0 Date: Wed, 15 Jul 2026 14:01:59 +0300 Subject: [PATCH] initial deploy stack --- .env.example | 13 +++ .gitea/workflows/deploy.yaml | 184 ++++++++++++++++++++++++++++++++ docker-compose.yml | 201 +++++++++++++++++++++++++++++++++++ 3 files changed, 398 insertions(+) create mode 100644 .env.example create mode 100644 .gitea/workflows/deploy.yaml create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5edcbb0 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Copy to /opt/stacks/myapp/.env ON THE HOST. Never commit the real .env - +# the deploy rsyncs this repo over the host with --delete and excludes .env +# precisely so the host copy survives. + +# The WireGuard address every service publishes on. A published port with no +# host_ip binds 0.0.0.0, and ufw cannot close it (Docker's iptables chain runs +# first). The deploy refuses to start if this is unset. +WG_IP=127.0.0.1 + +# puhososik JWT signing key. app.py falls back to a hardcoded default if this +# is missing, which lets anyone forge a session - so the deploy fails instead. +# Generate with: openssl rand -hex 32 +SECRET_KEY=024d71dd6f09c48e60603d3b9b299e53dd532b9d6a7b2a93e7e3c8c697d17457 diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..4b89e06 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,184 @@ +name: deploy + +on: + push: + branches: [main] + +concurrency: + group: deploy-myapp + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + + container: + image: docker:27-cli + volumes: + - /var/run/docker.sock:/var/run/docker.sock + # Same absolute path on both sides. Compose resolves relative bind + # mounts against its own cwd and hands the result to the HOST daemon; + # if the paths differ, every ./volume silently points at nothing. + - /opt/stacks:/opt/stacks + + # docker:27-cli is Alpine and has no bash. The runner's default shell for + # `run` is bash, so every step below would die with "bash: not found". + defaults: + run: + shell: sh + + env: + STACK_DIR: /opt/stacks/myapp + + steps: + - name: Install tools + run: | + apk add --no-cache git rsync + docker compose version >/dev/null 2>&1 || apk add --no-cache docker-cli-compose + docker compose version + + - name: Fetch the repo + run: | + git clone --depth 1 \ + "http://x-access-token:${{ secrets.GITEA_TOKEN }}@gitea:3000/${{ github.repository }}.git" \ + /src + echo "Deploying $(git -C /src rev-parse --short HEAD)" + + - name: Snapshot current stack (for rollback) + run: | + rm -rf "${STACK_DIR}.prev" + if [ -f "${STACK_DIR}/docker-compose.yml" ]; then + cp -a "$STACK_DIR" "${STACK_DIR}.prev" + echo "Snapshot taken." + else + echo "First deploy - nothing to roll back to." + fi + + - name: Sync repo onto the host + run: | + mkdir -p "$STACK_DIR" + # --delete makes the host mirror the repo: files deleted from git are + # deleted from the host. Everything in --exclude survives that. + # + # .env -> your secrets + WG_IP, host-only, must never be wiped + # data/ -> escape hatch for bind-mounted persistent data + rsync -a --delete \ + --exclude '.git' \ + --exclude '.gitea' \ + --exclude '.env' \ + --exclude 'data/' \ + /src/ "$STACK_DIR/" + + # --------------------------------------------------------------------- + # Fail on a missing WG_IP here, with a message that says so. Without this + # the next step still catches it -- ${WG_IP} expands to empty, the port + # loses its host_ip -- but it reports "a published port has no host_ip", + # which sends you looking for a compose bug that isn't there. + # --------------------------------------------------------------------- + - name: Pre-flight - check host .env + run: | + cd "$STACK_DIR" + if [ ! -f .env ]; then + echo "REFUSING TO DEPLOY: no ${STACK_DIR}/.env" + echo "It is host-only (never in git) and must define:" + echo " WG_IP=10.x.x.x # tunnel address to publish on" + echo " SECRET_KEY=... # puhososik JWT signing key" + exit 1 + fi + WG_IP=$(grep -E '^WG_IP=' .env | tail -1 | cut -d= -f2-) + if [ -z "$WG_IP" ]; then + echo "REFUSING TO DEPLOY: WG_IP is unset or empty in ${STACK_DIR}/.env" + exit 1 + fi + echo "OK - publishing on ${WG_IP}" + + # --------------------------------------------------------------------- + # GUARDRAIL 1 - static, runs BEFORE anything is deployed. + # + # Docker publishes ports via its own iptables chain, which is evaluated + # BEFORE ufw's INPUT chain. So `ufw deny 8080` does NOT close a published + # Docker port. Your firewall says the service is blocked; it is on the + # public internet. The only reliable defence is to never bind 0.0.0.0. + # + # `docker compose config` resolves the file (env vars and all) and emits + # a host_ip line for every port that has one. If any published port lacks + # a host_ip, it binds every interface -- so the counts must match. + # --------------------------------------------------------------------- + - name: Pre-flight - refuse to publish on all interfaces + run: | + cd "$STACK_DIR" + PUBLISHED=$(docker compose config | grep -c 'published:' || true) + BOUND=$(docker compose config | grep -c 'host_ip:' || true) + echo "published ports: $PUBLISHED | explicitly bound: $BOUND" + if [ "$PUBLISHED" != "$BOUND" ]; then + echo "" + echo "REFUSING TO DEPLOY: a published port has no host_ip." + echo "It would bind 0.0.0.0 and be reachable from the internet," + echo "regardless of what ufw says." + echo "Use \"\${WG_IP}:8080:80\", not \"8080:80\"." + docker compose config | grep -A4 'published:' + exit 1 + fi + echo "OK - every published port is pinned to an interface." + + - name: Build + run: | + cd "$STACK_DIR" + # Every service here is built from source, so `up -d` alone is not + # enough: it only builds when the image is MISSING. Once an image + # exists, a push that changes the code deploys the old binary and + # the job still goes green. Build explicitly, every time. + docker compose build + + - name: Deploy + run: | + cd "$STACK_DIR" + # --ignore-buildable: a plain `pull` also tries to pull the images we + # build ourselves (myapp-chessstars etc.), which exist in no registry, + # and fails the deploy. Only the sidecar images get pulled. + docker compose pull --ignore-buildable + # --wait blocks until healthchecks pass and exits non-zero if they + # don't. Without it, `up -d` is green the moment the daemon accepts + # the command, even if the app dies a second later. + docker compose up -d --remove-orphans --wait --wait-timeout 120 + + # --------------------------------------------------------------------- + # GUARDRAIL 2 - runtime. Belt and braces: what actually got bound? + # --------------------------------------------------------------------- + - name: Post-deploy - verify nothing landed on a public interface + run: | + cd "$STACK_DIR" + docker compose ps + if docker compose ps | grep -qE '0\.0\.0\.0:|\[::\]:'; then + echo "" + echo "PUBLIC BIND DETECTED after deploy. Rolling back." + exit 1 + fi + echo "OK - all published ports are on the tunnel only." + + - name: Diagnose and roll back + if: failure() + run: | + cd "$STACK_DIR" || exit 1 + echo "======================== STATUS ========================" + docker compose ps || true + echo "========================= LOGS =========================" + docker compose logs --tail=100 --no-color || true + + if [ -d "${STACK_DIR}.prev" ]; then + echo "====================== ROLLING BACK ====================" + rsync -a --delete --exclude '.env' --exclude 'data/' \ + "${STACK_DIR}.prev/" "$STACK_DIR/" + # The snapshot is source, not images, and the images currently on + # the host are the new ones we just built over the old tags. Roll + # the source back without rebuilding and `up` just re-runs the + # broken build -- the rollback would deploy exactly what failed. + docker compose build + docker compose up -d --remove-orphans --wait --wait-timeout 120 + echo "Rolled back to the previous stack." + else + echo "No snapshot. Stack left as-is for inspection." + fi + + # A successful rollback is still a failed deploy. Keep the red X. + exit 1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ad541e8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,201 @@ +# ============================================================================= +# Attack/Defence stack - all services, one file. +# +# Deployed by .gitea/workflows/deploy.yaml to $STACK_DIR (/opt/stacks/myapp). +# Build contexts are relative to this file, i.e. the repo root. +# +# TWO RULES THIS FILE MUST OBEY (the workflow enforces both and will refuse +# to deploy otherwise): +# +# 1. Every published port is pinned to ${WG_IP}. A bare "1338:8080" binds +# 0.0.0.0, and Docker's iptables chain is evaluated before ufw's INPUT +# chain -- so `ufw deny 1338` would NOT close it. Never omit the host_ip. +# +# 2. Anything that must survive a deploy lives either in a named volume or +# under a `data/` directory. The workflow rsyncs the repo over the host +# with --delete and only `data/` and `.env` are excluded from that. +# A bind mount anywhere else gets wiped on every push. +# +# Required in /opt/stacks/myapp/.env (host-only, never in git) - see +# .env.example: +# WG_IP=10.x.x.x # the WireGuard address to publish on +# SECRET_KEY=... # puhososik JWT signing key +# ============================================================================= + +name: myapp + +x-common: &common + restart: unless-stopped + security_opt: + - no-new-privileges:true + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + # ------------------------------------------------------------ chessstars + # Go + cgo engine. Listens on :8080 (PORT), state in /data. + chessstars: + <<: *common + build: ./chessstars + pids_limit: 256 + mem_limit: 256M + cpus: 1.0 + networks: [chessstars_net] + ports: + - "${WG_IP}:1338:8080" + volumes: + - ./chessstars/data:/data + environment: + AUTH_CLEANUP_TTL: 60m + AUTH_CLEANUP_INTERVAL: 5m + GAME_CLEANUP_FINISHED_TTL: 30m + GAME_CLEANUP_OPEN_TTL: 60m + GAME_CLEANUP_INTERVAL: 5m + + # The cleaners below only rm files on a mounted volume; none of them needs a + # socket. network_mode: none removes them as a pivot if a service falls. + chessstars_course_cleaner: + <<: *common + image: c4tbuts4d/dedcleaner:latest + network_mode: none + pids_limit: 32 + mem_limit: 32M + cpus: 0.1 + volumes: + - ./chessstars/data/courses:/courses + environment: + DELETE_AFTER: 60m + SLEEP: 5m + DIRS: /courses + + # --------------------------------------------------------------- example + # Flask, :5000. Notes moved from ./notes to ./example/data/notes so the + # rsync --delete in the deploy stops wiping them on every push (rule 2). + example: + <<: *common + build: ./example + pids_limit: 256 + mem_limit: 64M + cpus: 0.5 + networks: [example_net] + ports: + - "${WG_IP}:1337:5000" + volumes: + - ./example/data/notes:/app/notes + + example_cleaner: + <<: *common + image: c4tbuts4d/dedcleaner:latest + network_mode: none + pids_limit: 32 + mem_limit: 32M + cpus: 0.1 + volumes: + - ./example/data/notes:/notes + environment: + DELETE_AFTER: 30m + SLEEP: 30m + DIRS: /notes + + # ------------------------------------------------------------- hard_exam + # Flask, :1337. src/db.py resolves the DB to /data/data.db -> the + # container path is /app/data/data.db, so /app/data is what gets persisted. + # The old `./src/data.db:/app/data.db` mount pointed at a path the app never + # opens, and Docker would have created it as a directory. + hard_exam: + <<: *common + build: ./hard_exam + pids_limit: 128 + mem_limit: 256M + cpus: 0.5 + networks: [hard_exam_net] + ports: + - "${WG_IP}:51234:1337" + volumes: + - ./hard_exam/data:/app/data + + # ------------------------------------------------------------- puhososik + # Flask, :5000. SECRET_KEY signs JWTs - app.py falls back to + # 'default_secret_for_local_test' if unset, which would let anyone forge a + # token, so fail the deploy loudly instead of booting insecure. + puhososik: + <<: *common + build: ./puhososik + pids_limit: 256 + mem_limit: 128M + cpus: 0.5 + networks: [puhososik_net] + ports: + - "${WG_IP}:5252:5000" + volumes: + - ./puhososik/data:/app/data + environment: + SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY in /opt/stacks/myapp/.env} + + puhososik_cleaner: + <<: *common + build: ./puhososik/cleaner + network_mode: none + pids_limit: 32 + mem_limit: 32M + cpus: 0.1 + depends_on: + - puhososik + volumes: + - ./puhososik/data:/data + environment: + DB_PATH: /data/puhososik.db + DELETE_AFTER: 1800 + SLEEP: 300 + BATCH_SIZE: 400 + + # ----------------------------------------------- reverse_and_polish (pwn) + # socat -> patchelf'd binary as uid 1337. entrypoint.sh runs its own cleaner + # loop over /data/ctf.db, so no sidecar and nothing to persist here. + reverse_and_polish: + <<: *common + build: ./reverse_and_polish + pids_limit: 256 + mem_limit: 256M + cpus: 1.0 + networks: [reverse_and_polish_net] + ports: + - "${WG_IP}:7845:1337" + + # --------------------------------------------------------- skibidimonitor + # Rust/axum, :12345. BIND_ADDR is 0.0.0.0 *inside* the container - that is + # correct and unrelated to rule 1; the host_ip on the port mapping is what + # keeps it off the public internet. + skibidimonitor: + <<: *common + build: ./skibidimonitor + pids_limit: 256 + mem_limit: 256M + cpus: 1.0 + networks: [skibidimonitor_net] + ports: + - "${WG_IP}:12345:12345" + volumes: + - skibidi-data:/app/data + environment: + BIND_ADDR: 0.0.0.0:12345 + DATABASE_URL: sqlite:///app/data/skibidi.db?mode=rwc + SESSION_TTL_SECONDS: 300 + SNAPSHOT_CACHE_TTL_SECONDS: 300 + +# One network per service instead of the shared default bridge: without this +# every container resolves and reaches every other one by name, so a single RCE +# hands the attacker the whole stack. +networks: + chessstars_net: + example_net: + hard_exam_net: + puhososik_net: + reverse_and_polish_net: + skibidimonitor_net: + +volumes: + skibidi-data: