Author SHA1 Message Date
hermes c57aff3c33 docs: branch protection is a recommendation, not a blocker (operator decision)
The operator has decided not to enable branch protection on armistace/blog while the editor
is LAN-only:

  "leave it as a recommendation - I won't do that unless I take this out to the wider internet"

That is a reasonable call, so this records it as an ACCEPTED RISK rather than an open task.

Test behaviour changed to match. The suite still attempts a direct push to master with the
deploy key on EVERY run - the check is not removed, because that is what would catch the
setting being lost or never applied. But when protection is off it now reports
  ACCEPTED-RISK  master is NOT protected - the deploy key CAN push master directly
instead of FAIL, so the suite is green and CI-usable (it previously exited 1, meaning a
genuine regression would have been buried in expected noise).

The run flips back to "PASS master is protected" by itself the moment protection is enabled,
so the recommendation self-cancels with nobody having to remember it. Summary line now reads
  RESULT: 44 passed, 0 failed, 1 accepted-risk

Docs record WHY the risk is acceptable (the compensating control is reachability, not
immutability - nothing off-LAN can reach the editor, and the only LAN user is the operator)
and the two triggers that turn it back into a real finding:
  1. the editor becomes reachable off-LAN (NPM proxy, port-forward, VPN route)
  2. anyone other than the operator gets access
