feat(fleet): pin identity/group/path conventions + read-only audit playbook

Operator ratified four conventions on 2026-09-14. docs/pfi/fleet-conventions.md
is the pin; playbooks/audit-host-conventions.yaml is its instrument.

Pinned, verified free on all eight surveyed hosts (dynamically-allocated
system accounts cluster in 989-999 and descend, so 800-899 is safe):
  800-849  svc-* service accounts
  850      infra-ops uid+gid
  851      docker gid
  852-899  reserved for fleet-wide groups
  1000     the human account (vh)

Deliberately a pin for NEW hosts, not a migration mandate. The UID drift
(infra-ops is 1001/1002/1003/2001) is tolerable because there is no central
identity anywhere and a UID only has to agree where files cross hosts. They
do on /mnt/smithy — but that export is owned by Synology UIDs that resolve on
neither host and is 0777 throughout, so cross-host sharing works today
BECAUSE permissions are wide open. Aligning UIDs does not fix something
broken; it earns the right to drop that 777. Recorded as such rather than as
an urgent defect.

The audit playbook reports and never enforces, so a standard cannot quietly
become a flag day. Verified against nh3-dev, ana-docker, corviduo-dev and
nh3-extdev; it immediately surfaced two things the survey had missed —
llmuser holds sudo AND docker on ana-docker, and seven stacks on corviduo-dev
run from outside /opt/docker/compose (three under /home/vh, four under /opt,
including the three CI/CD-driven Worldtree deployments that must not be
moved).

