sysadmin needs to read the HOST's journalctl/systemctl/podman
state, but Forge's own container has no business holding real
mutation privilege over any of them. Each piece below is optional and
independent; sysadmin degrades gracefully without any of them
(falls back to kernel-log diagnosis, or reports the specific missing
piece -- see graphs/sysadmin.py's _discover_node).
./deploy/setup-sysadmin-host-access.shThis is idempotent and does everything in one go: checks
xdg-dbus-proxy is present, enables podman.socket, installs and
enables the two systemd --user units so both proxies persist across
reboots (no more relaunching background jobs by hand every session).
It ends by printing the exact .env.local lines to add. Read on only
if you want to understand what it's doing, or need to troubleshoot.
Provisioning a new host with Ansible instead? See
deploy/ansible/sysadmin-proxies.yml -- same steps, adapted to your
NiPoGi playbook once you get there.
journalctl reads journal files directly off disk; no daemon, no
socket. Just a bind mount and a binary (the Containerfile already
installs the binary):
podman run ... -v /var/log/journal:/host-journal:ro forge-coreSYSADMIN_JOURNAL_DIR=/host-journal
Discovery talks to systemd over D-Bus -- no file-based equivalent
exists. forge-dbus-proxy.sh uses xdg-dbus-proxy (the same tool
Flatpak uses to sandbox D-Bus access) to expose a NEW socket that only
forwards ONE read-only method call, ListUnits, and silently drops
everything else -- StartUnit/StopUnit/RestartUnit/Reboot/...
never reach the real bus at all, confirmed in practice: ListUnits
succeeds, a mutating call returns Access denied straight from the
proxy. tests/test_dbus_proxy_allowlist.py pins the filter and the
list. It used to forward five, four of which nothing called -- one of
them Properties.GetAll, which returns the manager's environment.
-v ${XDG_RUNTIME_DIR}/forge-dbus-proxy:/run/forge-dbus-proxy:ro
SYSADMIN_DBUS_ADDRESS=unix:path=/run/forge-dbus-proxy/bus
systemctl was tried first and abandoned -- confirmed with
SYSTEMD_LOG_LEVEL=debug in production that it does NOT use
DBUS_SYSTEM_BUS_ADDRESS at all for this: it hardcodes an attempt at
/run/systemd/private (a systemd-specific shortcut protocol to PID 1,
not standard D-Bus) and gives up immediately if that exact path
doesn't exist, with zero fallback. Mounting our proxy's socket
directly onto /run/systemd/private was tried next and also failed
(No data available -- a real D-Bus client doesn't speak that
shortcut protocol's handshake). graphs/sysadmin.py instead uses
busctl --json=short --address=<proxy> call ... for discovery --
busctl is a generic D-Bus client with none of systemctl's
container-detection quirks, and it was the one tool that worked
consistently against the proxy throughout this whole investigation.
journalctl/podman logs for actually reading logs are unaffected by
any of this (neither goes through D-Bus).
What this proxy's scope means for what Forge can see. The address
above is the SYSTEM bus (/run/dbus/system_bus_socket), so ListUnits
returns system units and nothing else. The two units this file installs
are systemd --user units -- so Forge cannot see its own proxies,
and graphs/sysadmin.py cannot answer "why is forge-podman-ro-proxy not
working?". That question has been asked once in 35 real sysadmin turns,
during the outage itself, and answering it would not have shortened the
outage: Forge is reactive and nobody had asked. What would have is
DETECTION, which is the next paragraph.
It matters more since harnais/collectors/units.py landed
(2026-09-15), which reports every unit that is not active or
inactive -- including activating (auto-restart), the shape a restart
loop has, and the shape both proxies had for those three days. A
system-level service failing that way is reported; these two are not.
What catches the outage on the first day. /health carries
host_access, for example {"podman": "ok", "systemd": "unobservable"},
with one entry per proxy this deployment has configured
(SYSADMIN_PODMAN_URL, SYSADMIN_DBUS_ADDRESS). It is produced by
running the same two collectors sysadmin runs, so it cannot say ok about
a path sysadmin then fails on. The web UI's status dot turns amber,
names the proxy and puts the unit to look at in its tooltip; the loss
and the recovery are each logged once (podman logs forge), whether or
not anyone is polling. It adds no host access -- both sockets are
already mounted -- and it is additive: status and the HTTP code never
change, because the Android app picks an address by whether /health
answers 2xx, and a lost proxy must not read on the phone as a Forge that
is down. Answers are reused for 30 seconds (harnais/host_probe.py).
Closing the diagnosis half means a second filtered proxy on the SESSION bus, with
ListUnits alone. Not done: it widens what Forge can reach into the
user's own systemd manager, which is a decision rather than an
oversight -- and a heavier one than the system bus's. The user owns
that manager, so a mistake in the filter there is arbitrary code
execution as the user (StartTransientUnit), where polkit would refuse
it on the system bus. It would need its own twin of the allowlist test
before it ships, and the units collector would cap the length and count
of the names it hands the model: any process of the user can create a
unit with a name of its choosing, which only root can on the system
bus.
podman.sock exposes podman's full REST API. A :ro bind mount only
protects the socket file, not the requests sent through it.
podman_ro_proxy.py (stdlib-only) forwards only
GET /containers/json and GET /containers/{id}/logs, and returns
403 on every other verb or path -- confirmed in practice: a POST /containers/create gets 403 Forbidden from the proxy itself. See
tests/test_podman_ro_proxy.py for the filter's own test coverage.
SYSADMIN_PODMAN_URL=unix:///run/forge-podman-ro-proxy/sock
Group access is required for journalctl -u <unit> on root-owned
system services (see "Group access" below) -- without it, sysadmin
still works, but only sees generic recent-entries queries and units
logging to the user session journal, not root services like
steamos-manager.service.
podman run -d --name forge \
--userns=keep-id \
--group-add keep-groups \
--env-file .env.local \
-v $(pwd)/data:/app/data \
-v /var/log/journal:/host-journal:ro \
-v ${XDG_RUNTIME_DIR}/forge-dbus-proxy:/run/forge-dbus-proxy:ro \
-v ${XDG_RUNTIME_DIR}/forge-podman-ro-proxy:/run/forge-podman-ro-proxy:ro \
-p 8000:8000 \
forge-coreSame effect, --group-add keep-groups's underlying crun annotation
spelled out directly (works the same way in compose, which has no
--group-add flag of its own):
services:
forge:
image: forge-core
container_name: forge
restart: unless-stopped
userns_mode: "keep-id"
annotations:
run.oci.keep_original_groups: "1"
ports:
- "8000:8000"
env_file:
- .env.local
volumes:
- ./data:/app/data
- /var/log/journal:/host-journal:ro
- ${XDG_RUNTIME_DIR}/forge-dbus-proxy:/run/forge-dbus-proxy:ro
- ${XDG_RUNTIME_DIR}/forge-podman-ro-proxy:/run/forge-podman-ro-proxy:ro(${XDG_RUNTIME_DIR} on the host side -- e.g. /run/user/1000 on a
rootless setup; the paths on the right of each : are what
SYSADMIN_JOURNAL_DIR/SYSADMIN_DBUS_ADDRESS/SYSADMIN_PODMAN_URL
in .env.local above actually refer to, since those are seen from
inside the container.)
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:
id -u # expected: 1000If 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.
Each of these has a distinct failure mode, so run them in order and stop at the first surprise:
# 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 5Confirmed 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.
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.
Confirmed root cause: rootless podman does NOT pass the host user's
supplementary groups (wheel, adm, etc.) into the container by
default -- podman exec forge id showed groups=0(root) even though
deck on the host is in wheel, which is exactly what the
system.journal file's ACL requires for read access
(getfacl showed group:wheel:r-x, nothing for "other"). Generic
journalctl -D ... -n N queries and podman logs still worked
without this because they don't depend on that ACL the same way; only
-u <unit> on root-owned services needs it.
Fix: crun (the default OCI runtime) can skip the setgroups() call
that normally strips supplementary groups on container entry --
--group-add keep-groups on the CLI, or the equivalent annotation
run.oci.keep_original_groups: "1" in compose (no --group-add flag
exists there). Confirmed in production: podman exec forge id then
shows real supplementary groups instead of just root, and
journalctl -D /host-journal -u steamos-manager.service returns real
entries.
These are the actual failures encountered getting this running the first time, kept here because they're easy to hit again on a fresh host:
-
mkdir: /run/forge-dbus-proxy: Permission non accordée--/runitself is root-owned; a plain user can't create directories there.forge-dbus-proxy.shnow defaults to$XDG_RUNTIME_DIR(rootless- friendly) instead of/rundirectly, so this shouldn't reoccur -- if it does, you're likely running as a user without a properXDG_RUNTIME_DIRset (checkecho $XDG_RUNTIME_DIR). -
busctl ... Failed to connect ... No such file or directoryeven with the right flags -- almost always the shell you're runningbusctlin doesn't have the env var set (new terminal tab,exportdidn't survive). Checkecho $FORGE_DBUS_PROXY_DIRfirst. -
busctl ... Call failed: org.freedesktop.DBus.Error.ServiceUnknownon a call that should be allowed -- this means the proxy connected fine but the--call=rule syntax was wrong. The correct form is--call=NAME=RULE(bus name first,method@pathsecond) -- easy to get backwards.forge-dbus-proxy.shhas the corrected syntax; if you hand-edit it, double check againstman xdg-dbus-proxy. -
curl ... Empty reply from serveronpodman_ro_proxy.py, with aFileNotFoundErrorconnecting to the upstream socket in the proxy's own stderr --podman.socketis very likely inactive. Havingpodman pswork from the CLI does NOT mean the REST API socket is running; the CLI talks to podman directly for local operations and doesn't need it. Fix:systemctl --user enable --now podman.socket. -
systemctl ... Failed to connect to system scope bus via local transport: No such file or directorypersisting even with the proxy confirmed working viabusctldirectly (same address, same socket) --systemctlnever even triesDBUS_SYSTEM_BUS_ADDRESSfor this:SYSTEMD_LOG_LEVEL=debugshows it hardcodes an attempt at/run/systemd/privatefirst and gives up immediately if that exact path doesn't exist, with no fallback to the env var at all. Bind-mounting the proxy's socket onto that exact path was tried next and produced a different error (No data available) -- that path uses a systemd-specific shortcut protocol, not standard D-Bus, so a generic D-Bus proxy can't speak it correctly either. The actual fix was to stop usingsystemctlfor discovery entirely and usebusctlinstead (see the section above) -- it has none of these quirks and was the one tool that worked consistently against the proxy throughout this whole investigation. -
journalctl -D ... -u <unit>returns nothing for a root-owned system service (e.g.steamos-manager.service), even though generic-n Nqueries andpodman logsboth work fine through the same mount --podman exec forge idshowinggroups=0(root)is the tell: rootless podman doesn't pass host supplementary groups (wheel,adm, ...) into the container by default, andsystem.journal's ACL requires one of those groups. Fix: see "Group access forjournalctl -u" above (--group-add keep-groupsor the compose annotation).and
journalctl --user -u forge-podman-ro-proxy.serviceshowscan't open file '.../deploy/podman_ro_proxy.py': No such file or directory-- the unit files use a repo path substituted at install time (__FORGE_REPO_DIR__, replaced bysetup-sysadmin-host-access.shwith wherever it's actually run from), never a hardcoded guess like~/Forge-- a clone living anywhere else (~/Documents/Forge,~/projects/forge, ...) would otherwise silently break. If you ever hand-edit or hand-install the.servicefiles instead of using the setup script, make sure__FORGE_REPO_DIR__has actually been replaced with a real path. After fixing the path, a unit that crash-looped enough times can still refuse to restart immediately ("Start request repeated too quickly") --systemctl --user reset-failed <unit>clears that lockout (the setup script does this automatically on every run).
Because the confirmation would only guard the path Forge's own code
chooses to take -- once a socket is reachable from inside the
container, anything else running in that same container (a future
bug, a compromised dependency, any code execution path at all) can
reach it directly, bypassing the confirmation entirely. These three
proxies make the mutating calls structurally unavailable regardless
of what code runs inside the container -- the same reasoning already
applied to keeping the git tool read-only in Forge itself, one
layer further down the stack.