Written down because an accepted risk is only acceptable if it is explicit and has a
revisit trigger - otherwise it just looks like an oversight later.
2026-09-29 16:40:49 +10:00
hermes 1ff8aaf2f1 Merge pull request 'fix: CodeGit black box - hardcoded panel height + CSS cache' (#5) from fix/codegit-panel-layout into master 2026-09-29 16:39:02 +10:00
hermes 2d57173e31 fix: CodeGit looked like an empty black box - hardcoded panel height + cache
Reported as "why can't i see anything from codegit its just a black box".

NOT a codegit bug. Verified first, exhaustively, through the authenticated API:
  overview  200    940 bytes
  log       200  79596 bytes   (full commit history)
  transfer  200   1460 bytes
  config    200   1174 bytes
no JS errors in the console; every DOM helper codegit calls exists.

ROOT CAUSE 1 - a generic CSS rule hardcodes a height
  theme/modules/common/_panel.scss -> theme/main.min.css:
    panel { ... height: 500px; ... }
  That forces EVERY modal panel to a fixed 500px box. Measured live:
    #dialog            404px  (clamped by max-height:70vh + overflow-y:auto)
    #codegit panel     500px, while its content is only 119px
    toolbar ("Branch") top 620 vs dialog bottom 490 -> OUTSIDE the modal
  So ~380px of the dialog was empty dead space and the Branch line rendered below the
  modal, invisible. Fixed to height:auto with max-height:58vh (long content still scrolls):
    before: panel 500 -> toolbar top 620, dialog bottom 490 (outside)
    after:  panel 136 -> toolbar top 256, dialog bottom 279 (inside)
  _panel.scss updated too so the source does not contradict the shipped CSS.

ROOT CAUSE 2 - the fix was invisible because of the browser cache
  Atheos serves theme/main.min.css and the generated bundles from stable URLs with no cache
  headers, so browsers legitimately reuse their cached copy - which is why the first attempt
  appeared to do nothing. nginx now sends Cache-Control: no-cache, must-revalidate for
  *.css/*.js: caching still works (cheap 304s) but changes are always picked up.

Adds
  scripts/fix-panel-layout.sh   idempotent re-apply for both fixes (lost on update-atheos)
  docs/known-issues.md §4, §5   both findings, with the measurements

Tests (44 passed, 1 failed - the failure is branch protection, a genuine open item)
  + panel must not be fixed at 500px
  + theme CSS must send Cache-Control: no-cache
2026-09-29 16:38:48 +10:00
hermes c65900638e Merge pull request 'fix: rDelete fatals on musl (GLOB_BRACE) - broke ALL deletion and plugin removal' (#4) from fix/glob-brace-musl into master 2026-09-29 16:28:57 +10:00
hermes c4a4de1da7 fix: rDelete fataled on musl (GLOB_BRACE) - broke ALL deletion and plugin removal
Reported as "deleting terminal just cycles in the ui". Root cause is much larger than the
plugin: it was a platform bug that broke deletion everywhere.

CAUSE
  traits/helpers.php::rDelete() did:
      glob($target . "{*,.[!.]*,..?*}", GLOB_BRACE|GLOB_MARK);
  GLOB_BRACE is a GLIBC extension. Alpine is musl, where the constant is undefined,
  and referencing an undefined constant is a FATAL in PHP 8:
      PHP Fatal error: Uncaught Error: Undefined constant "GLOB_BRACE"
      in traits/helpers.php:138
      #0 components/market/class.market.php(178): Common::rDelete()
      #1 components/market/controller.php(46): Market->remove()
  The fatal kills the request before Common::send(200), so the front end never gets a
  settle response and the UI spins forever. A hang, not an error - which is why it
  presented as "cycles in the ui".

BLAST RADIUS (measured, two call sites)
  components/market/class.market.php:178   removing a plugin from the market
  components/filetree/class.filetree.php:59  DELETING ANY FILE OR FOLDER IN THE EDITOR
  The second is core functionality, so this was never a plugin bug - installing the
  Terminal plugin is just what made it visible.

FIX
  patches/helpers-rDelete-GLOB_BRACE.diff - expand the brace pattern by hand when
  GLOB_BRACE is undefined, keeping upstream behaviour where it is defined. Verified:
  nested dirs + dotfiles + single files all delete correctly now.
  Upstream has NOT fixed this (checked main) so it cannot be resolved by updating.

PLUGIN REMOVED
  Once deletion worked, removal via the market succeeded and the plugin is now gone:
    {"text":"Plugin successfully removed.","status":200}
  That is the right end state - a browser shell was the largest attack surface, and
  macros already provide command execution. Removed through the market's own code path
  so the plugin list, addons cache and bundle stayed consistent.

TESTS (now 42 passed, 1 failed - the failure is branch protection, a genuine open item)
  + a GLOB_BRACE regression guard that fails loudly if the patch is lost to an update
  + the identity guard is INVERTED: FR-11 required an EMPTY identity so the agent could
    not impersonate the operator, but the operator has since asked for it to be set, so
    the test asserting emptiness was asserting the opposite of the current design. It
    now requires the identity to be present and to NOT look agent-generated.

docs/known-issues.md: new section 1 documents the GLOB_BRACE fault in full, including the
honest note that this is the real cost of choosing Alpine over a glibc base image.
2026-09-29 16:28:44 +10:00
hermes 0bdd15b31b Merge pull request 'fix: Terminal plugin (removed API) + Macro dialog crash; document both' (#3) from fix/plugin-and-macro-dialog into master 2026-09-29 16:22:39 +10:00
hermes 976b3f0005 docs: record the review follow-ups, with evidence for the one we declined
Second review round asked for three things. Two adopted, one measured and declined.

ADOPTED
  - Removing the Terminal plugin is recommended in writing as the right move (it is the
    largest attack surface, and macros already provide command execution). Left installed
    because it is the operator's plugin - removing a tool unilaterally is not the agent's
    call. Documented how: rename to Terminal.disabled (readDirectory() skips *.disabled).
  - Automatic password rotation and the deploy-key-read-only suggestion are recorded as
    operator decisions with their trade-offs.

DECLINED, WITH EVIDENCE
  - "php.ini should disable exec/shell_exec/passthru/system by default." Measured, this
    would break the editor's core function, so it cannot be a default:
        components/codegit/*   -> Common::safe_execute   31 call sites (every git op)
        components/macro/*     -> Common::raw_execute     1 call site
        common.php raw_execute -> function_exists("system") / function_exists("exec")
    Every commit, push and fetch from the editor runs through exec/system. It stays a
    deliberate exposure lever (S-7), not a default.

CORRECTED
  - Review claimed the redacted patch file had a syntax error. Verified on the guest with
    php83 -l: "No syntax errors detected". The placeholder is a plain string literal inside
    a double-quoted PHP string. That finding was wrong, and is recorded as such rather than
    "fixed" to satisfy a false positive.

Also fixed the section ordering in the doc.
2026-09-29 16:22:25 +10:00
hermes 8ea645386f fix: rotate the terminal plugin password, remove it from the docs
Responds to the independent security review, which correctly flagged that the previous
commit published a live credential.

The Terminal plugin's in-app password gate is a hardcoded constant. The previous commit
quoted the value verbatim in docs/known-issues.md - so a repo that is readable by
collaborators (and could become public) contained a working credential for a web shell
running as the php-fpm user. That was my error.

Changed:
  - ROTATED the password on the guest to a random value (no longer the upstream default,
    and no longer anything a reader of the source could guess).
  - REMOVED the value from docs/known-issues.md. The doc now describes the mechanism and
    says how to read it on the guest, rather than reproducing it.
  - REDACTED it from patches/Terminal-terminal.php.patched (shows a placeholder; the real
    value lives only on the guest).
  - Documented that a rebuild returns to the upstream default, so it must be rotated again
    after any rebuild.

Also records the honest recommendation the reviewers asked for: this plugin is the largest
attack surface in the container, it duplicates capability the macros already provide, and
the right move is to remove it unless a browser shell is specifically wanted. S-7
(disable exec/proc_open) is named as the lever that would neutralise both.

Note on what this does NOT fix: the RCE is inherent to the plugin - any browser terminal
runs commands as the web user, and the gate is a single shared constant with no per-user
auth. Rotating the value reduces trivial access; it does not make the plugin safe. That is
stated plainly rather than papered over.
2026-09-29 16:20:53 +10:00
hermes 5ff6a06e82 feat: document plugin failures and fix the Terminal plugin + macro dialog crash
Diagnosed two independent faults reported from the running editor.

1. Terminal plugin (market) did not work as shipped.
   Root cause: it is a port of Fluidbyte/Codiad-Terminal and calls Common::execute(),
   which Atheos REMOVED (renamed to raw_execute with a different return shape).
     plugin calls : Common::execute(...)      -> MISSING
     available    : Common::raw_execute(...)  -> ["code"=>int, "output"=>array]
   Fixed to raw_execute + implode of the output array. Verified against the real
   method: pwd/echo return correctly, and after chdir it reports the blog repo path,
   so the terminal opens in the right directory.

   Two further upstream defects found and deliberately NOT fixed, but documented so
   nobody trusts them: the block-list is dead code (`explode(",", BLOCKED === true)`
   passes a boolean, so ssh/telnet are never blocked), and `strtolower($command ===
   "exit")` means typing exit does not clear the auth flag.

   Security note recorded: this plugin is a browser shell as the php-fpm user, gated
   by a hardcoded in-app constant (PASSWORD="terminal"). It is not a secret. Strongest
   argument yet for keeping the container LAN-only.

2. Settings -> Macro dialog returned HTTP 500.
   Root cause: Atheos's own components/macro/dialog.php:35 does implode(", ",
   $macro["fTypes"]) which requires an ARRAY, but class.macro.php::save() writes the
   STRING "N/A" for folder macros. So ANY folder-type macro makes that dialog fatal:
     PHP Fatal error: Uncaught TypeError: implode(): Argument #2 ($array) must be of
     type ?array, string given in .../components/macro/dialog.php:35
   Our writer macros are all folder-scoped, so adding them exposed it.

   Fixed by correcting the DATA (fTypes ""N/A"" -> "[]") rather than patching Atheos's
   code, so the fix survives an Atheos update. Verified implode() now succeeds for all
   8 macros. The trade-off and the proper upstream fix are both documented.

Also records the diagnostic order for any future plugin problem, and the rule of
thumb: a Codiad-era plugin's Common:: calls are probably stale.

Adds:
  docs/known-issues.md                  both faults, root causes, fixes, verification
  patches/Terminal-terminal.php.patched the patched plugin file
  files/macros.db.php                   regenerated with the corrected fTypes
2026-09-29 16:19:34 +10:00
hermes 47b1860623 Merge pull request 'feat: writer-branch workflow macros (editor writes to a branch, merge in Gitea)' (#2) from feat/writer-branch-macros into master 2026-09-29 16:10:06 +10:00
hermes 0b72cf48b1 test: add a master-protection guard, fix three stale assertions
Responds to the independent review of the writer-branch PR.

Adopted (the reviewer's strongest recommendation): a test that PROVES master is
protected, rather than documenting the setting. It attempts a direct push to
master with the deploy key and requires it to be rejected - the same way an
attacker would find out. It currently FAILS, which is the correct result: branch
protection is genuinely not enabled on armistace/blog, and the failure is now
visible in every test run instead of buried in prose.

Cannot fix from here: setting branch protection on armistace/blog returns
 403 "user should be an owner or a collaborator with admin write"
for the bot account (it is a collaborator, not the owner). This is an operator
action, documented in docs/writer-branch-workflow.md with the exact Gitea
settings. Until it is done the writer-branch model is advisory, not enforced.

Also fixed three assertions that were stale rather than wrong:
  - "installer markers present" now accepts the post-install state (config.php
    populated) as valid, since the operator has completed the installer.
  - "posts found == 20" is now ">= 20" - the operator has added a file, so an
    exact count was always going to fail.
  - "branch == master" is now master|atheos-writer - the writer branch is the
    intended steady state under the new workflow, so asserting master was
    asserting the opposite of the design.

Result: 40 passed, 1 failed - the single failure being the branch-protection gap.
2026-09-29 16:09:47 +10:00
hermes 1c7db911df feat: add writer-branch workflow macros
Adds the branch-based publishing model: the editor writes to a long-lived
`atheos-writer` branch, and merging into `master` stays in Gitea.

Six macros installed into Atheos's macro store (a copy is kept in
files/macros.db.php so a rebuild reproduces them), invoked by right-clicking
the `blog` folder:

  1. Start/Join Writer Branch            idempotent create-or-switch + push -u
  2. Commit + Push Writer Branch         identity guard -> add -A -> commit (only
                                         if something is staged) -> push
  3. DESTRUCTIVE: Reset Writer to Master fetch -> reset --hard origin/master ->
                                         push --force-with-lease
  4. Status + Recent Log                 read-only
  5. Refresh Master (ff-only)            pull master down, return to writer
  6. Merge Master Into Writer Branch     when master has moved ahead

Atheos has no terminal and codegit exposes no branch-switching, so macros are
the only way to run arbitrary git from the UI. Verified: codegit's `checkout`
action takes a FILE argument - it is per-file revert, not branch switching.

Identity handling: the commit macros deliberately do NOT hardcode an identity.
php-fpm runs with HOME=/home/atheos (verified), the same global gitconfig the
operator edits over SSH, so hardcoding `git -c user.name=...` would OVERRIDE the
operator's own identity. Instead macro 2 guards on an identity existing and fails
with an actionable message - which is what design decision D1 intends.

Verified by exercising each command through the web server as the php-fpm user:
  - macro 1, 4, 5 return rc=0 with correct output
  - macro 2's identity guard fires with the exact RUNBOOK STEP 1 message
  - macro 3 was tested with real divergence: committed on the writer branch,
    reset --hard to master, force-pushed; origin/atheos-writer then matched
    origin/master exactly (0 ahead, 0 behind)

Security note recorded in the docs and README: this model only holds if branch
protection is enabled on armistace/blog's master. It is currently NOT enabled,
so the deploy key could force-push master directly - the operator needs to turn
it on (repo-owner action).

Also documents two hazards found while testing:
  - macro 2's `git add -A` stages everything, and a subsequent `reset --hard`
    deletes any untracked file that was staged. Keep scratch files outside
    workspace/blog/.
  - recovery procedure for a file lost that way (git fsck --lost-found ->
    ls-tree -> cat-file), which was used to restore a file during testing.
2026-09-29 16:07:25 +10:00
hermes e038f758ac Merge pull request 'feat: provision Alpine LXC 136 running Atheos, wired to Gitea' (#1) from feat/atheos-lxc-provision into master 2026-09-29 15:50:54 +10:00
hermes a6fb61d0ea 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:50:34 +10:00
31 changed files with 3266 additions and 1 deletions
+9
View File
@@ -0,0 +1,9 @@
# keys and credentials — never commit
*.pem
id_ed25519*
id_rsa*
*.key
.env
# AI-DLC scratch
aidlc-docs/**/*.tmp
+125 -1
View File
@@ -1,3 +1,127 @@
# atheos-blog-lxc
Alpine LXC running Atheos (browser editor) wired to Gitea + armistace/blog. LAN-only. Built with AI-DLC.
Provisioning for a **browser-based code editor** (Atheos) in an Alpine LXC, wired to Gitea, with the
`armistace/blog` repository cloned and ready to edit.
Built with the **AI-DLC** workflow — the full process trail (requirements, design, units, decisions,
audit log) lives in [`aidlc-docs/`](aidlc-docs/).
---
## What this is
A small, single-purpose Proxmox container that puts an editor in your browser so you can write blog
posts and commit them to Gitea — without VS Code, without a cloud IDE, and without a build toolchain.
| | |
|---|---|
| **Guest** | Proxmox LXC **136** on node `scotty` |
| **OS** | Alpine 3.23.2 (unprivileged, OpenRC) |
| **URL** | `http://192.168.178.57/` — **LAN only** |
| **Stack** | nginx → php-fpm 8.3 (unix socket) → Atheos IDE |
| **Sizes** | 2 vCPU · 1 GB RAM · 20 GB rootfs · `onboot=1` |
| **Editor** | [Atheos](https://github.com/Atheos/Atheos) — PHP, no database, real vim keybindings |
| **Project** | `armistace/blog` cloned to `/var/www/atheos/workspace/blog` |
| **Git auth** | per-repository **deploy key** (write-enabled) |
### What it deliberately is NOT
- **Not VS Code / code-server / Coder.** Atheos is an independent, lightweight PHP editor.
- **Not a build environment.** The blog is a Pelican site, but building/publishing happens in your
existing pipeline. There is intentionally **no Python, no pelican, no `make`** here — the presence
of Python would be a defect (verified by acceptance criterion AC-8).
- **Not internet-facing.** No reverse proxy host, no DNS record, no port-forward is created.
---
## Quick start
**If you just want to use it:**
1. Open **http://192.168.178.57/** and complete the Atheos first-run installer.
2. `ssh root@192.168.178.57`, then **set your git identity** — see **RUNBOOK.md STEP 1**. Git will
not let you commit until you do, and that is deliberate: the agent does not create your identity.
3. Add the deploy key to `armistace/blog` and switch the remote to SSH — **RUNBOOK.md STEP 2 & 3**.
👉 **The full operational guide is [RUNBOOK.md](RUNBOOK.md).** Start at STEP 1.
---
## Repo layout
```
.
├── README.md this file
├── RUNBOOK.md ← operational guide; START AT STEP 1
├── docs/
│ ├── security-notes.md credential model + hardening TODOs if ever exposed
│ ├── resiliency-notes.md what a non-PoC deployment would need
│ └── writer-branch-workflow.md how writing/publishing works from the editor
├── files/
│ ├── atheos.conf nginx vhost (install to /etc/nginx/http.d/atheos.conf)
│ ├── ssh-config ~atheos/.ssh/config (the gitea-blog alias)
│ └── macros.db.php installed copy of the Atheos writer macros
├── scripts/
│ ├── provision.sh rebuild the whole guest from scratch
│ └── update-atheos.sh update Atheos to latest upstream + re-pin
└── aidlc-docs/ AI-DLC process trail (requirements → design → units → audit)
```
---
## Rebuilding from scratch
The guest holds nothing unique — the blog's source of truth is Gitea — so a rebuild is cheap:
```sh
# on scotty (192.168.178.69), as root
pct destroy 136
sh scripts/provision.sh
```
`scripts/provision.sh` is written to be re-runnable and reproduces the documented build exactly,
including the pinned Atheos commit and its verified tarball checksum.
---
## Design decisions worth knowing
| # | Decision | Why |
|---|---|---|
| **D1** | **No git identity is configured by the agent.** You set it (RUNBOOK STEP 1). | You own the `armistace` identity; setting it programmatically would impersonate you and blur attribution (requirement FR-11). |
| **D2** | **Atheos is pinned to a commit SHA**, with `update-atheos.sh` to deliberately move it. | Atheos has no releases; a moving branch is not reproducible. |
| **D3** | nginx :80, web root `/var/www/atheos`, php-fpm over a **unix socket**. | Smallest network surface — nothing extra listens on the LAN. |
| **D6** | **Per-repository deploy key**, not a personal access token. | A Gitea PAT's scopes are *route groups* matched against your whole account, so a stolen token reaches **every repo you can write**. A deploy key reaches exactly one. |
Full rationale, including the alternatives that were rejected and the verification behind them, is in
[`aidlc-docs/design-artifacts/application-design.md`](aidlc-docs/design-artifacts/application-design.md)
and [`docs/security-notes.md`](docs/security-notes.md).
---
## Security at a glance
- **Writing happens on a branch, never on `master`.** The editor commits to a long-lived
`atheos-writer` branch and you merge via PR in Gitea — see
[`docs/writer-branch-workflow.md`](docs/writer-branch-workflow.md).
⚠️ This only holds if **branch protection is enabled on `armistace/blog`'s `master`** — without it
the deploy key could force-push master directly. Confirm with the `branch_protections` API call in
that document.
- Git auth: **deploy key scoped to `armistace/blog`**, private key mode `600`, owned by the php-fpm user.
- The SSH alias uses `IdentitiesOnly yes`, so no other key is ever offered.
- The web app runs as a **dedicated unprivileged user** (`atheos`), which also owns the workspace.
- ⚠️ **Accepted risk:** Atheos runs as a web app with shell-exec reach, so a compromise can use the
deploy key to write `blog`. That is the intended floor (one repo, one branch) rather than the
ceiling. Keep **branch protection on `blog`'s `master`**.
- If this is ever exposed to the wider web, work through `docs/security-notes.md` §2 first.
---
## Provenance
| | |
|---|---|
| Built | 2026-09-29 |
| AI-DLC stages executed | Workspace Detection, Requirements, Workflow Planning, Application Design, Units Generation, Infrastructure Design, Code Generation, Build & Test |
| Stages skipped (with rationale) | Reverse Engineering, User Stories, Functional Design, NFR Requirements, NFR Design — see `aidlc-docs/plans/execution-plan.md` |
| Atheos pin | `f90cd1806dbe0e25673a77e27390e8b1a240c3f6` (build 611), tarball sha256 `03ebcb1479b59f1898b5fa50808b181750b42211698f7f7b4042cf2514d78b20` |
+321
View File
@@ -0,0 +1,321 @@
# RUNBOOK — atheos-blog-lxc
Atheos browser editor for `armistace/blog`, running in an Alpine LXC on Proxmox.
| | |
|---|---|
| **Guest** | LXC **136** on node `scotty` (192.168.178.69) |
| **IP** | **http://192.168.178.57/** |
| **OS** | Alpine 3.23.2 · unprivileged · 2 vCPU · 1 GB RAM · 20 GB root · `onboot=1` |
| **App** | Atheos IDE, pinned to commit `f90cd1806dbe0e25673a77e27390e8b1a240c3f6` (build 611) |
| **Repo** | `git.aridgwayweb.com/hermes/atheos-blog-lxc` (this provisioning repo) |
| **Workspace** | `/var/www/atheos/workspace/blog` → clone of `armistace/blog` |
| **Access** | LAN only. No NPM proxy host, no DNS record, no port-forward. |
---
## ⚠️ STEP 1 — DO THIS FIRST: set your git identity
**The agent deliberately did not configure a git identity** (design decision D1, requirement FR-11).
Git will **refuse to commit** until you set one. This is your first action after SSH-ing in.
```sh
ssh root@192.168.178.57
# become the user that runs PHP and owns the workspace
su -s /bin/sh atheos
# set YOUR identity (these values are placeholders - use your real ones)
git config --global user.name "Andrew Ridgway"
git config --global user.email "ar17787@gmail.com"
# verify
git config --global --get user.name
git config --global --get user.email
```
> Why you and not the agent: you own the `armistace` identity. Configuring it under your name
> programmatically would impersonate you and blur commit attribution.
The same identity must be set for the deploy key's user, which is the **same account** (`atheos`),
so the single `su` above covers both the editor's commits and anything you do by hand.
---
## STEP 2 — install the deploy key (Phase 2 prerequisite)
The guest already has a per-repo deploy keypair. Only the **public** half is shared:
```sh
# print it again if you need it
cat /home/atheos/.ssh/id_ed25519_blog.pub
```
Add it in Gitea:
1. Open `armistace/blog` → **Settings** → **Deploy Keys** → **Add Deploy Key**
2. Title: `atheos-blog-lxc`
3. Paste the public key
4. ✅ **TICK "Allow write access"** — required, otherwise push is rejected
Public key (as generated 2026-09-29):
```
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINj03Tep9pULTvVDCC3q1ZtocKTZSmDhPsB61Zic1SPk atheos-blog-lxc deploy key (armistace/blog)
```
Fingerprint to confirm you pasted the right one:
```
SHA256:qHvsjjSX79aPZRoNTRI4j+HH3hArAuINIRu9ewxx7t8 (ED25519)
```
---
## STEP 3 — switch the remote to SSH (Phase 2)
Once the key is installed, tell the agent (or run it yourself):
```sh
su -s /bin/sh atheos -c \
'git -C /var/www/atheos/workspace/blog remote set-url origin gitea-blog:armistace/blog.git'
# verify the transport actually authenticates
su -s /bin/sh atheos -c 'ssh -T gitea-blog' # expect a Gitea greeting, not "Permission denied"
su -s /bin/sh atheos -c 'git -C /var/www/atheos/workspace/blog fetch --dry-run'
```
### Why the remote uses `gitea-blog` and not a hostname
`gitea-blog` is an SSH config alias defined in `/home/atheos/.ssh/config`:
```
Host gitea-blog
HostName 192.168.178.155 # LAN address
User gitea # <-- NOT `git`! see the trap below
IdentityFile ~/.ssh/id_ed25519_blog
IdentitiesOnly yes
Port 22
```
> ### ⚠️ TRAP: the SSH user is `gitea`, not `git`
> Every GitHub/Gitea tutorial uses `git@host`. **This instance does not.** Its `app.ini` sets
> `SSH_USER = gitea`.
>
> If you use `User git`, sshd replies **`Invalid user git`** and rejects the connection
> **before it ever reads `authorized_keys`** — so the error looks exactly like a rejected key
> and sends you hunting for a problem that isn't there. Confirmed in the sshd log on LXC 109:
> ```
> Invalid user git from 192.168.178.57 port 39780
> Connection closed by invalid user git 192.168.178.57 [preauth]
> ```
> Correct user → `Hi there! You've successfully authenticated with the deploy key named atheos-blog-lxc`.
Two reasons for the alias:
1. **`IdentitiesOnly yes`** means no other key is ever offered — a stray `git push` cannot
authenticate with a different (possibly broader) identity.
2. **It's the LAN address because that's the only path that works.** Verified 2026-09-29:
| Endpoint | Result |
|---|---|
| `git.aridgwayweb.com:22` | **unreachable** — the public path does not forward :22 (only :443 via NPM) |
| `192.168.178.155:22` | **OPEN** — Gitea's `app.ini` sets `SSH_DOMAIN = 192.168.178.155`, `SSH_PORT = 22`, `START_SSH_SERVER = false` |
So Gitea serves SSH from the LXC's own `sshd` and advertises the LAN address. Since this guest is
LAN-only, LAN transport is also the correct choice — no internet round-trip.
---
## STEP 4 — complete the Atheos installer
Open **http://192.168.178.57/** in a browser. The Atheos first-run installer appears and creates
the **admin user** — this is yours to own; the agent seeded no accounts.
Verify afterwards:
- you can open the **Workspace** and see a `blog` project
- opening `src/content/*.md` shows your posts
- saving persists (the files are at `/var/www/atheos/workspace/blog`)
### Atheos git settings
Open Atheos → (user menu) → **Settings** to confirm the git integration. The `codegit` component
handles commit/push. If you see *"detected dubious ownership"*, the `safe.directory` entries are
missing — restore them with:
```sh
su -s /bin/sh atheos -c 'git config --global --add safe.directory /var/www/atheos/workspace'
su -s /bin/sh atheos -c 'git config --global --add safe.directory /var/www/atheos/workspace/blog'
```
---
## Day-to-day operations
### Edit and commit
Do everything in the browser at `http://192.168.178.57/`. Commit via Atheos's git panel, or by hand:
```sh
ssh root@192.168.178.57
su -s /bin/sh atheos
cd /var/www/atheos/workspace/blog
git status
git add -A && git commit -m "post: <what changed>"
git push
```
### The build is NOT here
This box **only writes markdown**. Building/publishing the Pelican site happens in the existing
pipeline elsewhere. There is deliberately **no Python, no pelican, no `make`** on this guest.
### Services (OpenRC — not systemd)
```sh
rc-service nginx status
rc-service php-fpm83 status
rc-update show | grep -E 'nginx|php-fpm83' # both must be in 'default'
```
### Logs
```sh
tail -f /var/log/nginx/atheos.error.log # PHP fatals surface here
tail -f /var/log/nginx/atheos.access.log
```
### Reboot / recovery
```sh
pct reboot 136 # from scotty, or
pct stop 136 && pct start 136
```
Services are enabled at the OpenRC `default` runlevel, so they return automatically
(verified — see `aidlc-docs` acceptance criteria AC-6).
### Rebuild from scratch
The whole guest is reproducible:
```sh
# on scotty (192.168.178.69), as root
pct destroy 136
sh scripts/provision.sh # from this repo, on the node
```
Nothing unique lives on the guest: the blog's source of truth is Gitea. Only **uncommitted edits**
would be lost — commit often.
### Update Atheos (and re-pin)
Atheos has no releases, so this install is pinned to a commit. To move to the latest upstream commit,
install it, and re-pin:
```sh
sh scripts/update-atheos.sh # on the guest; preserves workspace/, backs up, verifies, re-pins
```
---
## Security model (summary — full detail in `docs/security-notes.md`)
- **Git auth = a per-repository deploy key**, scoped to `armistace/blog`, write-enabled.
Not a PAT (whose `write:repository` scope reaches **every** repo the account can write).
- Private key: `/home/atheos/.ssh/id_ed25519_blog`, mode `600`, owned by the php-fpm user.
- The web app and the key share one unprivileged user, so a compromised Atheos **can** write `blog`.
That is the accepted floor — one repo, and (with branch protection) one branch.
- **LAN-only.** If this is ever exposed: keep branch protection on `blog`'s `master`, terminate TLS
at NPM, and put authentication *in front* of Atheos. See `docs/security-notes.md` §2 (S-1…S-14).
### Container resource reporting (a real trap — cost debugging time)
`uptime` and `free` on Alpine are **busybox** applets. Busybox reads via the **`sysinfo()` syscall**,
which lxcfs **cannot intercept** — so inside this container they report the **PVE host**, not the
container. A container that restarted seconds ago will cheerfully claim *"up 2 days"*.
lxcfs **is** working correctly here — the virtualised files are right:
```
/proc/meminfo → MemTotal: 1048576 kB ← our 1024 MB limit
/proc/uptime → 105 s ← container
/proc/cpuinfo → 2 processors ← container
```
The container is correct; **the tool is wrong**. Fix = `apk add procps-ng`, whose `/usr/bin/free`
and `/usr/bin/uptime` read those virtualised `/proc` files:
| Tool | Reports | |
|---|---|---|
| `procps-ng` `free` | `1024 MB` | ✅ correct |
| busybox `free` | `15701 MB` | ❌ host |
| `procps-ng` `uptime` | `up 2 min` | ✅ correct |
| busybox `uptime` | `up 2 days` | ❌ host |
> **Note:** `apk add lxcfs` inside the guest is **not** a fix — lxcfs is a host-provided FUSE
> filesystem, not a guest package. `provision.sh` installs `procps-ng` for this reason.
> (`df` is unaffected: it stats the filesystem directly, so it was always accurate.)
---
## Reaching the guest when SSH from your laptop fails
The guest has **root SSH enabled** with your fleet keys installed:
- `ssh-rsa ... aridgway@strategenics-25`
- `ssh-ed25519 ... oct2025`
If those keys aren't what you're using, add/push a key via the PVE node (which always works):
```sh
ssh root@192.168.178.69 "pct exec 136 -- sh -c 'echo \"<your-pubkey>\" >> /root/.ssh/authorized_keys'"
```
---
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| **502 Bad Gateway** | nginx `fastcgi_pass` and the FPM pool `listen` disagree | `grep -E '^listen' /etc/php83/php-fpm.d/www.conf` and compare with the `fastcgi_pass` line in `/etc/nginx/http.d/atheos.conf`. Both must read `/run/php-fpm83/php-fpm.sock`. Then restart both services. |
| **"detected dubious ownership"** | `safe.directory` missing or the repo is owned by a different user | Re-add the two `safe.directory` entries (STEP 4) |
| **git commit refuses: "please tell me who you are"** | identity not set — **expected on a fresh build** | STEP 1 |
| **git push: Permission denied (publickey)** | deploy key not installed, **or the SSH user is wrong**, or "Allow write access" not ticked | Check all three. Especially: `grep User /home/atheos/.ssh/config` must say **`gitea`**, not `git` (see STEP 3 trap). On the Gitea host, confirm with `journalctl -u ssh \| tail` — `Invalid user git` means the username is wrong, not the key. |
| **push times out** | remote still pointing at `git.aridgwayweb.com` (:22 not forwarded) | STEP 3 — use the `gitea-blog` alias |
| **Atheos shows a blank page / 500** | PHP fatal | `tail -50 /var/log/nginx/atheos.error.log` |
| **Atheos installer says a path isn't writable** | ownership broken | `chown -R atheos:atheos /var/www/atheos` |
| **`free` / `uptime` show the PVE host's memory or days of uptime** | on Alpine these are **busybox** applets using the `sysinfo()` **syscall**, which lxcfs cannot intercept | `apk add procps-ng` — its `/usr/bin/free` and `/usr/bin/uptime` read the lxcfs-virtualised `/proc` files. Already installed by `provision.sh`; this bites if you rebuild by hand. |
| **PHP session errors** | rootfs full | `df -h /` (the rootfs is 20 GB and the app is tiny, so this indicates something else) |
---
## Change log
| Date | Change |
|---|---|
| 2026-09-29 | Initial build: LXC 136 on scotty, Alpine 3.23.2, nginx + php83-fpm (socket), Atheos pinned at `f90cd18` (build 611), `armistace/blog` cloned over HTTPS, per-repo deploy key generated. LAN-only. |
| 2026-09-29 | Added the **writer-branch workflow**: 6 macros installed into Atheos (start/join, commit+push, destructive reset-to-master, status, refresh master, merge master) writing to a long-lived `atheos-writer` branch; merging stays in Gitea. See `docs/writer-branch-workflow.md`. |
## Writer-branch workflow (quick reference)
The editor writes to `atheos-writer`; you merge to `master` in Gitea.
**Macros** (right-click the `blog` folder → Macros):
| # | Macro | Purpose |
|---|---|---|
| 1 | Start/Join Writer Branch | creates or re-joins `atheos-writer` (idempotent) |
| 2 | Commit + Push Writer Branch | the everyday button |
| 3 | **DESTRUCTIVE**: Reset Writer Branch to Master | wipes writer's commits, keeps your files |
| 4 | Status + Recent Log | read-only |
| 5 | Refresh Master (ff-only) | pull master's merges down |
| 6 | Merge Master Into Writer Branch | when master has moved ahead |
⚠️ **Requires branch protection on `armistace/blog`'s `master`** — it is **not currently enabled**,
so the deploy key can force-push master directly. See `docs/writer-branch-workflow.md` §Setup.
⚠️ **Macro 2 uses `git add -A`** — everything left in the repo gets published to Gitea. Keep scratch
files outside `workspace/blog/`.
Full detail, recovery steps and caveats: [`docs/writer-branch-workflow.md`](docs/writer-branch-workflow.md).
+64
View File
@@ -0,0 +1,64 @@
# AI-DLC State Tracking
## Project Information
- **Project**: atheos-blog-lxc
- **Start Date**: 2026-09-29T05:05:05Z
- **Current Stage**: INCEPTION — Workspace Detection (complete) → Requirements Analysis
- **Deploy Target**: Proxmox LXC (Alpine 3.23) — NOT the K3s cluster. Deploy-target flavour:
"self-hosted LXC", so `k3s-flavor.md` does NOT apply and no CI/registry pipeline is wired.
## Workspace State
- **Existing Code**: No (greenfield repo; the blog being edited is a separate existing repo)
- **Programming Languages**: none yet (deliverable is provisioning/docs; target app is PHP)
- **Build System**: none (Alpine + nginx/php-fpm provisioning; Makefile-driven install script)
- **Reverse Engineering Needed**: N/A for the repo; brownfield recon done on the *estate*
(blog repo, Gitea, PVE templates) — see audit.md Workspace Detection entry.
- **Workspace Root**: /home/hermes/dev/atheos-blog-lxc
## Existing Estate Facts (load-bearing — verify before trusting)
- **Atheos** (Atheos/Atheos, 677★, MIT, active): PHP web IDE, **no database**. Install = drop
files in a web root + make `config.php`, `data/`, `workspace/`, `plugins/`, `themes/` writable.
Optional PHP extensions for the plugin marketplace: **ZIP, OPENSSL, MBSTRING**, and
`allow_url_fopen=On`. Has git integration via its **codegit** component.
- **blog repo** = `armistace/blog` (public, default branch `master`). It is a **Pelican static
site**, not plain content:
- `src/content/*.md` (~20 posts) + `src/content/images/`
- `src/themes/cleanblog/`
- `src/pelicanconf.py`, `src/devpelconf.py`, `src/publishconf.py`, `src/Makefile`, `src/tasks.py`
- `requirements.txt`: `pelican[markdown]`, `markdown-markup-emoji`
- Root: `Dockerfile` (python:slim → `make html` → `make serve-global`), `kube/`, `.gitea/workflows/build_push.yml`
- **Implication**: to *regenerate/publish* the site you need Python+pelican (`make html`, `make github`).
Atheos alone edits the markdown + git and cannot build it.
- **PVE**: template `alpine-3.23-default_20260116_amd64.tar.xz` available (must download first).
Node `janeway` (.225) = 15.9 GB RAM (3.3 GB free), storage `extra` 874 GB free.
Node `data` (.42) = 7.9 GB RAM (only ~0.95 GB free) — avoid heavy guests.
- **Gitea**: LXC 109 on data (.42), guest .155, **1.27.3** (post-CVE-2026-60004 remediation).
`.151` = LXC 110 **nginxpm (NPM)** which fronts it; runners are on k3s nodes .161/.162.
- **Access**: `root@<pve-node>` + `pct exec`. Gitea token in `~/.hermes/credentials/gitea-api.env`.
## Code Location Rules
- **Application Code**: Workspace root (NEVER in aidlc-docs/)
- **Documentation**: aidlc-docs/ only
- **Structure patterns**: See code-generation.md Critical Rules
## Extension Configuration
| Extension | Enabled | Decided At |
|---|---|---|
| security-baseline | ❌ No (opt-out; TODOs documented in docs/security-notes.md) | Requirements Analysis |
| resiliency-baseline | ❌ No (opt-out; TODOs documented in docs/resiliency-notes.md) | Requirements Analysis |
| property-based-testing | ❌ No | Requirements Analysis |
## Stage Progress
### 🔵 INCEPTION PHASE
- [x] Workspace Detection
- [ ] Reverse Engineering (brownfield only — N/A; estate recon done inline)
- [x] Requirements Analysis
- [x] User Stories (SKIPPED — user-confirmed; substituted with a requirement→unit map)
- [x] Workflow Planning
- [x] Application Design (minimal depth — 5 design artifacts; D6 deploy-key model)
- [x] Units Generation (5 units; requirement→unit map substitutes the story map)
### 🟢 CONSTRUCTION PHASE
- [ ] Per-Unit Loop
- [ ] Build and Test
### 🟡 OPERATIONS PHASE
- [ ] Operations (placeholder)
+132
View File
@@ -0,0 +1,132 @@
# AI-DLC Audit Log
> APPEND-ONLY. Every user input captured verbatim, never summarized or paraphrased,
> with ISO-8601 timestamps. NEVER overwrite this file — always append new sections.
## Workspace Detection
**Timestamp**: 2026-09-29T05:05:05Z
**User Input**: "ok back to the plan. We need to engage AIDLC I want an alpine lxc that
1. install atheos (nginx php etc)
2. sets up git integrations (if required)
3. downloads https://git.aridgwayweb.com/armistace/blog ready for me to use
4. I will set upt he armistace ssh keys myself
5. is backed by a 20gb root (tiny its basically going to be used to write blog)"
**AI Response**: "Workspace Detection executed. New project (no existing aidlc-docs/).
Created workspace scaffold at ~/dev/atheos-blog-lxc. Conducted pre-kickoff reconnaissance:
(A) blog repo armistace/blog is a PELICAN STATIC SITE (Python), not plain markdown — it has
src/{content,pelicanconf.py,devpelconf.py,publishconf.py,Makefile,tasks.py,themes/cleanblog},
builds with `make html` and publishes with `make github`/publishconf.py. Atheos alone CANNOT
build/regenerate it; it edits the .md sources + git only.
(B) Atheos requires: PHP + web server (nginx+php-fpm), NO database; writable config.php, data,
workspace, plugins, themes; optional extensions ZIP, OPENSSL, MBSTRING (needed for plugin
marketplace); optionally allow_url_fopen=On. Ships git integration (codegit component).
(C) Alpine 3.23 (alpine-3.23-default_20260116_amd64.tar.xz) is available as a PVE template
but NOT yet downloaded on any node.
(D) CORRECTION to the 2026-09-29 Gitea incident record: 192.168.178.151 is LXC 110 (nginxpm /
NPM), NOT the Gitea Actions runner. The .151 traffic observed against Gitea was NPM proxying
Gitea's own web traffic; the runners run on the k3s nodes (k3s-3 .161, k3s-2 .162). Memory
corrected accordingly.
(E) git.aridgwayweb.com resolves to a PUBLIC IP (115.70.15.16) — Gitea is internet-facing via NPM."
**Context**: INCEPTION — Workspace Detection
## Requirements Analysis
**Timestamp**: 2026-09-29T05:05:05Z
**User Input**: (none yet — awaiting answers)
**AI Response**: "Created aidlc-docs scaffold (aidlc-state.md, audit.md, plans/, requirements/,
story-artifacts/, design-artifacts/, incidents/). Generated
aidlc-docs/requirements/requirement-verification-questions.md with 5 questions + stated
assumptions. Key gate: Q1 asks whether the box must also BUILD the Pelican site (Python) or is
an editor only (Atheos/PHP), because the blog repo is a Pelican static site, not plain markdown.
Presented the questions inline in Matrix (Andrew reviews in-chat, not in .md files)."
**Context**: INCEPTION — Requirements Analysis (Step 6 gate: awaiting user answers)
## Requirements Analysis — Gate Answers
**Timestamp**: 2026-09-29T05:10:00Z
**User Input** (verbatim):
"1. A - It doesn't need to build the pipeline is well known just writing markdown is more than enough
2. I'll supply them, give me the ip address and I'll ssh in and set up my credentials in gitea, you can't do this and I want it under armistace
3. scotty
4. LAN only
5. A
6. clone it but leave instructions to get it over to ssh once I add the key"
**AI Response**: "Answers recorded: Q1=A (editor only; Pelican build explicitly out of scope — the
existing pipeline handles it, 'just writing markdown is more than enough'); Q2=Andrew supplies
credentials himself over SSH, under the `armistace` identity (agent must NOT set an armistace git
identity or credentials); Q3=PVE node **scotty** (.69) — verified: 15.7 GB RAM (12.1 GB available),
8 GB swap, scotty-local-lvm thin pool 771 GB free, in-use VMIDs 104-107/119/125/126/133-135 so
**136 is free**; Alpine template not yet downloaded on scotty; Q4=LAN only (no NPM/DNS/forward);
Q5=A (opt out of security+resiliency, document the TODOs); Q6=clone the repo but leave RUNBOOK
instructions for switching the remote to SSH once the key is added."
**Context**: INCEPTION — Requirements Analysis (Step 7: generating requirements.md)
## Requirements Analysis — Approval
**Timestamp**: 2026-09-29T05:12:00Z
**User Input** (verbatim): "no I'm good to skip lets do it"
**AI Response**: "Requirements APPROVED. User Stories stage explicitly SKIPPED (user confirmed).
Per the AI-DLC rule for skipped stories, a **requirement→unit map** will be substituted for
unit-of-work-story-map.md. Proceeding to Workflow Planning."
**Context**: INCEPTION — Requirements Analysis complete → Workflow Planning
## Workflow Planning — Approval
**Timestamp**: 2026-09-29T05:15:00Z
**AI Prompt**: "Approve this execution plan? 6 stages to execute (Application Design, Units Generation, Infrastructure Design, Code Generation, Build and Test), 5 to skip (Reverse Engineering, User Stories, Functional Design, NFR Requirements, NFR Design)."
**User Response** (verbatim): "continue looks good"
**Status**: APPROVED
**Context**: INCEPTION — Workflow Planning complete → Application Design
## Application Design — Gate Answers
**Timestamp**: 2026-09-29T05:18:00Z
**User Input** (verbatim):
"1. B jI'll set it in there, remind me in the runbbok
2. good idea for now, but leave an update script that will reclone at latest and pin
3. looks good to me"
**AI Response**: "Answers recorded. Q1=B — the agent configures **NO git identity** (user.name/
user.email left unset); the RUNBOOK must explicitly prompt the operator to set them as their first
action after SSH. Q2 — pin Atheos to a resolved commit SHA **and** additionally ship an
**update/re-pin script** that re-clones Atheos at latest and re-pins the SHA. Q3 — layout accepted
(web root /var/www/atheos, workspace /var/www/atheos/workspace, nginx :80, php-fpm unix socket).
Generated the five application-design artifacts (components.md, component-methods.md, services.md,
component-dependency.md, application-design.md) with design decisions D1-D5 recorded."
**Context**: INCEPTION — Application Design complete (awaiting approval)
## Application Design — Security Model Decision (D6)
**Timestamp**: 2026-09-29T05:22:00Z
**User Input** (verbatim): "would it be safer to have an atheos user or use a application key? lets get this security model for the key right incase I want to expose to the wider web"
**AI Response**: "Researched against official sources + the live 1.27.3 instance rather than opining.
Verified: (a) Gitea PAT scopes are ROUTE GROUPS (e.g. write:repository) matched against the USER's
access, NOT per-repository — so a stolen armistace PAT reaches every repo the account can write;
the instance's DB shows armistace holding 8 tokens, one with scope `all`. (b) Deploy keys are
genuinely per-repository: swagger confirms /repos/{owner}/{repo}/keys (get,post,delete) and the
DeployKey schema carries read_only. (c) CVE-2026-28744 (fixed 1.26.2) confirms the scope mechanism
is real — 'repository-scoped access-token permissions on repository operations'.
RECOMMENDED and user ACCEPTED a per-repo deploy key over both a PAT and a dedicated Gitea user.
Rationale: a dedicated user still owns/needs access to `blog`, so it reduces nothing while adding a
credential and muddying attribution; the deploy key bounds blast radius to ONE repo natively.
Recorded as design decision **D6**. Wrote docs/security-notes.md (S-1..S-14 for wider-web exposure)
and docs/resiliency-notes.md (R-1..R-8). Agent confirmed it CANNOT add the deploy key itself (API
returns 'user should be an owner or a collaborator with admin write') — operator will add the public
half in the repo."
**Context**: INCEPTION — Application Design (security model finalised)
## Application Design — Approval
**Timestamp**: 2026-09-29T05:23:00Z
**User Input** (verbatim): "I like the model lets go with that. When you give me the public key (in here) I'll get it in the repo"
**AI Response**: "Design APPROVED including D6. Proceeding to Units Generation, then CONSTRUCTION."
**Context**: INCEPTION — Application Design complete → Units Generation
## Units Generation — Approval
**Timestamp**: 2026-09-29T05:26:00Z
**AI Prompt**: "Approve these 5 units and the U4 two-phase approach (clone over HTTPS now, deploy key generated + public half handed to operator, SSH switch as Phase 2 once the key is installed)?"
**User Response** (verbatim): "phase 2 is all you I'll give you the go ahead and you can switch it over. lets get building"
**Status**: APPROVED — units approved; U4 two-phase approved; **Phase 2 (SSH remote switch) is agent-owned, to be executed once the operator signals the deploy key is installed**. Construction to run AUTONOMOUSLY per the user's standing preference (no per-unit gates; stop only for hard blockers).
**Context**: INCEPTION complete → CONSTRUCTION PHASE (Infrastructure Design → Code Generation → Build and Test)
## OPERATIONAL NOTE — pre-existing Gitea CVE remediation (executed 2026-09-29, pre-construction)
**Timestamp**: 2026-09-29T05:26:00Z
**AI Response**: "Recorded for trail integrity: earlier in this same session (before this AI-DLC workflow
was engaged) the Gitea instance was found compromised via CVE-2026-60004 and remediated + upgraded
1.23.8 → 1.27.3. This deviates from the stage order — it happened before Inception. It is NOT an
output of any stage of this workflow. Full record: spine partition hermes-ops, source_ref
`gitea-cve-2026-60004-incident`. Also corrected a factual error in that record during this session:
192.168.178.151 is LXC 110 (nginxpm), NOT the Actions runner (user correction: '161 is the runner')."
**Context**: OUT-OF-STAGE — recorded for audit honesty
@@ -0,0 +1,76 @@
# Application Design (consolidated) — atheos-blog-lxc
**Stage**: INCEPTION — Application Design (minimal depth) · **Date**: 2026-09-29
**Deploy target**: Proxmox LXC `136` on node `scotty` (.69) — Alpine 3.23 · LAN-only
---
## 1. What this system is
A small, single-purpose guest whose only job is to put a **browser editor** in front of the
`armistace/blog` working copy. It is deliberately **not** a build environment.
```
┌───────────────────────── LXC 136 (scotty) ─────────────────────────┐
Browser (LAN) │ │
http://<ip>/ ────┼──► nginx :80 ──unix socket──► php-fpm (php83) ──► Atheos (PHP) │
│ │ │
│ ├──► workspace/blog/** (rw) │
│ └──► git CLI ──► local commit│
└────────────────────────────────────────────────────────────────────┘
│
(operator, later) SSH identity + SSH remote
▼
Gitea git.aridgwayweb.com
```
## 2. Components
| ID | Component | Role |
|---|---|---|
| C1 | LXC `136` on scotty, Alpine 3.23 | isolated runtime, OpenRC, 20 GB root, onboot |
| C2 | nginx :80 | HTTP front end; sends `*.php` to C3 |
| C3 | php-fpm `php83` | PHP runtime; ZIP/OPENSSL/MBSTRING; `allow_url_fopen=On` |
| C4 | Atheos (pinned SHA) | the editor; git via its `codegit` component |
| C5 | `/var/www/atheos/workspace` | project storage (writable) |
| C6 | git CLI | VCS; `safe.directory` set; **no identity** (operator-owned) |
| C7 | `blog` working copy | clone of `armistace/blog` on `master` |
| C8 | Gitea (external) | clone source; agent never pushes |
| C9 | Operator (external) | owns SSH key, git identity, Atheos admin, remote switch |
## 3. Key interfaces
1. **nginx → php-fpm**: `fastcgi_pass unix:<FPM_SOCKET>` must equal the pool's `listen =`. Mismatch ⇒ **502**.
2. **Atheos → filesystem**: writable `config.php`, `data/`, `workspace/`, `plugins/`, `themes/`. Failure ⇒ installer dependency error.
3. **Atheos → git**: needs `git` on PATH **and** `safe.directory` for the workspace (else *dubious ownership*). Identity intentionally unset.
4. **git → Gitea**: HTTPS clone (public); operator later switches to SSH.
5. **OpenRC**: `nginx` + `php-fpm83` enabled at `default` runlevel — otherwise first reboot breaks service (AC-6).
## 4. Design decisions (from the gate)
| # | Decision | Source |
|---|---|---|
| D1 | **No git identity configured by the agent.** `user.name`/`user.email` left unset; RUNBOOK instructs the operator to set them first. | Q1=B |
| D2 | **Atheos pinned to a resolved commit SHA**, with an additional **update script** that re-clones at latest and re-pins. | Q2 |
| D3 | Web root `/var/www/atheos`; workspace `/var/www/atheos/workspace`; nginx :80; php-fpm via unix socket. | Q3 |
| D4 | Atheos served at the **site root**. | assumed, accepted |
| D5 | Operator completes Atheos's first-run installer; **agent seeds no users**. | FR-11 / Q2 of requirements |
| **D6** | **Credential = a per-repository Gitea deploy key scoped to `armistace/blog`, write-enabled** (`read_only:false`), owned by the php-fpm user, aliased via `~/.ssh/config` with `IdentitiesOnly yes`. **Not** a PAT (account-wide scopes) and **not** a dedicated Gitea user (no blast-radius reduction for `blog`). Private key generated on the guest; public half handed to the operator to add in-repo. | Andrew, 2026-09-29 (security review) |
## 5. Scope guards (each is a testable negative)
- No database installed
- **No Python / pelican / `make`** — the presence of Python is a defect (AC-8)
- No NPM proxy host / DNS / port-forward (FR-10)
- No `armistace` credential, SSH key, or git identity created by the agent (FR-11)
## 6. Design-level risks handed to Infrastructure Design
- **C-1** Atheos on Alpine is under-documented → resolve the FPM socket and extension package names from the *installed* system, not from upstream docs.
- **C-2** OpenRC, not systemd → all service work uses `rc-service` / `rc-update`.
- **C-3** Alpine template must be downloaded on scotty first (`pveam update` + `download`).
- **C-4** The installer needs a writable web root before it will run.
## 7. Unit mapping
U1 guest (C1) · U2 web stack (C2,C3) · U3 Atheos (C4,C5) · U4 git + clone (C6,C7) · U5 docs + script (all)
@@ -0,0 +1,49 @@
# Component Dependencies — atheos-blog-lxc
**Stage**: INCEPTION — Application Design (minimal depth)
## Dependency matrix
Rows *depend on* columns. `→` = depends on, `—` = none.
| | C1 LXC | C2 nginx | C3 php-fpm | C4 Atheos | C5 workspace | C6 git | C7 blog |
|---|---|---|---|---|---|---|---|
| **C1 LXC** | — | — | — | — | — | — | — |
| **C2 nginx** | ✔ | — | ✔ (I-1) | — | — | — | — |
| **C3 php-fpm** | ✔ | — | — | — | ✔ (I-2) | — | — |
| **C4 Atheos** | — | — | ✔ | — | ✔ | ✔ (I-3) | — |
| **C5 workspace** | — | — | — | — | — | — | ✔ |
| **C6 git** | ✔ | — | — | — | — | — | — |
| **C7 blog** | — | — | — | — | ✔ | — | — |
**Reading it**: nothing works before C1; nginx is useless without php-fpm; Atheos depends on three
things simultaneously; the clone is a leaf.
## Build / provisioning order (topological)
```
C1 LXC
├─► C2 nginx ──► (needs) C3 php-fpm ──► C4 Atheos ──► C5 workspace ──► C7 blog (clone)
└─► C6 git ──────────────────────────────────────────────┘
```
- **Critical path**: C1 → C2/C3 → C4 → C5 → C7
- **Parallelisable**: nginx install and git install are independent
## External dependencies (version-pinned at build time)
| Dependency | Source | Pin strategy |
|---|---|---|
| Alpine rootfs | PVE template `alpine-3.23-default_20260116_amd64.tar.xz` | template filename is versioned |
| `nginx`, `php83*`, `git` | Alpine `main`/`community` repos | record installed versions in the RUNBOOK at build time |
| **Atheos** | `github.com/Atheos/Atheos` (master, no releases) | **pin to a resolved commit SHA** (design Q2); record it |
| `armistace/blog` | Gitea, branch `master` | clone `master`; note the commit for the record |
## Coupling risks (where the design is tight)
| Coupling | Why it's tight | Mitigation |
|---|---|---|
| nginx ↔ php-fpm socket path | a string must match exactly; failure is a 502 with no other signal | resolve from the installed pool config, then assert both sides match |
| FPM user ↔ workspace ownership | a UID mismatch yields "dubious ownership" or unwritable dirs | set ownership once from the actual FPM user, verify by writing a file |
| template filename ↔ Alpine version | a wrong/renamed template fails `pct create` | confirm the exact filename with `pveam available` before creating |
| Atheos master ↔ PHP 8.3 | upstream targets older PHP; 8.3 may warn | smoke-test the installer page and check the error log for PHP fatals |
@@ -0,0 +1,65 @@
# Component Methods / Interfaces — atheos-blog-lxc
**Stage**: INCEPTION — Application Design (minimal depth)
**Note**: No business logic exists to specify. These are the **contracts between components** —
the things that must match or the stack silently fails (the classic 502 / permission-denied class).
## I-1 · nginx → php-fpm (the interface most likely to break)
```
nginx server block:
listen 80;
root /var/www/atheos;
index index.php index.html;
location = /index.php { fastcgi_pass unix:<FPM_SOCKET>; ... }
location ~ \.php$ { fastcgi_pass unix:<FPM_SOCKET>; ... }
```
**Contract**: `<FPM_SOCKET>` in `nginx.conf` **must equal** the `listen =` path in the php-fpm pool.
Alpine's `php83-fpm` default differs between packages — resolve it from the installed
`/etc/php83/php-fpm.d/www.conf` rather than assuming.
- **Input**: HTTP request; **Output**: `text/html` from the PHP process
- **Failure mode if mismatched**: `502 Bad Gateway` (socket missing) — AC-2 fails
## I-2 · Atheos → filesystem (the write contract)
Atheos's installer requires **write access** to exactly these paths, relative to `/var/www/atheos`:
| Path | Why | Mode |
|---|---|---|
| `config.php` | installer writes the config; file must exist | writable by FPM user |
| `data/` | user accounts, settings | writable |
| `workspace/` | projects (incl. the `blog` clone) | writable |
| `plugins/` | plugin install/marketplace | writable |
| `themes/` | theme install | writable |
- **Owner/group**: the php-fpm run user (resolved from the pool config)
- **Failure mode**: installer shows a dependency error; AC-3 fails
## I-3 · Atheos (`codegit`) → git CLI
- **Requires**: `git` on `PATH`
- **Requires**: `safe.directory` for the workspace — otherwise git refuses with
`detected dubious ownership` because the FPM user differs from the filesystem owner
- **Identity**: `user.name` / `user.email` are **deliberately UNSET** (design Q1=B).
The operator sets them first thing after SSH (see RUNBOOK).
- **Failure mode**: "dubious ownership" errors, or commit refused for missing identity
## I-4 · git CLI → Gitea
- **Remote (initial)**: `https://git.aridgwayweb.com/armistace/blog.git` (public ⇒ no credential)
- **Remote (operator, later)**: `git@git.aridgwayweb.com:armistace/blog.git` — the RUNBOOK gives the
exact `git remote set-url` command for after the key is added
- **Contract**: the agent must **not** alter the remote to SSH itself (FR-6/FR-11)
## I-5 · OpenRC service contract (not systemd)
| Service | Enable | Purpose |
|---|---|---|
| `nginx` | `rc-update add nginx default` | HTTP |
| `php-fpm83` | `rc-update add php-fpm83 default` | FastCGI (verify exact rc name on 3.23) |
- **Failure mode if not enabled**: works until first reboot, then AC-6 fails. This is why NFR-2 is
verified with an actual reboot rather than a service-status check.
+33
View File
@@ -0,0 +1,33 @@
# Components — atheos-blog-lxc
**Stage**: INCEPTION — Application Design (minimal depth)
**Note**: This project has no application code to design. "Components" here are the **runtime
pieces of the provisioned guest and their interfaces**, recorded so unit boundaries are defensible
and a later agent inherits the map.
## Provisioned by the agent
| ID | Component | Purpose | Responsibilities |
|---|---|---|---|
| **C1** | **LXC guest** (`136` on scotty, Alpine 3.23) | The isolated runtime | OpenRC init, network (`eth0`, DHCP/static), 20 GB rootfs, timezone |
| **C2** | **nginx** | HTTP front end | Listen `:80`; serve static assets; dispatch `*.php` to C3 over a unix socket |
| **C3** | **php-fpm (`php83`)** | PHP FastCGI runtime | Execute Atheos PHP as a dedicated unprivileged user; extensions ZIP/OPENSSL/MBSTRING; `allow_url_fopen=On` |
| **C4** | **Atheos** (upstream PHP app, pinned SHA) | The browser editor | File tree, editor, and git operations via its `codegit` component |
| **C5** | **Atheos workspace** (`/var/www/atheos/workspace`) | Project storage | Holds working copies Atheos opens as projects; must be writable by C3 |
| **C6** | **git CLI** | VCS client | Invoked by C4's `codegit`; needs `safe.directory`, has **no configured identity** (operator sets it) |
| **C7** | **`blog` working copy** | The thing the operator edits | A clone of `armistace/blog` at `C5/blog`, on `master` |
## External / not provisioned by the agent
| ID | Component | Relationship |
|---|---|---|
| **C8** | **Gitea** (`git.aridgwayweb.com` → LXC 109, .155) | Clone source. **Read-only** from this project's perspective — the agent never pushes |
| **C9** | **Operator (human)** | SSHes in; **owns**: SSH key, git identity, Atheos admin account, and switching the remote to SSH |
## Explicit non-components (scope guards)
- **No database.** Atheos is file-backed; nothing is installed for it.
- **No Python / pelican / `make html`.** The build pipeline is out of scope (FR-1 / AC-8). The
presence of Python would be a **defect**, not a bonus.
- **No NPM proxy host, no DNS, no port-forward** (LAN-only, FR-10).
- **No Atheos user seeded by the agent** — C9 creates the first account via the installer.
@@ -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 |
@@ -0,0 +1,61 @@
# Requirement → Unit Map — atheos-blog-lxc
**Stage**: INCEPTION — Units Generation (Part 2)
> **Deliberate substitution.** The AI-DLC rules mandate `unit-of-work-story-map.md`, which maps
> *user stories* to units. The **User Stories stage was skipped** (user-confirmed at the Requirements
> gate: "no I'm good to skip"). With no stories to map, this document maps **requirements and
> acceptance criteria** to units instead — the same assurance (nothing is silently dropped) applied
> to the artifacts that actually exist.
## Functional requirements
| FR | Requirement (short) | Unit |
|---|---|---|
| FR-1 | Alpine 3.23 LXC on scotty, 20 GB root, 2 vCPU/1 GB, onboot, unprivileged | **U1** |
| FR-2 | nginx + PHP-FPM serving Atheos; ZIP/OPENSSL/MBSTRING; `allow_url_fopen=On` | **U2** |
| FR-3 | Atheos installed, pinned, 5 writable paths, no DB | **U3** |
| FR-4 | git integration configured (git installed, `safe.directory`, identity handling) | **U4** |
| FR-5 | `armistace/blog` cloned into `workspace/` on `master` | **U4** |
| FR-6 | Remote reachable; instructions to switch to SSH; agent doesn't switch it | **U4** (build) + **U5** (document) |
| FR-7 | RUNBOOK.md with IP, SSH, workspace path, remote-switch steps, credential setup | **U5** |
| FR-8 | README.md | **U5** |
| FR-9 | Repeatable provisioning script in the repo | **U5** |
| FR-10 | LAN-only; no NPM/DNS/forward | **U2** (bind + no proxy config) |
| FR-11 | Agent creates no `armistace` credentials or git identity | **U4** (verification asserts identity is empty) |
| FR-12 | `docs/security-notes.md` + `docs/resiliency-notes.md` | **U5** |
## Non-functional requirements
| NFR | Requirement (short) | Unit |
|---|---|---|
| NFR-1 | Fits comfortably in 20 GB | **U1** (sizing) |
| NFR-2 | Survives reboot with clone + edits intact | **U1** (onboot) + **U2** (services enabled) + **U3/U4** (persistence) → verified at Build & Test |
| NFR-3 | Repeatable / idempotent-friendly provisioning | **U5** |
| NFR-4 | No K3s dependency | all (negative) |
| NFR-5 | Timezone Australia/Brisbane | **U1** |
## Acceptance criteria
| AC | Short | Unit |
|---|---|---|
| AC-1 | `pct config 136` shows Alpine + 20 GB root + onboot | **U1** |
| AC-2 | `http://<ip>/` serves Atheos (not 502/404) | **U2**+**U3** |
| AC-3 | `blog` present as a project whose files open | **U3**+**U4** |
| AC-4 | Edit + save persists to disk | **U3**+**U4** |
| AC-5 | `git remote -v` points at Gitea; RUNBOOK gives the SSH switch | **U4**+**U5** |
| AC-6 | Reboot → Atheos serves again, clone + edits intact | Build & Test |
| AC-7 | RUNBOOK lets another agent re-provision/hand off | **U5** |
| AC-8 | **No Python/pelican present** (scope respected) | Build & Test |
## Coverage check
| | Count | Assigned to exactly one unit | Unassigned |
|---|---|---|---|
| Functional requirements | 12 | 12 ✔ | 0 |
| Non-functional requirements | 5 | 5 ✔ | 0 |
| Acceptance criteria | 8 | 8 ✔ | 0 |
**Every FR and NFR is assigned to exactly one unit.** ACs that require two units to build but one
moment to verify (AC-2, AC-3, AC-4) list both; AC-6 and AC-8 are whole-system properties verified
once at Build & Test rather than owned by a single unit — noted explicitly rather than left implicit.
+62
View File
@@ -0,0 +1,62 @@
# Services / Request Flow — atheos-blog-lxc
**Stage**: INCEPTION — Application Design (minimal depth)
There is no service layer to design (no application code). What matters is the **two flows** the
guest supports, and where each can break.
## Flow 1 — Operator edits and commits
```
Browser (LAN)
│ http://<lxc-ip>/
▼
nginx (C2, :80)
│ location ~ \.php$ → fastcgi_pass unix:<FPM_SOCKET> [I-1]
▼
php-fpm (C3, php83)
│ executes as the FPM user
▼
Atheos (C4, /var/www/atheos)
├── reads/writes workspace/blog/** [I-2 write contract]
└── on Commit: git (C6)
│ safe.directory must include workspace/ [I-3]
│ identity UNSET by design (Q1=B)
▼
commit created in the local clone
```
**Break points**: FPM socket mismatch (502) · unwritable `workspace/` (installer/editor errors) ·
dubious-ownership (git refuses) · missing identity (commit refused until operator sets it).
## Flow 2 — Operator pushes (operator-owned path)
```
Atheos Commit → operator adds SSH key + sets git identity → remote switched to SSH (operator)
│
▼
Gitea (C8) receives the push
```
The **agent builds the path but does not walk it**: it clones over HTTPS, leaves the remote as-is,
and documents the switch. Pushing is the operator's first hands-on act.
## Flow 3 — Build/publish (OUT OF SCOPE — happens elsewhere)
```
Gitea (C8) → existing pipeline (NOT on this guest) → make html → published site
```
Explicitly excluded (Q1=A). No Python, no pelican, no `make` on this guest. If this flow were
expected here, **C-1's risk profile would change materially** — it is not.
## Provisioning order (what must exist before what)
```
U1 guest → U2 nginx+php-fpm → U3 Atheos → U4 git + clone → U5 docs + script
▲
U4 also depends on U1 (git) ─────────────────┘
```
Each arrow is a real dependency: installing Atheos before php-fpm leaves nothing to serve it;
cloning before Atheos exists has no workspace to clone into.
@@ -0,0 +1,48 @@
# Unit of Work Dependency — atheos-blog-lxc
**Stage**: INCEPTION — Units Generation (Part 2)
## Dependency matrix
Rows *depend on* columns.
| | U1 Guest | U2 Web stack | U3 Atheos | U4 Git+clone | U5 Docs |
|---|---|---|---|---|---|
| **U1 Guest** | — | — | — | — | — |
| **U2 Web stack** | ✔ | — | — | — | — |
| **U3 Atheos** | ✔ | ✔ | — | — | — |
| **U4 Git+clone** | ✔ (git) | — | ✔ (workspace) | — | — |
| **U5 Docs** | ✔ | ✔ | ✔ | ✔ | — |
## Build order
```
U1 ──► U2 ──► U3 ──► U4 ──► U5
│ ▲ │
└─────────────────┴────────┘
(U4 needs git from U1)
```
**Critical path**: U1 → U2 → U3 → U4 → U5. Strictly sequential — no parallelism worth the
coordination cost at this size. Each unit already includes a verification step, so a failure is
caught at unit granularity rather than at the end.
## Why these boundaries
| Boundary | Reason |
|---|---|
| U1 before everything | nothing can be installed without the guest |
| U2 (nginx+php) is one unit, not two | the **socket contract (I-1)** spans both — splitting them would create a boundary through a single interface, which is exactly where integration bugs live |
| U3 after U2 | Atheos has nothing to run on until PHP exists |
| U4 separate from U3 | **different failure class**: U3 is PHP-runtime risk (C-1); U4 is VCS/permissions risk. Separating them keeps a U3 failure from disguising itself as "git is broken" |
| U4 before U5 | the RUNBOOK must document the real remote command, which only exists after the clone |
| U5 last | docs describe what was actually built |
## Cross-unit risks
| Risk | Units | Note |
|---|---|---|
| FPM user identity | U2 → U3 → U4 | U2 resolves the run user; U3 sets ownership; U4 owns the key. **All three must agree on the same user** — a mismatch yields "dubious ownership" (I-3) |
| FPM socket path | U2 (internal) | must match across nginx + pool |
| Pinned Atheos SHA | U3 → U5 | U5's update script must re-pin the same mechanism |
| Remote URL | U4 → U5 | U5 documents it; U4 must not change it to SSH |
@@ -0,0 +1,89 @@
# Unit of Work — atheos-blog-lxc
**Stage**: INCEPTION — Units Generation (Part 2: Generation)
**Date**: 2026-09-29
A unit = a logical grouping of work that ends in a **verifiable state**.
---
## U1 — Guest provisioning
| | |
|---|---|
| **Goal** | An Alpine 3.23 LXC exists, reachable, correctly sized, and boot-persistent |
| **Components** | C1 |
| **Produces** | VMID **136** on node `scotty`, 20 GB root, 2 vCPU, 1 GB RAM, unprivileged, `onboot=1`, TZ `Australia/Brisbane`, package index updated |
| **Preconditions** | Alpine 3.23 template downloaded on scotty (`pveam update` + `download`) |
| **Verification** | `pct config 136` shows Alpine + 20 GB root; the guest answers `ping`/`ssh`; date is Brisbane |
| **ACs** | AC-1 |
| **Notes** | Template download is part of this unit — it does not exist on scotty yet (risk C-3) |
## U2 — Web stack
| | |
|---|---|
| **Goal** | nginx serves PHP, and the two sides agree on the socket |
| **Components** | C2, C3 |
| **Produces** | `nginx`, `php83-fpm`, `php83-{session,zip,openssl,mbstring}` installed; pool + vhost configured; `allow_url_fopen=On`; **both services enabled at the OpenRC `default` runlevel** |
| **Preconditions** | U1 |
| **Verification** | A `phpinfo()`-style probe renders over HTTP (**not** 502/404); the nginx `fastcgi_pass` socket path **string-matches** the pool's `listen` (interface I-1); both services are in `rc-update show` |
| **ACs** | AC-2 |
| **Notes** | Resolve the actual FPM socket from the installed pool config, not from upstream docs (risk C-1). OpenRC idioms only — no systemd (risk C-2) |
## U3 — Atheos install
| | |
|---|---|
| **Goal** | Atheos runs and its installer page loads |
| **Components** | C4, C5 |
| **Produces** | Atheos at `/var/www/atheos` pinned to a resolved commit SHA; `config.php` present; `data/`, `workspace/`, `plugins/`, `themes/` writable by the FPM user |
| **Preconditions** | U2 |
| **Verification** | `http://<ip>/` returns the Atheos installer (HTTP 200, no PHP fatal in the error log); a write test inside `workspace/` succeeds as the FPM user |
| **ACs** | AC-2, AC-3 |
| **Notes** | **The genuinely uncertain unit** (risk C-1). If Atheos fights Alpine's PHP layout, stop and report rather than papering over it |
## U4 — Git + clone + deploy key
| | |
|---|---|
| **Goal** | The operator can open `blog`, edit it, and push from here once they add the key |
| **Components** | C6, C7 |
| **Produces** | `git` installed; `safe.directory` set for the workspace; clone of `armistace/blog` (branch `master`) at `workspace/blog`; **ed25519 deploy keypair generated on the guest** (private key owned by the FPM user, mode 600); `~/.ssh/config` host alias `gitea-blog` with `IdentitiesOnly yes`; `user.name`/`user.email` **deliberately left unset** |
| **Preconditions** | U1 (git), U3 (workspace exists) |
| **Verification** | `blog` appears as a project and its files open; `git -C workspace/blog remote -v` shows Gitea; the **public** key is emitted for the operator; `git config --get user.name` returns **empty** (proving D1) |
| **ACs** | AC-3, AC-4, AC-5 |
| **Notes** | Two phases by necessity: this unit leaves the remote on **HTTPS**; the SSH switch happens *after* the operator installs the deploy key (D6). The agent must not install it |
## U5 — Docs, scripts, handoff
| | |
|---|---|
| **Goal** | A different agent or a rebuild needs no prior context |
| **Components** | all |
| **Produces** | `README.md`, `RUNBOOK.md` (incl. **"set your git identity first"** prompt, the deploy-key install steps, and the exact `git remote set-url` command), `docs/security-notes.md`, `docs/resiliency-notes.md`, `scripts/provision.sh`, `scripts/update-atheos.sh` |
| **Preconditions** | U1–U4 |
| **Verification** | README + RUNBOOK exist and are linked; `provision.sh` is the exact sequence that was actually run; `update-atheos.sh` re-clones Atheos at latest and **re-pins the SHA** (D2) |
| **ACs** | AC-7 |
| **Notes** | The RUNBOOK is the operator-facing artifact: it must open with their required first action |
---
## Deferred / not a unit
- **The SSH remote switch** — operator-owned (D6). Documented in RUNBOOK, not built.
- **Atheos admin user creation** — operator completes the installer (D5).
- **Pelican build/publish** — out of scope entirely (Q1=A, AC-8).
## Greenfield code-organization strategy
No application code is generated, so the repo holds **only** provisioning + docs:
```
atheos-blog-lxc/
├── README.md
├── RUNBOOK.md
├── docs/{security-notes.md,resiliency-notes.md}
├── scripts/{provision.sh,update-atheos.sh}
└── aidlc-docs/ ← process trail only; never code
```
@@ -0,0 +1,75 @@
# Application Design Plan — atheos-blog-lxc
**Stage**: INCEPTION — Application Design (**minimal depth**)
**Date**: 2026-09-29
## Rationale for minimal depth
There is no application code to design — this project provisions a guest and installs an existing
PHP application. "Application Design" here means recording the **runtime component map and its
interfaces**, so that (a) the unit boundaries are defensible and (b) a later agent inheriting the
repo can see how the pieces talk to each other without re-deriving it.
## Plan (checkboxes)
- [ ] Generate `components.md` — component definitions + responsibilities
- [ ] Generate `component-methods.md` — the *interfaces* between components (not business logic)
- [ ] Generate `services.md` — orchestration / request flow
- [ ] Generate `component-dependency.md` — dependency matrix + data flow
- [ ] Generate `application-design.md` — consolidated design
- [ ] Validate design completeness and consistency
## Design questions
Most design decisions were already fixed in requirements.md. Below is the one that is genuinely
open, plus two where I state my decision so you can veto it rather than having to choose blind.
---
### Q1 (genuine — blocks U4) — what git committer identity should Atheos use?
Atheos's git integration commits as a configured user. You told me you'll own the `armistace`
credentials (**FR-11** — I must not create them). But `git` will refuse to commit at all without
*some* identity, so leaving it entirely unset risks a broken first commit for you.
- **A)** Set a **neutral non-armistace** identity, e.g. `Atheos <atheos@atheos.local>`, purely so
commits don't hard-fail; **you** override it with your own identity when you set up credentials.
*(Recommended — leaves the tool working without impersonating you.)*
- **B)** Set **nothing at all** — leave `user.name`/`user.email` unset, and the RUNBOOK tells you to
set them as your first action after SSH-ing in. *(Purest reading of FR-11; first commit fails until you do.)*
- **C)** Use a **placeholder under your name** (e.g. `armistace <ar17787@gmail.com>`) so commits are
attributed to you out of the box. *(Note: this creates an armistace identity, which is what FR-11
says I should not do — only pick this if you want to override that.)*
- **X)** Other (describe after the tag)
`[Answer]:`
---
### Q2 (decision stated — veto if wrong) — pin Atheos to a specific upstream commit
Upstream Atheos is an actively-moving master branch with no releases. For a reproducible
re-provision (FR-9), I propose pinning to a **specific verified commit SHA** resolved at build
time and recorded in the script + RUNBOOK, rather than tracking `master`.
`[Answer]:` *(leave blank to accept)*
---
### Q3 (decision stated — veto if wrong) — where things live
- Web root: `/var/www/atheos` (Atheos app)
- Atheos workspace: `/var/www/atheos/workspace` → this is where `blog` is cloned
- nginx listens on **:80**, `root /var/www/atheos`, `index index.php`
- php-fpm via **unix socket** (`/run/php-fpm83/...`) — not a TCP port, so nothing extra listens on the LAN
`[Answer]:` *(leave blank to accept)*
---
## Assumptions carried
- Atheos is served at the **site root** (`http://<ip>/`), not a subdirectory.
- The operator completes Atheos's first-run installer in the browser (creates the admin user);
the agent seeds neither the admin nor any Atheos user.
- `blog` is pre-cloned into `workspace/` so it appears as a ready-to-open project (your Q6).
+149
View File
@@ -0,0 +1,149 @@
# Execution Plan — atheos-blog-lxc
**Stage**: INCEPTION — Workflow Planning
**Date**: 2026-09-29
**Deploy Target**: Proxmox LXC (Alpine 3.23) on node **scotty** (.69). No K3s flavour, no CI.
---
## Detailed Analysis Summary
### Change Impact Assessment
- **User-facing changes**: Yes — a new web UI (Atheos) becomes reachable on the LAN.
- **Structural changes**: Yes — one new Proxmox guest (VMID 136).
- **Data model changes**: No — Atheos is database-less; it stores config in files.
- **API changes**: No.
- **NFR impact**: Low — a small single-purpose guest; NFRs already captured in requirements.md.
- **Existing systems touched**: `scotty` gains a guest + a template download. `armistace/blog` is
**read** (cloned) only — never pushed to by the agent.
### Risk Assessment
- **Risk Level**: **Medium** — the steps are well understood, but Atheos-on-Alpine is under-documented
(C-1) and the install has a browser-driven first-run step.
- **Rollback Complexity**: **Easy** — `pct destroy 136` removes the guest; nothing else is mutated.
- **Testing Complexity**: **Simple-to-Moderate** — all acceptance criteria are observable
(served page, file on disk, git remote, survives reboot).
### Component Relationships
- **Primary component**: LXC 136 on scotty.
- **Internal**: nginx → php-fpm (unix socket) → Atheos PHP app → workspace/ filesystem.
- **External (read-only)**: `git.aridgwayweb.com/armistace/blog` (public clone).
- **Operator-owned**: SSH key, git identity, Atheos login — the agent creates none of these.
---
## Workflow Visualization
```mermaid
flowchart TD
Start(["User Request"])
subgraph INCEPTION["🔵 INCEPTION PHASE"]
WD["Workspace Detection<br/><b>COMPLETED</b>"]
RE["Reverse Engineering<br/><b>SKIPPED</b>"]
RA["Requirements Analysis<br/><b>COMPLETED</b>"]
US["User Stories<br/><b>SKIPPED</b>"]
WP["Workflow Planning<br/><b>IN PROGRESS</b>"]
AD["Application Design<br/><b>EXECUTE</b>"]
UG["Units Generation<br/><b>EXECUTE</b>"]
end
subgraph CONSTRUCTION["🟢 CONSTRUCTION PHASE"]
FD["Functional Design<br/><b>SKIP</b>"]
NFRA["NFR Requirements<br/><b>SKIP</b>"]
NFRD["NFR Design<br/><b>SKIP</b>"]
ID["Infrastructure Design<br/><b>EXECUTE</b>"]
CG["Code Generation<br/><b>EXECUTE</b>"]
BT["Build and Test<br/><b>EXECUTE</b>"]
end
subgraph OPERATIONS["🟡 OPERATIONS PHASE"]
OPS["Operations<br/><b>PLACEHOLDER</b>"]
end
Start --> WD --> RA --> WP --> AD --> UG --> ID --> CG --> BT --> End(["Complete"])
style WD fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
style RA fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
style WP fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
style CG fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
style BT fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
style RE fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray: 5 5,color:#000
style US fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray: 5 5,color:#000
style FD fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray: 5 5,color:#000
style NFRA fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray: 5 5,color:#000
style NFRD fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray: 5 5,color:#000
style AD fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray: 5 5,color:#000
style UG fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray: 5 5,color:#000
style ID fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray: 5 5,color:#000
style OPS fill:#FFF59D,stroke:#F9A825,stroke-width:2px,color:#000
linkStyle default stroke:#333,stroke-width:2px
```
*(Plain-text equivalent for Matrix: WD ✅ → RA ✅ → WP ✅ → AD ▶ → UG ▶ → ID ▶ → CG ▶ → BT ▶ → Done.
Skipped: RE, US, FD, NFRA, NFRD.)*
---
## Phases to Execute
### 🔵 INCEPTION PHASE
- [x] Workspace Detection — COMPLETED
- [x] Reverse Engineering — **SKIP** (greenfield repo; the *estate* recon was folded into Workspace Detection and recorded in `aidlc-state.md` → Existing Estate Facts)
- [x] Requirements Analysis — COMPLETED
- [x] User Stories — **SKIP** (user-confirmed; single operator, no app code. A **requirement→unit map** substitutes for `unit-of-work-story-map.md`, per the skipped-stories rule)
- [x] Workflow Planning — COMPLETED
- [ ] **Application Design** — **EXECUTE (minimal depth)**
- **Rationale**: there *are* distinct components with real interfaces (nginx ↔ php-fpm socket, Atheos ↔ workspace, git ↔ remote). Recording them at minimal depth gives the handoff a component map and drives unit boundaries. Minimal, not comprehensive — no app code exists to design.
- [ ] **Units Generation** — **EXECUTE**
- **Rationale**: genuinely multi-unit work (guest, web stack, app, VCS, docs) with an ordering dependency.
### 🟢 CONSTRUCTION PHASE
- [ ] Functional Design — **SKIP**
- **Rationale**: no business logic to design; the "functions" are package installs and config files already specified in requirements.md.
- [ ] NFR Requirements — **SKIP**
- **Rationale**: all NFRs already captured (requirements.md §3); nothing new surfaces at construction.
- [ ] NFR Design — **SKIP**
- **Rationale**: same — NFRs here are simple resource/reboot-survival constraints, not a design problem.
- [ ] **Infrastructure Design** — **EXECUTE**
- **Rationale**: **this is the real design work for an infra project** — the guest spec, storage choice, template provenance, network mode, resource sizing, and the OpenRC service wiring. Getting this wrong is the main risk (C-1/C-2/C-3).
- [ ] **Code Generation** — EXECUTE (always)
- **Rationale**: the provisioning script + the docs are the deliverables.
- [ ] **Build and Test** — EXECUTE (always)
- **Rationale**: here "build and test" = **apply against the live node and verify**, plus the reboot-survival cycle (AC-6). Not a compiled artifact.
### 🟡 OPERATIONS PHASE
- [ ] Operations — PLACEHOLDER (future deployment/monitoring workflows)
---
## Unit Plan (provisional — finalised in Units Generation)
| Unit | Scope | Depends on | Primary ACs |
|---|---|---|---|
| **U1** | LXC creation: download Alpine 3.23 template on scotty, `pct create 136` (20 GB root, 2 vCPU, 1 GB, onboot, unprivileged), base config, timezone, package index | — | AC-1 |
| **U2** | Web stack: `nginx` + `php83-fpm` + `php83-{session,zip,openssl,mbstring}`, `allow_url_fopen`, php-fpm pool, nginx vhost | U1 | AC-2 |
| **U3** | Atheos install: fetch pinned upstream commit into the web root, correct ownership/permissions on `config.php`, `data/`, `workspace/`, `plugins/`, `themes/` | U2 | AC-2, AC-3 |
| **U4** | Git + clone: install git, `safe.directory`, committer identity (non-armistace), clone `armistace/blog` into `workspace/`, leave remote HTTP | U1, U3 | AC-3, AC-4, AC-5 |
| **U5** | Repo + docs: `README.md`, `RUNBOOK.md` (incl. SSH remote-switch + operator credential steps), `docs/security-notes.md`, `docs/resiliency-notes.md`, re-provision script | U1–U4 | AC-7 |
---
## Estimated Timeline
**No wall-clock estimate offered** — fabricating one would be worse than omitting it. The work is
5 units, several of which are a handful of commands each; the only genuinely uncertain step is
U3 (Atheos-on-Alpine, risk C-1). Each unit ends in a verifiable state, so progress is measurable
even without a time estimate.
---
## Success Criteria
- **Primary goal**: a LAN-reachable Atheos on Alpine where `armistace/blog` is cloned and editable,
with credentials owned entirely by the operator.
- **Key deliverables**: LXC 136 on scotty; Atheos served by nginx/PHP; the blog clone in the
workspace; README + RUNBOOK + re-provision script; security/resiliency TODO notes.
- **Quality gates**:
- AC-1…AC-8 all demonstrated with real command output (not assertions)
- Atheos served (not 502/404) — verified over HTTP from the LAN
- **Reboot-survival verified** (AC-6), not assumed
- **No Python/pelican present** (AC-8) — proves scope was respected
- **No `armistace` credential or git identity created by the agent** (FR-11)
- Independent review of the generated provisioning script before the Build-and-Test gate
- `audit.md` complete and append-only; `aidlc-state.md` current
+32
View File
@@ -0,0 +1,32 @@
# Unit of Work Plan — atheos-blog-lxc
**Stage**: INCEPTION — Units Generation (Part 1: Planning)
**Date**: 2026-09-29 · **Depth**: minimal
## Rationale
The unit boundaries were already surfaced in the approved execution plan and the operator approved
them ("continue looks good"). The decomposition is not genuinely in question — it follows the
component dependency chain directly. So this plan carries **no open questions**; asking any would be
ceremony. It is recorded here so the generation step is traceable rather than improvised.
## Decomposition principle
Units follow the **topological order of the dependency graph** — each unit ends in a state you can
verify, and no unit depends on a later one. This matters because a failure in U3 (the genuinely
uncertain one, Atheos-on-Alpine) must not leave U1/U2 work wasted or unverifiable.
## Plan
- [ ] Generate `unit-of-work.md` — unit definitions + responsibilities
- [ ] Generate `unit-of-work-dependency.md` — dependency matrix + build order
- [ ] Generate `requirement-to-unit-map.md` — **substitutes `unit-of-work-story-map.md`** (stories were skipped); every FR/NFR/AC assigned to exactly one unit
- [ ] Validate unit boundaries and dependencies
- [ ] Verify no requirement is unassigned
## Approval
Units were presented in the approved execution plan; the operator's "continue looks good" covers
this decomposition. No blocking questions.
`[Answer]:` *(leave blank — approval recorded from the execution-plan gate)*
@@ -0,0 +1,98 @@
# Requirement Verification Questions — atheos-blog-lxc
**Project**: Alpine LXC running Atheos, wired to Gitea, with `armistace/blog` cloned and ready.
**Stage**: INCEPTION — Requirements Analysis
**Date**: 2026-09-29
Answer each question by writing your choice after the `[Answer]:` tag (e.g. `[Answer]: B`).
Option **X) Other** is always available — describe it after the tag.
---
## 0. CRITICAL FINDING — please read before answering Q1
Reconnaissance found that **`armistace/blog` is a Pelican static site, not a folder of markdown**:
```
src/content/*.md ← ~20 posts (the actual writing)
src/themes/cleanblog/ ← theme
src/pelicanconf.py ← build config
src/Makefile ← `make html`, `make serve-global`, `make github`
requirements.txt ← pelican[markdown], markdown-markup-emoji
```
So there are two different needs, and they need different tools:
- **Atheos** = a browser editor. It edits files and does git (its `codegit` component).
It can edit the `.md`, commit and push — but it **cannot build or publish** the site.
- **Publishing the site** = running `make html` (+ `make github` / `pelican`), which needs
**Python + pelican**, not PHP.
**Q1. How important is it that this box can also BUILD/PUBLISH the site?**
- A) **Editor only** — I'll build/publish elsewhere (CI or another box). Atheos just edits + commits.
- B) **Also include Pelican** — install Python + pelican + make so I can run `make html` /
`make serve` on this box to preview and regenerate the site.
- C) **Also include Pelican AND a local preview server** — so I can click through the built site
from the browser on this LXC before pushing.
- X) Other (please describe after [Answer]: tag below)
[Answer]:
---
**Q2. Does Atheos's git integration need to work non-interactively (credentials pre-wired),
or will you supply them when you first push?**
- A) **Bake in the git identity + let me push over HTTPS with a Gitea token** (I'll give you /
you use an existing scoped token) so push works immediately without a prompt.
- B) **Clone read-only / leave push to me** — set up the clone and git config, but leave the
credential to me (e.g. via SSH key I install myself, per my point 4).
- C) **Use SSH from the start** — configure the remote as `git@git.aridgwayweb.com:armistace/blog.git`
and just wait for the key I'll add.
- X) Other (please describe after [Answer]: tag below)
[Answer]:
---
**Q3. Which PVE node should host the LXC, and on which storage?**
- A) **janeway (.225)** — 3.3 GB RAM free, `extra` storage 874 GB free. (Recommended: roomy.)
- B) **data (.42)** — only ~0.95 GB RAM free and no swap; a 20 GB guest is fine on disk but the
node is tight. (Not recommended.)
- C) **You pick** whatever is healthiest at build time.
- X) Other (please describe after [Answer]: tag below)
[Answer]:
---
**Q4. Should the container be reachable only on the LAN, or exposed via NPM
(nginx proxy manager) with a hostname like `blog-ide.aridgwayweb.com`?**
- A) **LAN only** — browse to `http://<lxc-ip>/`. Simplest, nothing new exposed.
- B) **Add an NPM proxy host** — I get a hostname; you add the proxy host + DNS (you own NPM).
- C) **LAN now, NPM later** — build LAN-only, leave a RUNBOOK section for adding NPM.
- X) Other (please describe after [Answer]: tag below)
[Answer]:
---
**Q5. Extensions (AI-DLC security/resiliency baseline). Your usual choice is to opt OUT for a
LAN-only PoC, and I document the "what to do if this ever moves external" TODOs instead. Confirm?**
- A) **Opt out of both, document the TODOs** (your standing preference).
- B) **Enable security-baseline** too.
- C) **Enable resiliency-baseline** too.
- D) **Enable both.**
- X) Other (please describe after [Answer]: tag below)
[Answer]:
---
## Notes / assumptions I'll carry unless you say otherwise
- **Q6 (implicit, shout if wrong):** "downloads … ready for me to use" = the repo is cloned to a
path Atheos can open as a **project**, on the correct branch, with git identity configured —
not that the container does anything to the blog's remote. The blog stays a separate repo; this
project's repo holds the **provisioning**.
- LXC defaults I propose: **2 vCPU, 1 GB RAM, 20 GB root**, Alpine 3.23, `onboot=1`, unprivileged.
- I will **not** touch `armistace` SSH keys — that's your point 4.
+129
View File
@@ -0,0 +1,129 @@
# Requirements — atheos-blog-lxc
**Project**: Alpine LXC running Atheos, wired to Gitea, with `armistace/blog` cloned and ready to edit.
**Stage**: INCEPTION — Requirements Analysis
**Date**: 2026-09-29
**Deploy Target**: Proxmox LXC on node **scotty** (.69). Not the K3s cluster.
---
## 1. Intent Analysis Summary
| | |
|---|---|
| **User request** | An Alpine LXC that (1) installs Atheos with nginx+PHP, (2) sets up git integration, (3) has `git.aridgwayweb.com/armistace/blog` cloned and ready to use, (4) leaves SSH key setup to the operator, (5) is backed by a 20 GB root. |
| **Request type** | New Project (greenfield repo; provisioning a new guest) |
| **Scope estimate** | Multiple components — guest creation, web stack, web app, VCS wiring, docs |
| **Complexity estimate** | Moderate — no application code to write; the risk is in correct provisioning and in not fighting Atheos's install model |
### The one finding that shaped this
`armistace/blog` is a **Pelican static site**, not plain markdown: it has `pelicanconf.py`,
`Makefile`, `publishconf.py` and `themes/cleanblog`. Building/publishing needs **Python + pelican**,
which Atheos (PHP) cannot do. Andrew confirmed (**Q1=A**) that the existing pipeline already handles
the build and that *"just writing markdown is more than enough"* — so the **build is explicitly out
of scope**. This box is an **editor + git client**, nothing more.
---
## 2. Functional Requirements
| ID | Requirement | Source |
|---|---|---|
| **FR-1** | An **Alpine 3.23 LXC** is created on PVE node **scotty** (.69) with a **20 GB rootfs**, 2 vCPU and 1 GB RAM, `onboot=1`, unprivileged. | user point 5 + Q3 |
| **FR-2** | **nginx + PHP-FPM** are installed and configured to serve Atheos; PHP includes the extensions Atheos wants for its plugin marketplace (**ZIP, OPENSSL, MBSTRING**) and `allow_url_fopen=On`. | user point 1 |
| **FR-3** | **Atheos** (github.com/Atheos/Atheos) is installed into the web root, pinned to a specific commit, with `config.php`, `data/`, `workspace/`, `plugins/` and `themes/` writable by the PHP-FPM user. No database is required. | user point 1 |
| **FR-4** | Atheos's **git integration** is configured and usable: git installed, a safe.directory set for the workspace, and Atheos's own git settings present (committer identity, default branch). | user point 2 |
| **FR-5** | `armistace/blog` is **cloned into `workspace/`** on its default branch (`master`) so it appears as an Atheos project, ready to open and edit. | user points 3, 6 |
| **FR-6** | The clone is **read-from-Gitea ready**, and the repo is left with instructions to **switch the remote to SSH** once the operator adds their key. The agent must **not** switch the remote nor install credentials itself. | Q2 + Q6 |
| **FR-7** | A **RUNBOOK.md** documents: the container's IP, how to log in over SSH, where the workspace/project lives, exactly how to move to an SSH remote (`git remote set-url`) after adding the key, plus credential/identity setup and common ops. | Q2 + Q6 + standing preference |
| **FR-8** | A **README.md** documents what this is, the install method, and the one-time provisioning steps. | standing preference |
| **FR-9** | The project repo (`hermes/atheos-blog-lxc`) holds a **repeatable provisioning script** so the whole LXC can be re-created from scratch. | AI-DLC / standing preference |
| **FR-10** | **Reachability is LAN-only**: Atheos served on the LXC's own IP on port 80. No NPM proxy host, no DNS record, no router port-forward is created. | Q4 |
| **FR-11** | The agent **does not create or configure `armistace` credentials or git identity**. The operator performs that over SSH, under their own identity. | Q2 (explicit restriction) |
---
## 3. Non-Functional Requirements
| ID | Requirement |
|---|---|
| **NFR-1** | Rootfs is 20 GB and must stay comfortably within it (Atheos + nginx + PHP + git + the blog repo is well under 1 GB). |
| **NFR-2** | The container must survive a **reboot** and return serving Atheos with the clone still present and functional (verify, don't assume). |
| **NFR-3** | Provisioning must be **repeatable and idempotent-friendly**: a single documented script plus manual steps. |
| **NFR-4** | The guest must not depend on the K3s cluster; it is a standalone LXC. |
| **NFR-5** | Timezone should match the operator (**Australia/Brisbane**) so Atheos/git timestamps are sane. |
---
## 4. Explicitly Out of Scope (confirmed by the operator)
- **Building/publishing the Pelican site.** No Python, pelican, `make html`, or preview server on
this box (Q1=A). The existing pipeline handles the build.
- **SSH key creation/installation for `armistace`.** Operator does this (Q2, user point 4).
- **Any NPM / public exposure** (Q4=LAN only).
- **Atheos user accounts beyond the initial install.** The operator will create their own login
during Atheos's first-run installer.
---
## 5. Constraints & Risks
| ID | Constraint / risk | Mitigation |
|---|---|---|
| **C-1** | 🔴 **Atheos on Alpine is off the beaten path.** Upstream only documents Ubuntu/CentOS/Apache/nginx. Alpine's PHP packaging and paths differ. | Verify the package set on Alpine 3.23 before installing (php83 + php83-fpm + php83-session + php83-zip + php83-openssl + php83-mbstring). Confirm the FPM socket path and nginx `fastcgi_pass` match. |
| **C-2** | 🔴 **Alpine is OpenRC, not systemd.** Service management is `rc-service`/`rc-update`. | Provision with OpenRC idioms; do not emit systemd units. |
| **C-3** | 🔴 **The Alpine template is not yet downloaded on scotty.** | `pveam update` + `pveam download local alpine-3.23-default_...` before `pct create`. |
| **C-4** | 🟠 **Atheos first-run installer needs the web root writable** and will create a config + admin user. | Set ownership/permissions correctly; document that the operator completes the installer in the browser. |
| **C-5** | 🟠 **`git clone` of an internet-facing Gitea from a new guest** — the clone is public (repo is public), so no credential is needed for the *clone*. | Confirm the repo stays public; clone over the public URL. If it ever goes private, the RUNBOOK covers adding a credential. |
| **C-6** | 🟡 Agent must respect the **no-armistace-credentials** restriction. | FR-11; state it plainly in the RUNBOOK so a future agent does not "helpfully" set it. |
---
## 6. Extension Configuration
| Extension | Enabled | Decided At |
|---|---|---|
| security-baseline | ❌ No | Requirements Analysis (Q5=A) |
| resiliency-baseline | ❌ No | Requirements Analysis (Q5=A) |
| property-based-testing | ❌ No | Requirements Analysis (Q5=A) |
Per the standing preference, the opted-out concerns are **not dropped** — they are written up as
"what to do if this ever moves external" in `docs/security-notes.md` and `docs/resiliency-notes.md`
(see FR-12 below).
| ID | Requirement | Source |
|---|---|---|
| **FR-12** | Even with both extensions opted out, produce `docs/security-notes.md` and `docs/resiliency-notes.md` listing the concrete hardening TODOs and their code touch-points, and cross-reference them from the README. | standing preference |
---
## 7. Acceptance Criteria (Given/When/Then)
- **AC-1** — *Given* the LXC is created, *when* I run `pct config 136` on scotty, *then* it shows
Alpine 3.23, rootfs 20 GB, and it is onboot-enabled.
- **AC-2** — *Given* provisioning is complete, *when* I open `http://<lxc-ip>/` from the LAN, *then*
Atheos's first-run installer (or login) is served by nginx/PHP — not a 502/404.
- **AC-3** — *Given* I complete the Atheos installer, *when* I open the workspace, *then* `blog` is
present as a project whose files I can open and edit.
- **AC-4** — *Given* the `blog` project is open, *when* I edit a markdown file and save, *then* the
change persists to disk inside the container.
- **AC-5** — *Given* the clone, *when* I run `git -C workspace/blog remote -v`, *then* it points at
Gitea (initially HTTP), and the RUNBOOK gives the exact `git remote set-url` command to move to SSH.
- **AC-6** — *Given* the container is rebooted, *when* it comes back, *then* Atheos serves again and
the clone and any edits are still present.
- **AC-7** — *Given* the RUNBOOK, *when* a different agent picks the repo up, *then* they can
re-provision or hand off without prior context.
- **AC-8** — *Given* the build is out of scope, *when* I search the container, *then* no Python/pelican
build tooling is installed (proving scope was respected).
---
## 8. Key Requirements Summary
- One small **Alpine LXC (136) on scotty**, 20 GB root, serving **Atheos** over **nginx+PHP** on the LAN.
- **Atheos is an editor + git client** here — deliberately **not** a build environment.
- **`armistace/blog` cloned into Atheos's workspace**, ready to write in.
- **Operator-owned credentials**: the agent sets up the clone and the instructions; the operator adds
the SSH key and, later, switches the remote.
- **RUNBOOK + README + re-provision script**, because a later agent or a re-build must not need me.
+383
View File
@@ -0,0 +1,383 @@
# Known Issues & Patches
Upstream Atheos is an actively-moving project with **no releases**, and its plugin market ships
plugins that are **ports from Codiad** (Atheos's predecessor). Two of those plugins use APIs that
Atheos has since removed or changed. This file records what we hit, what we changed, and why the
fix is done the way it is.
Pinned Atheos commit: `f90cd1806dbe0e25673a77e27390e8b1a240c3f6` (build 611).
---
## 1. 🔴 **Deleting any file or folder was broken** — `GLOB_BRACE` does not exist on musl
**Symptom:** removing the Terminal plugin from the market **cycled forever in the UI** (never
completed, never errored). Deleting a file or folder in the editor was broken by the same cause.
**Root cause — a glibc-only constant referenced on musl.** `traits/helpers.php::rDelete()`:
```php
$files = glob($target . "{*,.[!.]*,..?*}", GLOB_BRACE|GLOB_MARK);
```
`GLOB_BRACE` is a **glibc extension**. Alpine uses **musl**, where the constant is *undefined* —
and touching an undefined constant is a **fatal error** in PHP 8 (a warning in older versions):
```
PHP Fatal error: Uncaught Error: Undefined constant "GLOB_BRACE"
in /var/www/atheos/traits/helpers.php:138
#0 components/market/class.market.php(178): Common::rDelete()
#1 components/market/controller.php(46): Market->remove()
```
Because the fatal kills the request before `Common::send(200)`, the front end never receives a
settle response — so the UI **spins indefinitely**. A hang, not an error.
**Blast radius — measured, two call sites:**
| Caller | What breaks |
|---|---|
| `components/market/class.market.php:178` | removing a plugin from the market |
| `components/filetree/class.filetree.php:59` | **deleting any file or folder in the editor** |
The second is core editor functionality, so this is **not** a plugin bug — installing the Terminal
plugin is merely what made it visible. Verified directly: with the original code, `rDelete()` fatals
and the target is left on disk.
**Fix applied** (`patches/helpers-rDelete-GLOB_BRACE.diff`) — expand the brace pattern by hand when
`GLOB_BRACE` is absent, keeping upstream's exact behaviour where it *is* defined:
```php
if (defined("GLOB_BRACE")) {
$files = glob($target . "{*,.[!.]*,..?*}", GLOB_BRACE | GLOB_MARK);
} else {
$files = array_merge(
glob($target . "/*", GLOB_MARK) ?: [],
glob($target . "/.[!.]*", GLOB_MARK) ?: [],
glob($target . "/..?*", GLOB_MARK) ?: []
);
}
```
The three patterns are precisely what the brace expansion produces (`*`, dotfiles, and `.`/`..`-like
names), so the directory listing is equivalent.
**Verified** — `php83 -l` clean, then a real deletion of a tree containing nested dirs and two
dotfiles:
```
entries before: 10
rDelete() returned: true
exists after: gone (OK)
single file: rDelete=true exists=gone (OK)
```
**Upstream has NOT fixed this** — the same line is still in `main` (checked directly), so it cannot
be resolved by updating Atheos. Re-apply this patch after any update (`scripts/update-atheos.sh`).
**Consequence for the platform choice:** this is the real cost of Alpine. A glibc-based image
(Debian/Ubuntu) would not have hit it. It is patchable in one place, so Alpine stays — but it is worth
knowing that `GLOB_BRACE` is the kind of thing musl breaks.
---
## 2. 🔴 Plugin market: the **Terminal** plugin does not work as shipped
**Symptom:** installed the Terminal plugin from the market; it failed / did not function.
**Root cause — a removed API.** `plugins/Terminal/terminal.php` calls `Common::execute(...)`, which
**no longer exists** in Atheos (it was renamed):
```
plugin calls : Common::execute(...) -> MISSING
available : Common::raw_execute(...) -> returns ["code" => int, "output" => array]
Common::safe_execute(...)
```
The plugin's header says it is a port of `Fluidbyte/Codiad-Terminal`, so it targets the old API. Its
return-shape assumption is also stale (the old `execute()` returned `text`; `raw_execute()` returns
`output` as an array).
**Fix applied** (`patches/Terminal-terminal.php.patched`):
```diff
- $output = Common::execute("pwd");
+ $output = Common::raw_execute("pwd");
- $_SESSION["activeDir"] = array([$_SESSION["project"]] => $output["text"]);
+ $_SESSION["activeDir"] = array([$_SESSION["project"]] => implode("\n", (array)$output["output"]));
- $output = Common::execute($cmd);
- return $output["text"];
+ $output = Common::raw_execute($cmd);
+ return implode("\n", (array)$output["output"]);
```
**Verified** against the real method (`php83 -l` clean; both call patterns exercised):
| Call | Result |
|---|---|
| `raw_execute("pwd")` | `code=0`, `output=["/root",""]` |
| `raw_execute("echo ...")` | `code=0`, returns the text |
| after `chdir` to the repo | returns `/var/www/atheos/workspace/blog` — so the terminal opens in the blog |
**Re-apply after an Atheos update** — `scripts/update-atheos.sh` replaces the application files, so
this patch is lost on update. Re-apply it, or drop the plugin.
### Two further defects in the same plugin (NOT fixed — upstream, and mostly harmless)
1. **`terminal.php:95`** — `$blocked = explode(",", BLOCKED === true);` passes a **boolean** to
`explode()`. This evaluates to `explode(",", false)`, so the intended block-list
(`define("BLOCKED","ssh,telnet")`) **never actually blocks anything**. The plugin's own guard is
dead code. Cosmetic in effect (the command would just fail on its own), but worth knowing rather
than trusting.
2. **`terminal.php:124`** — `if (strtolower($command === "exit"))` passes a boolean to
`strtolower()`. Should be `strtolower($command) === "exit"`. Result: typing `exit` does not clear
`term_auth`; the session stays authenticated until the browser session ends.
Left as-is: they are upstream bugs, and fixing them changes the plugin's behaviour beyond the
minimum needed to make it run. Recorded here so nobody "fixes" them by accident or trusts the
block-list.
### ⚠️ Security note
This plugin gives a **browser terminal that executes shell commands as the php-fpm user**
(`atheos`). Two things matter:
1. **The plugin ships with a well-known, hardcoded gate constant.** Upstream is literally
`define("PASSWORD", "<the default value in the plugin source>");`. Anyone who can reach the page
can read the source on GitHub and unlock it. **We have rotated it to a random value** — retrieve
it from the `PASSWORD` define in `plugins/Terminal/terminal.php` on the guest. It is deliberately
**not recorded in this repo** (see below).
2. **It is a shell, so treat it as a privilege decision, not a convenience one.** Installing it
means the editor can run arbitrary commands as `atheos` — the same user that owns the deploy key.
Because of (1) and (2), the in-app password is treated as a **secret**:
- it is **not** written into this repo, the README, or the RUNBOOK
- to read it: `ssh root@192.168.178.57 'grep PASSWORD /var/www/atheos/plugins/Terminal/terminal.php'`
- it changes per install; `scripts/provision.sh` does not set one, so a rebuild returns to the
upstream default — **rotate it again after a rebuild**
**Outcome: the plugin has been REMOVED.** Once the `GLOB_BRACE` fault (§1) was fixed, removal through
the market succeeded — and that is the right end state: a browser shell is the single largest attack
surface in the container, and **Atheos macros already provide command execution**, so it added little
you did not already have. Removal was done by invoking the market's own `remove()` path, so the plugin
list, the `addons` cache and the generated bundle all stayed consistent:
```
{"text":"Plugin successfully removed.","status":200}
plugins/ -> (only README.md remains)
addons cache references to Terminal: 0
```
The notes above are kept because they document what the plugin *is* and how it failed — and because
the same stale-API pattern will recur with any other Codiad-era plugin. To reinstall, use the
market: it will re-introduce the `Common::execute` fault, so `patches/Terminal-terminal.php.patched`
was the fix — **it has been deleted along with the plugin**. If you ever reinstall, re-derive it:
`grep -rhoE 'Common::[a-zA-Z_]+' plugins/Terminal/` and expect `Common::execute` to be missing.
`docs/security-notes.md` §2 S-7 covers disabling `exec`/`proc_open`, which would neutralise **both**
this plugin and the macros. That is the correct lever if you ever expose this container.
---
## 3. 🟠 The Macro dialog crashed when folder-scoped macros existed
**Symptom:** the Settings → Macro dialog returned HTTP 500.
**Root cause — invalid data by Atheos's own design.** `components/macro/dialog.php:35` does:
```php
implode(", ", $macro["fTypes"]) // requires an ARRAY
```
but `components/macro/class.macro.php` `save()` writes the **string** `"N/A"` for folder macros:
```php
"fTypes" => $type === "file" ? explode(",", $fTypes) : "N/A",
```
So **any folder-type macro** makes that dialog fatal:
```
PHP Fatal error: Uncaught TypeError: implode(): Argument #2 ($array) must be of type ?array,
string given in /var/www/atheos/components/macro/dialog.php:35
```
**Fix applied — correct the DATA, not Atheos's code.** The stored `fTypes` for folder macros is now
`[]` (an empty array) instead of `"N/A"`:
```sh
sed -i 's/"fTypes": "N\/A"/"fTypes": []/g' /var/www/atheos/data/macros.db.php
```
Verified: `implode()` now succeeds for all 8 macros.
**Why data rather than code:** patching `dialog.php` would be overwritten by `update-atheos.sh`
(and by any Atheos update), whereas valid data keeps working. The trade-off is that the UI's own
"save" path could re-introduce `"N/A"` if a macro is edited in the UI — see the follow-up below.
**Follow-up (not done):** the real upstream fix is
`$type === "file" ? explode(",", $fTypes) : []` in `class.macro.php::save()`. Applying it would stop
the UI re-introducing the bad value, at the cost of a patch that must be re-applied after updates.
Worth doing if the dialog starts 500-ing again after editing a macro in the UI.
---
## 4. 🟠 The CodeGit panel looked like an empty black box
**Symptom:** the CodeGit dialog appeared as a mostly-empty black rectangle. Nothing was *broken* —
it was unreadable, which reads as "broken".
**This was NOT a codegit bug.** Verified exhaustively first:
```
all four panels return real content through the authenticated API:
overview 200 940 bytes
log 200 79596 bytes (full commit history)
transfer 200 1460 bytes
config 200 1174 bytes
no JS errors in the browser console; all DOM helpers (replaceClass etc.) exist
```
**Root cause — the generic `panel` rule hardcodes a height.** `theme/modules/common/_panel.scss`
(after minification, in `theme/main.min.css`):
```css
panel { padding: 5px; width: calc(100% - 200px); overflow: auto; overflow-y: scroll;
height: 500px; position: relative; border-left: var(--borderLight); }
```
Measured live, that produces:
| Element | Height | Problem |
|---|---|---|
| `#dialog` | 404px | clamped by `max-height: 70vh` + `overflow-y: auto` |
| `#codegit panel` | 500px | **forced**, while its content is 119px |
| `toolbar` ("Branch: …") | top **620** vs dialog bottom **490** | **pushed OUTSIDE the modal** |
So ~380px of the dialog was **empty dead space**, and anything after the panel — including the
Branch line — rendered *below* the modal, invisible. A generic `panel { height: 500px }` is exactly
the kind of rule that looks like a void.
**Fix applied:**
```css
panel { ... height: auto; max-height: 58vh; overflow-y: auto; ... }
```
Content-sized, capped so long content (the 79KB log) still scrolls. Verified live:
```
before: panel 500px → toolbar top 620, dialog bottom 490 (outside)
after: panel 136px → toolbar top 256, dialog bottom 279 (inside)
```
`theme/modules/common/_panel.scss` is updated too, so the source does not contradict the shipped CSS
(there is no Sass compiler on the guest, so `main.min.css` is edited directly).
---
## 5. 🟠 CSS/JS changes were invisible behind the browser cache
**Symptom:** a fix to the theme did not appear until a hard refresh — so the panel fix above looked
like it had not worked at all.
**Root cause:** Atheos serves `theme/main.min.css` and the generated `public/*.min.js|css` bundles
from **stable URLs with no cache headers**, so nginx adds only `Last-Modified`/`ETag` and browsers
legitimately reuse their cached copy.
**Fix:** nginx now sends `Cache-Control: no-cache, must-revalidate` for `*.css`/`*.js`. Note this does
**not** disable caching — it forces **revalidation**, so normal loads still get a cheap 304 while any
change is guaranteed to be picked up on the next load.
**If you ever see a stale UI again:** force a reload (Ctrl/Cmd-Shift-R). That is the first thing to try
before assuming a fix failed.
---
## 6. Checking for plugin problems in future
The plugin's JS is bundled into `public/plugins.min.js` (built by `classes/sourcemanager.php`), and
its rightbar button is rendered by `components/sidebars/sb-right.php` from `plugin.json`. Both were
verified working:
```
plugins.min.js contains 'plugins/Terminal/init.js' -> YES (bundle is fine)
rightbar HTML -> <a onclick="atheos.Terminal.open();">
<i class="fas fa-terminal"></i>Terminal</a>
```
So when a plugin misbehaves, check in this order:
```sh
# 1. did it install?
tail /var/www/atheos/data/log/market.log.php
# 2. is its JS in the served bundle?
grep -c "plugins/<Name>/init.js" /var/www/atheos/public/plugins.min.js
# 3. is it a stale-API port? (Codiad-era plugins are the risk)
grep -rhoE 'Common::[a-zA-Z_]+' /var/www/atheos/plugins/<Name>/ | sort -u
# then check each exists: grep -rn "function <name>" /var/www/atheos/common.php /var/www/atheos/traits/
# 4. is there a PHP fatal?
grep -E 'PHP Fatal' /var/log/nginx/atheos.error.log | tail
```
**Rule of thumb:** if a plugin was ported from Codiad, assume its `Common::` calls are stale and
verify them before trusting it.
---
## 7. Follow-ups not done (recorded so they are not forgotten)
These came out of review. They are **operator decisions**, not agent ones — several change what the
editor can do.
### 7.0 Branch protection on `master` — ACCEPTED RISK
Deliberately **not** enabled while the editor is LAN-only (operator decision). Full reasoning and the
"enable it before you expose this host" trigger are in
[`docs/writer-branch-workflow.md` §2](writer-branch-workflow.md#2-protect-master-on-the-blog-repo--recommendation-deliberately-not-enabled).
The suite still probes for it on every run and reports `ACCEPTED-RISK` (not a failure) while it is
off, so the recommendation self-cancels the moment it is enabled.
### 7.1 Remove the Terminal plugin — ✅ DONE
Removed through the market's own code path once §1 was fixed (see §2). A browser shell was the largest
attack surface in the container and macros already cover command execution.
### 7.2 ❌ `disable_functions` cannot be "on by default" here — measured
Review asked for `php.ini` to disable `exec`, `shell_exec`, `passthru`, `system` by default.
**This would break the editor's core function.** Evidence:
```
components/codegit/* → Common::safe_execute (31 call sites — every git operation)
components/macro/* → Common::raw_execute (1 call site)
common.php raw_execute → function_exists("system") / function_exists("exec")
```
Every git commit, push, fetch and status from the editor goes through `exec`/`system`. Disabling them
turns the editor into a text editor that cannot commit. So it stays **a deliberate exposure lever**
(S-7 in `docs/security-notes.md`), to be applied only when the container is exposed — at which point
you would also lose macros and git integration, which is why S-7 already says *"test, do not
blind-apply"*.
### 7.3 Rotate the terminal password automatically
If the plugin is kept, `provision.sh` should generate a random password rather than leaving the
upstream default. Not implemented: it would hardcode a secret into a repo file, which is the same
class of problem this file already documents.
### 7.4 Deploy key could be read-only
Review suggested making the deploy key read-only if writes are unnecessary. **They are necessary** —
that is the whole point of the writer-branch workflow (macro 2 pushes). Not applicable.
### 7.5 The real upstream fix for §3
`class.macro.php::save()` should write `[]` instead of `"N/A"` for folder macros. See §3 — the fix is
currently applied to the data, so it survives updates, at the cost of being reversible from the UI.
+55
View File
@@ -0,0 +1,55 @@
# Resiliency Notes — atheos-blog-lxc
**Status**: resiliency-baseline extension **opted out** (LAN-only PoC) per Andrew's standing preference.
**Purpose**: record what a non-PoC deployment would need, so the trail survives.
---
## 1. What this guest is, from a resiliency standpoint
A **single small container that can be destroyed and rebuilt** — that is the design, not a gap.
`armistace/blog` is the durability anchor: the source of truth lives in **Gitea**, not on this guest.
Losing the LXC loses only:
- the Atheos install (reproducible: `scripts/provision.sh`)
- the local clone (re-clonable)
- **uncommitted edits** ← the only genuinely perishable data
## 2. Current posture
| Aspect | State | Note |
|---|---|---|
| Rootfs | 20 GB, thin-provisioned on `scotty-local-lvm` | thin pool had 771 GB free at design time |
| `onboot` | enabled | survives a node restart |
| Backups | **none** | acceptable: nothing unique is stored |
| Snapshots | none taken (a `pre-*` snapshot is used during *upgrades* only) | |
| Rebuild path | `scripts/provision.sh` | **this is the resiliency mechanism** |
| Reboot survival | **verified**, not assumed | AC-6 — service returns and the clone persists |
## 3. If this ever moves beyond a LAN PoC
| # | Item | Touch-point |
|---|---|---|
| R-1 | **Scheduled snapshot** of the guest (`pct snapshot` / PBS) with retention | PVE / PBS |
| R-2 | **Automated verification of the rebuild path** — run `provision.sh` into a throwaway VMID on a schedule; a backup you have never restored is a claim | CI or cron on the PVE node |
| R-3 | **Alert on guest down** (`pct status` + HTTP probe) | observability stack |
| R-4 | **Push-based durability for in-progress work**: an auto-commit/ahead-of-time branch so uncommitted edits stop being the failure mode | a small hook or Atheos-side job |
| R-5 | **Disk alert at 80%** of the 20 GB rootfs (a full rootfs silently breaks PHP sessions) | observability stack |
| R-6 | **Move the clone's durability to the repo**: rely on Gitea backup/restore rather than guest backups | Gitea |
| R-7 | **Document RTO/RPO**: here RTO ≈ re-run `provision.sh` (minutes); RPO = last commit | RUNBOOK |
| R-8 | **`scotty-local-lvm` thin-pool monitoring** — a thin pool that fills takes down *every* guest on it, not just this one | PVE node |
## 4. Design choices that already help
- **Nothing unique is stored on the guest** — the deliberately tiny 20 GB root is a *feature*: it
bounds what can be lost and keeps rebuild cheap.
- **Provisioning is a script**, so rebuild is not a memory exercise (FR-9).
- **The build pipeline is elsewhere** (Q1=A), so this guest is not on the critical path for publishing.
- **OpenRC services are enabled at boot** (AC-6 verifies it), so a reboot is not a repair event.
## 5. Accepted risks
| Risk | Why accepted | Revisit trigger |
|---|---|---|
| No backups | no unique data; rebuild is scripted | if the guest starts holding anything unique |
| Single guest on a single node | LAN PoC scope | on any production expectation → R-1/R-3 |
| Uncommitted edits are perishable | normal for a git worktree; operator controls commit cadence | if it bites → R-4 |
+88
View File
@@ -0,0 +1,88 @@
# 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 |
+152
View File
@@ -0,0 +1,152 @@
# Writer-Branch Workflow
How writing and publishing work from the Atheos editor, and why the branch is structured this way.
## The model
The editor **never touches `master`**. It writes to a long-lived branch (`atheos-writer`), and
merging into `master` is done by the operator in Gitea.
```
┌──────────────────────────────────────────┐
Atheos editor ────┤ atheos-writer (long-lived) │
(macros) │ • write here │
│ • commit + push here │
│ • can be reset --hard back to master │
└───────────────┬──────────────────────────┘
│ operator opens a PR in Gitea
│ operator reviews
▼
master (protected: RECOMMENDED, currently OFF - see §2)
│
▼
existing Pelican build pipeline
```
**Why:** the editor is a web app with shell-exec reach (`codegit` + `macro` both run commands as the
php-fpm user). Bounding what it can reach is the whole point of design decision D6. Keeping `master`
behind a PR means a compromised or fat-fingered editor **cannot** publish directly — it can only
propose.
## Setup — do these once
### 1. Set your git identity (required, RUNBOOK STEP 1)
```sh
ssh root@192.168.178.57
su -s /bin/sh atheos
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
```
This is the *same* global gitconfig php-fpm uses (`HOME=/home/atheos` — verified), so macros commit
as **you**. If it is not set, macro **2** refuses with an actionable error rather than
committing under a bogus identity.
### 2. Protect `master` on the blog repo — RECOMMENDATION, deliberately not enabled
**Status: accepted risk. Branch protection is OFF, and that is a decision, not an oversight.**
> *"leave it as a recommendation — I won't do that unless I take this out to the wider internet"*
> — operator
The deploy key **can** push `master` directly, which means the writer-branch model is **advisory, not
enforced**. The operator has weighed this and chosen not to add the gate while the editor is LAN-only:
- **The compensating control is reachability, not immutability.** The editor is LAN-only (FR-10). The
threat this setting defends against is a compromised editor publishing straight to `master`; while
nothing off-LAN can reach the editor, and the only user on the LAN is the operator, that is largely
theoretical.
- **The cost is real now, the benefit is not.** Protection adds a merge gate for the sole user, who is
also the repo owner — friction with no realistic risk reduction today.
#### ⚠️ Enable it BEFORE either of these happens
1. **The editor becomes reachable off-LAN** — an NPM proxy entry, a port-forward, or a
Tailscale/WireGuard route to `blog-ide.aridgwayweb.com`.
2. **Anyone other than the operator gets access to the editor.**
At that point a deploy key that can force-push `master` stops being theoretical. The setting
(`armistace/blog` → **Settings** → **Branches** → rule for `master`):
| Setting | Value |
|---|---|
| **Enable push** | **off** ← direct pushes blocked, including the deploy key |
| **Enable merge whitelist** | **on** → add `armistace` |
| Require approvals | optional (you're the only human here) |
Verify:
```bash
curl -s -H "Authorization: token $GITEA_TOKEN" \
https://git.aridgwayweb.com/api/v1/repos/armistace/blog/branch_protections
```
#### It is still checked on every test run
`build-and-test.sh` attempts a direct push to `master` with the deploy key and requires it to be
**rejected**. While protection is off that reports as `ACCEPTED-RISK`, not `FAIL` — so the suite stays
green and CI-usable — but **the check runs every time**. It flips to
`PASS master is protected` by itself the moment protection is enabled, and this recommendation
becomes moot without anyone having to remember it.
### 3. The macros
Installed directly into Atheos's macro store (`data/macros.db.php`); a copy is kept in this repo at
`files/macros.db.php` so a rebuild reproduces them. They appear when you **right-click the `blog`
folder** in the file tree → **Macros**.
| # | Macro | Command | Notes |
|---|---|---|---|
| 1 | **Start/Join Writer Branch** | `git checkout atheos-writer \|\| git checkout -b atheos-writer` then `push -u` | idempotent — safe to run repeatedly |
| 2 | **Commit + Push Writer Branch** | identity guard → `git add -A` → commit (only if staged changes) → `git push origin atheos-writer` | the everyday button |
| 3 | **DESTRUCTIVE: Reset Writer Branch to Master** | `git fetch` → `git reset --hard origin/master` → `git push --force-with-lease` | wipes the branch's commits, keeps your files |
| 4 | **Status + Recent Log** | `git status --short --branch`, `git log --oneline -8`, `git branch` | read-only |
| 5 | **Refresh Master (ff-only)** | `git checkout master` → `git pull --ff-only` → back to `atheos-writer` | brings master's merges down |
| 6 | **Merge Master Into Writer Branch** | `git merge origin/master` → push | for when master has moved ahead |
Output is returned into the UI — you see git's actual stdout/stderr, not a silent success.
## Day-to-day
1. Right-click `blog` → Macros → **1. Start/Join Writer Branch** (first time; also safe later)
2. Write your post, save
3. Right-click `blog` → Macros → **2. Commit + Push Writer Branch**
4. Open a PR: `atheos-writer` → `master`
5. Review the diff in Gitea, merge
### ⚠️ Important: macro 2 uses `git add -A`
`git add -A` stages **everything** in the repo, including files you may not intend to publish
(drafts, scratch files, `.orig`/`.bak` leftovers). A `reset --hard` will also **delete any untracked
file that has been staged**.
Practical consequences:
- `atheos-writer` is a **publishing** branch — everything you leave in the tree gets committed and
pushed to Gitea.
- If you want a scratch area, keep it **outside** the repo (e.g. `workspace/scratch/`, not
`workspace/blog/`).
- To unstage before a reset: `git reset` (clears the index without touching your files).
## Recovering a deleted file
`reset --hard` does not touch the object database, so a file that was ever staged or committed is
still recoverable:
```sh
su -s /bin/sh atheos
cd /var/www/atheos/workspace/blog
git fsck --lost-found | grep commit # find dangling commits
git ls-tree <commit> path/to/file # confirm it is there
git cat-file -p <blob-sha> > path/to/file # restore it
```
`git reflog --all` also shows recently discarded commits. This is how the empty
`src/content/please_no_facebook.md` was recovered after a `reset --hard` in testing.
## Changing the branch name
Edit macros 1, 2, 3, 5 and 6 in **Settings → Macro** and replace `atheos-writer`. The branch name is
literal in each command — there is no variable. (Macros have no input prompt, so a
branch name that changes per task would need edit-then-run.)
+52
View File
@@ -0,0 +1,52 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root /var/www/atheos;
index index.php index.html;
# --- logging ---------------------------------------------------------
access_log /var/log/nginx/atheos.access.log;
error_log /var/log/nginx/atheos.error.log;
# --- PHP: the socket path here MUST byte-match the pool's `listen =`
# in /etc/php83/php-fpm.d/www.conf (interface I-1 -> else bare 502)
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php-fpm83/php-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location / {
try_files $uri $uri/ =404;
}
# --- hardening (free, no downside) -----------------------------------
# never serve the VCS directory, the app's private data, or dotfiles
location ~ /\.git { deny all; }
location ~ /\.ssh { deny all; }
location ~ /\. { deny all; }
location ~ ^/data/ { deny all; }
location ~ ^/(config\.php|common\.php|controller\.php|traits/) { deny all; }
location ~* \.(md|py|sh|log|conf|ini|sql|bak|orig)$ { deny all; }
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 64m;
# Atheos serves theme/main.min.css and the generated *.min.js/css bundles from STABLE URLs
# with no cache headers. Without this, a CSS/JS change stays invisible behind a cached copy
# until the user hard-refreshes. "no-cache" permits caching but forces revalidation, so a
# 304 keeps normal loads fast while guaranteeing updates are picked up on the next load.
location ~* \.(css|js)$ {
add_header Cache-Control "no-cache, must-revalidate";
try_files $uri =404;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php/*|[
{
"uuid": "macro-947e-4763-b7b6f2",
"title": "Execute",
"type": "file",
"fTypes": [
"sh"
],
"command": "%PATH%",
"icon": "fas fa-magic",
"owner": "ATHEOS"
},
{
"uuid": "macro-53a0-4ebb-56e800",
"title": "Execute",
"type": "file",
"fTypes": [
"php"
],
"command": "cd %FOLDER% && php %BASENAME%",
"icon": "fas fa-magic",
"owner": "ATHEOS"
},
{
"uuid": "macro-writer-0001-start",
"title": "1. Start/Join Writer Branch",
"type": "folder",
"fTypes": [],
"command": "cd %PATH% && (git checkout atheos-writer 2>/dev/null || git checkout -b atheos-writer) && git push -u origin atheos-writer",
"icon": "fas fa-code-branch",
"owner": "armistace"
},
{
"uuid": "macro-writer-0002-commit",
"title": "2. Commit + Push Writer Branch",
"type": "folder",
"fTypes": [],
"command": "cd %PATH% && (git config --get user.email >/dev/null || { echo 'ERROR: no git identity set. Run over SSH: git config --global user.name \"Your Name\" && git config --global user.email \"you@example.com\" (RUNBOOK STEP 1)'; exit 1; }) && git add -A && (git diff --cached --quiet || git commit -m \"writer: $(date +%F_%H%M)\") && git push origin atheos-writer",
"icon": "fas fa-cloud-upload-alt",
"owner": "armistace"
},
{
"uuid": "macro-writer-0003-reset",
"title": "3. DESTRUCTIVE: Reset Writer Branch to Master",
"type": "folder",
"fTypes": [],
"command": "cd %PATH% && git fetch origin && git reset --hard origin/master && git push --force-with-lease origin atheos-writer",
"icon": "fas fa-exclamation-triangle",
"owner": "armistace"
},
{
"uuid": "macro-writer-0004-status",
"title": "4. Status + Recent Log",
"type": "folder",
"fTypes": [],
"command": "cd %PATH% && git status --short --branch && echo '--- recent commits:' && git log --oneline -8 && echo '--- local branches:' && git branch",
"icon": "fas fa-info-circle",
"owner": "armistace"
},
{
"uuid": "macro-writer-0005-refresh",
"title": "5. Refresh Master (ff-only)",
"type": "folder",
"fTypes": [],
"command": "cd %PATH% && git checkout master && git pull --ff-only && git checkout atheos-writer",
"icon": "fas fa-sync-alt",
"owner": "armistace"
},
{
"uuid": "macro-writer-0006-pullmaster",
"title": "6. Merge Master Into Writer Branch",
"type": "folder",
"fTypes": [],
"command": "cd %PATH% && (git config --get user.email >/dev/null || { echo 'ERROR: no git identity set. Run over SSH: git config --global user.name \"Your Name\" && git config --global user.email \"you@example.com\" (RUNBOOK STEP 1)'; exit 1; }) && git fetch origin && git merge origin/master -m \"merge master into atheos-writer\" && git push origin atheos-writer",
"icon": "fas fa-code-merge",
"owner": "armistace"
}
]|*/?>
+25
View File
@@ -0,0 +1,25 @@
# ~atheos/.ssh/config — install to /home/atheos/.ssh/config (mode 600, owner atheos)
#
# gitea-blog: per-repository deploy key for armistace/blog (design decision D6).
#
# !! THE USER IS `gitea`, NOT `git` !!
# This instance's app.ini sets SSH_USER = gitea.
# With `git` the sshd on that host answers "Invalid user git" and rejects the
# connection BEFORE reading authorized_keys - which looks identical to a
# rejected key. Verified in the sshd log on LXC 109, 2026-09-29.
#
# Why the LAN address rather than git.aridgwayweb.com:
# - app.ini sets SSH_DOMAIN = 192.168.178.155 / SSH_PORT = 22
# with START_SSH_SERVER = false, i.e. SSH is served by the Gitea LXC's own sshd.
# - the public path does NOT forward :22 (only :443, via NPM). Verified:
# git.aridgwayweb.com:22 -> unreachable
# 192.168.178.155:22 -> OPEN
# - this guest is LAN-only, so LAN transport is correct and avoids an internet round-trip.
#
# IdentitiesOnly yes ensures no other key is ever offered to the server.
Host gitea-blog
HostName 192.168.178.155
User gitea
IdentityFile ~/.ssh/id_ed25519_blog
IdentitiesOnly yes
Port 22
+22
View File
@@ -0,0 +1,22 @@
--- /var/www/atheos/traits/helpers.php.orig
+++ /var/www/atheos/traits/helpers.php
@@ -135,7 +135,18 @@
if (is_dir($target)) {
- $files = glob($target . "{*,.[!.]*,..?*}", GLOB_BRACE|GLOB_MARK); //GLOB_MARK adds a slash to directories returned
+ // GLOB_BRACE is a glibc extension and is UNDEFINED under musl (Alpine), where
+ // referencing it is a fatal error in PHP 8. Expand the brace pattern by hand
+ // so this works on both libcs.
+ if (defined("GLOB_BRACE")) {
+ $files = glob($target . "{*,.[!.]*,..?*}", GLOB_BRACE | GLOB_MARK);
+ } else {
+ $files = array_merge(
+ glob($target . "/*", GLOB_MARK) ?: [],
+ glob($target . "/.[!.]*", GLOB_MARK) ?: [],
+ glob($target . "/..?*", GLOB_MARK) ?: []
+ );
+ }
// $files = glob($target . "/*");
foreach ($files as $file) {
+223
View File
@@ -0,0 +1,223 @@
#!/bin/sh
# ==============================================================================
# build-and-test.sh — captures evidence for every acceptance criterion (AC-1..AC-8)
# Run ON THE GUEST: ssh root@192.168.178.57 'sh -s' < build-and-test.sh
# ==============================================================================
W=/var/www/atheos
B=$W/workspace/blog
pass=0; fail=0; note=0
ok() { echo " PASS $1"; pass=$((pass+1)); }
bad() { echo " FAIL $1"; fail=$((fail+1)); }
note() { echo " ACCEPTED $1"; note=$((note+1)); }
chk() { if [ "$2" = "$3" ]; then ok "$1 ($2)"; else bad "$1 (got '$2', want '$3')"; fi; }
asuser() { su -s /bin/sh atheos -c "$1"; }
echo "=============================================================="
echo "AC-2 Atheos is served by nginx+PHP (not 502/404)"
echo "=============================================================="
CODE=$(wget -qO /tmp/idx.html --server-response http://127.0.0.1/ 2>&1 | sed -n 's/.*HTTP\/1\.[01] \([0-9]*\).*/\1/p' | head -1)
chk "HTTP status for /" "$CODE" "200"
if grep -qi '<title>Atheos IDE</title>' /tmp/idx.html 2>/dev/null; then ok "served page is the Atheos IDE"; else bad "served page is not Atheos"; fi
# Either state is valid: pre-install shows the installer, post-install shows the login/app.
if grep -qiE 'install|setup' /tmp/idx.html 2>/dev/null; then
ok "installer markers present (pre-install state)"
elif [ -f "$W/config.php" ] && [ -s "$W/config.php" ]; then
ok "installer already completed (config.php populated)"
else
bad "neither installer markers nor a populated config.php"
fi
SOCK=$(sed -n 's/^listen = //p' /etc/php83/php-fpm.d/www.conf | head -1)
NGX=$(sed -n 's/.*fastcgi_pass unix:\([^;]*\);.*/\1/p' /etc/nginx/http.d/atheos.conf | head -1)
echo " --- socket contract (I-1):"
echo " php-fpm listen : $SOCK"
echo " nginx fastcgi : $NGX"
chk "socket paths byte-match" "$NGX" "$SOCK"
echo
echo "=============================================================="
echo "AC-3 'blog' is present with files that can be opened"
echo "=============================================================="
POSTS=$(ls $B/src/content/*.md 2>/dev/null | wc -l | tr -d ' ')
if [ "$POSTS" -ge 20 ]; then ok "posts found ($POSTS, expected >= 20)"; else bad "posts found ($POSTS, expected >= 20)"; fi
if [ -d "$W/workspace/blog/.git" ]; then ok "it is a git working copy"; else bad "not a git working copy"; fi
chk "theme present" "$(ls $B/src/themes 2>/dev/null | tr -d ' ')" "cleanblog"
chk "workspace owner" "$(stat -c '%U' $W/workspace/blog)" "atheos"
echo
echo "=============================================================="
echo "AC-4 An edit persists to disk (as the FPM user)"
echo "=============================================================="
asuser "printf 'x\n' > $B/.ac4-probe" >/dev/null 2>&1
if [ "$(asuser "cat $B/.ac4-probe" 2>/dev/null)" = "x" ]; then ok "write+read back as 'atheos'"; else bad "cannot write as atheos"; fi
asuser "rm -f $B/.ac4-probe" >/dev/null 2>&1
for p in config.php data workspace plugins themes; do
if asuser "test -w $W/$p" 2>/dev/null; then ok "writable: $p"; else bad "NOT writable: $p"; fi
done
echo
echo "=============================================================="
echo "AC-5 Remote points at Gitea; the SSH switch is documented"
echo "=============================================================="
REMOTE=$(asuser "git -C $B remote get-url origin" 2>/dev/null)
echo " remote now: $REMOTE"
case "$REMOTE" in
*aridgwayweb.com*armistace/blog*) ok "remote targets armistace/blog on Gitea (HTTPS)" ;;
gitea-blog:armistace/blog*) ok "remote targets armistace/blog via the deploy-key alias" ;;
*) bad "remote does not target the blog repo" ;;
esac
# A repo may legitimately sit on master, or on the long-lived writer branch (docs/writer-branch-workflow.md).
BR=$(asuser "git -C $B rev-parse --abbrev-ref HEAD" 2>/dev/null)
case "$BR" in
master|main) ok "branch ($BR)" ;;
atheos-writer) ok "branch ($BR - writer-branch workflow)" ;;
*) bad "branch is unexpected: $BR" ;;
esac
# Phase 2: once the SSH alias is in use, prove the transport actually authenticates
case "$REMOTE" in
gitea-blog:*)
AUTH=$(asuser 'ssh -o BatchMode=yes -o ConnectTimeout=8 -T gitea-blog 2>&1' | head -1)
case "$AUTH" in
*successfully\ authenticated*) ok "deploy key authenticates over SSH" ;;
*) bad "deploy key does NOT authenticate: $AUTH" ;;
esac
;;
esac
echo
echo "=============================================================="
echo "AC-6 Survives reboot (services enabled, data intact)"
echo "=============================================================="
echo " container uptime: $(cut -d. -f1 /proc/uptime)s (reboot was issued during the test)"
for s in nginx php-fpm83 sshd; do
if rc-update show 2>/dev/null | grep -qE "^\s+$s\s+\|\s+default"; then ok "$s enabled at boot"; else bad "$s NOT enabled at boot"; fi
if rc-service "$s" status >/dev/null 2>&1; then ok "$s running"; else bad "$s not running"; fi
done
if [ -S "$SOCK" ]; then ok "fpm socket exists after reboot"; else bad "fpm socket missing"; fi
echo
echo "=============================================================="
echo "AC-8 SCOPE GUARD: no build toolchain present (must be ABSENT)"
echo "=============================================================="
for t in python python3 pelican pip pip3 make; do
if command -v "$t" >/dev/null 2>&1; then bad "$t IS present - scope violated!"; else ok "$t absent (correct)"; fi
done
echo " --- also check no database engine was installed (Atheos needs none):"
for d in mysqld mariadbd postgres redis-server; do
if command -v "$d" >/dev/null 2>&1; then bad "$d present - unexpected"; else ok "$d absent (correct)"; fi
done
echo
echo "=============================================================="
echo "REGRESSION: panel has no hardcoded height (empty-modal bug)"
echo "=============================================================="
# panel { height:500px } forced every modal panel to a fixed box: short content left a large
# empty black region and anything after the panel (codegit's Branch toolbar) was pushed outside
# the modal. docs/known-issues.md §4. Fails loudly if lost to an update.
PNL=$(grep -oE '(^|[;}])panel\{[^}]*\}' /var/www/atheos/theme/main.min.css 2>/dev/null | head -1)
if [ -z "$PNL" ]; then
bad "could not read the panel rule from theme/main.min.css"
elif echo "$PNL" | grep -q 'height:500px'; then
bad "panel is fixed at 500px - re-run scripts/fix-panel-layout.sh"
else
ok "panel is content-sized ($(echo "$PNL" | grep -oE 'height:[^;]*' | head -1))"
fi
# and the CSS/JS cache header must be present, or fixes stay invisible
if wget -qO /dev/null --server-response --header='Host: 127.0.0.1' http://127.0.0.1/theme/main.min.css 2>&1 | grep -qi 'Cache-Control.*no-cache'; then
ok "theme CSS sends Cache-Control: no-cache (changes are picked up)"
else
bad "theme CSS has no Cache-Control - a CSS fix can stay hidden behind the browser cache"
fi
echo
echo "=============================================================="
echo "REGRESSION: rDelete works (GLOB_BRACE is glibc-only; musl lacks it)"
echo "=============================================================="
# That broke deleting ANY file/folder in the editor AND removing a market plugin (the UI
# would spin forever because the fatal killed the response before Common::send()).
# docs/known-issues.md §1. This test fails loudly if the patch is lost (e.g. by an update).
rm -rf /tmp/rdel_guard 2>/dev/null
if mkdir -p /tmp/rdel_guard/a/b/c /tmp/rdel_guard/.hd 2>/dev/null \
&& touch /tmp/rdel_guard/f.txt /tmp/rdel_guard/.dot /tmp/rdel_guard/a/b/c/deep.txt; then
ROUT=$(php83 -r 'require_once("/var/www/atheos/common.php"); echo Common::rDelete("/tmp/rdel_guard/") ? "ok" : "fail";' 2>&1 | grep -viE 'deprecat|warning|notice' | tail -1)
if [ "$ROUT" = "ok" ] && [ ! -e /tmp/rdel_guard ]; then
ok "rDelete removed a nested tree incl. dotfiles (delete works)"
else
if echo "$ROUT" | grep -q 'GLOB_BRACE'; then
bad "rDelete FATALS on GLOB_BRACE - re-apply patches/helpers-rDelete-GLOB_BRACE.diff"
else
bad "rDelete failed (got: $ROUT)"
fi
fi
else
bad "could not build the rDelete test tree"
fi
rm -rf /tmp/rdel_guard 2>/dev/null
echo "=============================================================="
# The whole branch-based workflow assumes the deploy key CANNOT push master. That is a
# repo-side setting we cannot verify from here by inspection - so test it the way an
# attacker would: attempt a direct push to master and require it to be REJECTED.
if [ "${SKIP_MASTER_PUSH_TEST:-0}" = "1" ]; then
echo " SKIPPED (SKIP_MASTER_PUSH_TEST=1)"
else
PUSH_OUT=$(asuser "git -C $B push origin HEAD:master --dry-run" 2>&1)
if echo "$PUSH_OUT" | grep -qiE 'protected|not allowed|denied|forbidden|remote rejected'; then
ok "master is protected - the deploy key CANNOT push it"
else
# ACCEPTED RISK, not a failure. The operator has decided NOT to enable branch protection
# while this editor is LAN-only; the control is "it is not reachable off-LAN", not "master
# is immutable". So this is reported, not failed - but it is checked on EVERY run, so the
# moment protection does go on, the run says so and the recommendation below becomes moot.
note "master is NOT protected - the deploy key CAN push master directly"
echo " ACCEPTED because the editor is LAN-only (see docs/writer-branch-workflow.md)."
echo " BECOMES A REAL FINDING THE MOMENT THIS HOST IS REACHABLE OFF-LAN. Before that:"
echo " armistace/blog -> Settings -> Branches -> add rule for 'master'"
echo " Enable push = OFF, merge whitelist = armistace"
fi
fi
echo
echo "=============================================================="
echo "IDENTITY: git identity is SET by the operator (FR-11 superseded by explicit request)"
echo "=============================================================="
# FR-11 originally required the agent to leave the identity EMPTY so it could not impersonate
# the operator. The operator has since explicitly asked for it to be set, so the requirement is
# superseded - and the guard is inverted: the identity must now be present and must be the
# OPERATOR's, not an agent-invented one. Guarding the old expectation would assert the opposite
# of the current design and fail forever (which is what it did).
NAME=$(asuser 'git config --get user.name' 2>/dev/null)
MAIL=$(asuser 'git config --get user.email' 2>/dev/null)
if [ -n "$NAME" ]; then ok "git user.name set ($NAME)"; else bad "git user.name is empty - macro 2 will refuse to commit"; fi
if [ -n "$MAIL" ]; then ok "git user.email set ($MAIL)"; else bad "git user.email is empty"; fi
case "$MAIL" in
*hermes*|*bot*|*noreply@anthropic*|*agent*) bad "identity looks agent-generated: $MAIL" ;;
"") ;;
*) ok "identity is an operator address, not an agent-invented one" ;;
esac
if [ -f /home/atheos/.ssh/id_ed25519_blog ]; then ok "deploy key present"; else bad "deploy key missing"; fi
chk "deploy key mode" "$(stat -c '%a' /home/atheos/.ssh/id_ed25519_blog 2>/dev/null)" "600"
chk "deploy key owner" "$(stat -c '%U' /home/atheos/.ssh/id_ed25519_blog 2>/dev/null)" "atheos"
chk "ssh config mode" "$(stat -c '%a' /home/atheos/.ssh/config 2>/dev/null)" "600"
echo
echo "=============================================================="
echo "RESOURCES"
echo "=============================================================="
echo " rootfs: $(df -hP / | tail -1 | awk '{print $2" total, "$3" used, "$4" avail ("$5")"}')"
# NOTE: on Alpine, `free`/`uptime` are BUSYBOX applets that use the sysinfo() syscall, which
# lxcfs cannot intercept - so they report the PVE HOST, not this container. procps-ng (installed
# by provision.sh) replaces them with binaries that read the lxcfs-virtualised /proc files.
# Detect via apk, which is the reliable signal (the binaries are plain files either way).
if apk info -e procps-ng >/dev/null 2>&1; then
echo " memory: $(/usr/bin/free -m 2>/dev/null | awk 'NR==2{print $3"MB used of "$2"MB"}')"
echo " uptime: $(/usr/bin/uptime 2>/dev/null | sed 's/.*up /up /;s/,.*//') (container-accurate)"
else
echo " memory: (procps-ng NOT installed - busybox free/uptime would report the HOST)"
fi
echo " lxcfs virtualises /proc: meminfo=$(grep MemTotal /proc/meminfo | awk '{print $2"kB"}') uptime=$(cut -d. -f1 /proc/uptime)s"
echo " listening ports: $(netstat -tln 2>/dev/null | awk '/LISTEN/{split($4,a,":"); print a[length(a)]}' | sort -u | tr '\n' ' ')"
echo
echo "=============================================================="
printf 'RESULT: %d passed, %d failed, %d accepted-risk\n' "$pass" "$fail" "$note"
echo "=============================================================="
[ "$fail" -eq 0 ] || exit 1
+76
View File
@@ -0,0 +1,76 @@
#!/bin/sh
# Re-apply the UI/layout fixes to a rebuilt or updated Atheos tree.
#
# Why this exists: Atheos has no releases and `scripts/update-atheos.sh` replaces the whole
# application directory, so any local patch is lost on update. These are the two that were
# needed to make the editor usable. Both are IDEMPOTENT - run it as often as you like.
#
# 1. panel { height: 500px } -> height:auto; max-height:58vh
# Every modal panel was forced to a fixed 500px box, so dialogs with short content showed
# a large empty black region and anything after the panel (codegit's "Branch:" toolbar)
# was pushed OUTSIDE the modal. See docs/known-issues.md §4.
#
# 2. nginx: Cache-Control: no-cache for *.css/*.js
# Theme/bundle changes were invisible behind the browser cache. §5.
#
# Usage: sh scripts/fix-panel-layout.sh (run ON the guest, as root)
set -e
ATH=/var/www/atheos
CSS=$ATH/theme/main.min.css
SCSS=$ATH/theme/modules/common/_panel.scss
NGX=/etc/nginx/http.d/atheos.conf
echo "=== 1. panel layout ==="
OLD='height:500px;position:relative;border-left:var(--borderLight)'
NEW='height:auto;max-height:58vh;position:relative;border-left:var(--borderLight)'
if [ ! -f "$CSS" ]; then
echo " !! $CSS not found - is Atheos installed?"
elif grep -qF "$NEW" "$CSS"; then
echo " already applied"
elif grep -qF "$OLD" "$CSS"; then
[ -f "$CSS.orig" ] || cp -a "$CSS" "$CSS.orig"
php83 -r '$f=$argv[1];$o=$argv[2];$n=$argv[3];$s=file_get_contents($f);
if(strpos($s,$o)===false){exit(1);} file_put_contents($f,str_replace($o,$n,$s));' "$CSS" "$OLD" "$NEW"
echo " applied to main.min.css"
else
echo " !! expected panel rule not found - inspect manually"
echo " (upstream may have changed it; diff against $CSS.orig)"
fi
# keep the SCSS source consistent so the shipped CSS is not contradicted by the source
if [ -f "$SCSS" ] && grep -q 'height: 500px;' "$SCSS"; then
[ -f "$SCSS.orig" ] || cp -a "$SCSS" "$SCSS.orig"
php83 -r '$f=$argv[1];$s=file_get_contents($f);
$s=str_replace("height: 500px;","height: auto;\n\tmax-height: 58vh;",$s);
file_put_contents($f,$s);' "$SCSS"
echo " synchronised _panel.scss"
fi
echo "=== 2. nginx cache headers ==="
if [ ! -f "$NGX" ]; then
echo " !! $NGX not found"
elif grep -q 'must-revalidate' "$NGX"; then
echo " already applied"
else
[ -f "$NGX.orig" ] || cp -a "$NGX" "$NGX.orig"
cat > /tmp/.cacheblock <<'EOF'
# Atheos serves theme/main.min.css and the generated *.min.js/css bundles from STABLE URLs
# with no cache headers, so a CSS/JS fix stays invisible behind the browser cache until a
# hard refresh. "no-cache" permits caching but forces revalidation: normal loads still get a
# cheap 304, and a change is guaranteed to be picked up on the next load.
location ~* \.(css|js)$ {
add_header Cache-Control "no-cache, must-revalidate";
try_files $uri =404;
}
EOF
php83 -r '$f=$argv[1];$b=$argv[2];$s=file_get_contents($f);
$p=strrpos($s,"}"); if($p===false){exit(1);}
file_put_contents($f,substr($s,0,$p).file_get_contents($b).substr($s,$p));' "$NGX" /tmp/.cacheblock
rm -f /tmp/.cacheblock
nginx -t >/dev/null 2>&1 && rc-service nginx reload >/dev/null 2>&1 && echo " applied + nginx reloaded" \
|| echo " !! nginx -t failed - restore $NGX.orig"
fi
echo "=== done ==="
+246
View File
@@ -0,0 +1,246 @@
#!/bin/sh
# ==============================================================================
# provision.sh — rebuild the Atheos blog editor LXC from scratch
# Project: atheos-blog-lxc (AI-DLC)
# Target : Proxmox LXC 136 on node scotty (192.168.178.69)
#
# RUN THIS ON THE PVE NODE (scotty), as root. It is written to be re-runnable:
# each step checks state before acting.
#
# This script reproduces exactly what was executed during the initial build on
# 2026-09-29. It deliberately does NOT create any `armistace` credential, git
# identity, or Gitea user (requirement FR-11).
# ==============================================================================
set -eu
VMID=136
NODE_HOST="192.168.178.69"
TEMPLATE_STORAGE="LARGE_STORAGE_1"
ROOTFS_STORAGE="scotty-local-lvm"
TEMPLATE="alpine-3.23-default_20260116_amd64.tar.xz"
BRIDGE="vmbr0"
ROOTFS_SIZE=20
CORES=2
MEMORY=1024
SWAP=256
TZ_NAME="Australia/Brisbane"
# --- Atheos pin (design decision D2: pinned, not tracking a moving branch) ----
# Recorded at build time 2026-09-29 from github.com/Atheos/Atheos (default branch `main`)
ATHEOS_SHA="f90cd1806dbe0e25673a77e27390e8b1a240c3f6"
ATHEOS_BUILD=611
ATHEOS_TARBALL_SHA256="03ebcb1479b59f1898b5fa50808b181750b42211698f7f7b4042cf2514d78b20"
# --- the blog repo (read-only clone source; agent never pushes) --------------
BLOG_REPO="https://git.aridgwayweb.com/armistace/blog.git"
GITEA_SSH_HOST="192.168.178.155" # LAN address: Gitea SSH_DOMAIN; public :22 is not forwarded
GITEA_SSH_USER="gitea" # !! app.ini SSH_USER=gitea - NOT the usual `git`.
# Using `git` yields "Invalid user git" from sshd,
# which is rejected BEFORE authorized_keys is read.
WEBROOT="/var/www/atheos"
FPM_USER="atheos"
FPM_SOCKET="/run/php-fpm83/php-fpm.sock"
log() { printf '\n=== %s\n' "$*"; }
# ==============================================================================
log "1/9 verify we are on the PVE node and the VMID is free"
hostname
pct status "$VMID" >/dev/null 2>&1 && { echo "ERROR: VMID $VMID already exists - aborting"; exit 1; }
# ==============================================================================
log "2/9 ensure the Alpine template is present"
pveam update >/dev/null 2>&1 || true
if ! pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep -q "$TEMPLATE"; then
pveam download "$TEMPLATE_STORAGE" "$TEMPLATE"
else
echo "template already present"
fi
# ==============================================================================
log "3/9 create the container (U1)"
pct create "$VMID" "$TEMPLATE_STORAGE:vztmpl/$TEMPLATE" \
--hostname atheos-blog \
--cores "$CORES" \
--memory "$MEMORY" \
--swap "$SWAP" \
--rootfs "$ROOTFS_STORAGE:$ROOTFS_SIZE" \
--net0 "name=eth0,bridge=$BRIDGE,ip=dhcp" \
--unprivileged 1 \
--onboot 1 \
--ostype alpine \
--features "" \
--description "Atheos browser editor for armistace/blog (AI-DLC project atheos-blog-lxc). LAN-only. Deploy key auth. See Gitea hermes/atheos-blog-lxc RUNBOOK."
pct start "$VMID"
sleep 8
pct status "$VMID"
# ==============================================================================
log "4/9 base configuration (U1): timezone, ssh, apt/apk index"
pct exec "$VMID" -- sh -c "
apk update >/dev/null 2>&1
apk add --no-cache tzdata openssh >/dev/null 2>&1
cp /usr/share/zoneinfo/$TZ_NAME /etc/localtime
echo $TZ_NAME > /etc/timezone
rc-update add sshd default >/dev/null 2>&1
rc-service sshd start >/dev/null 2>&1
date
"
# ==============================================================================
log "5/9 web stack (U2): nginx + php83 + extensions"
pct exec "$VMID" -- sh -c '
apk add --no-cache nginx php83 php83-fpm \
php83-session php83-zip php83-openssl php83-mbstring php83-ctype php83-curl \
php83-dom php83-json php83-iconv php83-fileinfo php83-simplexml php83-xml \
php83-tokenizer procps-ng >/dev/null 2>&1
# procps-ng (NOT lxcfs) is what makes free/uptime report the CONTAINER rather than the host.
# busybox applets use the sysinfo() syscall, which lxcfs cannot intercept; procps-ng reads the
# lxcfs-virtualised /proc/meminfo and /proc/uptime. Verified: procps free -> 1024MB, busybox -> host.
# dedicated service user (I-4: FPM user == file owner == key owner)
adduser -D -s /sbin/nologin -h /home/atheos atheos 2>/dev/null || true
mkdir -p /var/www/atheos/workspace /run/php-fpm83 /var/log/nginx
chown nginx:nginx /run/php-fpm83
chmod 755 /run/php-fpm83
chown -R atheos:atheos /var/www/atheos /home/atheos
# timezone (NFR-5)
sed -i "s|^;\?date.timezone =.*|date.timezone = Australia/Brisbane|" /etc/php83/php.ini
# --- the socket contract (I-1): pool listen MUST byte-match nginx fastcgi_pass ---
cp -n /etc/php83/php-fpm.d/www.conf /etc/php83/php-fpm.d/www.conf.orig 2>/dev/null || true
sed -i "s|^user = .*|user = atheos|" /etc/php83/php-fpm.d/www.conf
sed -i "s|^group = .*|group = atheos|" /etc/php83/php-fpm.d/www.conf
sed -i "s|^listen = .*|listen = '"$FPM_SOCKET"'|" /etc/php83/php-fpm.d/www.conf
grep -q "^listen.owner" /etc/php83/php-fpm.d/www.conf || echo "listen.owner = nginx" >> /etc/php83/php-fpm.d/www.conf
grep -q "^listen.group" /etc/php83/php-fpm.d/www.conf || echo "listen.group = nginx" >> /etc/php83/php-fpm.d/www.conf
grep -q "^listen.mode" /etc/php83/php-fpm.d/www.conf || echo "listen.mode = 0660" >> /etc/php83/php-fpm.d/www.conf
rc-update add nginx default >/dev/null 2>&1
rc-update add php-fpm83 default >/dev/null 2>&1
'
# --- nginx vhost: installed BY THE SCRIPT so provisioning is repeatable end-to-end -----------
# (review finding: leaving this as a manual "see README" step broke the project's own
# repeatability requirement FR-9 — a rebuild from scratch would serve the distro default page)
pct exec "$VMID" -- sh -c "cat > /etc/nginx/http.d/atheos.conf" <<'NGINXCONF'
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root /var/www/atheos;
index index.php index.html;
access_log /var/log/nginx/atheos.access.log;
error_log /var/log/nginx/atheos.error.log;
# The socket path here MUST byte-match the pool's `listen =` in
# /etc/php83/php-fpm.d/www.conf (interface I-1 - a mismatch yields a bare 502).
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php-fpm83/php-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location / { try_files $uri $uri/ =404; }
# never serve the VCS dir, the app's private data, or dotfiles
location ~ /\.git { deny all; }
location ~ /\.ssh { deny all; }
location ~ /\. { deny all; }
location ~ ^/data/ { deny all; }
location ~ ^/(config\.php|common\.php|controller\.php|traits/) { deny all; }
location ~* \.(md|py|sh|log|conf|ini|sql|bak|orig)$ { deny all; }
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 64m;
}
NGINXCONF
# remove the distro default vhost so ours is authoritative, then start the stack
pct exec "$VMID" -- sh -c '
rm -f /etc/nginx/http.d/default.conf
rc-service php-fpm83 restart >/dev/null 2>&1
rc-service nginx restart >/dev/null 2>&1
sleep 2
# prove the socket contract holds BEFORE Atheos is installed, so a 502 can only ever
# mean an nginx/php-fpm problem and never be confused with an Atheos problem
SOCK=$(sed -n "s/^listen = //p" /etc/php83/php-fpm.d/www.conf | head -1)
NGX=$(sed -n "s/.*fastcgi_pass unix:\([^;]*\);.*/\1/p" /etc/nginx/http.d/atheos.conf | head -1)
if [ "$SOCK" = "$NGX" ]; then echo "socket contract OK: $SOCK"; else echo "SOCKET MISMATCH: pool=$SOCK nginx=$NGX"; exit 1; fi
'
# ==============================================================================
log "6/9 Atheos install at the pinned commit (U3)"
pct exec "$VMID" -- sh -c "
cd /tmp
wget -q -O atheos.tar.gz https://codeload.github.com/Atheos/Atheos/tar.gz/$ATHEOS_SHA
echo '$ATHEOS_TARBALL_SHA256 atheos.tar.gz' | sha256sum -c - || { echo 'CHECKSUM MISMATCH - abort'; exit 1; }
rm -rf /tmp/atheos-extract && mkdir -p /tmp/atheos-extract
tar xzf atheos.tar.gz -C /tmp/atheos-extract
cd /tmp/atheos-extract/Atheos-$ATHEOS_SHA
for f in \$(ls -A); do
[ \"\$f\" = workspace ] && continue
cp -a \"\$f\" $WEBROOT/ 2>/dev/null || true
done
cd $WEBROOT
touch config.php && chmod 664 config.php
for d in data workspace plugins themes; do mkdir -p \$d; chmod 775 \$d; done
chown -R atheos:atheos $WEBROOT
echo \"Atheos build: \$(cat .version)\"
"
# ==============================================================================
log "7/9 git + clone + deploy key (U4)"
pct exec "$VMID" -- sh -c "
apk add --no-cache git openssh-client >/dev/null 2>&1
cd $WEBROOT/workspace
su -s /bin/sh atheos -c 'git config --global --add safe.directory $WEBROOT/workspace'
su -s /bin/sh atheos -c 'git config --global --add safe.directory $WEBROOT/workspace/blog'
# clone over HTTPS (public repo => no credential at this stage; U4 phase 1)
su -s /bin/sh atheos -c 'test -d blog/.git || git clone $BLOG_REPO blog'
# deploy keypair: private key stays on the guest, owned by the FPM user
mkdir -p /home/atheos/.ssh && chmod 700 /home/atheos/.ssh && chown atheos:atheos /home/atheos/.ssh
su -s /bin/sh atheos -c 'test -f /home/atheos/.ssh/id_ed25519_blog || ssh-keygen -t ed25519 -f /home/atheos/.ssh/id_ed25519_blog -N \"\" -C \"atheos-blog-lxc deploy key (armistace/blog)\"'
chmod 600 /home/atheos/.ssh/id_ed25519_blog
chmod 644 /home/atheos/.ssh/id_ed25519_blog.pub
chown atheos:atheos /home/atheos/.ssh/id_ed25519_blog /home/atheos/.ssh/id_ed25519_blog.pub
"
# NB the ~/.ssh/config alias is written by the install-files step (see files/ssh-config)
# IMPORTANT: its User must be "$GITEA_SSH_USER" (gitea), NOT `git`.
pct exec "$VMID" -- sh -c "chmod 600 /home/atheos/.ssh/config 2>/dev/null; chown atheos:atheos /home/atheos/.ssh/config 2>/dev/null; true"
# ==============================================================================
log "8/9 PUBLISH the deploy key public half to the operator"
pct exec "$VMID" -- sh -c "
echo
echo ' ----- BEGIN deploy key (public half) -----'
cat /home/atheos/.ssh/id_ed25519_blog.pub
echo ' ----- END deploy key (public half) -----'
ssh-keygen -lf /home/atheos/.ssh/id_ed25519_blog.pub
"
cat <<'INSTRUCTIONS'
>>> OPERATOR ACTION: add the key above to Gitea
Repo: armistace/blog -> Settings -> Deploy Keys -> Add Deploy Key
Title: atheos-blog-lxc
TICK "Allow write access" (required for push)
Then tell the agent to run phase 2 (switch the remote to SSH):
git -C /var/www/atheos/workspace/blog remote set-url origin gitea-blog:armistace/blog.git
INSTRUCTIONS
# ==============================================================================
log "9/9 smoke test"
pct exec "$VMID" -- sh -c '
ip -4 addr show eth0 | grep inet
rc-service nginx status | head -1
rc-service php-fpm83 status | head -1
ls -lad /run/php-fpm83/php-fpm.sock
'
echo
echo "Provisioning complete. Now open http://<guest-ip>/ in a browser and complete"
echo "the Atheos first-run installer (creates the admin user - operator owned)."
echo "IMPORTANT: first action after SSH -> set your git identity. See RUNBOOK.md step 1."
+99
View File
@@ -0,0 +1,99 @@
#!/bin/sh
# ==============================================================================
# update-atheos.sh — update the Atheos install on LXC 136 and RE-PIN the commit
# Design decision D2: Atheos has no releases, so the install is pinned to a
# resolved commit SHA for reproducibility. This script is the deliberate escape
# hatch: it fetches the latest upstream commit, updates the install, and
# re-pins by rewriting the recorded SHA.
#
# RUN THIS ON THE GUEST (as root), or via: ssh root@192.168.178.57 'sh -s' < update-atheos.sh
#
# It does NOT touch your git identity, your deploy key, the blog clone, or
# anything under workspace/. Only the Atheos application files are replaced.
# ==============================================================================
set -eu
WEBROOT="/var/www/atheos"
FPM_USER="atheos"
STATE_FILE="/etc/atheos-blog-lxc.pin"
log() { printf '\n=== %s\n' "$*"; }
log "current pin"
[ -f "$STATE_FILE" ] && cat "$STATE_FILE" || echo "(no pin recorded - first run)"
echo "installed build: $(cat $WEBROOT/.version 2>/dev/null || echo unknown)"
log "resolve latest upstream commit (branch: main)"
LATEST_SHA=$(wget -qO- https://api.github.com/repos/Atheos/Atheos/commits/main \
| sed -n 's/.*"sha": *"\([a-f0-9]\{40\}\)".*/\1/p' | head -1)
if [ -z "$LATEST_SHA" ]; then
echo "ERROR: could not resolve the upstream SHA (network or rate limit). Aborting."
exit 1
fi
echo "latest: $LATEST_SHA"
CURRENT_SHA=$(sed -n 's/^SHA=//p' "$STATE_FILE" 2>/dev/null || true)
if [ "$LATEST_SHA" = "$CURRENT_SHA" ]; then
echo "Already at the latest pin ($LATEST_SHA). Nothing to do."
exit 0
fi
echo "updating: $CURRENT_SHA -> $LATEST_SHA"
log "download + extract to a staging dir (install is NOT touched until verified)"
rm -rf /tmp/atheos-update && mkdir -p /tmp/atheos-update
cd /tmp/atheos-update
wget -q -O atheos.tar.gz "https://codeload.github.com/Atheos/Atheos/tar.gz/$LATEST_SHA"
sha256sum atheos.tar.gz | sed 's/^/ tarball sha256: /'
tar xzf atheos.tar.gz
SRC="/tmp/atheos-update/Atheos-$LATEST_SHA"
test -d "$SRC" || { echo "ERROR: extract produced no $SRC"; exit 1; }
log "sanity-check the staged tree looks like Atheos"
for f in index.php common.php controller.php classes; do
test -e "$SRC/$f" || { echo "ERROR: staged tree is missing $f - refusing to install"; exit 1; }
done
echo "staged tree looks sane"
log "back up the current app files (excluding workspace/)"
BK="/root/atheos-backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BK"
for f in $(ls -A "$WEBROOT"); do
[ "$f" = workspace ] && continue
cp -a "$WEBROOT/$f" "$BK/" 2>/dev/null || true
done
echo "backup: $BK"
log "install the new files (workspace/ preserved)"
cd "$SRC"
for f in $(ls -A); do
[ "$f" = workspace ] && continue
rm -rf "$WEBROOT/$f"
cp -a "$f" "$WEBROOT/" 2>/dev/null || true
done
chown -R "$FPM_USER:$FPM_USER" "$WEBROOT"
echo "new build: $(cat $WEBROOT/.version 2>/dev/null || echo unknown)"
log "re-pin"
{
echo "# atheos-blog-lxc Atheos pin - managed by scripts/update-atheos.sh"
echo "# Updated: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "SHA=$LATEST_SHA"
} > "$STATE_FILE"
cat "$STATE_FILE"
log "restart + smoke test"
rc-service php-fpm83 restart >/dev/null 2>&1 || true
rc-service nginx restart >/dev/null 2>&1 || true
sleep 2
CODE=$(wget -qO- --server-response http://127.0.0.1/ 2>&1 | sed -n 's/.*HTTP\/1\.[01] \([0-9]*\).*/\1/p' | head -1)
echo "local HTTP status: ${CODE:-unknown}"
if [ "${CODE:-000}" != "200" ]; then
echo
echo "WARNING: the app did not return 200."
echo "Roll back with: rm -rf $WEBROOT/* (except workspace/) && cp -a $BK/* $WEBROOT/"
exit 1
fi
echo
echo "Update complete and re-pinned to $LATEST_SHA."
echo "NOTE: re-run your verification (open the UI). If Atheos changed its installer/config"
echo " semantics, the web UI will tell you - this script only replaces application files."