◐ Off-By-One · answer catalog

infra-security-firewall-auth-reopen

1 answer(s)godocker

echo "$TOKEN" # e.g. 464fc3118555f0ed11b1b3ca70bb37aec291b8e7cf2b79f9

📦 Source in repository (JSON)

Answer

Incident: UFW hardening cut live bunkerd API ports (18080 REST / 19090 gRPC) while auth was disabled. Opening those ports on an unauthenticated daemon would expose spawn/exec/destroy to the network — so the reopen is auth-first, verify, then firewall.

Step 0 — Preconditions

Step 1 — Auth-FIRST: generate 48-hex token and enable auth in config

# 48 hex chars == 24 bytes of entropy
TOKEN=$(openssl rand -hex 24)
echo "$TOKEN"                       # e.g. 464fc3118555f0ed11b1b3ca70bb37aec291b8e7cf2b79f9

Write it into /etc/bunkerd/config.yaml (preserve the rest of the file):

server:
  grpc_addr: :19090
  rest_addr: :18080
auth:
  enabled: true
  token: "<THE 48-HEX TOKEN>"       # regex ^[0-9a-f]{48}$
agent:
  base_data_dir: "/var/lib/bunkerd"
  ssh_dir: "/etc/bunkerd/ssh"
  port_range_start: 20000
  port_range_end: 20100
  port_range_per_agent: 10
  max_agents: 5

Safe edit (round-trips the YAML, refuses a malformed token):

python3 - "$TOKEN" <<'EOF'
import re, sys, yaml
tok = sys.argv[1]
assert re.fullmatch(r'[0-9a-f]{48}', tok), "token must be exactly 48 lowercase hex"
c = yaml.safe_load(open('/etc/bunkerd/config.yaml'))
c['auth']['enabled'] = True
c['auth']['token'] = tok
yaml.safe_dump(c, open('/etc/bunkerd/config.yaml','w'), sort_keys=False, default_flow_style=False)
print("config updated: auth.enabled=true, token len", len(tok))
EOF

Step 2 — Redeploy HEAD (deploy checkout; rewritten-twin lineage)

The box lineage is a rewritten twin commit — same content, different hash — so a plain git pull can refuse or fast-forward wrong. fetch + reset --hard origin/main makes the checkout byte-identical to upstream:

cd /path/to/bunkerd-deploy          # the deploy checkout
git fetch origin
git reset --hard origin/main        # discards twin divergence; HEAD == origin/main
git status --porcelain              # must be empty
make build VERSION="$(git rev-parse --short HEAD)" LDFLAGS="-s -w -X main.version=$(git rev-parse --short HEAD)"
# proto unchanged -> DO NOT run buf generate; only regenerate if api.proto/grpc.proto diffs
# install: install -m 0755 ./bin/bunkerd /usr/local/bin/bunkerd

Step 3 — Restart via pkill (systemd Restart=always picks up new binary + config)

systemctl stop bunkerd || true      # make sure it isn't mid-restart
pkill -x bunkerd                    # or: pkill -f '^/usr/local/bin/bunkerd'
# do NOT manually restart: Restart=always brings it up with the new binary/config
sleep 2
systemctl show bunkerd -p NRestarts # NRestarts +1 vs. before is expected (one kill)
systemctl is-active bunkerd         # active

Pitfall: the agent registry is in-memory — the restart orphans pre-existing agents from the API. This self-heals: fresh spawns work immediately; destroy of an orphan returns 200. No manual registry migration needed.

Step 4 — Verify auth enforcement BEFORE touching the firewall (401 / 401 / 200)

Still firewalled, probe locally:

B=http://<ip-address>:18080
TOKEN="<48-HEX-TOKEN>"
# 1) no token            -> expect 401
curl -s -o /dev/null -w '%{http_code}\n' -X POST $B/v1/agents/spawn
# 2) wrong token         -> expect 401
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "X-Session-Token: deadbeef" $B/v1/agents/spawn
# 3) correct token       -> expect 200
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "X-Session-Token: $TOKEN" $B/v1/agents/spawn

Only when the sequence is exactly 401 / 401 / 200 may you open the firewall. Never open ports on an unauthenticated daemon.

Step 5 — UFW allow (only now)

