Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,18 @@
# Set to true only for a genuinely local-only instance you want open on
# purpose. Never set it on anything reachable over WireGuard/LAN.
# API_ALLOW_UNAUTHENTICATED=false
#
# Interactive docs: /docs, /redoc and /openapi.json. FastAPI mounts
# these itself, so API_TOKEN above cannot cover them -- they are either
# published to anyone who can reach the port, or not mounted at all.
# Not mounted by default; turn on while developing against the API.
# API_DOCS_ENABLED=false

# --- API rate limiting -------------------------------------------------------
# In-memory sliding window, per client IP, single-process only.
# Applies to the same routes as API_TOKEN above.
# Applies to the same routes as API_TOKEN above, plus /health -- which
# stays open (healthchecks have no token) but is metered, because each
# hit costs an outbound call to the inference server.
# RATE_LIMIT_ENABLED=true
# RATE_LIMIT_REQUESTS=30
# RATE_LIMIT_WINDOW_SECONDS=60
78 changes: 62 additions & 16 deletions Containerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
FROM python:3.12
# Audit E-4: this tag is mutable. `python:3.12` is rebuilt for every
# CPython patch release and every Debian security update, so two
# builds of the same Forge commit can land on two different base
# images -- the same drift the requirements.txt pins just closed, one
# layer down. Pin it to a digest with:
#
# ./deploy/pin-base-image.sh --write
#
# which rewrites this line to FROM ...python:3.12@sha256:<digest> and
# leaves a one-line diff to commit. Re-run it to bump, deliberately,
# with the move visible in git history instead of happening silently
# on the next rebuild.
#
# Left as a tag here rather than a digest baked in blind: a digest is
# only meaningful if it's one you resolved yourself, on your own
# build host, from the registry you actually pull from.
FROM docker.io/library/python@sha256:cfdcc988c45d6a933e0ec3fd9ce46e6f78174d3f082eea8f2f4d6f1f72f32b89

WORKDIR /app

