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.
This commit is contained in:
2026-09-29 15:44:11 +10:00
commit c2897ed660
26 changed files with 2333 additions and 0 deletions
@@ -0,0 +1,150 @@
# Infrastructure Design — atheos-blog-lxc
**Stage**: CONSTRUCTION — Infrastructure Design
**Date**: 2026-09-29 · **Deploy target**: Proxmox LXC (Alpine 3.23) on node `scotty` (.69)
> **No open questions.** Every infrastructure decision was settled at the Requirements and Application
> Design gates (Q1–Q6, D1–D6). Re-asking them would be ceremony. This artifact maps the approved
> design onto concrete infrastructure, and records the *provenance* of each choice.
---
## 1. Guest specification (U1)
| Property | Value | Provenance / rationale |
|---|---|---|
| Node | **scotty** (192.168.178.69) | user Q3 |
| VMID | **136** | verified free — in-use on scotty: 104-107, 119, 125, 126, 133-135 |
| OS | **Alpine 3.23** (`alpine-3.23-default_20260116_amd64.tar.xz`) | user requirement ("alpine lxc") |
| Rootfs | **20 GB** | user requirement (point 5) |
| Storage | `scotty-local-lvm` (thin) | 771 GB free at design time; thin keeps 20 GB nominal, minimal actual |
| vCPU | 2 | idle PHP app; 1 risks slow composer-less PHP ops |
| RAM | 1024 MB | nginx + php-fpm is trivial; scotty has 12.1 GB available |
| Swap | 256 MB | PHP can spike on large files; cheap insurance |
| Privileged? | **unprivileged** | default-safe; nothing here needs device access |
| `onboot` | **1** | NFR-2 |
| Network | `eth0`, `bridge=vmbr0`, DHCP | matches all existing guests; LAN-only (FR-10) |
| DNS | router-provided | LAN-only |
| Timezone | `Australia/Brisbane` | NFR-5 |
| Features | **none required** | ⚠️ see note below |
### ⚠️ Note on LXC `features`
The observability project hit `status=226/NAMESPACE` because a container lacked **`nesting`**.
That was **systemd**. This guest is **OpenRC** on Alpine, which does not need `nesting`.
So: `features` left **unset**, and if a service fails to start we revisit `nesting` rather than
pre-emptively enabling a feature that widens the container's escape surface.
### Template provenance
The Alpine 3.23 template is **not yet present on scotty** (risk C-3). U1 therefore begins with
`pveam update` + `pveam download local alpine-3.23-default_...`, and the download is verified before
`pct create` runs.
---
## 2. Runtime stack (U2)
| Layer | Choice | Notes |
|---|---|---|
| HTTP | `nginx` (Alpine `main`) | listens `:80`, `root /var/www/atheos` |
| PHP | `php83-fpm` + `php83` CLI | Alpine 3.23's PHP is **8.3** — confirmed available (`php83` present in the v3.23 index) |
| Extensions | `php83-session`, `php83-zip`, `php83-openssl`, `php83-mbstring` | Atheos: ZIP/OPENSSL/MBSTRING for the plugin marketplace; `session` is not optional |
| PHP ini | `allow_url_fopen=On` | Atheos marketplace requirement (recorded as accepted risk; turn off if unused — S-14) |
| Socket | **unix socket** | nothing extra listens on the LAN; path resolved from the *installed* pool config, not assumed |
### The socket contract (interface I-1 — the 502 risk)
nginx's `fastcgi_pass` and the php-fpm pool's `listen` are two strings that must be **byte-identical**.
The design does not assume a path: U2 **reads the pool config after install** and writes nginx to
match, then asserts both sides agree. A mismatch produces a bare 502 with no other diagnostic.
---
## 3. Application layer (U3)
| Item | Value |
|---|---|
| Web root | `/var/www/atheos` |
| Source | `github.com/Atheos/Atheos`, **pinned to a resolved commit SHA** (D2) |
| Database | **none** — Atheos is file-backed |
| Writable paths | `config.php`, `data/`, `workspace/`, `plugins/`, `themes/` |
| Ownership | owned by the **php-fpm run user** (resolved from the pool) |
| Run user | resolved at U2; **U2/U3/U4 must agree on the same user** (cross-unit risk) |
---
## 4. VCS layer (U4)
| Item | Value | Provenance |
|---|---|---|
| `git` | Alpine package | user point 2 |
| `safe.directory` | workspace path | prevents *dubious ownership* (I-3) |
| Committer identity | **deliberately UNSET** | **D1 / Q1=B** — operator sets it first |
| Clone | `armistace/blog` branch `master` → `workspace/blog` | user point 3, Q6 |
| Remote (initial) | `https://git.aridgwayweb.com/armistace/blog.git` → **HTTPS** (public repo) | U4 phase 1 |
| Credential | **ed25519 deploy key**, per-repo, write-enabled | **D6** |
| Private key | `~<fpmuser>/.ssh/id_ed25519_blog`, mode `600`, owned by FPM user | D6 |
| SSH alias | `Host gitea-blog` + `IdentitiesOnly yes` | D6 — a stray push cannot hit another repo |
| Remote (final) | `gitea-blog:armistace/blog.git` | U4 **phase 2**, after operator installs the key |
---
## 5. Networking & exposure
- **LAN-only** (FR-10). nginx binds `:80` on the guest; nothing else is configured.
- **No NPM proxy host, no DNS record, no router forward** — deliberately not created.
- php-fpm on a unix socket ⇒ **only `:80` and `:22` listen**.
- `git.aridgwayweb.com` resolves to a **public IP** — the *clone* traverses the internet, but the
guest itself is not exposed. Noted because it makes Gitea-side scoping (D6) the meaningful control.
---
## 6. Operations & persistence (OpenRC, not systemd)
| Service | Enable command | Failure if missed |
|---|---|---|
| `nginx` | `rc-update add nginx default` | serves until reboot, then **AC-6 fails** |
| `php-fpm83` | `rc-update add php-fpm83 default` | same |
- Logs: `/var/log/nginx/`, php-fpm error log (path resolved at install)
- **Verification uses an actual reboot**, not a service-status check — a service can be "started"
and still not be enabled.
- Monitoring/alerting: **out of scope** (R-3, S-12). Observability stack is a separate project.
---
## 7. Deployment architecture
```
LAN 192.168.178.0/24
│
┌────────▼─────────┐
│ scotty (.69) │ PVE node
│ ┌────────────┐ │
│ │ LXC 136 │ │ Alpine 3.23 · 2 vCPU · 1 GB · 20 GB root
│ │ │ │
:80 ─────────┼──┤ nginx │ │ unprivileged · onboot=1
│ │ │ unix │ │
│ │ ▼ socket │ │
│ │ php-fpm83 │ │
│ │ │ │ │
│ │ ├─► /var/www/atheos{,/workspace/blog}
│ │ └─► git ─┼──┼──► gitea-blog:armistace/blog.git
│ └────────────┘ │ (deploy key, D6)
└──────────────────┘
│
internet (clone only)
▼
git.aridgwayweb.com (Gitea LXC 109)
```
---
## 8. Infrastructure decisions summary
| # | Decision | Why |
|---|---|---|
| I-1 | Unprivileged, no `features` | default-safe; `nesting` is a systemd need, not ours |
| I-2 | Thin-provisioned `scotty-local-lvm` | 20 GB nominal on 771 GB free; bounded blast radius |
| I-3 | php-fpm over **unix socket** | smallest network surface |
| I-4 | Same user for FPM, file ownership, and key | three-way identity agreement — the top cross-unit risk |
| I-5 | **No** monitoring/alerting | out of scope, recorded (R-3/S-12) |
| I-6 | Reboot-based verification | a "started" service is not an "enabled" one |