steid

@jamesgill /

feat: ship Steid as an installable binary

Milestone 5b. A stranger can install Steid on a fresh Linux box in one command
and reach it over HTTPS; the artifact is a binary plus its assets, not a
container.

The artifact is a directory, not a single file, because Topcoat can embed the
asset *manifest* but not the asset *bytes* — that path pairs with
AssetConfig::hosted_at and expects a CDN. And it is glibc rather than static
musl for a reason that outlives the toolchain trouble: musl buys a binary with
no runtime dependencies, and Steid hard-requires git on PATH, so the portability
cannot be used. Building on bullseye pins the glibc floor at 2.31; bookworm
would need 2.36 and silently exclude Ubuntu 22.04.

A TLS proxy is mandatory rather than conventional. Topcoat 0.5 has no TLS at
all — no rustls, no ACME, no HTTPS listener — and git authenticates over HTTP
Basic, so without a proxy an access token crosses the network in cleartext on
every push. install.sh therefore installs Caddy and writes its config, rather
than leaving the most important part as an exercise.

Two findings shaped the operability work. topcoat::start already drains on
SIGTERM, so systemd restarts do not cut a clone mid-pack and no wiring was
needed — the work was learning that. And the peer socket address is discarded at
accept time and never reaches a handler, so rate limiting is header-based by
necessity: it keys on the *rightmost* X-Forwarded-For entry, which is the one
the nearest proxy wrote and the only one a client cannot forge, with a global
cap behind it because a forged key cannot be disproved.

STEID_SETUP_TOKEN does not undermine 0002. What that ADR refused to put in
config was the owner's password — long-lived and prone to going stale in a
repository. This is the one-time claim secret the operator was already copying
out of a log line, now validated for strength and never printed.

Verified by building a real Linux artifact and booting it on Debian 11: glibc
2.31, /healthz ok, setup page 200, stylesheet served. Rate limiting, the weak
token refusal and SIGTERM drain were each exercised against a running instance.
install.sh has never been run end to end — no VPS, no domain — and is
desk-checked only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwc7URWKVhkAuRTWiDmjA
JamesPatrickGill authored 7 days agoparentdce0bf3Browse files3dd72938a900977a74755eb620382a870e42489a

17 files changed+1892 −30