Expand All @@ -20,23 +36,53 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
podman \
&& rm -rf /var/lib/apt/lists/*

# --- Non-root runtime user (audit E-4) --------------------------------
# Everything above runs as root because installing packages needs to;
# nothing below does. uvicorn binds 8000, which is unprivileged, and
# Forge's only writable path is /app/data (MEMORY_FILE, TRACE_FILE,
# RAG_DB_FILE and WORKSPACE_DIR all default under `data/`, and /app is
# WORKDIR). So there is no reason for the serving process to be able
# to rewrite /app/src/forge/ -- which is exactly what a `shell` or
# `files` dispatch could do while it was root.
#
# UID/GID 1000 is not arbitrary and not cosmetic. Under rootless
# podman the container's UID 0 is already mapped to your host UID, and
# that mapping is what makes the 0660 proxy sockets and the mounted
# journal readable at all. Dropping to a container UID that maps to a
# *subuid* would silently break every one of those. Running this image
# non-root therefore requires --userns=keep-id at runtime, so that
# container UID 1000 maps back to host UID 1000. See deploy/README.md,
# "Running non-root" -- the image half without the runtime half is a
# broken container, not a safer one.
#
# python:3.12 (Debian) ships no user at 1000, so this claims it
# cleanly; useradd fails the build loudly if that ever stops being
# true rather than quietly picking a different id.
RUN groupadd --gid 1000 forge \
&& useradd --uid 1000 --gid 1000 --create-home --shell /usr/sbin/nologin forge \
&& mkdir -p /app/data \
&& chown -R forge:forge /app/data /home/forge \
&& python -m compileall -q /app/src

# HOME matters here in a way it didn't as root: the podman client
# resolves its config and scratch paths from it, and /root exists
# while /home/forge only does because it was just created.
ENV HOME=/home/forge

USER forge:forge

EXPOSE 8000

# Default: HTTP API (accessible from browser / other machines)
# Override for REPL: podman run -it ... forge-core python -m forge.main
#
# `mkdir -p /run/systemd/system` -- NOT running real systemd, just
# satisfying its own sd_booted() check (per systemd's own man page:
# "Internally, this function checks whether the directory
# /run/systemd/system/ exists" -- nothing more). Without this,
# `systemctl` inside sysadmin bails out with "System has not been
# booted with systemd... Failed to connect to system scope bus...
# Host is down" *before ever attempting* the bus connection --
# happens regardless of DBUS_SYSTEM_BUS_ADDRESS being set correctly,
# confirmed in production on 2026-08-11 (the filtered D-Bus proxy
# worked fine when tested directly with busctl; systemctl inside the
# container still refused). /run is a fresh tmpfs at container start,
# so this can't be done at build time -- it has to happen here, in
# CMD, every time the container starts. `exec` keeps uvicorn as PID 1
# so it still receives SIGTERM directly from `podman stop`.
CMD ["sh", "-c", "mkdir -p /run/systemd/system && exec uvicorn forge.api:app --host 0.0.0.0 --port 8000"]
# Plain exec form: uvicorn is PID 1 and receives SIGTERM directly from
# `podman stop`, with no shell in between.
#
# This used to be wrapped in `sh -c "mkdir -p /run/systemd/system &&
# ..."`, to satisfy sd_booted() before `systemctl` would attempt a bus
# connection. Nothing calls systemctl any more -- graphs/sysadmin.py
# discovers units with `busctl --json=short`, which has no such check
# -- so the directory served no purpose and the mkdir was doing
# nothing but adding a shell and a failure mode.
CMD ["uvicorn", "forge.api:app", "--host", "0.0.0.0", "--port", "8000"]
69 changes: 69 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ logging to the user session journal, not root services like

```bash
podman run -d --name forge \
--userns=keep-id \
--group-add keep-groups \
--env-file .env.local \
-v $(pwd)/data:/app/data \
Expand All @@ -122,6 +123,7 @@ services:
image: forge-core
container_name: forge
restart: unless-stopped
userns_mode: "keep-id"
annotations:
run.oci.keep_original_groups: "1"
ports:
Expand All @@ -141,6 +143,73 @@ rootless setup; the paths on the right of each `:` are what
in `.env.local` above actually refer to, since those are seen from
*inside* the container.)

## Running non-root (and why `--userns=keep-id` is not optional)

The image sets `USER forge` (UID/GID 1000) so the serving process
can't rewrite `/app/src/forge/` or anything else in the container.
That half is in the Containerfile. The other half is at runtime, and
**an image built non-root without `--userns=keep-id` is a broken
container, not a hardened one.**

Why: under rootless podman the container's UID 0 is already mapped to
your host UID. That mapping is what lets the container read the two
proxy sockets (mode 0660, owned by your host user) and write to
`./data`. A container UID of 1000 maps by default to a *subuid*
instead -- a stranger to all of those -- so `sysadmin` loses both
proxies and Forge loses its writable directory. `--userns=keep-id`
maps your host UID to the same UID inside, putting `forge` back where
container-root used to be.

Check your host UID first:

```bash
id -u # expected: 1000
```

If it isn't 1000, use the explicit form -- `--userns=keep-id:uid=1000,gid=1000`
on the CLI, or `userns_mode: "keep-id:uid=1000,gid=1000"` in compose
-- which pins the mapping to the image's user regardless of the host
id.

### What to verify after switching

Each of these has a distinct failure mode, so run them in order and
stop at the first surprise:

```bash
# 1. Non-root, and mapped to your host user.
podman exec forge id
# expect: uid=1000(forge) gid=1000(forge)

# 2. Supplementary groups survived the user namespace.
# This is the one to watch: --group-add keep-groups and
# --userns=keep-id are two different mechanisms touching the same
# thing, and keep-groups is what journalctl -u depends on.
podman exec forge id
# expect: extra groups listed, not just 1000(forge)

# 3. The writable path is actually writable.
podman exec forge touch /app/data/.write-test && echo OK

# 4. Both proxies still reachable. Use a *root-owned* unit here:
# it's the one subject to the wheel ACL on system.journal, so it
# tests group access rather than just "no entries".
podman exec forge podman --url unix:///run/forge-podman-ro-proxy.sock ps
podman exec forge journalctl -D /host-journal -u steamos-manager.service -n 5
```

Confirmed working on the Deck (2026-08-13): `uid_map` shows
`1000 0 1`, which is keep-id's signature -- container UID 1000 is the
host user. Supplementary groups survive the switch, because the
kernel evaluates the journal ACL against the host-side GIDs, not the
names visible inside the container.

### If something breaks and you need the old behaviour now

`user: "0:0"` in compose (or `--user 0:0` on the CLI) overrides the
image's `USER` without a rebuild. That is the whole revert: one line,
no image work, and it restores exactly the previous posture.

## Group access for `journalctl -u` on root-owned system services

Confirmed root cause: rootless podman does NOT pass the host user's
Expand Down
11 changes: 11 additions & 0 deletions deploy/compose.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ services:
image: forge-core
container_name: forge
restart: unless-stopped
# L'image tourne en UID 1000 (utilisateur `forge`), pas root.
# keep-id est la moitie obligatoire cote runtime : sans elle,
# l'UID 1000 du container est mappe sur un subuid et perd l'acces
# aux sockets des proxies (0660, propriete de l'utilisateur hote)
# ET a ./data. Voir deploy/README.md, section "Running non-root".
# Si l'UID hote n'est pas 1000 : keep-id:uid=1000,gid=1000.
# Echappatoire immediate si quelque chose casse, sans rebuild :
# decommenter `user: "0:0"` ci-dessous, qui restaure l'ancien
# comportement root.
userns_mode: "keep-id"
# user: "0:0"
annotations:
run.oci.keep_original_groups: "1"
ports:
Expand Down
79 changes: 79 additions & 0 deletions deploy/pin-base-image.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
#
# Resolve the Containerfile's base image tag to an immutable digest
# and (with --write) pin the FROM line to it. Audit E-4.
#
# Why this is a script and not just a digest committed by hand: a
# digest is only correct for the moment it was resolved, and the
# whole point of pinning is that bumping it is a deliberate, visible
# act. `python:3.12` is a moving tag -- it gets rebuilt for every
# CPython patch release and every Debian security update, so two
# builds of the same Forge commit can sit on two different base
# images. That's the same problem the requirements pins fixed, one
# layer down.
#
# Usage:
# ./deploy/pin-base-image.sh # show the digest, change nothing
# ./deploy/pin-base-image.sh --write # rewrite the FROM line
#
# To bump later: put the tag back (or edit the tag in TAG below),
# re-run with --write, and commit the one-line diff. The diff is the
# record of what you moved onto and when.

set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.." # repo root, regardless of cwd

TAG="${FORGE_BASE_TAG:-docker.io/library/python:3.12}"
WRITE=0
[[ "${1:-}" == "--write" ]] && WRITE=1

if ! command -v podman >/dev/null 2>&1; then
echo "podman not found -- this needs to run on the build host." >&2
exit 1
fi

echo "Pulling ${TAG} to resolve its digest..."
podman pull "${TAG}" >/dev/null

# RepoDigests is the registry-side content address: pulling by it
# gets the exact same bytes forever, where the tag does not. Take the
# first entry -- an image pulled from one registry has one.
DIGEST_REF="$(podman image inspect --format '{{index .RepoDigests 0}}' "${TAG}")"

if [[ -z "${DIGEST_REF}" ]]; then
echo "No RepoDigest on ${TAG} -- was it built locally rather than pulled?" >&2
exit 1
fi

echo
echo "Resolved: ${DIGEST_REF}"
echo

if [[ "${WRITE}" -eq 0 ]]; then
echo "Nothing written. Re-run with --write to pin the Containerfile:"
echo " FROM ${DIGEST_REF}"
exit 0
fi

# Matches both the tag form and an already-pinned digest form, so
# re-running to bump works the same as running it the first time.
python3 - "$DIGEST_REF" <<'PY'
import re
import sys
from pathlib import Path

digest_ref = sys.argv[1]
path = Path("Containerfile")
text = path.read_text(encoding="utf-8")
new_text, count = re.subn(
r"^FROM\s+\S+$", f"FROM {digest_ref}", text, count=1, flags=re.MULTILINE
)
if count != 1:
sys.exit("Could not find a single FROM line to rewrite -- edit by hand.")
path.write_text(new_text, encoding="utf-8")
print(f"Containerfile FROM pinned to {digest_ref}")
PY

echo
echo "Now review and commit the diff:"
echo " git diff Containerfile"
Loading
Loading