Files
atheos-blog-lxc/docs/security-notes.md
T
hermes c2897ed660 feat: provision Alpine LXC 136 running Atheos, wired to Gitea
Provisions a LAN-only browser code editor (Atheos) in an Alpine 3.23 LXC on the
scotty PVE node, with armistace/blog cloned and ready to edit. Built via the
AI-DLC workflow; the full process trail is in aidlc-docs/.

Deliverables
- scripts/provision.sh          rebuild the guest from scratch (re-runnable)
- scripts/update-atheos.sh      move Atheos to latest upstream and RE-PIN
- scripts/build-and-test.sh     captures evidence for every acceptance criterion
- files/atheos.conf             nginx vhost (socket path byte-matches the FPM pool)
- files/ssh-config              ~atheos/.ssh/config deploy-key alias
- RUNBOOK.md                    operational guide (START AT STEP 1)
- docs/security-notes.md        credential model + wider-web hardening TODOs
- docs/resiliency-notes.md      what a non-PoC deployment would need

Design decisions
- D1 no git identity is configured by the agent; the operator sets it. Git will
  refuse the first commit until then, which is deliberate (requirement FR-11).
- D2 Atheos pinned to commit f90cd18 (build 611) with a verified tarball sha256,
  plus update-atheos.sh as the deliberate escape hatch.
- D3 nginx :80, web root /var/www/atheos, php-fpm over a unix socket.
- D6 git auth is a per-repository DEPLOY KEY scoped to armistace/blog, not a
  personal access token (a PAT's write:repository scope reaches every repo the
  account can write).

Verified on the live guest (40 checks, 0 failures)
- AC-2  Atheos served over HTTP 200 by nginx+php-fpm; socket paths byte-match
- AC-3  20 posts + themes/cleanblog present; owned by the php-fpm user
- AC-4  all five writable paths writable as the service user
- AC-5  remote targets armistace/blog; deploy key authenticates over SSH
- AC-6  survival proven with a real container restart, marker byte-identical
- AC-8  no python/pelican/pip/make and no DB engine - scope boundary enforced
- FR-11 git identity is empty; deploy key mode 600 owned by the service user

Traps documented in the RUNBOOK (each cost real debugging time)
- The Gitea SSH user is `gitea`, NOT `git` (app.ini SSH_USER=gitea). With `git`,
  sshd answers "Invalid user git" and rejects before reading authorized_keys,
  which is indistinguishable from a bad key.
- The Gitea SSH endpoint is the LAN address (:22 is not forwarded publicly).
- `uptime` and `free` report the PVE HOST inside an LXC without lxcfs, so a
  container that just restarted can claim days of uptime.
2026-09-29 15:44:11 +10:00

5.9 KiB

Security Notes — atheos-blog-lxc

Status: security-baseline extension opted out (LAN-only PoC) per Andrew's standing preference. Purpose: preserve the hardening trail so that if this ever moves to the wider web, the work is already scoped, and nobody has to re-derive it. Every item names its code touch-point.

Reviewed: 2026-09-29. Design decision D6 (see application-design.md).


1. The credential model (the load-bearing decision)

Chosen: a per-repository Gitea deploy key, scoped to armistace/blog, write-enabled.

Why not the alternatives

Option Scope if stolen Verdict
Deploy key (chosen) armistace/blog only ✅ per-repo scoping, explicit read_only flag
armistace personal access token every repo armistace can write (scopes are route-groups like write:repository, matched against user access — not per-repo) ❌ account-wide blast radius
armistace personal SSH key whole account, incl. web/API ❌
Dedicated "Atheos" Gitea user whatever it owns/grants — no reduction for blog ❌ adds a credential + muddies attribution

Verified, not assumed (2026-09-29, live instance 1.27.3):

  • API schema: AccessToken.scopes = ["read:repository","write:repository",…]; DeployKey carries read_only: bool and is bound to one repository. Deploy-key endpoints /repos/{owner}/{repo}/keys exist and are per-repo.
  • The instance's own token inventory shows the risk concretely: armistace holds 8 access tokens, one with scope all.