Also supersedes the CLAUDE.md posture that made corviduo-dev the one host
excluded from fleet normalisation: the operator ruled all ops on it belong to
infra-ops. Its application layer stays CI/CD-owned.
This commit is contained in:
vh
2026-09-14 13:30:30 -07:00
parent 826a63b00c
commit abef67aacf
3 changed files with 321 additions and 1 deletions
+197
View File
@@ -0,0 +1,197 @@
# Fleet conventions — identities, groups, paths
Ratified by the operator 2026-09-14. This is the **pin**: new hosts are built
to it, existing hosts converge opportunistically. It is deliberately *not* a
migration mandate — see § Non-goals.
Audit any host against it with:
```bash
scripts/elway <host> --playbook playbooks/audit-host-conventions.yaml
```
---
## 1. Identity
### 1.1 The human account
| | |
|---|---|
| **Standard** | `vh`, UID/GID 1000 |
| **Legacy** | `lkraven` on nh3-dev, nh3-docker, nh3-extdev, irv-ml1, ana-docker, esh-docker-vm |
`vh` matches the operator's mail (`vh@phasefinal.com`), git identity and Gitea
namespace (`vh/*`), so it is the name everything else already uses.
⚠ **Do not retro-rename the six legacy hosts.** Renaming UID 1000 where the home
directory is populated, systemd user services are lingering, SSH configs point
at it and live agent sessions are running is real blast radius for a cosmetic
gain — and the thing that actually mattered (a personal username owning *shared*
infrastructure) was removed on 2026-09-14 when the docker trees went to
`root:docker`. New hosts get `vh`; old hosts keep `lkraven` until there is an
independent reason to rebuild them.
### 1.2 The ops account
| | |
|---|---|
| **Name** | `infra-ops` — on every host, no exceptions |
| **UID/GID** | **850** (pinned; see § 3) |
| **Sudo** | `/etc/sudoers.d/infra-ops` → `infra-ops ALL=(ALL) NOPASSWD:ALL` |
| **Home** | `/home/infra-ops`, owned `infra-ops:infra-ops`, mode **0700** |
| **Groups** | `docker` where Docker is installed |
`infra-ops` is the fleet's ops identity and is the same party as the `infra-ops`
althing handle. Mode `0700` is not paranoia: these homes hold credentials
(`~/.config/worldtree/personal-admin-token`, `~/.config/cloudflare/…`,
`~/.config/secrets-broker/bootstrap.env`). Verified safe before adoption —
nh3-extdev and corviduo-dev had already been running at `0700` for months,
including systemd units that read from that home.
⚠ The UID is currently **1001 / 1002 / 1003 / 2001** across the fleet. See § 3
for why that is tolerable today and what it costs.
### 1.3 Service accounts
| | |
|---|---|
| **Name** | `svc-<service>` — e.g. `svc-arbo`, `svc-brokkr`, `svc-llm` |
| **UID/GID** | allocated from **800–849** |
| **Shell** | `/usr/sbin/nologin` unless the account genuinely needs to log in |
| **Home** | `/var/lib/<service>` for daemons; `/home/svc-<service>` only if it needs one |
```bash
useradd --system --uid 8NN --user-group --shell /usr/sbin/nologin \
--home-dir /var/lib/<service> --create-home svc-<service>
```
**Legacy, not renamed:** `llmuser`, `sduser`, `brokkr`, `arbotrain`, `nas`,
`deploy`. Six accounts, six naming styles, all sitting in the human UID range
(1000+) where `/etc/passwd` cannot distinguish them from people. That is the
defect the convention fixes going forward.
⚠ **A service account must not hold interactive root.** As of 2026-09-14
`llmuser` is in the `sudo` group on ana-docker. Flagged, not yet resolved.
---
## 2. Groups
| group | GID | membership |
|---|---|---|
| `docker` | **851** (pinned) | the human account + `infra-ops` |
| `sudo` | 27 (Debian default) | the human account only |
⚠ **`docker` membership is root-equivalent** — anyone in it can bind-mount `/`
into a container. The group makes access *consistent*; it does not create
privilege separation. A future non-root deployer (a contractor, a tenant agent)
needs a dedicated `deploy` group, not an invitation to `docker`.
Current GIDs are **109 / 110 / 112 / 989** — assigned by `docker-ce` at install
time. Pinning means `groupadd -g 851 docker` *before* installing Docker.
---
## 3. UID/GID allocation map
Measured across eight hosts on 2026-09-14: dynamically-allocated system accounts
cluster in **989–999** and descend. **800–899 is free on every host**, which is
what makes it safe to pin there.
```
0–99 OS reserved
100–799 Debian dynamic system allocation — never pin here
800–849 svc-* service accounts (fleet-pinned)
850 infra-ops (uid + gid) (fleet-pinned)
851 docker (gid) (fleet-pinned)
852–899 reserved for fleet-wide groups
900–999 Debian allocates system accounts here — leave clear
1000 the human account (vh)
1001+ additional humans
```
### Why the existing drift is tolerable
There is **no central identity** anywhere on the fleet — every host is
`passwd: files systemd`. So a UID only has to be consistent where files cross
hosts. They do: `/mnt/smithy` is NFS-mounted on both nh3-dev (`infra-ops`=1001)
and irv-ml1 (`infra-ops`=1003), so UID 1001 on that export means `infra-ops` on
one box and `llmuser` on the other.
**That collision is not currently biting, and the reason is the finding:** the
export is owned by the Synology's own UIDs (1024, 1031) which resolve on
*neither* host, and every directory on it is `0777`. Cross-host sharing works
today *because* the permissions are wide open.
So aligning UIDs does not fix something broken — it **earns the right to drop
the 777 on shared storage**. That is a real improvement and a real project. It
is not a prerequisite for anything currently running.
---
## 4. Paths
| path | ownership | mode | contents |
|---|---|---|---|
| `/opt/docker` | `root:docker` | `2775` | the deploy tree root |
| `/opt/docker/compose/<stack>/` | `root:docker` | `2775` | `compose.yaml`, `.env` |
| `/opt/docker/conf/<stack>/` | `root:docker` | `2775` | bind-mounted config |
| `/opt/docker/compose/<stack>/.env` | `root:docker` | **`0640`** | stack secrets |
| `/home/<user>` | `<user>:<user>` | `0700` | |
The setgid bit on directories is load-bearing: it makes every file created
afterwards inherit the `docker` group, so the scheme does not decay back into
whoever-touched-it-last ownership.
⚠ **A stack lives under `/opt/docker/compose/<stack>/` or it is invisible.**
Every piece of fleet automation walks that path. `talk` sat at `~/talk` until
2026-09-14 and was silently skipped by the docker-ce upgrade's restart loop for
exactly that reason. A stack outside the convention is not merely untidy — it is
excluded from every tool that will ever be written.
### Permission-sensitive files — never widen these
Some daemons **refuse to start** when their files are group-accessible, and they
fail at their *next restart* rather than at the moment of the change. That
delayed detonation is worse than a loud failure, so these are excluded by name
*and* by mode from `normalize-docker-tree.yaml`:
| file | required | breaks |
|---|---|---|
| `acme.json` | `0600` | traefik refuses to start |
| `id_*`, `*.key`, `*.pem`, `*.pfx` | `0600` | ssh refuses a group-readable private key |
Anything already at `0600`/`0400` is deliberate. Leave it.
---
## 5. Non-goals
- **Not a migration mandate.** Existing hosts converge when there is an
independent reason to touch them. Nothing here justifies a flag day.
- **Not privilege separation.** Both `docker` membership and `infra-ops` sudo are
root-equivalent today. This document standardises *consistency*, not least
privilege.
- **Not a central identity system.** LDAP/SSSD would make § 3 moot. Deliberately
out of scope — the fleet is small enough that a pinned allocation map is
cheaper than a directory service, and a directory service is a new SPOF.
---
## 6. Open items
| item | state |
|---|---|
| `llmuser` holds `sudo` on ana-docker | flagged 2026-09-14, unresolved |
| `/mnt/smithy` NFS export is `0777` throughout | blocked on § 3 UID alignment |
| Synapse appservice tokens sit in plaintext on ana-docker | flagged 2026-09-14; belongs in the vault |
| Legacy service accounts in the human UID range | not renamed, by decision |
## Changelog
- **2026-09-14** — ratified. Deleted the dormant `linus` account on ana-docker
(passwordless root, last used 2026-04-11, archived to
`/root/account-archive/linus-20260914.tar.gz`). Standardised `infra-ops` home
to `0700` on five hosts. Normalised corviduo-dev's docker tree, the operator
having ruled that all ops on that host belong to `infra-ops`.