.gitignore+3 −0View file
@@ -11,3 +11,6 @@
1111 # Local environment
1212 .env
1313 .env.prod
14+
15+# Release artefacts built by release.sh.
16+/dist
README.md+334 −0View file
@@ -0,0 +1,334 @@
1+# Steid
2+
3+A personal gitforge, in Rust. It hosts your repositories, and it is meant to become the
4+place your work as a whole lives — code, writing, projects — under one identity you
5+control.
6+
7+Steid is **portfolio-first**. Gitea and Forgejo are GitHub scaled down; their unit is the
8+repository and the profile is a directory listing bolted to the side. Steid inverts that:
9+the profile page at `/{handle}` is the product, and git repositories are one kind of
10+thing that appears on it. That framing wins every tie-break in the design.
11+
12+It runs as a single binary with a SQLite database, shells out to `git` for everything
13+git-shaped, and is small enough to host on the cheapest VPS you can find.
14+
15+## What works today
16+
17+Honestly and only:
18+
19+- **Identity and sessions.** One user. The instance is claimed on first run with a setup
20+ token, then you sign in with a password. There is no registration — see
21+ [limitations](#current-limitations).
22+- **The profile page** at `/{handle}`, listing what you have.
23+- **Repositories.** Created through the browser, stored as bare repos on disk and
24+ records in SQLite. Public or private.
25+- **Clone and push over HTTPS**, through `git http-backend`, authenticated with personal
26+ access tokens over HTTP Basic. Private repositories require a token to clone; public
27+ ones are anonymous.
28+- **Personal access tokens** — issue, list, revoke, at `/{handle}/settings/tokens`.
29+- **Browsing** — file tree, file contents, and commit log, at
30+ `/{handle}/repos/{name}/tree/{rev}` and `/log`.
31+- **A JSON API** — `/api/me`, `/api/users/{handle}`, `/api/users/{handle}/repos`. Not a
32+ separate product; every use case gets a second surface where that makes sense, which is
33+ what keeps the use-case layer honest.
34+
35+Writing (markdown posts), multi-user, organizations, issues, pull requests and SSH
36+transport are **not built yet**. The plan for them is in [`plans/ROADMAP.md`](plans/ROADMAP.md).
37+
38+## Requirements
39+
40+- Linux, x86_64 or aarch64, with systemd.
41+- **`git` on `PATH`.** Not optional: Steid runs `git init --bare` to create a repository
42+ and `git http-backend` to serve every clone and push. One `apt-get install git`.
43+- **A TLS-terminating reverse proxy, and a domain name for it.** Also not optional.
44+ Steid speaks plain HTTP — the web framework it is built on (Topcoat 0.5) has no TLS
45+ support at all: no rustls, no ACME, no HTTPS listener. And because git authenticates
46+ with HTTP Basic, an instance served over plain HTTP leaks your access token to every
47+ hop on the network, on every push. The installer sets Caddy up for you.
48+- About 100 MB of disk before any repositories, plus whatever you push.
49+
50+## Install
51+
52+### One command
53+
54+```sh
55+curl -fsSL https://REPLACE-ME.example.com/steid/install.sh | sh -s -- --domain git.example.com
56+```
57+
58+Point the domain's DNS at the machine first, and make sure ports 80 and 443 are open —
59+Caddy needs both to obtain a certificate.
60+
61+That does the following, and nothing else:
62+
63+1. installs `git`, `curl` and `ca-certificates`
64+2. downloads the release tarball for your architecture and **verifies its SHA-256**
65+3. unpacks it to `/opt/steid` — the binary with its `assets/` directory beside it
66+4. creates a `steid` system user and `/var/lib/steid` for state, owned by it
67+5. writes `/etc/steid/steid.env`, and a systemd unit that listens on `127.0.0.1:3000`
68+6. installs Caddy and writes a Caddyfile that terminates TLS for your domain and proxies
69+ to that loopback port
70+7. starts both
71+
72+Re-run it with a newer `--version` to upgrade. It is idempotent: it replaces the binary
73+and assets, leaves `/var/lib/steid` alone, and will not overwrite `/etc/steid/steid.env`
74+once it exists.
75+
76+Options: `--version`, `--port`, `--flavour musl|gnu`, and `--no-caddy` if you are
77+bringing your own proxy. `sh install.sh --help` lists them.
78+
79+**On piping a URL into a shell.** It is a real trust decision, and "it's convenient" is
80+not an answer to it. Download and read it first if you would rather:
81+
82+```sh
83+curl -fsSL https://REPLACE-ME.example.com/steid/install.sh -o install.sh
84+less install.sh
85+sudo sh install.sh --domain git.example.com
86+```
87+
88+Or do it by hand — the manual path below is the same steps, written out.
89+
90+### Manually
91+
92+Every command as root, on Debian or Ubuntu. Adjust paths and package manager to taste;
93+nothing here is specific to a distribution except `apt-get`.
94+
95+```sh
96+# 1. Prerequisites. git is mandatory — Steid shells out to it for everything.
97+apt-get update
98+apt-get install -y git curl ca-certificates
99+
100+# 2. Fetch and verify the release for your architecture.
101+VERSION=0.1.0
102+TARGET=x86_64-unknown-linux-musl # or aarch64-unknown-linux-musl
103+BASE=https://REPLACE-ME.example.com/steid/releases/download
104+curl -fsSLO "$BASE/v$VERSION/steid-$VERSION-$TARGET.tar.gz"
105+curl -fsSLO "$BASE/v$VERSION/steid-$VERSION-$TARGET.tar.gz.sha256"
106+sha256sum -c "steid-$VERSION-$TARGET.tar.gz.sha256"
107+tar -xzf "steid-$VERSION-$TARGET.tar.gz"
108+
109+# 3. Install. `assets/` MUST end up beside the binary: at startup Steid walks up
110+# from its own executable looking for `assets/manifest.toml`, and exits if it
111+# is not there. Moving the binary on its own gives you a program that will not
112+# boot.
113+mkdir -p /opt/steid
114+cp "steid-$VERSION-$TARGET/steid" /opt/steid/steid
115+cp -r "steid-$VERSION-$TARGET/assets" /opt/steid/assets
116+chmod 755 /opt/steid/steid
117+
118+# 4. A user to run as, and one directory for all state.
119+useradd --system --home-dir /var/lib/steid --shell /usr/sbin/nologin steid
120+mkdir -p /var/lib/steid/repos
121+chown -R steid:steid /var/lib/steid
122+chmod 750 /var/lib/steid
123+
124+# 5. Configuration.
125+mkdir -p /etc/steid
126+cat > /etc/steid/steid.env <<'EOF'
127+STEID_DATABASE_URL=sqlite:/var/lib/steid/steid.db?mode=rwc
128+STEID_DATA_DIR=/var/lib/steid/repos
129+HOST=127.0.0.1
130+PORT=3000
131+EOF
132+chmod 640 /etc/steid/steid.env
133+chown root:steid /etc/steid/steid.env
134+
135+# 6. The service. deploy/steid.service in this repo is the file to copy.
136+cp deploy/steid.service /etc/systemd/system/steid.service
137+systemctl daemon-reload
138+systemctl enable --now steid
139+
140+# 7. The proxy. deploy/Caddyfile is a working starting point — change the
141+# hostname on its first line to yours.
142+apt-get install -y caddy # or follow https://caddyserver.com/docs/install
143+cp deploy/Caddyfile /etc/caddy/Caddyfile
144+$EDITOR /etc/caddy/Caddyfile
145+systemctl reload caddy
146+```
147+
148+There is **no migration step**. Steid runs its migrations itself on every boot.
149+
150+## First run: claiming the instance
151+
152+A fresh instance has no owner. On startup it prints a setup token to its log:
153+
154+```
155+ This steid has no owner yet. Claim it at /auth/setup with:
156+
157+ <token>
158+```
159+
160+Two things about that token that will bite you otherwise:
161+
162+- It is printed **only while the instance is unclaimed**. Once you have claimed it, it is
163+ never shown again — because it no longer exists.
164+- It lives **in memory only**. Every restart mints a new one, and the one in an older log
165+ line is dead.
166+
167+So read it from the current run's journal:
168+
169+```sh
170+journalctl -u steid --no-pager | tail -n 30
171+```
172+
173+Then open `https://git.example.com/auth/setup`, paste it in, and choose your handle and
174+password. After that, `/auth/login`.
175+
176+### Or set the token yourself
177+
178+If reading a log line during the first minute of a scripted install is awkward, set the
179+token instead and skip the race:
180+
181+```sh
182+STEID_SETUP_TOKEN=$(head -c 32 /dev/urandom | base64)
183+```
184+
185+It is only consulted while the instance is unclaimed, it is never printed, and Steid
186+**refuses to start** if it is weak — at least 32 characters, no whitespace, and at least
187+eight distinct characters, so `abababab…` is rejected rather than accepted.
188+
189+## Using it
190+
191+1. Create a repository at `/{handle}/repos/new`. Public or private.
192+2. Issue a personal access token at `/{handle}/settings/tokens`. It is shown once.
193+3. Push:
194+
195+```sh
196+git remote add origin https://git.example.com/me/repos/my-project.git
197+git push -u origin main
198+```
199+
200+Git will ask for a username and password. The token goes in the **password** field; the
201+username is ignored (a token pasted as the username with an empty password also works,
202+because people do that). Let your credential helper remember it:
203+
204+```sh
205+git config --global credential.helper store
206+```
207+
208+Cloning a public repository needs no credentials at all.
209+
210+## Configuration
211+
212+Environment variables, read from `/etc/steid/steid.env` by the systemd unit.
213+
214+| Variable | Default | What it is |
215+|---|---|---|
216+| `STEID_DATABASE_URL` | `sqlite:steid.db?mode=rwc` | SQLite connection string. The directory must be writable, not just the file — SQLite writes `-wal` and `-shm` siblings. |
217+| `STEID_DATA_DIR` | `./data` | Where bare repositories live, as `{data_dir}/{handle}/{name}.git`. |
218+| `STEID_INSECURE_COOKIES` | unset (false) | **Never set this in production.** It strips `Secure` from the session cookie so it survives plain-HTTP localhost, and exists only for local development. |
219+| `HOST` | `127.0.0.1` in the shipped unit | Bind address. Read by the framework, so deliberately *not* `STEID_`-prefixed. Keep it on loopback; the proxy is the way in. |
220+| `PORT` | `3000` | Likewise not `STEID_`-prefixed. |
221+
222+Restart after editing: `systemctl restart steid`.
223+
224+## Backup and restore
225+
226+The entire backup surface is **two paths**:
227+
228+- `/var/lib/steid/steid.db` — the SQLite database (users, repositories, tokens, sessions)
229+- `/var/lib/steid/repos` — the bare git repositories
230+
231+Which is to say: back up `/var/lib/steid`. `rsync` is enough.
232+
233+```sh
234+systemctl stop steid
235+rsync -a /var/lib/steid/ backup-host:/backups/steid/
236+systemctl start steid
237+```
238+
239+Stopping first is the honest version: SQLite in WAL mode leaves `-wal` and `-shm` files,
240+and copying them while a write is in flight can capture a torn state. If you would rather
241+not stop the service, snapshot the database properly and copy the repositories live —
242+they are only written during a push:
243+
244+```sh
245+sqlite3 /var/lib/steid/steid.db ".backup '/tmp/steid-backup.db'"
246+rsync -a /tmp/steid-backup.db /var/lib/steid/repos backup-host:/backups/steid/
247+```
248+
249+To restore: install Steid as above, stop it, drop both paths back into `/var/lib/steid`,
250+`chown -R steid:steid /var/lib/steid`, start it. Migrations run on boot, so a database
251+from an older version is brought forward automatically.
252+
253+## Building from source
254+
255+```sh
256+cargo install topcoat-cli --version 0.5.0 --locked
257+topcoat asset bundle --release
258+```
259+
260+**`cargo build --release` on its own is not enough.** It produces a binary that will not
261+boot: `AssetBundle::load()` walks up from the executable looking for
262+`assets/manifest.toml`, and `build.rs` does not write one. `topcoat asset bundle` runs
263+`cargo build` itself and then writes the bundle to `target/assets`. The binary at
264+`target/release/steid` needs that directory beside it.
265+
266+Requires rustc ≥ 1.95 — Topcoat 0.5 demands it, and on an older toolchain `cargo add
267+topcoat` silently resolves to an empty `v0.0.0` placeholder instead of failing.
268+
269+For development, `topcoat dev` does the bundling for you, and a local `.env` with
270+`STEID_INSECURE_COOKIES=true` is needed for the session cookie to survive plain-HTTP
271+localhost.
272+
273+### Cutting a release
274+
275+```sh
276+./release.sh --target x86_64-unknown-linux-musl
277+```
278+
279+Produces `dist/steid-<version>-<target>.tar.gz` and a `.sha256` beside it. The tarball
280+extracts to a self-contained directory: the binary, `assets/`, and this README. Building
281+a Linux artefact on macOS needs a container (the script uses Docker) or a cross
282+toolchain; `--native` builds for the host instead, which is useful for checking the
283+artefact layout and not for releasing.
284+
285+A container image also exists — see [`Dockerfile`](Dockerfile) — but the supported
286+artefact is the plain binary.
287+
288+## Current limitations
289+
290+Deliberately blunt. Steid is early.
291+
292+- **Single user.** One account, claimed on first run. No registration, no invites, no
293+ organizations yet.
294+- **No TLS of its own.** A reverse proxy is mandatory, not recommended — see
295+ [requirements](#requirements).
296+- **No encryption at rest.** The database and repositories are plain files. Anyone with
297+ the disk has everything. Token *values* are hashed, and passwords are Argon2, but
298+ repository contents are not encrypted.
299+- **Rate limiting covers `/auth/login` and `/auth/setup` only** — 10 attempts a minute
300+ per client, with a global backstop. Token authentication on the git routes is *not*
301+ limited; a token is 256 bits, so guessing is not the concern there, but unbounded
302+ hashing on an open endpoint still is.
303+- **Rate limiting keys on forwarded headers, because Topcoat 0.5 does not expose the
304+ peer address at all.** Behind a reverse proxy — the supported deployment — that works.
305+ Exposed directly to the internet with no proxy, a caller can vary the header and get a
306+ fresh budget each time, leaving only the global cap. Run it behind the proxy.
307+- **Tokens never expire** and carry no "last used" timestamp, which makes it hard to know
308+ which are safe to revoke.
309+- **No CI/CD**, no issues, no pull requests, no code review, no SSH transport, no
310+ webhooks, no federation.
311+- **No writing yet** — posts and markdown are the next milestone, and they are the point
312+ of the whole thing.
313+- **Light mode is untested.** It is defined; nobody has looked at it.
314+- **Not battle-tested.** It has not run under load, has not been audited, and has been
315+ deployed by approximately one person. Do not put anything irreplaceable in it that is
316+ not also somewhere else.
317+
318+More, in unflattering detail, in [`plans/current.md`](plans/current.md).
319+
320+## Project layout
321+
322+`plans/` is the source of truth for intent, not the code:
323+
324+| File | Holds |
325+|---|---|
326+| [`plans/ROADMAP.md`](plans/ROADMAP.md) | vision, stack, the milestone ladder |
327+| [`plans/current.md`](plans/current.md) | the active milestone, and every known gap |
328+| [`plans/progress.md`](plans/progress.md) | what shipped, and the decisions worth not rediscovering |
329+| [`plans/architecture.md`](plans/architecture.md) | layer rules and conventions |
330+| [`plans/decisions/`](plans/decisions/) | ADRs |
331+
332+The code is a single crate in three layers — `domain`, `application`, `infrastructure` —
333+with dependencies pointing inward. Every use case takes an `Actor` and authorizes before
334+any side effect.
deploy/Caddyfile+30 −0View file
@@ -0,0 +1,30 @@
1+# Caddy in front of Steid.
2+#
3+# This is the reference copy, for the manual installation path in README.md.
4+# install.sh writes the same file to /etc/caddy/Caddyfile with the domain
5+# substituted — if you change one, change the other.
6+#
7+# WHY A PROXY IS MANDATORY, not a nicety: Topcoat 0.5 has no TLS support at all —
8+# no rustls, no ACME, no HTTPS listener. Steid authenticates git over HTTP Basic,
9+# so on a plain-HTTP instance a personal access token is sent in cleartext on
10+# every clone and every push. Caddy is used because obtaining and renewing a
11+# certificate is automatic and needs no configuration beyond the hostname below.
12+#
13+# Requirements for the certificate to issue: the domain's A/AAAA record must
14+# already point at this machine, and ports 80 and 443 must be reachable.
15+
16+git.example.com {
17+ # Steid listens on loopback only (HOST=127.0.0.1 in /etc/steid/steid.env),
18+ # so this proxy is the only way in.
19+ reverse_proxy 127.0.0.1:3000 {
20+ # Git's smart HTTP protocol streams: the client sends ref negotiation and
21+ # waits on a response that is generated as it goes. Buffering either
22+ # direction turns a clone into a long silence and can stall negotiation
23+ # outright, so flush every write straight through.
24+ flush_interval -1
25+ }
26+
27+ # Deliberately no `encode`. Git packfiles are already compressed and the git
28+ # client sets its own Accept-Encoding; re-compressing them costs CPU on the
29+ # hot path and buys nothing.
30+}
deploy/steid.service+57 −0View file
@@ -0,0 +1,57 @@
1+# Steid as a systemd service.
2+#
3+# This is the reference copy, for the manual installation path in README.md.
4+# install.sh writes an identical file to /etc/systemd/system/steid.service —
5+# if you change one, change the other.
6+#
7+# Assumes the layout install.sh creates:
8+# /opt/steid/steid the binary
9+# /opt/steid/assets/ the asset bundle, which MUST sit beside the binary
10+# /var/lib/steid/ all state: the SQLite database and the bare repos
11+# /etc/steid/steid.env configuration
12+
13+[Unit]
14+Description=Steid
15+Documentation=https://github.com/JamesPatrickGill/steid
16+After=network-online.target
17+Wants=network-online.target
18+
19+[Service]
20+Type=simple
21+User=steid
22+Group=steid
23+
24+# WorkingDirectory is /opt/steid, where nothing is written. The asset bundle is
25+# found either way — `AssetBundle::load()` resolves relative to the executable,
26+# verified by running the binary from `/` — but `dotenvy` reads `.env` from the
27+# *working directory*, so pointing this at the state directory would let a file
28+# dropped there silently override the configuration below.
29+WorkingDirectory=/opt/steid
30+ExecStart=/opt/steid/steid
31+EnvironmentFile=/etc/steid/steid.env
32+
33+# git reads and writes config relative to HOME, and it is not optional: without
34+# it git warns, and in some setups fails, on every subprocess Steid spawns.
35+Environment=HOME=/var/lib/steid
36+
37+Restart=on-failure
38+RestartSec=2s
39+
40+# Hardening. Steid needs exactly one writable path, executes git from /usr, and
41+# talks to nothing but the loopback listener, so most of this is free.
42+NoNewPrivileges=true
43+PrivateTmp=true
44+PrivateDevices=true
45+ProtectSystem=strict
46+ProtectHome=true
47+ProtectKernelTunables=true
48+ProtectKernelModules=true
49+ProtectControlGroups=true
50+RestrictSUIDSGID=true
51+RestrictNamespaces=true
52+LockPersonality=true
53+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
54+ReadWritePaths=/var/lib/steid
55+
56+[Install]
57+WantedBy=multi-user.target
install.sh+362 −0View file
@@ -0,0 +1,362 @@
1+#!/bin/sh
2+#
3+# Steid installer for a fresh Debian/Ubuntu server.
4+#
5+# curl -fsSL https://.../install.sh | sh -s -- --domain git.example.com
6+#
7+# It installs Steid to /opt/steid, keeps state in /var/lib/steid, runs it as an
8+# unprivileged system user under systemd, and puts Caddy in front of it with an
9+# automatic HTTPS certificate for the domain you give.
10+#
11+# Re-running it is an upgrade: it downloads the requested version, replaces the
12+# binary and assets, rewrites the unit and the Caddyfile, and restarts. It never
13+# touches /var/lib/steid, and it never overwrites /etc/steid/steid.env once that
14+# exists, so anything you have edited there survives.
15+#
16+# ON `curl | sh`: you are being asked to run a script you have not read, as root,
17+# from a URL. That is a real trust decision and "it's convenient" is not an
18+# answer to it. Two honest alternatives: download it first and read it
19+# (`curl -fsSL … -o install.sh; less install.sh; sh install.sh --domain …`), or
20+# follow the manual path in README.md, which is the same dozen commands written
21+# out. Nothing here is magic; the script exists to save typing, not to be trusted
22+# blindly.
23+
24+set -eu
25+
26+# --- PLACEHOLDER ------------------------------------------------------------
27+#
28+# !! There is no published release yet. This URL does not resolve. !!
29+#
30+# Set it to the base URL under which release directories live. The layout the
31+# script expects, and which release.sh produces, is:
32+#
33+# ${RELEASE_BASE_URL}/v${VERSION}/steid-${VERSION}-${TARGET}.tar.gz
34+# ${RELEASE_BASE_URL}/v${VERSION}/steid-${VERSION}-${TARGET}.tar.gz.sha256
35+#
36+# For GitHub releases that is:
37+# https://github.com/JamesPatrickGill/steid/releases/download
38+RELEASE_BASE_URL="${STEID_RELEASE_BASE_URL:-https://REPLACE-ME.example.com/steid/releases/download}"
39+
40+# The version to install. Pinned rather than "latest" because there is no
41+# redirect to resolve "latest" against, and a pinned default makes the upgrade
42+# path explicit: `--version 0.2.0`.
43+VERSION="${STEID_VERSION:-0.1.0}"
44+# ----------------------------------------------------------------------------
45+
46+# The build target to fetch. musl is preferred — one static binary that does not
47+# care which glibc the host has — but whether Steid builds against musl at all is
48+# still being established, so the choice is a variable rather than a fact.
49+# Override with --flavour gnu if the published artefacts are glibc.
50+FLAVOUR="musl"
51+
52+DOMAIN=""
53+PORT="3000"
54+INSTALL_DIR="/opt/steid"
55+STATE_DIR="/var/lib/steid"
56+CONF_DIR="/etc/steid"
57+STEID_USER="steid"
58+INSTALL_CADDY=1
59+
60+die() { echo "install.sh: $*" >&2; exit 1; }
61+say() { echo "==> $*"; }
62+
63+usage() {
64+ cat >&2 <<'USAGE'
65+Usage: install.sh --domain <hostname> [options]
66+
67+ --domain <hostname> the public hostname, e.g. git.example.com. Its DNS must
68+ already point at this machine or the certificate cannot
69+ be issued. Required.
70+ --version <v> release to install (default: the pinned one above)
71+ --flavour <musl|gnu> which build to fetch (default: musl)
72+ --port <n> loopback port Steid listens on (default: 3000)
73+ --no-caddy install and run Steid but do not touch Caddy. Only for
74+ putting your own TLS-terminating proxy in front. Steid
75+ has no TLS of its own; without a proxy, access tokens
76+ cross the network in cleartext.
77+ -h, --help this
78+USAGE
79+ exit "${1:-0}"
80+}
81+
82+# --- arguments --------------------------------------------------------------
83+
84+while [ $# -gt 0 ]; do
85+ case "$1" in
86+ --domain) DOMAIN="${2:-}"; [ -n "$DOMAIN" ] || die "--domain needs a hostname"; shift 2 ;;
87+ --version) VERSION="${2:-}"; [ -n "$VERSION" ] || die "--version needs a value"; shift 2 ;;
88+ --flavour) FLAVOUR="${2:-}"; [ -n "$FLAVOUR" ] || die "--flavour needs a value"; shift 2 ;;
89+ --port) PORT="${2:-}"; [ -n "$PORT" ] || die "--port needs a number"; shift 2 ;;
90+ --no-caddy) INSTALL_CADDY=0; shift ;;
91+ -h|--help) usage 0 ;;
92+ *) echo "install.sh: unknown argument: $1" >&2; usage 1 ;;
93+ esac
94+done
95+
96+# Fail early and loudly rather than half-installing. Everything below this point
97+# assumes these hold.
98+[ "$(id -u)" = "0" ] || die "must run as root (try: sudo sh install.sh --domain …)"
99+
100+if [ "$INSTALL_CADDY" = 1 ]; then
101+ [ -n "$DOMAIN" ] || die "--domain is required. Caddy needs a real hostname to
102+ obtain a certificate for, and Steid has no TLS of its own. If you are putting
103+ your own proxy in front, pass --no-caddy."
104+fi
105+
106+if [ -n "$DOMAIN" ]; then
107+ case "$DOMAIN" in
108+ *[!A-Za-z0-9.-]*|-*|.*|*.) die "'$DOMAIN' does not look like a hostname" ;;
109+ esac
110+fi
111+
112+case "$PORT" in
113+ ''|*[!0-9]*) die "--port must be a number" ;;
114+esac
115+
116+case "$FLAVOUR" in
117+ musl|gnu) ;;
118+ *) die "--flavour must be 'musl' or 'gnu'" ;;
119+esac
120+
121+case "$RELEASE_BASE_URL" in
122+ *REPLACE-ME*) die "RELEASE_BASE_URL is still the placeholder. Edit the top of
123+ this script (or set STEID_RELEASE_BASE_URL) to point at real release artefacts." ;;
124+esac
125+
126+command -v systemctl >/dev/null 2>&1 || die "no systemd here; follow the manual path in README.md"
127+command -v apt-get >/dev/null 2>&1 || die "this installer only knows apt (Debian/Ubuntu).
128+ The manual path in README.md works on anything with systemd."
129+
130+case "$(uname -m)" in
131+ x86_64|amd64) ARCH="x86_64" ;;
132+ aarch64|arm64) ARCH="aarch64" ;;
133+ *) die "unsupported architecture: $(uname -m). Only x86_64 and aarch64 are built." ;;
134+esac
135+
136+TARGET="${ARCH}-unknown-linux-${FLAVOUR}"
137+NAME="steid-${VERSION}-${TARGET}"
138+URL="${RELEASE_BASE_URL}/v${VERSION}/${NAME}.tar.gz"
139+
140+say "installing Steid ${VERSION} (${TARGET}) for ${DOMAIN:-<no domain>}"
141+
142+# --- prerequisites ----------------------------------------------------------
143+
144+# git is not optional and not a runtime nicety: Steid shells out to `git init
145+# --bare` to create a repository and to `git http-backend` to serve every clone
146+# and push. Without it the install succeeds and the first repository fails.
147+say "installing prerequisites (git, curl, ca-certificates)"
148+export DEBIAN_FRONTEND=noninteractive
149+apt-get update -qq
150+apt-get install -y -qq --no-install-recommends git curl ca-certificates
151+
152+# --- download ---------------------------------------------------------------
153+
154+TMP="$(mktemp -d)"
155+# shellcheck disable=SC2064 # $TMP is expanded now on purpose: it never changes.
156+trap "rm -rf '$TMP'" EXIT INT TERM
157+
158+say "downloading ${URL}"
159+curl -fsSL "$URL" -o "${TMP}/${NAME}.tar.gz" \
160+ || die "download failed. Is version ${VERSION} published for ${TARGET}?"
161+curl -fsSL "${URL}.sha256" -o "${TMP}/${NAME}.tar.gz.sha256" \
162+ || die "checksum file missing next to the tarball; refusing to install unverified"
163+
164+say "verifying checksum"
165+( cd "$TMP" && sha256sum -c "${NAME}.tar.gz.sha256" >/dev/null ) \
166+ || die "checksum mismatch — the download is corrupt or tampered with"
167+
168+tar -xzf "${TMP}/${NAME}.tar.gz" -C "$TMP"
169+[ -x "${TMP}/${NAME}/steid" ] || die "tarball has no steid binary at ${NAME}/steid"
170+# The bundle must ship and must land beside the binary: AssetBundle::load() walks
171+# up from the executable looking for assets/manifest.toml and the process exits
172+# at startup without it.
173+[ -f "${TMP}/${NAME}/assets/manifest.toml" ] || die "tarball has no assets/manifest.toml"
174+
175+# --- user and directories ---------------------------------------------------
176+
177+if ! id "$STEID_USER" >/dev/null 2>&1; then
178+ say "creating system user ${STEID_USER}"
179+ # --home is the state directory: git wants a HOME, and giving it the one
180+ # directory the service can write keeps that from being a surprise later.
181+ useradd --system --home-dir "$STATE_DIR" --shell /usr/sbin/nologin "$STEID_USER"
182+fi
183+
184+mkdir -p "$INSTALL_DIR" "$STATE_DIR" "$CONF_DIR"
185+# State is exactly two things: the SQLite database file and the repository
186+# directory. Both live here, and together they are the entire backup surface.
187+mkdir -p "${STATE_DIR}/repos"
188+chown -R "${STEID_USER}:${STEID_USER}" "$STATE_DIR"
189+chmod 750 "$STATE_DIR"
190+
191+# --- install files ----------------------------------------------------------
192+
193+# Stop before replacing the binary: overwriting a running executable in place
194+# fails with ETXTBSY, and a half-swapped install/assets pair would serve stale
195+# hashed CSS until the next restart anyway.
196+if systemctl is-active --quiet steid 2>/dev/null; then
197+ say "stopping steid for the upgrade"
198+ systemctl stop steid
199+fi
200+
201+say "installing to ${INSTALL_DIR}"
202+install -m 0755 "${TMP}/${NAME}/steid" "${INSTALL_DIR}/steid"
203+rm -rf "${INSTALL_DIR}/assets"
204+cp -R "${TMP}/${NAME}/assets" "${INSTALL_DIR}/assets"
205+if [ -f "${TMP}/${NAME}/README.md" ]; then
206+ cp "${TMP}/${NAME}/README.md" "${INSTALL_DIR}/README.md"
207+fi
208+chown -R root:root "$INSTALL_DIR"
209+# Read-only to the service user on purpose: Steid never writes here.
210+chmod -R a+rX "$INSTALL_DIR"
211+
212+# --- configuration ----------------------------------------------------------
213+
214+# Written once and then left alone, so an upgrade cannot silently revert a
215+# setting someone deliberately changed.
216+if [ ! -f "${CONF_DIR}/steid.env" ]; then
217+ say "writing ${CONF_DIR}/steid.env"
218+ cat > "${CONF_DIR}/steid.env" <<EOF
219+# Steid configuration. Restart after editing: systemctl restart steid
220+
221+# All state lives under ${STATE_DIR}. SQLite writes -wal and -shm siblings, so
222+# the directory must be writable, not just the file.
223+STEID_DATABASE_URL=sqlite:${STATE_DIR}/steid.db?mode=rwc
224+STEID_DATA_DIR=${STATE_DIR}/repos
225+
226+# HOST and PORT are read by the web framework itself and are deliberately not
227+# STEID_-prefixed. Loopback only: Caddy is the way in, and binding 0.0.0.0 would
228+# expose plain HTTP — and therefore access tokens in cleartext — to the internet.
229+HOST=127.0.0.1
230+PORT=${PORT}
231+
232+# Deliberately absent: STEID_INSECURE_COOKIES. It strips Secure from the session
233+# cookie and exists only for plain-HTTP local development. Setting it here would
234+# hand out a session cookie that any network hop can read.
235+EOF
236+ chmod 640 "${CONF_DIR}/steid.env"
237+ chown "root:${STEID_USER}" "${CONF_DIR}/steid.env"
238+else
239+ say "keeping existing ${CONF_DIR}/steid.env"
240+fi
241+
242+say "writing /etc/systemd/system/steid.service"
243+cat > /etc/systemd/system/steid.service <<EOF
244+# Managed by install.sh. Re-running the installer rewrites this file.
245+[Unit]
246+Description=Steid
247+After=network-online.target
248+Wants=network-online.target
249+
250+[Service]
251+Type=simple
252+User=${STEID_USER}
253+Group=${STEID_USER}
254+WorkingDirectory=${INSTALL_DIR}
255+ExecStart=${INSTALL_DIR}/steid
256+EnvironmentFile=${CONF_DIR}/steid.env
257+Environment=HOME=${STATE_DIR}
258+Restart=on-failure
259+RestartSec=2s
260+
261+NoNewPrivileges=true
262+PrivateTmp=true
263+PrivateDevices=true
264+ProtectSystem=strict
265+ProtectHome=true
266+ProtectKernelTunables=true
267+ProtectKernelModules=true
268+ProtectControlGroups=true
269+RestrictSUIDSGID=true
270+RestrictNamespaces=true
271+LockPersonality=true
272+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
273+ReadWritePaths=${STATE_DIR}
274+
275+[Install]
276+WantedBy=multi-user.target
277+EOF
278+
279+systemctl daemon-reload
280+systemctl enable --quiet steid
281+say "starting steid"
282+systemctl restart steid
283+
284+# --- caddy ------------------------------------------------------------------
285+
286+if [ "$INSTALL_CADDY" = 1 ]; then
287+ if ! command -v caddy >/dev/null 2>&1; then
288+ say "installing Caddy from its official apt repository"
289+ apt-get install -y -qq --no-install-recommends debian-keyring debian-archive-keyring apt-transport-https gnupg
290+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
291+ | gpg --dearmor --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
292+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
293+ > /etc/apt/sources.list.d/caddy-stable.list
294+ apt-get update -qq
295+ apt-get install -y -qq caddy
296+ else
297+ say "Caddy already installed"
298+ fi
299+
300+ # Rewritten every run so the domain and port always match this install. If
301+ # you have hand-edited it, back it up first — this is the one file the
302+ # installer overwrites.
303+ say "writing /etc/caddy/Caddyfile for ${DOMAIN}"
304+ mkdir -p /etc/caddy
305+ cat > /etc/caddy/Caddyfile <<EOF
306+# Managed by install.sh. Re-running the installer rewrites this file.
307+#
308+# Steid has no TLS of its own (Topcoat 0.5 ships none), and it authenticates git
309+# over HTTP Basic — so without this proxy every push would send a personal access
310+# token in cleartext. Caddy obtains and renews the certificate automatically,
311+# provided ${DOMAIN} resolves here and ports 80 and 443 are open.
312+${DOMAIN} {
313+ reverse_proxy 127.0.0.1:${PORT} {
314+ # Git's smart HTTP is a streaming protocol in both directions; buffering
315+ # it turns a clone into a long silence and can stall negotiation.
316+ flush_interval -1
317+ }
318+}
319+EOF
320+ systemctl enable --quiet caddy
321+ systemctl reload caddy 2>/dev/null || systemctl restart caddy
322+fi
323+
324+# --- report -----------------------------------------------------------------
325+
326+# A moment for the service to either come up or fall over, so the message below
327+# reflects reality rather than optimism.
328+sleep 2
329+if ! systemctl is-active --quiet steid; then
330+ echo >&2
331+ echo "install.sh: steid is installed but not running. Look at:" >&2
332+ echo " journalctl -u steid -n 50 --no-pager" >&2
333+ exit 1
334+fi
335+
336+if [ "$INSTALL_CADDY" = 1 ]; then
337+ BASE_URL="https://${DOMAIN}"
338+else
339+ BASE_URL="http://127.0.0.1:${PORT} (put your own TLS proxy in front of this)"
340+fi
341+
342+cat <<EOF
343+
344+Steid ${VERSION} is running.
345+
346+ Service systemctl status steid
347+ Logs journalctl -u steid -f
348+ Config ${CONF_DIR}/steid.env
349+ State ${STATE_DIR} (the database and the repos — back up this directory)
350+
351+Next: claim the instance.
352+
353+ The setup token is printed to the log at startup, and ONLY while the instance
354+ is unclaimed. It is held in memory, so every restart mints a new one:
355+
356+ journalctl -u steid --no-pager | tail -n 30
357+
358+ Then open ${BASE_URL}/auth/setup and paste it in.
359+
360+To upgrade later, re-run this script with a newer --version. It replaces the
361+binary and assets and leaves ${STATE_DIR} untouched.
362+EOF
plans/ROADMAP.md+17 −2View file
@@ -58,9 +58,17 @@ a baseline.
5858 | 4a | **Clone over HTTP** — `git http-backend`, public repos, no auth | done |
5959 | 4b | **Push and tokens** — PATs over HTTP Basic, push, private clone | done |
6060 | 5 | **Repo browsing** — tree, blob, commit log | done |
61| 6 | **Writing** — posts, markdown | active |
61+| 5b | **Deployable by anyone** — binary release, installer, TLS proxy | active |
62+| 6 | **Writing** — posts, markdown | not started |
6263 | 7 | **Identity, full** — multi-user, orgs, invites, registration policy | not started |
63| 8+ | Projects/showcases · issues & PRs · SSH transport · federation | not started |
64+| 8+ | Projects/showcases · issues & PRs · CI/CD · SSH transport · federation | not started |
65+
66+**CI/CD is a milestone, eventually, and it is 8+.** Push-to-release — a repository that
67+builds and publishes itself on push — is the obvious end state for a forge that already
68+receives pushes, and it is what "source code integration" means for anyone arriving from
69+GitHub. It needs receive-pack hooks, a job model, isolation to run untrusted code, and
70+somewhere to keep artefacts, so it is a milestone in its own right rather than a feature.
71+Recorded now because it was asked for; deliberately not scheduled.
6472
6573 `/api` is not a milestone. It's a standing practice: every milestone that adds a use
6674 case exposes it under `/api` where that makes sense. An API isn't a feature, it's a
@@ -93,6 +101,13 @@ previous attempts. Milestone 3 is deliberately small and ships something visible
93101 profile; if milestone 4 starts to sprawl, that is the signal to bank it and go back to
94102 writing.
95103
104+**Milestone 5b was inserted rather than renumbered.** Deployment became urgent the
105+moment Steid could host its own source: a gitforge nobody can install is a demo. It is
106+lettered rather than numbered for the same reason 4a/4b were — decisions
107+[0003](decisions/0003-scoped-urls.md) and [0004](decisions/0004-root-handles-grouped-routes.md)
108+both refer forward to "Milestone 7" meaning multi-user identity, and renumbering would
109+quietly falsify accepted records to save a table row.
110+
96111 **Milestone 4 was split before it started**, acting on that warning rather than waiting
97112 to be surprised by it. It bundled two subsystems — personal access tokens and the smart
98113 HTTP protocol — and neither shipped anything until both were done. Split, 4a is
plans/current.md+68 −15View file
@@ -4,24 +4,75 @@
44 > [progress.md](progress.md). If this file starts reading like a changelog, it has
55 > drifted — that's exactly what went wrong last time.
66
7## Active: Milestone 6 — Writing
7+## Active: Milestone 5b — Deployable by anyone
88
9**Goal:** posts, written in markdown, at `/{handle}/posts/{slug}`, appearing on the
10profile. The second portfolio feature, and the one that makes Steid something other than
11a git host.
9+**Goal:** a stranger can install Steid on a fresh Linux box in one command and reach it
10+over HTTPS, and the author can publish an instance and push Steid's own source to it.
11+Distributed as **a binary plus its assets**, not a container.
1212
13**Not planned yet.** Steps get laid out at the start of the milestone.
13+**Out of scope:** push-to-release and any CI/CD (8+ — see
14+[ROADMAP.md](ROADMAP.md#milestone-ladder)), multi-user registration (7), and hosting
15+release artefacts on Steid itself, which it has no feature for.
16+
17+### Steps
18+
19+- [x] Release build producing `steid-<version>-<target>.tar.gz` — binary, `assets/`,
20+ README — and verified to boot from a clean extraction
21+- [x] `install.sh` — download, systemd unit, Caddy with automatic HTTPS, idempotent
22+- [x] `README.md` — the repo has none, and it is the front door of a portfolio project
23+- [x] Operability: `/healthz`, graceful shutdown on SIGTERM
24+- [x] `STEID_SETUP_TOKEN` as an optional override, so claiming is not a race with
25+ `journalctl`
26+- [x] Rate limiting on `/auth/login` and `/auth/setup`
27+- [x] Backup and restore, documented — two paths, rsync is enough
28+
29+### Done when
30+
31+`curl … | sh -s -- --domain git.example.com` on a fresh VPS yields a working HTTPS
32+instance, and Steid's own source is pushed to it and browsable there.
33+
34+### Settled
35+
36+- **A binary plus `assets/`, not a container.** Topcoat can embed the asset *manifest*
37+ (`Manifest::parse` + `include_str!`, for WASM) but not the asset *bytes* — that path
38+ pairs with `AssetConfig::hosted_at`, which expects a CDN. A single self-contained file
39+ would mean fighting the framework, so the artifact is a directory. The Dockerfile stays
40+ as a secondary path.
41+- **A TLS proxy is mandatory, not conventional.** Topcoat 0.5 has no TLS: no rustls, no
42+ ACME, no HTTPS listener — checked, not assumed. Since git authenticates over HTTP
43+ Basic, without TLS a personal access token crosses the network in cleartext on every
44+ push. Caddy is chosen because automatic certificates are its headline feature and the
45+ config is two lines.
46+- **glibc, built on Debian bullseye — not static musl.** Two reasons, the second
47+ decisive. musl failed on `ring` (Debian's `musl-gcc` wrapper rejects `-m64`) and would
48+ need a real cross toolchain. But **musl's whole point is a binary with no runtime
49+ dependencies, and Steid hard-requires `git` on `PATH`** — anyone installing it already
50+ has a package manager, so the portability musl buys cannot be used. Building on
51+ bullseye pins the glibc floor at 2.31, covering Debian 11+ and Ubuntu 20.04+; building
52+ on bookworm would need 2.36 and silently exclude Ubuntu 22.04, which is still
53+ everywhere. Verified: a release build in `rust:1.97-slim-bullseye` succeeds, 11.5 MB.
54+- **The runtime binary links a TLS stack it never uses.** `ring` ← `rustls` ← `ureq` ←
55+ Topcoat's `icon-iconify`/`tailwind` features, whose `ureq` exists to download the
56+ Tailwind CLI *at build time*. Those features are enabled on the normal dependency as
57+ well as the build one, so the crypto comes along for the ride. Moving them to
58+ build-dependencies only would shrink the binary and drop an unused dependency from the
59+ attack surface — worth trying, not yet attempted, and it is what made the musl attempt
60+ fail where it did.
61+- **Rsync is the deployment mechanism for now**, and that is enough while the artifact is
62+ two paths. Push-to-release is the eventual answer and is parked at 8+.
1463
1564 ### Open
1665
17- **Which markdown crate**, and whether rendering is trusted. `pulldown-cmark` is the
18 obvious choice and is not currently a dependency. Raw HTML in markdown is the decision
19 inside it: a single-author instance can trust its own input, but the moment Milestone 7
20 adds a second user that assumption is a stored-XSS hole. Deciding now is cheaper than
21 retrofitting a sanitiser.
22- **Whether a repository's README renders on its page.** It is the feature that makes a
23 repo page look like a portfolio piece rather than a file list, and it falls out of the
24 markdown pipeline this milestone builds — so it belongs here rather than back in 5.
66+- **Whether to add `STEID_TRUSTED_PROXY`.** The rate limiter keys on `X-Forwarded-For`
67+ because **Topcoat 0.5 discards the peer address at accept time and never exposes it** —
68+ a handler sees headers and nothing else. Behind a proxy that is fine. Exposed directly,
69+ a caller can vary the header for a fresh budget, leaving only the global cap. An
70+ explicit "trust forwarded headers" flag defaulting to off would close it, at the cost
71+ of one more thing an operator must get right. Not picked.
72+- **A licence.** A public portfolio repository probably wants one, and the README
73+ deliberately says nothing about licensing rather than guessing.
74+- **A domain.** Caddy needs a real hostname to obtain a certificate. This is the one
75+ blocker that is DNS rather than code.
2576
2677 ### Carried over — small, unblocked
2778
@@ -73,8 +124,10 @@ a git host.
73124
74125 Ordered. Pull from the top.
75126
761. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Still open
77 whether writing or projects/showcases is the better first portfolio feature.
127+1. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Open when it
128+ starts: which markdown crate, and whether raw HTML in markdown is trusted — safe for a
129+ single author, a stored-XSS hole the moment Milestone 7 adds a second user. A
130+ repository's README rendering on its page falls out of the same pipeline.
78131
79132 ## Open questions
80133
plans/progress.md+52 −1View file
@@ -2,7 +2,7 @@
22
33 ## This attempt (#3, Topcoat)
44
5317 tests. Active milestone in [current.md](current.md).
5+339 tests. Active milestone in [current.md](current.md).
66
77 ### Milestone 0 — Skeleton · done
88
@@ -402,6 +402,57 @@ browse route while its owner sees it.
402402 build. The stub answering "nothing there" rather than `todo!()` is what let the pages
403403 be developed and run before the adapter existed.
404404
405+### Milestone 5b — Deployable by anyone · in progress
406+
407+Distributed as a binary plus its `assets/` directory, installed by one command, behind
408+Caddy for automatic HTTPS. `release.sh`, `install.sh`, `deploy/`, a `README.md`, and the
409+operability the service needs: `/healthz`, `STEID_SETUP_TOKEN`, and rate limiting.
410+
411+#### Topcoat findings, both load-bearing
412+
413+- **`topcoat::start()` already handles SIGTERM.** `serve()` calls `serve_until(…,
414+ shutdown_signal())`, and that selects on Ctrl+C *and* SIGTERM on Unix: it stops
415+ accepting, drops the listener so a replacement process can bind the port, and drains
416+ in-flight requests up to `shutdown_timeout`. So systemd stop/restart does not cut a
417+ clone mid-pack, and **no wiring was needed** — the work was finding this out rather
418+ than building it.
419+- **The peer socket address is not reachable from a handler.** `internal_serve` discards
420+ it at accept time (`let (stream, _remote) = accepted?;`) and never puts it on the
421+ request extensions; the context exposes only parts, method, uri, headers and
422+ extensions. **Any IP-based decision in Steid is therefore header-based by necessity**,
423+ not by choice, and closing that would take an upstream change.
424+
425+#### Decisions worth remembering
426+
427+- **The rate limiter keys on the *rightmost* `X-Forwarded-For` entry.** A proxy
428+ *appends* the address it accepted from, so the last entry is the one the nearest proxy
429+ wrote and the only one a client cannot forge. The leftmost — what "the real client IP"
430+ usually means — is exactly the attacker-controlled one. Two proxy hops collapse clients
431+ onto the inner proxy's address, which is stricter, so being wrong that way is safe.
432+- **A global cap backs the per-key one**, because without a peer address a forged key
433+ cannot be disproved. It turns key forgery from a total bypass into a modest speed-up.
434+ The cost is that a flood can lock the login form for a minute — a recoverable denial
435+ against an unrecoverable guessed password.
436+- **The limiter's map is bounded.** An unbounded map keyed by attacker-controlled values
437+ is itself the denial of service; at the cap it sweeps expired entries and otherwise
438+ falls through to the global window, which is stricter rather than permissive.
439+- **glibc on bullseye, not static musl.** musl failed on `ring` (Debian's `musl-gcc`
440+ rejects `-m64`), but the decisive argument is that **musl buys a dependency-free binary
441+ and Steid hard-requires `git` on PATH** — the portability is unusable. Bullseye pins the
442+ glibc floor at 2.31, covering Debian 11+ and Ubuntu 20.04+; bookworm would need 2.36
443+ and silently exclude Ubuntu 22.04.
444+- **The runtime binary links a TLS stack it never uses.** `ring` ← `rustls` ← `ureq` ←
445+ Topcoat's `icon-iconify`/`tailwind`, whose `ureq` downloads the Tailwind CLI *at build
446+ time*. Those features are on the normal dependency as well as the build one, so the
447+ crypto ships too. Moving them would shrink the binary and drop an unused dependency
448+ from the attack surface. Not attempted; it is also what made the musl build fail where
449+ it did.
450+- **`STEID_SETUP_TOKEN` does not undermine [0002](decisions/0002-first-run-claim-not-config-bootstrap.md).**
451+ What that ADR refused to put in configuration was the owner's *password* — long-lived,
452+ goes stale, ends up in a repository. This is the one-time claim secret the operator was
453+ already copying out of a log line. It is validated for strength, never printed, and
454+ ignored entirely once claimed.
455+
405456 ---
406457
407458 ## Reference: what attempt #2 proved
release.sh+222 −0View file
@@ -0,0 +1,222 @@
1+#!/usr/bin/env bash
2+#
3+# Build a Steid release artefact: a tarball that extracts to a self-contained
4+# directory containing the binary, the asset bundle beside it, and a README.
5+#
6+# steid-<version>-<target>/
7+# steid
8+# assets/ <- manifest.toml + content-hashed CSS
9+# README.md
10+#
11+# Why a plain binary and not a container: Steid is meant to be installable on a
12+# £4 VPS by someone who does not run Docker. The container image still exists
13+# (see ./Dockerfile) and this script uses Docker as a *build* tool, but nothing
14+# in the shipped artefact depends on it.
15+#
16+# THE BUILD COMMAND MATTERS. `cargo build --release` alone produces a binary
17+# that will not boot: `main` calls `AssetBundle::load()`, which walks up from the
18+# executable looking for `assets/manifest.toml`, and `build.rs` never writes one.
19+# `topcoat asset bundle --release` runs `cargo build --release` itself, then
20+# scans the linked binary for the assets it declares and writes them plus the
21+# manifest to `target/assets`. That is the only supported way to build Steid.
22+#
23+# ON MACOS you cannot produce a Linux artefact with the host toolchain — there is
24+# no linker for it and `build.rs` runs a platform-specific Tailwind binary. This
25+# script therefore builds inside a container of the target platform by default
26+# (Docker, with qemu emulation when the arch differs from the host). `--native`
27+# skips all that and builds with the local toolchain, which is what you want for
28+# a quick smoke test of the artefact layout, not for a release.
29+#
30+# Usage:
31+# ./release.sh # default target, via Docker
32+# ./release.sh --target aarch64-unknown-linux-musl
33+# ./release.sh --native # host target, local toolchain
34+# ./release.sh --target x86_64-unknown-linux-gnu --version 0.1.0
35+#
36+set -euo pipefail
37+
38+# --- parameters -------------------------------------------------------------
39+
40+# glibc, not musl — measured, then decided.
41+#
42+# musl was tried and failed on `ring`: Debian's `musl-gcc` wrapper rejects the
43+# `-m64` that cc-rs passes, so it would need a real cross toolchain rather than
44+# `musl-tools`. But the decisive argument is not that it was awkward. **musl buys
45+# a binary with no runtime dependencies, and Steid hard-requires `git` on PATH** —
46+# anyone installing this already has a package manager and a distro, so the
47+# portability is unusable. The musl path below still works if a cross toolchain
48+# ever makes it worthwhile; nothing else in the script cares which is chosen.
49+TARGET="${STEID_RELEASE_TARGET:-x86_64-unknown-linux-gnu}"
50+
51+# Bullseye pins the glibc floor at 2.31, which covers Debian 11+ and Ubuntu
52+# 20.04+. Building on bookworm would need 2.36 and silently exclude Ubuntu 22.04,
53+# which is still everywhere — and the failure lands on the user as
54+# `GLIBC_2.36 not found`, at startup, with nothing pointing at the build.
55+#
56+# Pinned to 1.97 for the same reason the Dockerfile pins it: rustc >= 1.95 is a
57+# hard floor for Topcoat 0.5, and on an older toolchain `topcoat` silently
58+# resolves to an empty `v0.0.0` placeholder instead of failing.
59+RUST_IMAGE="${STEID_RUST_IMAGE:-rust:1.97-slim-bullseye}"
60+TOPCOAT_CLI_VERSION="${STEID_TOPCOAT_CLI_VERSION:-0.5.0}"
61+
62+VERSION=""
63+OUT_DIR="dist"
64+NATIVE=0
65+
66+usage() {
67+ sed -n '2,32p' "$0" | sed 's/^#\{1,2\} \{0,1\}//'
68+ exit "${1:-0}"
69+}
70+
71+while [ $# -gt 0 ]; do
72+ case "$1" in
73+ --target) TARGET="${2:?--target needs a triple}"; shift 2 ;;
74+ --version) VERSION="${2:?--version needs a value}"; shift 2 ;;
75+ --out) OUT_DIR="${2:?--out needs a directory}"; shift 2 ;;
76+ --native) NATIVE=1; shift ;;
77+ -h|--help) usage 0 ;;
78+ *) echo "release.sh: unknown argument: $1" >&2; usage 1 ;;
79+ esac
80+done
81+
82+REPO_ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)"
83+cd "$REPO_ROOT"
84+
85+# The version is the crate version unless overridden. Read with grep rather than
86+# a TOML parser so this script has no dependencies of its own.
87+if [ -z "$VERSION" ]; then
88+ VERSION="$(grep -m1 '^version *= *"' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
89+fi
90+[ -n "$VERSION" ] || { echo "release.sh: could not determine version" >&2; exit 1; }
91+
92+if [ "$NATIVE" = 1 ]; then
93+ TARGET="$(rustc -vV | sed -n 's/^host: //p')"
94+fi
95+
96+NAME="steid-${VERSION}-${TARGET}"
97+STAGE="${OUT_DIR}/${NAME}"
98+
99+echo "release.sh: building ${NAME}"
100+
101+# --- build ------------------------------------------------------------------
102+
103+rm -rf "$STAGE"
104+mkdir -p "$STAGE"
105+
106+if [ "$NATIVE" = 1 ]; then
107+ command -v topcoat >/dev/null 2>&1 || {
108+ echo "release.sh: topcoat CLI not found." >&2
109+ echo " cargo install topcoat-cli --version ${TOPCOAT_CLI_VERSION} --locked" >&2
110+ exit 1
111+ }
112+ echo "release.sh: local build (host toolchain) — NOT a release artefact"
113+ topcoat asset bundle --release
114+ cp target/release/steid "$STAGE/steid"
115+ cp -R target/assets "$STAGE/assets"
116+else
117+ command -v docker >/dev/null 2>&1 || {
118+ echo "release.sh: docker not found, and a Linux artefact cannot be built" >&2
119+ echo " with the host toolchain. Install Docker, or run on Linux with" >&2
120+ echo " --native, or use a cross toolchain." >&2
121+ exit 1
122+ }
123+
124+ case "$TARGET" in
125+ x86_64-*) PLATFORM="linux/amd64" ;;
126+ aarch64-*) PLATFORM="linux/arm64" ;;
127+ *) echo "release.sh: don't know the Docker platform for ${TARGET}" >&2; exit 1 ;;
128+ esac
129+
130+ # musl needs the cross-linker and the std for the target. On a glibc builder
131+ # image this is a cross-compile even when the arch matches the host, which is
132+ # the point: build.rs keeps running against glibc.
133+ EXTRA_SETUP=""
134+ BIN_PATH="target/release/steid"
135+ case "$TARGET" in
136+ *-musl)
137+ EXTRA_SETUP="apt-get update && apt-get install -y --no-install-recommends musl-tools && rm -rf /var/lib/apt/lists/* && rustup target add ${TARGET} && export CARGO_BUILD_TARGET=${TARGET}"
138+ BIN_PATH="target/${TARGET}/release/steid"
139+ ;;
140+ esac
141+
142+ echo "release.sh: building in ${RUST_IMAGE} on ${PLATFORM}"
143+
144+ # A throwaway image built from a heredoc rather than the repo Dockerfile: that
145+ # one produces a runtime *image*, this one produces files to copy out. Keeping
146+ # them separate means neither has to compromise for the other.
147+ IMAGE_TAG="steid-release-build:${VERSION}-${TARGET}"
148+ docker buildx build \
149+ --platform "$PLATFORM" \
150+ --load \
151+ --tag "$IMAGE_TAG" \
152+ --build-arg "TARGET=${TARGET}" \
153+ --file - . <<EOF
154+FROM ${RUST_IMAGE}
155+ARG TARGET
156+RUN cargo install topcoat-cli --version ${TOPCOAT_CLI_VERSION} --locked
157+WORKDIR /src
158+COPY . .
159+RUN set -eux; ${EXTRA_SETUP:-true}; \\
160+ topcoat asset bundle --release; \\
161+ mkdir -p /out; \\
162+ cp ${BIN_PATH} /out/steid; \\
163+ cp -r target/assets /out/assets
164+EOF
165+
166+ # `docker create` + `docker cp` rather than a bind mount: the build ran on a
167+ # possibly-emulated platform and this needs no write access to the host tree.
168+ CONTAINER="$(docker create --platform "$PLATFORM" "$IMAGE_TAG" /bin/true)"
169+ trap 'docker rm -f "$CONTAINER" >/dev/null 2>&1 || true' EXIT
170+ docker cp "${CONTAINER}:/out/steid" "$STAGE/steid"
171+ docker cp "${CONTAINER}:/out/assets" "$STAGE/assets"
172+ docker rm -f "$CONTAINER" >/dev/null
173+ trap - EXIT
174+fi
175+
176+chmod 755 "$STAGE/steid"
177+
178+# The README ships inside the tarball so an unpacked directory on a server is
179+# self-explanatory without network access.
180+cp README.md "$STAGE/README.md"
181+
182+# --- sanity checks ----------------------------------------------------------
183+
184+# The single failure mode worth guarding: an artefact whose assets are missing or
185+# in the wrong place boots fine in CI and dies on the user's first request.
186+[ -f "$STAGE/assets/manifest.toml" ] || {
187+ echo "release.sh: assets/manifest.toml is missing — was this built with" >&2
188+ echo " 'topcoat asset bundle' and not a bare 'cargo build'?" >&2
189+ exit 1
190+}
191+
192+# --- package ----------------------------------------------------------------
193+
194+TARBALL="${OUT_DIR}/${NAME}.tar.gz"
195+# `--no-xattrs` and COPYFILE_DISABLE because macOS's bsdtar otherwise stores
196+# Apple extended attributes, and GNU tar on the machine that extracts this then
197+# prints a warning line per file: "Ignoring unknown extended header keyword
198+# 'LIBARCHIVE.xattr.com.apple.provenance'". Harmless, and it makes a release look
199+# broken in the first thirty seconds a stranger spends with it.
200+COPYFILE_DISABLE=1 tar --no-xattrs -czf "$TARBALL" -C "$OUT_DIR" "$NAME" 2>/dev/null \
201+ || COPYFILE_DISABLE=1 tar -czf "$TARBALL" -C "$OUT_DIR" "$NAME"
202+
203+# A checksum file per tarball, which is what install.sh fetches and verifies.
204+# Written next to the tarball with a bare name inside it so `sha256sum -c` works
205+# from the download directory.
206+(
207+ cd "$OUT_DIR"
208+ if command -v sha256sum >/dev/null 2>&1; then
209+ sha256sum "${NAME}.tar.gz" > "${NAME}.tar.gz.sha256"
210+ else
211+ # macOS has shasum, not sha256sum. Same output format.
212+ shasum -a 256 "${NAME}.tar.gz" > "${NAME}.tar.gz.sha256"
213+ fi
214+)
215+
216+echo
217+echo "release.sh: wrote"
218+echo " ${TARBALL}"
219+echo " ${TARBALL}.sha256"
220+echo
221+echo "Upload both to the release named v${VERSION}. install.sh expects exactly"
222+echo "these filenames."
src/application/config.rs+70 −1View file
@@ -1,4 +1,4 @@
1use std::path::PathBuf;
1+use std::{fmt, path::PathBuf};
22
33 use serde::Deserialize;
44
@@ -26,6 +26,46 @@ pub struct AppConfig {
2626 /// anyone on the path can lift it.
2727 #[serde(default)]
2828 pub insecure_cookies: bool,
29+
30+ /// A setup token supplied by the operator instead of one generated at boot.
31+ ///
32+ /// Only ever consulted while the instance is unclaimed — a claimed instance holds
33+ /// no token at all, so this is inert the moment an owner exists. It exists so a
34+ /// scripted install can claim an instance without racing `journalctl` for the
35+ /// generated token, and it is validated by `SetupToken::from_operator` before it
36+ /// is used: a weak value fails startup rather than being accepted.
37+ ///
38+ /// This does not reopen `plans/decisions/0002`. What that decision refused to put
39+ /// in configuration is the owner's *password* — a long-lived credential that goes
40+ /// stale the moment it is changed in the app. This is the one-time claim secret,
41+ /// which is dead as soon as it has been used once and which the operator was
42+ /// already reading out of the log by hand.
43+ ///
44+ /// [`Secret`] keeps it out of any `Debug` rendering of the config, and `main`
45+ /// takes it before the config reaches the app context.
46+ #[serde(default)]
47+ pub setup_token: Option<Secret>,
48+}
49+
50+/// A configured value that must not reach a log line.
51+///
52+/// `AppConfig` is `Debug`, lives in the app context, and is one `dbg!` away from
53+/// standard error; the redacted `Debug` is what makes that safe.
54+#[derive(Clone, Deserialize)]
55+pub struct Secret(String);
56+
57+impl Secret {
58+ /// The value itself, at the one place that needs it.
59+ #[must_use]
60+ pub fn expose(&self) -> &str {
61+ &self.0
62+ }
63+}
64+
65+impl fmt::Debug for Secret {
66+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67+ f.write_str("Secret(redacted)")
68+ }
2969 }
3070
3171 fn default_database_url() -> String {
@@ -77,6 +117,35 @@ mod tests {
77117 assert_eq!(config.data_dir, PathBuf::from("/srv/steid/repos"));
78118 }
79119
120+ #[test]
121+ fn a_setup_token_is_absent_unless_configured() {
122+ let config = from_pairs(&[]).expect("defaults should satisfy every field");
123+
124+ assert!(config.setup_token.is_none());
125+ }
126+
127+ #[test]
128+ fn reads_the_setup_token_from_the_environment() {
129+ let config = from_pairs(&[("STEID_SETUP_TOKEN", "b2a9c17e4d5f80316a7c9e2b4d8f0135")])
130+ .expect("an explicit token should parse");
131+
132+ assert_eq!(
133+ config.setup_token.as_ref().map(Secret::expose),
134+ Some("b2a9c17e4d5f80316a7c9e2b4d8f0135")
135+ );
136+ }
137+
138+ #[test]
139+ fn a_configured_setup_token_is_redacted_in_debug_output() {
140+ let secret = "b2a9c17e4d5f80316a7c9e2b4d8f0135";
141+ let config = from_pairs(&[("STEID_SETUP_TOKEN", secret)]).expect("an explicit token");
142+
143+ let rendered = format!("{config:?}");
144+
145+ assert!(!rendered.contains(secret), "{rendered}");
146+ assert!(rendered.contains("Secret(redacted)"), "{rendered}");
147+ }
148+
80149 #[test]
81150 fn ignores_unprefixed_variables() {
82151 let config = from_pairs(&[("DATA_DIR", "/should/be/ignored")])
src/application/mod.rs+1 −1View file
@@ -19,7 +19,7 @@ pub mod token;
1919
2020 pub use browse::{Browsed, FileView, LOG_LIMIT, MAX_BLOB_BYTES, browse_repo, repo_log};
2121 pub use claim::{OwnerSpec, claim_instance, is_claimed};
22pub use config::AppConfig;
22+pub use config::{AppConfig, Secret};
2323 pub use error::{Error, Result};
2424 pub use git::{GitClientHeaders, GitEndpoint, GitOperation, GitService, serve_git};
2525 pub use identity::{Identity, describe_identity};
src/domain/setup_token.rs+99 −0View file
@@ -3,6 +3,8 @@ use std::fmt;
33 use rand::Rng;
44 use subtle::ConstantTimeEq;
55
6+use super::error::DomainError;
7+
68 /// The one-time secret that authorises claiming an unclaimed installation.
79 ///
810 /// Held in memory only: restarting an unclaimed instance rotates it, and it never
@@ -31,6 +33,61 @@ impl SetupToken {
3133 self.0.as_bytes().ct_eq(presented.as_bytes()).into()
3234 }
3335
36+ /// Characters an operator-supplied token must have.
37+ ///
38+ /// A generated token is 64 hex characters; requiring half that of a hand-supplied
39+ /// one leaves room for the shapes an install script actually produces —
40+ /// `openssl rand -hex 16`, a UUID, a passphrase — while staying far outside
41+ /// guessing range. Below this the claim window stops being protected by the token
42+ /// at all, which is the whole point of `plans/decisions/0002`.
43+ const MIN_OPERATOR_CHARS: usize = 32;
44+
45+ /// Distinct characters required, so length alone cannot be padding.
46+ ///
47+ /// A crude entropy floor, not a real estimate: it rejects `aaaa…`, `0000…` and
48+ /// `abababab…` without pretending to score a passphrase. Anything a random
49+ /// generator produces clears it easily.
50+ const MIN_DISTINCT_CHARS: usize = 8;
51+
52+ /// Adopts a token the operator supplied, so a scripted install can claim an
53+ /// instance without scraping the log for a generated one.
54+ ///
55+ /// Validates rather than accepting whatever it is given: a short token turns the
56+ /// claim window into something guessable, and failing loudly at startup is the
57+ /// only moment anyone is watching.
58+ ///
59+ /// # Errors
60+ ///
61+ /// Returns a validation error if the value is too short, contains whitespace or
62+ /// control characters, or repeats too few distinct characters. The message never
63+ /// includes the value.
64+ pub fn from_operator(value: &str) -> Result<Self, DomainError> {
65+ let invalid = |reason: &str| DomainError::validation("setup token", reason);
66+
67+ if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
68+ return Err(invalid("must not contain whitespace or control characters"));
69+ }
70+
71+ let characters = value.chars().count();
72+ if characters < Self::MIN_OPERATOR_CHARS {
73+ return Err(invalid(&format!(
74+ "must be at least {} characters, and this one is {characters}",
75+ Self::MIN_OPERATOR_CHARS
76+ )));
77+ }
78+
79+ let distinct: std::collections::BTreeSet<char> = value.chars().collect();
80+ if distinct.len() < Self::MIN_DISTINCT_CHARS {
81+ return Err(invalid(&format!(
82+ "repeats too few distinct characters to be unguessable ({} of {} needed)",
83+ distinct.len(),
84+ Self::MIN_DISTINCT_CHARS
85+ )));
86+ }
87+
88+ Ok(Self(value.to_owned()))
89+ }
90+
3491 /// The token, for printing to the operator exactly once.
3592 pub fn reveal(&self) -> &str {
3693 &self.0
@@ -92,6 +149,48 @@ mod tests {
92149 assert!(!SetupToken::generate().matches(""));
93150 }
94151
152+ #[test]
153+ fn an_operator_token_is_adopted_as_given() {
154+ let supplied = "b2a9c17e4d5f80316a7c9e2b4d8f0135";
155+
156+ let token = SetupToken::from_operator(supplied).expect("32 varied characters");
157+
158+ assert!(token.matches(supplied));
159+ }
160+
161+ #[test]
162+ fn a_short_operator_token_is_refused() {
163+ let error = SetupToken::from_operator("b2a9c17e4d5f8031").expect_err("16 characters");
164+
165+ assert!(format!("{error}").contains("at least 32"));
166+ }
167+
168+ #[test]
169+ fn a_repetitive_operator_token_is_refused() {
170+ assert!(SetupToken::from_operator(&"ab".repeat(24)).is_err());
171+ }
172+
173+ #[test]
174+ fn an_operator_token_with_whitespace_is_refused() {
175+ assert!(SetupToken::from_operator("b2a9c17e4d5f8031 6a7c9e2b4d8f0135").is_err());
176+ }
177+
178+ #[test]
179+ fn a_rejected_operator_token_is_never_echoed() {
180+ let secret = "short-but-secret";
181+
182+ let error = SetupToken::from_operator(secret).expect_err("too short");
183+
184+ assert!(!format!("{error}").contains(secret));
185+ }
186+
187+ #[test]
188+ fn a_generated_token_would_satisfy_the_operator_rules() {
189+ let generated = SetupToken::generate();
190+
191+ assert!(SetupToken::from_operator(generated.reveal()).is_ok());
192+ }
193+
95194 #[test]
96195 fn debug_output_redacts_the_token() {
97196 let token = SetupToken::generate();
src/infrastructure/web/health.rs+70 −0View file
@@ -0,0 +1,70 @@
1+//! Liveness for a process supervisor and an uptime check.
2+
3+use sqlx::SqlitePool;
4+use topcoat::{
5+ Result,
6+ context::Cx,
7+ router::{StatusCode, route},
8+};
9+
10+use super::context::pool;
11+
12+/// Whether this instance can serve.
13+///
14+/// Unauthenticated by design — a health check that needs a session cannot be polled
15+/// by the thing that restarts the process — so the body is two fixed words and says
16+/// nothing about the instance: not its version, not its name, not whether it is
17+/// claimed.
18+///
19+/// It touches the database because the pool is what an "up" process most plausibly
20+/// loses: the file moved, the disk filled, the connection limit exhausted. A check
21+/// that cannot fail is decoration. `SELECT 1` costs a round trip to a local file and
22+/// no table access, which is cheap enough to poll every few seconds.
23+#[route(GET "/healthz")]
24+async fn healthz(cx: &Cx) -> Result<(StatusCode, &'static str)> {
25+ Ok(match reachable(pool(cx)).await {
26+ Ok(()) => (StatusCode::OK, "ok\n"),
27+ Err(error) => {
28+ // The reason goes to the log, where an operator can see it; the response
29+ // stays opaque because anyone can reach this.
30+ eprintln!("steid: health check failed: {error}");
31+ (StatusCode::SERVICE_UNAVAILABLE, "unavailable\n")
32+ }
33+ })
34+}
35+
36+async fn reachable(pool: &SqlitePool) -> Result<(), sqlx::Error> {
37+ sqlx::query("SELECT 1").fetch_one(pool).await.map(|_| ())
38+}
39+
40+#[cfg(test)]
41+mod tests {
42+ use std::str::FromStr;
43+
44+ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
45+
46+ use super::*;
47+ use crate::infrastructure::database::test_support::test_pool;
48+
49+ #[tokio::test]
50+ async fn a_working_pool_is_reachable() {
51+ let pool = test_pool().await;
52+
53+ assert!(reachable(&pool).await.is_ok());
54+ }
55+
56+ #[tokio::test]
57+ async fn a_broken_pool_is_not_reachable() {
58+ // Lazy so the failure lands on the query rather than on construction, which
59+ // is how the interesting case arrives in production: a pool that opened fine
60+ // at boot and cannot reach its file now.
61+ let options = SqliteConnectOptions::from_str("sqlite:/nonexistent/steid.db")
62+ .expect("the url should parse");
63+ let pool = SqlitePoolOptions::new().connect_lazy_with(options);
64+
65+ assert!(
66+ reachable(&pool).await.is_err(),
67+ "a database that cannot be opened must fail the check"
68+ );
69+ }
70+}
src/infrastructure/web/mod.rs+2 −0View file
@@ -4,9 +4,11 @@ pub mod api;
44 pub mod browse;
55 pub mod context;
66 pub mod git;
7+pub mod health;
78 pub mod layout;
89 pub mod pages;
910 pub mod profile;
11+pub mod rate_limit;
1012 pub mod repo;
1113 pub mod session_cookie;
1214 pub mod settings;
src/infrastructure/web/rate_limit.rs+427 −0View file
@@ -0,0 +1,427 @@
1+//! In-memory rate limiting for the endpoints where guessing is feasible.
2+//!
3+//! `/auth/login` and `/auth/setup` are the only places a secret can be attacked by
4+//! repetition: a personal access token is 256 bits, but a human-chosen password is
5+//! not, and the setup token is worth guessing for exactly as long as the instance is
6+//! unclaimed. The limiter also caps how often an anonymous caller can make the server
7+//! run Argon2, which is deliberately expensive.
8+//!
9+//! Fixed windows rather than a token bucket: a window is two integers and a
10+//! comparison, it is trivial to reason about when reading a log, and the burst it
11+//! permits at a window boundary — twice the limit across two adjacent windows — does
12+//! not matter at these rates. A bucket's smoother refill buys nothing here.
13+
14+use std::{
15+ collections::HashMap,
16+ sync::Mutex,
17+ time::{Duration, Instant},
18+};
19+
20+use topcoat::{
21+ context::{Cx, app_context},
22+ router::{Body, Response, StatusCode, header::RETRY_AFTER, headers},
23+};
24+
25+/// How long a window lasts.
26+const WINDOW: Duration = Duration::from_secs(60);
27+
28+/// Attempts one client may make per window.
29+///
30+/// Ten a minute is far above what a person signing in ever needs — a mistyped
31+/// password twice, then a password manager — and far below what makes guessing
32+/// worthwhile: an online attack against even a weak six-character password would take
33+/// centuries at this rate.
34+const PER_CLIENT: u32 = 10;
35+
36+/// Attempts *everyone together* may make per window.
37+///
38+/// This is the backstop for a forged client key (see [`client_key`]). Without a peer
39+/// address there is no way to prove a caller is who its headers claim, so an attacker
40+/// who can forge a different `X-Forwarded-For` per request would otherwise get an
41+/// unlimited number of per-client budgets. Sixty a minute is six independent people
42+/// each hitting their own limit at once, which a single-owner instance will never see,
43+/// and it turns key forgery from a bypass into a six-fold speed-up.
44+///
45+/// The cost is that a flood can lock out a legitimate sign-in for up to a minute.
46+/// That is the right way round: a temporary denial of the login form is recoverable,
47+/// a guessed password is not.
48+const GLOBAL: u32 = 60;
49+
50+/// Distinct client keys tracked at once.
51+///
52+/// The map is keyed by something the caller influences, so it must not be allowed to
53+/// grow with the number of keys an attacker can invent. Past this many live keys,
54+/// expired entries are swept and any still-unknown key falls back to the global
55+/// window alone — which is stricter, not laxer, so filling the map is not a way out.
56+const MAX_KEYS: usize = 4096;
57+
58+/// The shared limiter. One per process, held in Topcoat's app context.
59+#[derive(Debug)]
60+pub struct RateLimiter {
61+ window: Duration,
62+ per_client: u32,
63+ global: u32,
64+ max_keys: usize,
65+ state: Mutex<State>,
66+}
67+
68+#[derive(Debug)]
69+struct State {
70+ clients: HashMap<Box<str>, Window>,
71+ global: Window,
72+}
73+
74+/// A fixed window: when it opened, and how many attempts have landed in it.
75+#[derive(Debug, Clone, Copy)]
76+struct Window {
77+ opened: Instant,
78+ hits: u32,
79+}
80+
81+impl Window {
82+ fn new(now: Instant) -> Self {
83+ Self {
84+ opened: now,
85+ hits: 0,
86+ }
87+ }
88+
89+ /// Records an attempt, rolling into a fresh window first if this one has expired.
90+ fn record(&mut self, now: Instant, window: Duration) -> u32 {
91+ if now.duration_since(self.opened) >= window {
92+ *self = Self::new(now);
93+ }
94+
95+ self.hits = self.hits.saturating_add(1);
96+ self.hits
97+ }
98+
99+ fn expired(&self, now: Instant, window: Duration) -> bool {
100+ now.duration_since(self.opened) >= window
101+ }
102+
103+ fn remaining(&self, now: Instant, window: Duration) -> Duration {
104+ window.saturating_sub(now.duration_since(self.opened))
105+ }
106+}
107+
108+/// What the limiter decided about one attempt.
109+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110+pub enum Decision {
111+ Allowed,
112+ /// Refused, with how long until the window it exhausted rolls over.
113+ Throttled(Duration),
114+}
115+
116+impl Default for RateLimiter {
117+ fn default() -> Self {
118+ Self::new(WINDOW, PER_CLIENT, GLOBAL, MAX_KEYS)
119+ }
120+}
121+
122+impl RateLimiter {
123+ /// Builds a limiter with explicit limits. Tests use this; `main` uses
124+ /// [`Default`].
125+ #[must_use]
126+ pub fn new(window: Duration, per_client: u32, global: u32, max_keys: usize) -> Self {
127+ Self {
128+ window,
129+ per_client,
130+ global,
131+ max_keys,
132+ state: Mutex::new(State {
133+ clients: HashMap::new(),
134+ global: Window::new(Instant::now()),
135+ }),
136+ }
137+ }
138+
139+ /// Records an attempt for `key` and says whether it may proceed.
140+ pub fn check(&self, key: &str) -> Decision {
141+ self.check_at(key, Instant::now())
142+ }
143+
144+ /// [`check`](Self::check) with the clock supplied, so tests can move time.
145+ fn check_at(&self, key: &str, now: Instant) -> Decision {
146+ let mut state = self.state.lock().unwrap_or_else(|poisoned| {
147+ // A panic while holding the lock would otherwise disable the limiter for
148+ // the life of the process, which is the wrong way to fail.
149+ self.state.clear_poison();
150+ poisoned.into_inner()
151+ });
152+
153+ // Every attempt counts against the global window, including one that is about
154+ // to be refused for its own key: an attacker rotating keys still pays here.
155+ let global_hits = state.global.record(now, self.window);
156+ if global_hits > self.global {
157+ return Decision::Throttled(state.global.remaining(now, self.window));
158+ }
159+
160+ if !state.clients.contains_key(key) && state.clients.len() >= self.max_keys {
161+ let window = self.window;
162+ state.clients.retain(|_, entry| !entry.expired(now, window));
163+
164+ if state.clients.len() >= self.max_keys {
165+ // Still full of live entries, so this attempt is covered by the
166+ // global window only. Refusing to grow is what bounds the memory.
167+ return Decision::Allowed;
168+ }
169+ }
170+
171+ let entry = state
172+ .clients
173+ .entry(key.into())
174+ .or_insert_with(|| Window::new(now));
175+
176+ if entry.record(now, self.window) > self.per_client {
177+ return Decision::Throttled(entry.remaining(now, self.window));
178+ }
179+
180+ Decision::Allowed
181+ }
182+
183+ /// How many client keys are currently held. For tests and diagnostics.
184+ #[must_use]
185+ pub fn tracked_keys(&self) -> usize {
186+ self.state.lock().map_or(0, |state| state.clients.len())
187+ }
188+}
189+
190+/// Applies the shared limiter to this request, returning the 429 to send when the
191+/// caller has spent its budget.
192+///
193+/// Call it first in a handler, before any password hashing or token comparison.
194+pub fn throttled(cx: &Cx) -> Option<Response> {
195+ match app_context::<RateLimiter>(cx).check(&client_key(cx)) {
196+ Decision::Allowed => None,
197+ Decision::Throttled(retry_after) => {
198+ eprintln!("steid: rate limited an auth attempt");
199+ Some(too_many_requests(retry_after))
200+ }
201+ }
202+}
203+
204+/// The 429 itself. Deliberately a bare line of text: it says nothing about which
205+/// limit was hit or what the instance is.
206+fn too_many_requests(retry_after: Duration) -> Response {
207+ let seconds = retry_after.as_secs().max(1);
208+
209+ let mut response = Response::new(Body::from("too many attempts, try again shortly\n"));
210+ *response.status_mut() = StatusCode::TOO_MANY_REQUESTS;
211+ response.headers_mut().insert(RETRY_AFTER, seconds.into());
212+
213+ response
214+}
215+
216+/// What the limiter counts against — an approximation of the client's address.
217+///
218+/// **Topcoat 0.5 does not expose the peer address.** `internal_serve` discards it at
219+/// accept time (`let (stream, _remote) = accepted?;`) and never puts it on the request
220+/// extensions, so a handler has nothing but headers to go on. That constrains this
221+/// entirely, and it is worth restating rather than rediscovering.
222+///
223+/// So: the **rightmost** `X-Forwarded-For` entry, then `X-Real-IP`, then a single
224+/// shared key.
225+///
226+/// Rightmost, not leftmost, because a proxy *appends* the address it accepted the
227+/// connection from. A client that sends its own `X-Forwarded-For: 1.2.3.4` gets
228+/// `1.2.3.4, <real address>` by the time Steid sees it, so the last entry is the one
229+/// the nearest proxy wrote and the only one it cannot forge. The leftmost entry —
230+/// what "the real client IP" usually means — is exactly the attacker-controlled one.
231+/// This assumes a single proxy hop; behind two, the rightmost entry is the inner
232+/// proxy, and every client collapses onto one key. Stricter, so safe to be wrong
233+/// about.
234+///
235+/// The trade-off this cannot escape: on an instance exposed **directly** to the
236+/// internet, with no proxy appending anything, a caller can invent a fresh
237+/// `X-Forwarded-For` per request and get a fresh per-client budget each time. The
238+/// global window above is what keeps that from being a total bypass. Closing it
239+/// properly needs the peer address, which means a change in Topcoat.
240+fn client_key(cx: &Cx) -> String {
241+ let headers = headers(cx);
242+ let value = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
243+
244+ let forwarded = value("x-forwarded-for")
245+ .and_then(|list| list.rsplit(',').next())
246+ .and_then(address);
247+
248+ forwarded
249+ .or_else(|| value("x-real-ip").and_then(address))
250+ // No proxy header at all: everyone shares one window. Right for a directly
251+ // exposed instance, where the alternative is no limit; a proxy that forwards
252+ // neither header collapses its whole userbase into this key.
253+ .unwrap_or_else(|| "direct".to_owned())
254+}
255+
256+/// Accepts a header fragment only if it looks like an address, so a hostile header
257+/// cannot become an arbitrarily large or arbitrarily weird map key.
258+fn address(raw: &str) -> Option<String> {
259+ /// Longest textual IPv6 address, with a zone and an embedded IPv4 tail.
260+ const MAX: usize = 64;
261+
262+ let candidate = raw.trim();
263+
264+ let plausible = !candidate.is_empty()
265+ && candidate.len() <= MAX
266+ && candidate
267+ .chars()
268+ .all(|c| c.is_ascii_hexdigit() || matches!(c, '.' | ':' | '%' | '[' | ']'));
269+
270+ plausible.then(|| candidate.to_owned())
271+}
272+
273+#[cfg(test)]
274+mod tests {
275+ use super::*;
276+
277+ fn limiter() -> RateLimiter {
278+ RateLimiter::new(Duration::from_secs(60), 3, 100, 16)
279+ }
280+
281+ #[test]
282+ fn allows_attempts_up_to_the_limit() {
283+ let limiter = limiter();
284+ let now = Instant::now();
285+
286+ for _ in 0..3 {
287+ assert_eq!(limiter.check_at("a", now), Decision::Allowed);
288+ }
289+ }
290+
291+ #[test]
292+ fn refuses_the_attempt_after_the_limit() {
293+ let limiter = limiter();
294+ let now = Instant::now();
295+
296+ for _ in 0..3 {
297+ limiter.check_at("a", now);
298+ }
299+
300+ assert!(matches!(limiter.check_at("a", now), Decision::Throttled(_)));
301+ }
302+
303+ #[test]
304+ fn reports_how_long_until_the_window_rolls() {
305+ let limiter = limiter();
306+ let now = Instant::now();
307+
308+ for _ in 0..4 {
309+ limiter.check_at("a", now);
310+ }
311+
312+ let Decision::Throttled(retry_after) = limiter.check_at("a", now + Duration::from_secs(20))
313+ else {
314+ panic!("the key is over its limit");
315+ };
316+
317+ assert_eq!(retry_after, Duration::from_secs(40));
318+ }
319+
320+ #[test]
321+ fn recovers_once_the_window_has_passed() {
322+ let limiter = limiter();
323+ let now = Instant::now();
324+
325+ for _ in 0..4 {
326+ limiter.check_at("a", now);
327+ }
328+
329+ assert_eq!(
330+ limiter.check_at("a", now + Duration::from_secs(60)),
331+ Decision::Allowed
332+ );
333+ }
334+
335+ #[test]
336+ fn keys_are_independent() {
337+ let limiter = limiter();
338+ let now = Instant::now();
339+
340+ for _ in 0..4 {
341+ limiter.check_at("a", now);
342+ }
343+
344+ assert_eq!(limiter.check_at("b", now), Decision::Allowed);
345+ }
346+
347+ #[test]
348+ fn the_global_window_catches_a_caller_rotating_keys() {
349+ let limiter = RateLimiter::new(Duration::from_secs(60), 3, 5, 16);
350+ let now = Instant::now();
351+
352+ for index in 0..5 {
353+ assert_eq!(
354+ limiter.check_at(&format!("k{index}"), now),
355+ Decision::Allowed
356+ );
357+ }
358+
359+ assert!(matches!(
360+ limiter.check_at("k5", now),
361+ Decision::Throttled(_)
362+ ));
363+ }
364+
365+ #[test]
366+ fn the_map_does_not_grow_past_its_cap() {
367+ let limiter = RateLimiter::new(Duration::from_secs(60), 3, u32::MAX, 16);
368+ let now = Instant::now();
369+
370+ for index in 0..5_000 {
371+ limiter.check_at(&format!("k{index}"), now);
372+ }
373+
374+ assert!(limiter.tracked_keys() <= 16);
375+ }
376+
377+ #[test]
378+ fn expired_entries_are_swept_to_make_room() {
379+ let limiter = RateLimiter::new(Duration::from_secs(60), 3, u32::MAX, 16);
380+ let now = Instant::now();
381+
382+ for index in 0..16 {
383+ limiter.check_at(&format!("old{index}"), now);
384+ }
385+
386+ // A minute later every one of those has expired, so the newcomer is tracked
387+ // rather than being waved through on the global window alone.
388+ let later = now + Duration::from_secs(61);
389+ for _ in 0..4 {
390+ limiter.check_at("new", later);
391+ }
392+
393+ assert!(matches!(
394+ limiter.check_at("new", later),
395+ Decision::Throttled(_)
396+ ));
397+ assert!(limiter.tracked_keys() <= 16);
398+ }
399+
400+ #[test]
401+ fn a_key_beyond_the_cap_is_still_covered_by_the_global_window() {
402+ let limiter = RateLimiter::new(Duration::from_secs(60), 3, 20, 2);
403+ let now = Instant::now();
404+
405+ for index in 0..20 {
406+ limiter.check_at(&format!("k{index}"), now);
407+ }
408+
409+ assert!(matches!(
410+ limiter.check_at("k20", now),
411+ Decision::Throttled(_)
412+ ));
413+ }
414+
415+ #[test]
416+ fn an_address_is_accepted() {
417+ assert_eq!(address(" 203.0.113.7 "), Some("203.0.113.7".to_owned()));
418+ assert_eq!(address("2001:db8::1"), Some("2001:db8::1".to_owned()));
419+ }
420+
421+ #[test]
422+ fn a_hostile_header_value_is_rejected() {
423+ assert_eq!(address(""), None);
424+ assert_eq!(address("not an address"), None);
425+ assert_eq!(address(&"9".repeat(65)), None);
426+ }
427+}
src/infrastructure/web/setup.rs+27 −5View file
@@ -5,6 +5,7 @@ use topcoat::{
55 Result,
66 context::Cx,
77 router::{
8+ IntoResponse, Response,
89 content::Form,
910 error::{SeeOther, redirect, see_other},
1011 page, route,
@@ -22,7 +23,10 @@ use crate::{
2223 },
2324 };
2425
25use super::context::{claimed, hex, pool, setup_token};
26+use super::{
27+ context::{claimed, hex, pool, setup_token},
28+ rate_limit::throttled,
29+};
2630
2731 #[derive(Debug, Deserialize)]
2832 struct ClaimForm {
@@ -57,8 +61,17 @@ async fn setup_page(cx: &Cx) -> Result {
5761 }
5862 }
5963
64+/// Claims the instance.
65+///
66+/// Rate limited before anything else happens: the setup token is the only thing
67+/// standing between a stranger and ownership of an unclaimed instance, and unlike a
68+/// personal access token it may have been chosen by a person.
6069 #[route(POST "/auth/setup")]
61async fn claim(cx: &Cx, Form(form): Form<ClaimForm>) -> Result<SeeOther> {
70+async fn claim(cx: &Cx, Form(form): Form<ClaimForm>) -> Result<Response> {
71+ if let Some(response) = throttled(cx) {
72+ return Ok(response);
73+ }
74+
6275 // Absent once claimed, so a claimed instance cannot be re-claimed even if the
6376 // use case were somehow reached.
6477 let token = setup_token(cx).ok_or_else(|| redirect("/"))?;
@@ -90,7 +103,7 @@ async fn claim(cx: &Cx, Form(form): Form<ClaimForm>) -> Result<SeeOther> {
90103
91104 sign_in(cx, &actor, &sessions).await?;
92105
93 Ok(see_other("/"))
106+ see_other("/").into_response(cx)
94107 }
95108
96109 #[page("/auth/login")]
@@ -109,8 +122,17 @@ async fn login_page(cx: &Cx) -> Result {
109122 }
110123 }
111124
125+/// Signs in.
126+///
127+/// Rate limited first, before the password is hashed: a human-chosen password is the
128+/// one secret here that guessing can reach, and Argon2 is deliberately expensive
129+/// enough that an unlimited endpoint is also a way to burn the server's CPU.
112130 #[route(POST "/auth/login")]
113async fn sign_in_route(cx: &Cx, Form(form): Form<LoginForm>) -> Result<SeeOther> {
131+async fn sign_in_route(cx: &Cx, Form(form): Form<LoginForm>) -> Result<Response> {
132+ if let Some(response) = throttled(cx) {
133+ return Ok(response);
134+ }
135+
114136 let pool = pool(cx).clone();
115137 let users = SqliteUserRepo::new(pool.clone());
116138 let sessions = SqliteSessionRepo::new(pool);
@@ -124,7 +146,7 @@ async fn sign_in_route(cx: &Cx, Form(form): Form<LoginForm>) -> Result<SeeOther>
124146
125147 sign_in(cx, &actor, &sessions).await?;
126148
127 Ok(see_other("/"))
149+ see_other("/").into_response(cx)
128150 }
129151
130152 #[route(POST "/auth/logout")]
src/main.rs+51 −5View file
@@ -4,7 +4,9 @@ use steid::{
44 infrastructure::{
55 self,
66 repository::SqliteUserRepo,
7 web::{context::SetupState, session_cookie::InsecureCookieTokenStore},
7+ web::{
8+ context::SetupState, rate_limit::RateLimiter, session_cookie::InsecureCookieTokenStore,
9+ },
810 },
911 };
1012 use topcoat::{
@@ -18,27 +20,60 @@ use topcoat::{
1820 async fn main() -> Result<(), Box<dyn std::error::Error>> {
1921 dotenvy::dotenv().ok();
2022
21 let config = AppConfig::from_env()?;
23+ let mut config = AppConfig::from_env()?;
2224 let pool = infrastructure::database::connect(&config.database_url).await?;
2325
26+ // Taken out of the config before it reaches the app context, so the only copy the
27+ // running application holds is the `SetupToken` in `SetupState` — which is
28+ // redacted in `Debug` and dropped entirely once the instance is claimed.
29+ let configured_token = config.setup_token.take();
30+
2431 let mut builder = Router::builder()
2532 .assets(AssetBundle::load()?)
2633 .cookies()
2734 .sessions(session_config(config.insecure_cookies))
2835 .discover()
2936 .app_context(config)
30 .app_context(pool.clone());
37+ .app_context(pool.clone())
38+ // One limiter for the process, shared by every request. Constructing it per
39+ // request would count each attempt against an empty window and limit nothing.
40+ .app_context(RateLimiter::default());
3141
3242 // The setup token exists only while the instance is unclaimed, so a claimed
3343 // installation has no token in context for a claim attempt to match against.
3444 if !is_claimed(&SqliteUserRepo::new(pool)).await? {
35 let token = SetupToken::generate();
36 announce_setup(&token);
45+ let token = match &configured_token {
46+ // Fails startup rather than serving a claim page guarded by a weak
47+ // secret. This is the one moment an operator is watching the output.
48+ Some(supplied) => {
49+ let token = SetupToken::from_operator(supplied.expose()).map_err(|error| {
50+ eprintln!();
51+ eprintln!(" !! STEID_SETUP_TOKEN is not usable: {error}");
52+ eprintln!(" !! Unset it to have one generated, or supply a stronger value.");
53+ eprintln!();
54+ error
55+ })?;
56+ announce_configured_setup();
57+ token
58+ }
59+ None => {
60+ let token = SetupToken::generate();
61+ announce_setup(&token);
62+ token
63+ }
64+ };
65+
3766 builder = builder.app_context(SetupState(token));
3867 }
3968
69+ // Graceful shutdown needs no wiring: `topcoat::start` serves until Ctrl+C or
70+ // SIGTERM, then stops accepting, lets in-flight requests finish within the
71+ // service's shutdown timeout, and returns. A `systemctl restart` therefore does
72+ // not cut a clone mid-pack.
4073 topcoat::start(builder.build()).await?;
4174
75+ println!("steid: shut down");
76+
4277 Ok(())
4378 }
4479
@@ -62,6 +97,17 @@ fn session_config(insecure_cookies: bool) -> SessionConfig {
6297 }
6398 }
6499
100+/// Prints the claim instructions when the operator supplied the token themselves.
101+///
102+/// Says that a token is in force without repeating it: they already have it, and
103+/// printing it would copy a secret they chose into the journal.
104+fn announce_configured_setup() {
105+ println!();
106+ println!(" This steid has no owner yet. Claim it at /auth/setup with the token");
107+ println!(" from STEID_SETUP_TOKEN.");
108+ println!();
109+}
110+
65111 /// Prints the claim instructions. The only time the token is ever revealed.
66112 fn announce_setup(token: &SetupToken) {
67113 println!();