Threat model this addresses. Atheos is itself the untrusted component: it is a file editor with shell-exec reach (codegit invokes git; Atheos uses exec/system) and it is reachable by anyone on the LAN. The goal is not to make compromise impossible — it is to make the blast radius one repository when it happens.

Implementation (code touch-points)

Item Where Detail
Keypair generated on the guest, never leaves it scripts/provision.sh, U4 ssh-keygen -t ed25519 -f ~/<fpmuser>/.ssh/id_ed25519_blog -N ''
Key owned only by the php-fpm run user, mode 600 U4 not readable by nginx or other users
IdentitiesOnly yes + a named host alias ~/<fpmuser>/.ssh/config Host gitea-blog so a stray push cannot target another repo
Remote uses the alias U4 gitea-blog:armistace/blog.git
Public half handed to the operator via Matrix operator adds it: repo → Settings → Deploy Keys → Allow write access

⚠️ Honest limitation. For push to work, the deploy key must be readable by the web-app user, so a full Atheos compromise can use it to write blog. Unavoidable for any push-capable editor. The deploy key makes that the floor (one repo, one protected branch), not the ceiling (8 repos).


2. If this is ever exposed beyond the LAN

# Item Touch-point
S-1 Terminate TLS at NPM; never serve Atheos plaintext to the internet NPM proxy host
S-2 Authenticate in front of Atheos, not just inside it — an NPM Access List / SSO so the app is not the only gate NPM
S-3 fail2ban on the guest for failed Atheos logins and nginx 401/403s guest + nginx.conf log format
S-4 Rate-limit the login endpoint in nginx (limit_req_zone) nginx.conf
S-5 Force HTTPS redirect; set Strict-Transport-Security nginx.conf
S-6 Session hardening: session.cookie_secure=1, session.cookie_httponly=1, session.cookie_samesite=Lax php.ini
S-7 Disable PHP dangerous functions Atheos does not need: exec, shell_exec, system, passthru, proc_open, popen — except git needs proc_open/exec, so this needs testing before applying php.ini — ⚠️ test, do not blind-apply
S-8 open_basedir to /var/www/atheos + /tmp to contain a file-read bug php.ini
S-9 nginx hardening: X-Content-Type-Options, X-Frame-Options, Referrer-Policy, deny dotfiles, deny *.md/*.py/.git serving nginx.conf
S-10 Protect the deploy key + config from being served: verify /.ssh, /config.php, /data are not web-reachable nginx.conf — verify with curl, not by reading config
S-11 Gitea-side: consider an IP allowlist / rate limit on the git endpoint if the box is public Gitea/NPM
S-12 Alerting on: nginx 5xx rate, new git remote, deploy-key use, file changes under /var/www/atheos not built — would need the observability stack
S-13 Keep Atheos patched — it is PHP, no releases; re-pin deliberately (see scripts/update-atheos.sh) update script
S-14 allow_url_fopen=On is currently enabled (needed for the plugin marketplace). Turn it off for internet exposure unless the marketplace is genuinely required php.ini

3. Standing hygiene (applies now, LAN or not)

  • Branch protection on armistace/blog master — so a compromised editor cannot force-push master, only propose. (Repo-side; operator-owned.)
  • The deploy key is write-enabled by explicit choice. If push ever becomes unnecessary, flip it to read-only in the repo settings — zero code change.
  • No secrets in git: the private key is generated on the guest and never committed (.gitignore).
  • Agent must never create armistace credentials (FR-11).

4. Accepted risks (conscious, recorded)

Risk Why accepted Revisit trigger
Atheos has shell-exec capability it is a git-integrated editor; unavoidable if exposed publicly → apply S-2, S-7, S-8
Deploy key readable by the web user required for push if push no longer needed → set read-only
LAN has no TLS LAN-only scope (FR-10) on any external exposure → S-1
allow_url_fopen=On plugin marketplace if the marketplace is unused → set Off
No monitoring/alerting out of scope; observability stack is separate if exposed publicly → S-12