diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..e27b411 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,66 @@ +# deploy/ + +The configuration that makes this application a running service, under version +control. + +## Why this directory exists + +`STAGING-STATE.md` recorded `mechcomp.service` as delivered at +`deploy/mechcomp.service` on 2026-09-11. The unit was real; the directory was +not. The file existed only on CT 100, so the repository claimed to hold +something it did not, and the only way to answer a question about the service +was to read the container. + +That is the failure mode this directory closes. The repository is the single +source of truth. Where these files and the hosts disagree, the disagreement is +a finding — and the direction of travel is from here outward, not from the host +back. + +## What is here + +| File | Lives on | Installed at | +|---|---|---| +| `mechcomp.service` | CT 100 | `/etc/systemd/system/mechcomp.service` | +| `nginx/mechanical-compiler.conf` | CT 101 | `/etc/nginx/sites-available/mechanical-compiler`, symlinked from `sites-enabled/` | + +Both are imported **verbatim** as they ran on 2026-09-12. The first commit of a +configuration file records reality, not intentions; any improvement is a later +diff that can be read against a known-good starting point. + +## What is deliberately not here + +**`/etc/mechcomp/mechcomp.env`.** It is the deployment's own answers — +`MECHCOMP_BIND`, `MECHCOMP_PORT`, `MECHCOMP_BASE_URL` — and it is +`root:mechcomp 0640` because a deployment's bindings belong to the deployment. +A committed copy would be a second source of truth for the one file that is +supposed to differ between instances. `ENVIRONMENT.md` documents the keys; the +values stay on the host. + +**Certificates and keys.** `/etc/ssl/mechcomp/` on CT 101 holds a leaf signed by +the local staging CA. Keys are never committed. + +## Two facts about the nginx vhost worth knowing before editing it + +**CT 101 does not know the public name.** Its `server_name` is +`mechanical-compiler.dev.infra`, a hosts-file name resolvable on `srv-b`, CT 100 +and CT 101 only, and its certificate is signed by the local staging CA. Public +TLS terminates at `wg-pk`, which proxies inward over the WireGuard tunnel to a +DNAT on `srv-b`. `dev.mechcomp.kane-il.us` appears nowhere in this file and does +not need to. + +**Both server blocks are `default_server`.** CT 101 answers whatever `Host` +arrives, which is why the public name works without being named. That is +acceptable because `10.20.0.0/24` is a portless service bridge with LAN traffic +dropped, so the only thing that can reach port 443 is the tunnel — but it means +`server_name` is documentation here rather than a filter, and adding a second +site to CT 101 will require that to change. + +## Applying a change + +Infrastructure mode (`PROCESS.md` §2). One command group, read-only first, a +rollback copy of the file before editing, `nginx -t` before `reload`, and the +change proven from outside the container rather than from within it. + +A change to either file lands in the repository first and is copied outward. +Editing on the host and back-porting reintroduces exactly the drift this +directory was created to end. diff --git a/deploy/mechcomp.service b/deploy/mechcomp.service new file mode 100644 index 0000000..e2dc956 --- /dev/null +++ b/deploy/mechcomp.service @@ -0,0 +1,56 @@ +[Unit] +# The real service. Replaces mechcomp-placeholder.service, which existed only to +# prove the nginx chain on CT 101 independently of the application +# (STAGING-STATE.md, "Placeholder backend"). That proof is done and the +# application now exists, so the placeholder is removed rather than left +# competing for 10.20.0.10:8770. +Description=Mechanical Compiler composer +Documentation=https://gitea.barternetwork.us/TheRON/mechanical-compiler +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=mechcomp +Group=mechcomp + +# Binding, base URL and everything else come from here. The unit deliberately +# passes no --host or --port: the deployment declares those, not the command +# line, and a flag here would silently outrank the file the operator edits. +EnvironmentFile=/etc/mechcomp/mechcomp.env + +WorkingDirectory=/var/www/mechcomp +ExecStart=/var/www/mechcomp/venv/bin/python -m mechcomp.web + +Restart=on-failure +RestartSec=3 + +# Hardening, matching the set the placeholder already carried. +NoNewPrivileges=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +RemoveIPC=yes + +# ProtectSystem=strict makes everything read-only except what is named here. +# The composer writes nothing today; the data directory is listed because the +# design record and any future artifact belong there and not in the working +# tree, which is also the service user's home (F-029). +ReadWritePaths=/var/lib/mechcomp + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=mechcomp + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx/mechanical-compiler.conf b/deploy/nginx/mechanical-compiler.conf new file mode 100644 index 0000000..d27e202 --- /dev/null +++ b/deploy/nginx/mechanical-compiler.conf @@ -0,0 +1,31 @@ +server { + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + + server_name mechanical-compiler.dev.infra; + + ssl_certificate /etc/ssl/mechcomp/server.crt; + ssl_certificate_key /etc/ssl/mechcomp/server.key; + + location / { + proxy_pass http://10.20.0.10:8770; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_read_timeout 300s; + client_max_body_size 8m; + } +} + +server { + listen 80; + listen [::]:80; + + server_name mechanical-compiler.dev.infra; + + return 301 https://$host$request_uri; +} diff --git a/docs/IDENTITY-CONTRACT.md b/docs/IDENTITY-CONTRACT.md new file mode 100644 index 0000000..6f08dae --- /dev/null +++ b/docs/IDENTITY-CONTRACT.md @@ -0,0 +1,236 @@ +# IDENTITY-CONTRACT.md + +How the Mechanical Compiler learns who a visitor is, and the boundary that keeps +it from learning anything else. + +| | | +|---|---| +| Created | 2026-09-12 | +| Status | **Specified, not implemented.** See §8. | +| Audience | This project, and whoever builds the membership system | +| Companions | `PROCESS.md`, `STAGING-STATE.md`, `deploy/README.md` | + +--- + +## 0. Why this document exists separately + +It is the only document in this repository written to be read by someone who +does not work on this repository. + +The membership system is a different application, in a different container, with +a different release schedule and a different author. Two systems that must agree +on an interface need that interface written down once, somewhere both of them +can point at. Burying it in `ENVIRONMENT.md` — which describes how *this* host +is built — guarantees the other side never finds it. + +The compiler is expected to catalogue a very large number of applications over a +very long time. An interface that is small enough to state on one page is the +only kind that survives that. + +--- + +## 1. The shape: relying party, not integration + +**The compiler does not authenticate anyone. It is told.** + +Authorisation is decided by the reverse proxy on CT 101, against the membership +system. By the time a request reaches the application, the decision is already +made. The application reads an identity and acts; it never asks a question about +eligibility, because it has nothing to ask the question with. + +This is the same rule the project applies to physical claims. `PRECISION.md` +puts codes and permitting out of scope — the compiler records what is true and +never issues a verdict. "Who may do this" is a verdict. It belongs to whoever +holds the membership roll, and not here. + +The practical consequence is that the two systems share a hostname's parent +domain and nothing else. Either can be rewritten entirely without the other +being read. + +--- + +## 2. The chain as it actually is + +``` +browser + -> wg-pk public TLS for dev.mechcomp.kane-il.us + -> WireGuard tunnel + -> DNAT on srv-b scoped to the hub as source + -> CT 101 nginx local staging-CA TLS; DECIDES; sets the headers + -> CT 100 :8770 the application +``` + +Two things about this that are easy to get wrong: + +**CT 101 does not know the public name.** Its `server_name` is +`mechanical-compiler.dev.infra` and its certificate is local. See +`deploy/README.md`. + +**The decision belongs at CT 101, never at `wg-pk`.** The hub carries twenty +peers, is estate infrastructure this project does not own, and every change +there is an escalation under `PROCESS.md` §7. CT 101 already shares `vmbr1` with +CT 100 and the membership container, so the eligibility check is a local call +that never leaves the service bridge. Putting authorisation at the hub would +make every future adjustment to who-may-do-what an estate change, and would +teach a shared transport about one project's membership roll. + +--- + +## 3. What crosses the boundary + +Two request headers, set by CT 101, and nothing else. + +| Header | Meaning | +|---|---| +| `X-Kane-Auth-Email` | The identity. An email address, which is the identifier. | +| `X-Kane-Auth-Method` | How it was established, e.g. `kane-fabric/oidc`. Free text, recorded verbatim. | + +They map onto `design_record.Author.email` and `Author.method`, which exist and +are tested today. When they arrive, `verified` becomes true and the rendered +design record line changes from + +``` +author someone@example.org (self-declared, unverified) +``` + +to + +``` +author someone@example.org (verified: kane-fabric/oidc) +``` + +Nothing in `design_record.py` changes shape. The parser already refuses to +recover `verified` from text — a record read back is always self-declared, +because a file cannot attest to its own verification. That asymmetry was built +for this moment. + +**A handle is not the identity.** A person may later choose a display name. It +does not replace the address and it does not travel in this contract. + +--- + +## 4. Three invariants, all of them CT 101's job + +**I-1. Inbound `X-Kane-Auth-*` is stripped at every location, before anything +else.** nginx forwards headers it was given. Without the strip, any client can +send `X-Kane-Auth-Email` and be whoever they like. This is the invariant the +other two exist to support, and it is worth landing before there is anything to +forge against. + +**I-2. CT 100 is reachable only from CT 101.** The application binds +`10.20.0.10:8770` on a portless service bridge with LAN traffic dropped. +`web/app.py` defaults to loopback rather than `0.0.0.0` for the same reason: +behind a proxy, binding too narrowly fails loudly as a 502, and binding too +widely fails silently as an open service nobody notices. + +**I-3. An absent header means unauthenticated. It is never an error and never a +default identity.** The composer must run from a bare checkout on a machine with +no proxy in front of it, and an anonymous visitor is an ordinary, expected +caller. A missing header that produced a 500, or that silently became +`admin@somewhere`, would be worse than no authentication at all. + +--- + +## 5. One gated prefix + +Everything requiring membership lives under `/m/`. Everything else is public. + +``` +/ the composer page public +/api/build build and view a section public +/m/stl export members +/m/... anything gated later members +``` + +nginx gets **one** `location /m/` block, written once and not edited again. +Gating a new endpoint afterwards is choosing a URL in Python — no proxy change, +no shared-infrastructure change, no escalation. Ungating one is the same move in +reverse. + +That cheapness is the point. It means the placement of the line is a reversible +decision rather than a structural one. + +**Where the line is today: export is gated; looking is not.** The catalogue and +the composer are open to anyone. What requires membership is producing an +artifact that carries a design record, because the record names an author and an +author only means something once somebody established who they are. The site is +the shopfront; the wall goes where the claim is made. + +--- + +## 6. What the compiler must never be told + +This list is the boundary. It is short on purpose, and it is the part most +likely to erode. + +- **What a building is.** Membership attaches to buildings. The compiler has no + representation of one and must not acquire it. +- **What a membership level is.** `CURRENT RESIDENT`, `HOA MEMBER`, `3D PRINTER` + — the compiler does not know these strings exist. By the time a request + arrives, the level has already been resolved into "this request may proceed". +- **The membership roll.** No lookup, no directory, no list of who exists. +- **Group names in any form**, including as a configuration value. A + `REQUIRED_GROUP` setting in the application would be the leak arriving by the + side door. + +If the application ever needs one of these to answer a request, the design has +failed and the fix is at the proxy, not here. + +The test: the membership system must be able to restructure its entire model — +rename every level, change what a building is, replace its storage — without a +line of this repository being read. + +--- + +## 7. What the membership system must provide + +Minimum, and deliberately not more. How it is implemented is that project's +business. + +- **An eligibility endpoint** reachable from CT 101 on the service bridge, which + answers a request with `2xx` when the caller may proceed and `401` when not. + Whatever session or token the caller carries is between that endpoint and the + browser; CT 101 forwards and does not interpret. +- **Two response headers on a `2xx`**, carrying the address and the method, for + CT 101 to promote onto the proxied request. + +That is the whole of it. The endpoint's path, its session mechanism, its login +page and its storage are not this contract's concern and should not be +constrained by it. + +--- + +## 8. Current state + +**Not implemented.** Specified here so that it is designed once, before the +endpoints that will depend on it exist. + +What is true today: the service is world-reachable and unauthenticated, and +`/m/` is not yet gated. The first endpoint under it, STL export, will therefore +ship open. That is a deliberate, recorded, temporary state and not an oversight +— it is the same posture the whole service already has, and it changes when the +membership system offers §7's endpoint. + +An earlier version of `WORK-ORDER-004` and of `HANDOFF.md` §4 called the open +service "acceptable for a development name". That reasoning was wrong: +`dev.mechcomp.kane-il.us` is production, `dev` abbreviates *Mechanical Compiler +Developers*, and the name is on printed material. The question is carried openly +instead. + +**I-1, the inbound strip, does not depend on any of this** and should land on +its own. It costs one directive and removes a forgery that would otherwise +become possible the moment §3 is implemented. + +--- + +## 9. What this removes + +`HANDOFF.md` §4 listed an access-control layer inside the compiler as the last +priority. **It is deleted, not deferred.** The compiler will not have an ACL, a +user table, a login form, or a session. Authorisation lives at the proxy against +the membership system, and an item leaves the roadmap rather than moving down +it. + +Persistence — the priority above it — changes shape rather than disappearing. A +saved design belongs to a verified person, and the verified person now comes +from outside, so it should be built knowing the identity is external.