ufw allow 18080/tcp comment 'bunkerd REST API (auth required)'
ufw allow 19090/tcp comment 'bunkerd gRPC (auth required)'
ufw status verbose                  # confirm both allowed

Step 6 — External probes + live E2E

From a second host (through the firewall), using the token:

# external auth check
curl -s -o /dev/null -w '%{http_code}\n' -H "X-Session-Token: $TOKEN" http://BOX:18080/v1/agents     # 200
curl -s -o /dev/null -w '%{http_code}\n' http://BOX:18080/v1/agents                                 # 401
# live lifecycle E2E
curl -s -X POST -H "X-Session-Token: $TOKEN" http://BOX:18080/v1/agents/spawn                       # {"id": "...", "status":"running"}
curl -s -X POST -H "X-Session-Token: $TOKEN" http://BOX:18080/v1/agents/exec                         # {"exit_code":0,...}
curl -s -X POST -H "X-Session-Token: $TOKEN" http://BOX:18080/v1/agents/destroy                      # {"destroyed":true}
# gRPC smoke (grpcurl)
grpcurl -H "x-session-token: $TOKEN" -plaintext BOX:19090 bunkerd.v1.AgentService/List

Pitfall — nested ssh quoting: greps run through ssh box 'grep "..." ...' lose inner quotes and false-negative. Keep probes direct or export the pattern to a file; e.g. verify a marker with grep -cF '"spawn"' state.txt locally first.

Rollback: ufw delete allow 18080/tcp && ufw delete allow 19090/tcp, then git reset --hard <prev> + rebuild + restart.

Evidence & signatures

Verified in this sandbox against the live box + faithful simulation of HEAD semantics:

| # | Check | Result |
|---|-------|--------|
| 1 | **Live daemon state** — port 18080 responds; unauthenticated `GET /` and `/v1/agents` → `401 SESSION_TOKEN_MISSING`; current config token `test-regression-token` → `401 SESSION_TOKEN_INVALID`; UUID-format token passes format check → `SESSION_NOT_FOUND`; gRPC **19090 not listening** (cut state confirmed) | observed |
| 2 | **48-hex token generation** — `openssl rand -hex 24` → `593374b4…a5dcb5` (len 48, `^[0-9a-f]{48}$` holds) | pass |
| 3 | **Config round-trip** — YAML with new 48-hex token parses; structure/keys preserved; `test-regression-token` fails the 48-hex validator | pass |
| 4 | **Auth-FIRST gate (sim of HEAD)** — no token → 401, wrong token → 401, correct 48-hex token → 200 | pass |
| 5 | **E2E lifecycle (authenticated)** — spawn → `{"id":"agent-…","status":"running"}`, exec → `{"exit_code":0,"stdout":"hello-from-agent"}`, destroy → `{"destroyed":true}` | pass |
| 6 | **Never open ports on unauthenticated daemon** — unauthenticated spawn still 401 after "allow" (auth is firewall-independent) | pass |
| 7 | **In-memory registry orphan on restart** — agent `agent-29c78f8f` listed before restart; after `pkill`+relaunch the registry is empty (orphaned), fresh spawn self-heals, destroy of orphan → 200 | pass |
| 8 | **Nested ssh quoting false-negative** — local `grep -cF '"spawn"'` → 1 match; nested-ssh single-quoted variant → 0 (quotes eaten); double-quote mangling → parse error | pass |
| 9 | **Redeploy reset of twin commit** — scratch repo with divergent local twin (ahead-by 1): `git fetch + reset --hard origin/main` → HEAD == origin/main, 0 dirty files | pass |

Edge cases handled: token regex rejected at write time (no malformed config); restart gate verified *before* any firewall change; orphaned-agent destroy converges (200, no 5xx); external-probe commands use the header scheme the daemon actually checks (`X-Session-Token`); environment limits (no systemd/ufw, `/etc` read-only in the sandbox) mean the runbook was executed against the simulation — the `Restart=always` + `NRestarts +1` behavior is documented from the systemd unit semantics and the pkill trigger is exercised verbatim.
{"model": "deepseek-v4-flash", "problem_class": "infra-security-firewall-auth-reopen", "result": "passed", "tests": 9}
Generated from the verified corpus · MIT licensedBack to the catalog