Skip to content

Commit b06c4b7

Browse files
FlyM1ssclaude
andcommitted
security(backend): --read-only rootfs + full-boot pre-cutover gate (BOOT_CHECK_ONLY)
Implements the read-only-rootfs item deferred at backend-deploy.yml (the old note at :263-265): the serving container now runs --read-only, and the deploy ships a pre-cutover gate that validates the FULL boot under the exact flags before cutover, closing the "gate exits before the app phase" gap that forced the deferral. ExecStart + gate both gain --read-only and the shared tmpfs set: /tmp (rw,size=512m,mode=1777) Chromium temp profiles + shm redirect, nft mktemp, /tmp/fingpt_cache /app/staticfiles (uid=1001,gid=1001) collectstatic at boot /home/fingpt (uid=1001,gid=1001) fontconfig, ~/.edgar, yfinance (uid pin or the #331 MCP-child EACCES returns) /app/logs + /app/media stay non-tmpfs on purpose: zero writers today, so a future stray write must fail LOUDLY. /dev/shm is podman-managed and unaffected. Gate redesign (entrypoint.sh BOOT_CHECK_ONLY=1, wrapping the final exec): probes every writable surface + $HOME (covers the fail-SOFT MCP-child writes /health/ can never catch), backgrounds the image's own CMD byte-identical (no positional args on the podman line: zero-drift), polls /health/ inside the container (~90s window), bounded SIGTERM+watchdog-SIGKILL shutdown. Subsumes --egress-check-only, which stays for manual/local use. Deliberate asymmetries, pinned by sentinels: gate /app/runtime is a THROWAWAY tmpfs (the real bind mount's :U re-chown + os.replace() of the shared DuckDB are unsafe under the serving container) and gate REDIS_URL uses throwaway DB15. Accepted residual: the :U/:Z relabel path (#322 class) stays covered only by the post-cutover ~130s health window. Deploy step gets an explicit command_timeout: 20m. Dockerfile: pre-bake Playwright's DEPENDENCIES_VALIDATED marker via a real one-time chromium launch as uid1001 (the driver's marker write is fail-soft under --read-only but would force per-process revalidation), with a loud test -f build guard. Also fixes the pre-existing prod break where /api/sync_preferred_urls/ (used by the shipped extension) 500s: /app/data is root-owned, so the manager write EACCESes. Storage path is now env-configurable (PREFERRED_LINKS_PATH), pinned to /app/runtime/preferred_links.json in the image, seeded on first use from the in-image default file; local dev keeps the in-repo path. Sentinels extended in test_dockerfile_nonroot.py (flag extractor widened to tmpfs/memory/read-only with equals-form pinning, parity + closed asymmetry list, frozen tmpfs target sets, boot-check branch structure, marker bake) and new tests/test_preferred_links_manager.py (env override, seeding, fallback, write path, Dockerfile pin). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b116244 commit b06c4b7

6 files changed

Lines changed: 464 additions & 54 deletions

File tree

.github/workflows/backend-deploy.yml

Lines changed: 65 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@ jobs:
172172
username: ${{ env.DROPLET_USER }}
173173
key: ${{ secrets.DEPLOY_SSH_KEY }}
174174
port: 22
175+
# The default 10m is now shared by the multi-GB image pull, the pre-cutover
176+
# FULL-boot gate (store build + collectstatic + gunicorn up + /health/ poll,
177+
# ~2-3min), cutover, and image retirement -- give it explicit headroom.
178+
command_timeout: 20m
175179
envs: REMOTE_IMAGE,GHCR_USERNAME,GHCR_TOKEN,SYSTEMD_UNIT
176180
script: |
177181
set -euo pipefail
@@ -202,28 +206,44 @@ jobs:
202206
podman start fingpt-redis >/dev/null 2>&1 || true
203207
fi
204208
205-
# Pre-cutover firewall validation: run the NEW image's REAL entrypoint in
206-
# check-only mode on fingpt-net BEFORE the systemctl restart's --replace kills
207-
# the currently-serving container. --egress-check-only exercises the byte-
208-
# identical root-init PID1 runs at cutover (generate + sentinel checks + nft
209-
# load + transition-proof self-test + setpriv drop), then exits before the app
210-
# phase; an inline re-copy of that sequence (this step's previous shape) can
211-
# silently drift from the boot path it is supposed to prove. On failure, abort
212-
# so the OLD container keeps serving (there is no auto-rollback). This closes
213-
# the CI-invisible gap: the test job is pytest-only and never touches nft/netns.
214-
# The gate runs under the IDENTICAL frozen cap/pids flags as the ExecStart
215-
# below (cap-to-step map at the override; parity pinned in
216-
# Main/backend/tests/test_dockerfile_nonroot.py). That parity IS the safety
217-
# property: check-only mode exits right after the setpriv drop, so all five
218-
# caps the root-init needs (nft + chown + setuid/setgid + bounding-set drop)
219-
# are exercised HERE -- a too-narrow set aborts the deploy with the old
220-
# container still serving, instead of crash-looping the new unit at cutover.
221-
echo "Validating egress firewall on the new image (pre-cutover)..."
222-
podman run --rm --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID --cap-add=SETPCAP --pids-limit=1024 --network fingpt-net \
223-
--env REDIS_URL=redis://fingpt-redis:6379/0 \
224-
"$REMOTE_IMAGE" --egress-check-only \
225-
|| { echo "ERROR: egress firewall pre-cutover validation FAILED; aborting deploy (old container left serving)"; exit 1; }
226-
echo "Egress firewall pre-cutover validation passed."
209+
# Pre-cutover FULL-BOOT validation: run the NEW image's REAL entrypoint on
210+
# fingpt-net BEFORE the systemctl restart's --replace kills the currently-
211+
# serving container. BOOT_CHECK_ONLY=1 (mode lives at the END of
212+
# Main/backend/entrypoint.sh, wrapping the final exec) exercises the byte-
213+
# identical PID1 path that runs at cutover: the full root-init (generate +
214+
# sentinel checks + nft load + transition-proof self-test + setpriv drop --
215+
# so this SUBSUMES the old --egress-check-only gate, which stays in the
216+
# entrypoint for manual/local use), then the full app phase (store build,
217+
# collectstatic, Playwright verify), then explicit writable-surface probes
218+
# over the tmpfs set (these cover the fail-SOFT MCP-child $HOME writes a
219+
# /health/ poll can never catch), then backgrounds the image's own CMD and
220+
# polls /health/ INSIDE the container until gunicorn answers (hence no
221+
# --publish: the gate's :8000 stays netns-private). No positional args
222+
# after the image ref, so the Dockerfile CMD flows through byte-identical
223+
# (zero-drift property). On failure, abort so the OLD container keeps
224+
# serving (there is no auto-rollback). This closes the CI-invisible gap:
225+
# the test job is pytest-only and never touches nft/netns/--read-only.
226+
# The gate runs the IDENTICAL frozen cap/pids/read-only/tmpfs/memory flags
227+
# as the ExecStart below (cap-to-step map + tmpfs map at the override;
228+
# parity pinned in Main/backend/tests/test_dockerfile_nonroot.py), with
229+
# exactly one deliberate asymmetry: /app/runtime here is a THROWAWAY
230+
# tmpfs, never the real /home/deploy/fingpt/runtime bind mount. Mounting
231+
# the real dir pre-cutover is dangerous: :U would re-chown it under the
232+
# serving container, and a new-recipe store build would os.replace() the
233+
# shared DuckDB while the old container still serves it
234+
# (truthlayer/retrieve.py). Same isolation for redis: DB15 is a throwaway
235+
# keyspace so gate boot-time writes can never touch the serving
236+
# container's rate-limit/budget counters in DB0 (only settings_prod.py
237+
# and ops/egress_firewall.py consume REDIS_URL, and the firewall
238+
# self-test checks host:port reachability only, DB-agnostic).
239+
echo "Validating full boot (root-init + read-only rootfs + /health/) on the new image (pre-cutover)..."
240+
podman run --rm --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID --cap-add=SETPCAP --pids-limit=1024 --read-only --tmpfs=/tmp:rw,size=512m,mode=1777 --tmpfs=/app/staticfiles:rw,uid=1001,gid=1001 --tmpfs=/home/fingpt:rw,uid=1001,gid=1001 --tmpfs=/app/runtime:rw,size=512m --memory=1.7g --memory-swap=2g --network fingpt-net \
241+
--env-file /home/deploy/fingpt/envs/.env.production \
242+
--env REDIS_URL=redis://fingpt-redis:6379/15 \
243+
--env BOOT_CHECK_ONLY=1 \
244+
"$REMOTE_IMAGE" \
245+
|| { echo "ERROR: full-boot pre-cutover validation FAILED; aborting deploy (old container left serving)"; exit 1; }
246+
echo "Full-boot pre-cutover validation passed."
227247
228248
# Record the currently-deployed image for manual rollback (no auto-rollback).
229249
PREV_IMAGE=$(podman inspect --format '{{.ImageName}}' "$SYSTEMD_UNIT" 2>/dev/null || echo "unknown")
@@ -260,15 +280,34 @@ jobs:
260280
# --pids-limit=1024 is the fork-bomb ceiling: worst case today is ~400-600
261281
# tasks (gunicorn workers x threads + agent fan-out + Playwright/Chromium);
262282
# revisit if AGENT_MAX_CONCURRENCY or GUNICORN_WORKERS is raised.
263-
# A read-only rootfs is DELIBERATELY deferred: the gate exits before the app
264-
# phase, so --read-only would ship unvalidated pre-cutover -- and collectstatic
265-
# writes /app/staticfiles at boot, which needs a tmpfs/volume design first.
283+
# --read-only rootfs: the ONLY writable surfaces are the tmpfs set below,
284+
# /dev/shm (podman-managed 64MB tmpfs; gunicorn --worker-tmp-dir, untouched
285+
# by --read-only), and the /app/runtime bind mount. The tmpfs map:
286+
# /tmp Chromium temp profiles + shm redirect
287+
# (--disable-dev-shm-usage), nft mktemp at root-init,
288+
# /tmp/fingpt_cache; sized + world-writable-sticky;
289+
# /app/staticfiles collectstatic writes it at boot (0 files today, kept
290+
# writable as future-proofing); uid1001 tmpfs;
291+
# /home/fingpt fontconfig cache, edgartools ~/.edgar import-time
292+
# marker, yfinance cache; MUST carry uid=1001,gid=1001
293+
# or the MCP-child EACCES bug (#331 class) returns.
294+
# /app/logs and /app/media are vestigial with ZERO writers -- deliberately
295+
# NOT tmpfs, so a future stray write fails LOUDLY instead of vanishing
296+
# into RAM. Playwright's DEPENDENCIES_VALIDATED marker is pre-baked at
297+
# image build (Dockerfile) so first browser launch needs no rootfs write.
298+
# The pre-cutover gate above boots the FULL app under these exact flags
299+
# (BOOT_CHECK_ONLY=1) before cutover, so a missing/mis-owned tmpfs aborts
300+
# the deploy with the old container still serving. Accepted residual: the
301+
# gate's /app/runtime is a throwaway tmpfs, so the :U chown / :Z SELinux
302+
# relabel path of the REAL bind mount (#322 class) is NOT exercised
303+
# pre-cutover; it stays covered only by the post-cutover ~130s health
304+
# window in the Verify step below.
266305
OVERRIDE_DIR="$HOME/.config/systemd/user/${SYSTEMD_UNIT}.service.d"
267306
mkdir -p "$OVERRIDE_DIR"
268307
cat > "$OVERRIDE_DIR/override.conf" <<EOF
269308
[Service]
270309
ExecStart=
271-
ExecStart=/usr/bin/podman run --name ${SYSTEMD_UNIT} --replace --rm --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID --cap-add=SETPCAP --pids-limit=1024 --cgroups=split --sdnotify=conmon -d --memory=1.7g --memory-swap=2g --network fingpt-net -v /home/deploy/fingpt/runtime:/app/runtime:U,Z --publish 127.0.0.1:8000:8000 --env-file /home/deploy/fingpt/envs/.env.production --env REDIS_URL=redis://fingpt-redis:6379/0 ${REMOTE_IMAGE}
310+
ExecStart=/usr/bin/podman run --name ${SYSTEMD_UNIT} --replace --rm --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID --cap-add=SETPCAP --pids-limit=1024 --read-only --tmpfs=/tmp:rw,size=512m,mode=1777 --tmpfs=/app/staticfiles:rw,uid=1001,gid=1001 --tmpfs=/home/fingpt:rw,uid=1001,gid=1001 --cgroups=split --sdnotify=conmon -d --memory=1.7g --memory-swap=2g --network fingpt-net -v /home/deploy/fingpt/runtime:/app/runtime:U,Z --publish 127.0.0.1:8000:8000 --env-file /home/deploy/fingpt/envs/.env.production --env REDIS_URL=redis://fingpt-redis:6379/0 ${REMOTE_IMAGE}
272311
EOF
273312
274313
systemctl --user daemon-reload

Main/backend/Dockerfile

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ ENV PATH="/app/.venv/bin:$PATH" \
4040
PIP_NO_CACHE_DIR=1 \
4141
PIP_DISABLE_PIP_VERSION_CHECK=1 \
4242
TRUTHLAYER_DB_PATH=/app/runtime/truthlayer.duckdb \
43-
HOME=/home/fingpt
43+
HOME=/home/fingpt \
44+
PREFERRED_LINKS_PATH=/app/runtime/preferred_links.json
4445

4546
COPY . .
4647

@@ -83,6 +84,33 @@ RUN groupadd --system --gid 1001 fingpt \
8384
&& mkdir -p /home/fingpt \
8485
&& chown -R fingpt:fingpt /app/staticfiles /app/media /app/logs /tmp/fingpt_cache /app/runtime /home/fingpt
8586

87+
# Pre-bake Playwright's DEPENDENCIES_VALIDATED marker so the --read-only rootfs
88+
# (backend-deploy.yml) never needs the write. At FIRST browser launch Playwright
89+
# validates host requirements (an ldd walk over the browser executable) and caches
90+
# success by writing a 0-byte DEPENDENCIES_VALIDATED marker next to the executable
91+
# (driver/package/lib/server/registry/index.js, _validateHostRequirementsForExecutable-
92+
# IfNeeded). The write itself is fail-SOFT (`.catch(() => {})`), so under --read-only
93+
# nothing crashes -- but the marker never lands and every fresh process re-runs the
94+
# validation on its first launch. Bake it organically instead: a real one-time
95+
# chromium launch as the runtime uid (setpriv, same drop the entrypoint performs;
96+
# there is deliberately no USER line), i.e. exactly the code path that writes the
97+
# marker in prod today. /ms-playwright ships world-writable in the Playwright base
98+
# image, so uid1001 can write it. --no-sandbox/--disable-dev-shm-usage: the docker
99+
# build environment lacks the userns the Chromium sandbox wants and its /dev/shm is
100+
# tiny; both flags only shape the spawned browser process, not the dependency
101+
# validation that writes the marker. The trailing `test -f` fails the BUILD loudly
102+
# if Playwright ever moves the marker. Residual: the marker's validity window is 30
103+
# days from its mtime (= image build time); past that, Playwright re-validates once
104+
# per process and fail-soft skips the re-write -- back to pre-marker behavior,
105+
# never a crash.
106+
RUN setpriv --reuid=1001 --regid=1001 --init-groups \
107+
python -c "from playwright.sync_api import sync_playwright; \
108+
p = sync_playwright().start(); \
109+
b = p.chromium.launch(args=['--no-sandbox', '--disable-dev-shm-usage']); \
110+
b.close(); p.stop(); print('✓ Chromium one-time launch OK')" \
111+
&& test -f /ms-playwright/chromium_headless_shell-*/DEPENDENCIES_VALIDATED \
112+
&& echo "✓ DEPENDENCIES_VALIDATED marker baked"
113+
86114
ENTRYPOINT ["/app/entrypoint.sh"]
87115

88116
EXPOSE 8000

Main/backend/datascraper/preferred_links_manager.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@
1212

1313
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
1414

15+
# In-image/in-repo default storage file. Local dev reads AND writes it directly.
16+
# In the production container /app/data is root-owned and the rootfs is read-only,
17+
# so writes there EACCES -> 500 on /api/sync_preferred_urls/ (used by the shipped
18+
# extension). The prod image therefore pins PREFERRED_LINKS_PATH to
19+
# /app/runtime/preferred_links.json (Dockerfile ENV; /app/runtime is the writable
20+
# persistent volume) and this file becomes the read-only SEED the runtime copy is
21+
# initialized from on first use.
22+
DEFAULT_STORAGE_PATH = Path(__file__).resolve().parent.parent / 'data' / 'preferred_links.json'
23+
24+
1525
class PreferredLinksManager:
1626
"""Manages preferred links with JSON file storage."""
1727

@@ -21,13 +31,13 @@ def __init__(self, storage_path: str = None):
2131
2232
Args:
2333
storage_path: Path to the JSON storage file.
24-
Defaults to backend/data/preferred_links.json
34+
Defaults to $PREFERRED_LINKS_PATH if set (the prod image
35+
points it at the writable /app/runtime volume), else
36+
backend/data/preferred_links.json (local dev).
2537
"""
2638
if storage_path is None:
27-
backend_dir = Path(__file__).resolve().parent.parent
28-
self.storage_path = backend_dir / 'data' / 'preferred_links.json'
29-
else:
30-
self.storage_path = Path(storage_path)
39+
storage_path = os.environ.get('PREFERRED_LINKS_PATH') or DEFAULT_STORAGE_PATH
40+
self.storage_path = Path(storage_path)
3141

3242
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
3343

@@ -37,7 +47,25 @@ def __init__(self, storage_path: str = None):
3747
self._init_storage()
3848

3949
def _init_storage(self):
40-
"""Initialize the storage file with default structure."""
50+
"""Initialize the storage file, seeding from the in-image default file.
51+
52+
When the storage path is redirected off the default (prod: the writable
53+
/app/runtime volume) and the runtime copy is missing, seed it from the
54+
read-only in-image default file so curated defaults survive the move.
55+
Falls back to the hardcoded defaults if the seed is absent or unreadable.
56+
"""
57+
if DEFAULT_STORAGE_PATH.exists() and DEFAULT_STORAGE_PATH != self.storage_path.resolve():
58+
try:
59+
seed_data = json.loads(DEFAULT_STORAGE_PATH.read_text())
60+
self._write_data(seed_data)
61+
logging.info(
62+
f"Seeded preferred links storage at {self.storage_path} "
63+
f"from {DEFAULT_STORAGE_PATH}"
64+
)
65+
return
66+
except (json.JSONDecodeError, OSError) as e:
67+
logging.error(f"Error seeding preferred links from default file: {e}")
68+
4169
default_links = [
4270
"https://finance.yahoo.com",
4371
"https://www.sec.gov/search-filings",

Main/backend/entrypoint.sh

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,57 @@ python -c "from playwright.async_api import async_playwright; print('✓ Playwri
108108
exit 1
109109
}
110110

111+
# Deploy pre-cutover FULL-BOOT gate mode (see backend-deploy.yml). By this point the
112+
# ENTIRE boot has already run under the deploy's exact --read-only/tmpfs/cap flags:
113+
# root-init (nft load + sentinels + self-test + setpriv drop), the OPENAI key gate,
114+
# the cache mkdir, the truth-layer store build, collectstatic, and the Playwright
115+
# verify above -- so this mode SUBSUMES the --egress-check-only gate. It then proves
116+
# the two things a later /health/ poll alone can never catch:
117+
# (a) every writable surface is really writable by uid1001. MCP stdio children
118+
# write $HOME fail-SOFT (logger.error in mcp_client/apps.py), so a missing or
119+
# root-owned tmpfs there would pass every health check while silently killing
120+
# the sec-edgar child -- exactly the EACCES class fixed in #331;
121+
# (b) the image's own CMD ("$@", byte-identical: the deploy gate passes no
122+
# positional args) comes up and answers /health/ within a bounded window.
123+
# Placement is load-bearing (pinned by test_dockerfile_nonroot): this branch wraps
124+
# the final exec, and the fall-through path still ends in exec "$@".
125+
if [ "${BOOT_CHECK_ONLY:-}" = "1" ]; then
126+
echo "Boot check: probing writable surfaces..."
127+
for dir in /app/staticfiles /app/runtime "${CACHE_FILE_PATH:-/tmp/fingpt_cache}" "$HOME"; do
128+
probe="$dir/.boot-check-probe"
129+
( touch "$probe" && rm -f "$probe" ) 2>/dev/null \
130+
|| { echo "FATAL: boot check: cannot write $dir (missing/mis-owned tmpfs under --read-only?)" >&2; exit 1; }
131+
done
132+
echo "Boot check: writable surfaces OK; starting app for /health/ validation..."
133+
"$@" &
134+
APP_PID=$!
135+
HEALTH_OK=0
136+
tries=0
137+
# ~90s window (30 x 3s; refused connections fail fast), same python-urllib
138+
# probe as the Dockerfile HEALTHCHECK.
139+
while [ "$tries" -lt 30 ]; do
140+
if python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/', timeout=5)" 2>/dev/null; then
141+
HEALTH_OK=1
142+
break
143+
fi
144+
kill -0 "$APP_PID" 2>/dev/null \
145+
|| { echo "FATAL: boot check: app exited before /health/ answered" >&2; exit 1; }
146+
tries=$((tries + 1))
147+
sleep 3
148+
done
149+
# Bounded shutdown: SIGTERM the app, watchdog hard-SIGKILLs if it hangs, so the
150+
# gate can never wedge the deploy past its own window.
151+
kill "$APP_PID" 2>/dev/null || true
152+
( sleep 20; kill -9 "$APP_PID" 2>/dev/null ) &
153+
WATCHDOG_PID=$!
154+
wait "$APP_PID" 2>/dev/null || true
155+
kill "$WATCHDOG_PID" 2>/dev/null || true
156+
if [ "$HEALTH_OK" = "1" ]; then
157+
echo "Boot check: /health/ answered; full boot validated under deploy flags."
158+
exit 0
159+
fi
160+
echo "FATAL: boot check: /health/ did not answer within the ~90s window" >&2
161+
exit 1
162+
fi
163+
111164
exec "$@"

0 commit comments

Comments
 (0)