@jpgilldev / steid

steid/README.md
14.7 KBRaw
1# Steid
2
3A personal gitforge, in Rust. It hosts your repositories, and it is meant to become the
4place your work as a whole lives — code, writing, projects — under one identity you
5control.
6
7Steid is **portfolio-first**. Gitea and Forgejo are GitHub scaled down; their unit is the
8repository and the profile is a directory listing bolted to the side. Steid inverts that:
9the profile page at `/{handle}` is the product, and git repositories are one kind of
10thing that appears on it. That framing wins every tie-break in the design.
11
12It runs as a single binary with a SQLite database, shells out to `git` for everything
13git-shaped, and is small enough to host on the cheapest VPS you can find.
14
15## What works today
16
17Honestly 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
35Writing (markdown posts), multi-user, organizations, issues, pull requests and SSH
36transport 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
55curl -fsSL https://REPLACE-ME.example.com/steid/install.sh | sh -s -- --domain git.example.com
56```
57
58Point the domain's DNS at the machine first, and make sure ports 80 and 443 are open —
59Caddy needs both to obtain a certificate.
60
61That does the following, and nothing else:
62
631. installs `git`, `curl` and `ca-certificates`
642. downloads the release tarball for your architecture and **verifies its SHA-256**
653. unpacks it to `/opt/steid` — the binary with its `assets/` directory beside it
664. creates a `steid` system user and `/var/lib/steid` for state, owned by it
675. writes `/etc/steid/steid.env`, and a systemd unit that listens on `127.0.0.1:3000`
686. installs Caddy and writes a Caddyfile that terminates TLS for your domain and proxies
69 to that loopback port
707. starts both
71
72Re-run it with a newer `--version` to upgrade. It is idempotent: it replaces the binary
73and assets, leaves `/var/lib/steid` alone, and will not overwrite `/etc/steid/steid.env`
74once it exists.
75
76Options: `--version`, `--port`, `--flavour musl|gnu`, and `--no-caddy` if you are
77bringing 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
80not an answer to it. Download and read it first if you would rather:
81
82```sh
83curl -fsSL https://REPLACE-ME.example.com/steid/install.sh -o install.sh
84less install.sh
85sudo sh install.sh --domain git.example.com
86```
87
88Or do it by hand — the manual path below is the same steps, written out.
89
90### Manually
91
92Every command as root, on Debian or Ubuntu. Adjust paths and package manager to taste;
93nothing 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.
97apt-get update
98apt-get install -y git curl ca-certificates
99
100# 2. Fetch and verify the release for your architecture.
101VERSION=0.1.0
102TARGET=x86_64-unknown-linux-musl # or aarch64-unknown-linux-musl
103BASE=https://REPLACE-ME.example.com/steid/releases/download
104curl -fsSLO "$BASE/v$VERSION/steid-$VERSION-$TARGET.tar.gz"
105curl -fsSLO "$BASE/v$VERSION/steid-$VERSION-$TARGET.tar.gz.sha256"
106sha256sum -c "steid-$VERSION-$TARGET.tar.gz.sha256"
107tar -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.
113mkdir -p /opt/steid
114cp "steid-$VERSION-$TARGET/steid" /opt/steid/steid
115cp -r "steid-$VERSION-$TARGET/assets" /opt/steid/assets
116chmod 755 /opt/steid/steid
117
118# 4. A user to run as, and one directory for all state.
119useradd --system --home-dir /var/lib/steid --shell /usr/sbin/nologin steid
120mkdir -p /var/lib/steid/repos
121chown -R steid:steid /var/lib/steid
122chmod 750 /var/lib/steid
123
124# 5. Configuration.
125mkdir -p /etc/steid
126cat > /etc/steid/steid.env <<'EOF'
127STEID_DATABASE_URL=sqlite:/var/lib/steid/steid.db?mode=rwc
128STEID_DATA_DIR=/var/lib/steid/repos
129HOST=127.0.0.1
130PORT=3000
131EOF
132chmod 640 /etc/steid/steid.env
133chown root:steid /etc/steid/steid.env
134
135# 6. The service. deploy/steid.service in this repo is the file to copy.
136cp deploy/steid.service /etc/systemd/system/steid.service
137systemctl daemon-reload
138systemctl 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.
142apt-get install -y caddy # or follow https://caddyserver.com/docs/install
143cp deploy/Caddyfile /etc/caddy/Caddyfile
144$EDITOR /etc/caddy/Caddyfile
145systemctl reload caddy
146```
147
148There is **no migration step**. Steid runs its migrations itself on every boot.
149
150## First run: claiming the instance
151
152A 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
160Two 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
167So read it from the current run's journal:
168
169```sh
170journalctl -u steid --no-pager | tail -n 30
171```
172
173Then open `https://git.example.com/auth/setup`, paste it in, and choose your handle and
174password. After that, `/auth/login`.
175
176### Or set the token yourself
177
178If reading a log line during the first minute of a scripted install is awkward, set the
179token instead and skip the race:
180
181```sh
182STEID_SETUP_TOKEN=$(head -c 32 /dev/urandom | base64)
183```
184
185It 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
187eight distinct characters, so `abababab…` is rejected rather than accepted.
188
189## Using it
190
1911. Create a repository at `/{handle}/repos/new`. Public or private.
1922. Issue a personal access token at `/{handle}/settings/tokens`. It is shown once.
1933. Push:
194
195```sh
196git remote add origin https://git.example.com/me/repos/my-project.git
197git push -u origin main
198```
199
200Git will ask for a username and password. The token goes in the **password** field; the
201username is ignored (a token pasted as the username with an empty password also works,
202because people do that).
203
204You only type it once. Git's credential helper stores it and answers every later push —
205which is the whole reason pushing to GitHub feels like it needs no credentials. Use the
206one that keeps secrets in your operating system's keystore:
207
208```sh
209git config --global credential.helper osxkeychain # macOS (usually already set)
210git config --global credential.helper manager # Windows
211git config --global credential.helper libsecret # Linux
212```
213
214**Not `credential.helper store`.** It writes the token in clear text to
215`~/.git-credentials`, and a token is a password that never expires. Steid keeps only a
216hash of it precisely so that a stolen database contains nothing anyone can present;
217storing the plaintext on your laptop hands back what that was protecting.
218
219**Do not put the token in the remote URL** (`https://user:token@host/...`) either. It
220goes into `.git/config` in clear text and shows up in `git remote -v`, in shell history,
221and in any log that records the URL.
222
223Cloning a public repository needs no credentials at all.
224
225## Configuration
226
227Environment variables, read from `/etc/steid/steid.env` by the systemd unit.
228
229| Variable | Default | What it is |
230|---|---|---|
231| `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. |
232| `STEID_DATA_DIR` | `./data` | Where bare repositories live, as `{data_dir}/{handle}/{name}.git`. |
233| `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. |
234| `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. |
235| `PORT` | `3000` | Likewise not `STEID_`-prefixed. |
236
237Restart after editing: `systemctl restart steid`.
238
239## Backup and restore
240
241The entire backup surface is **two paths**:
242
243- `/var/lib/steid/steid.db` — the SQLite database (users, repositories, tokens, sessions)
244- `/var/lib/steid/repos` — the bare git repositories
245
246Which is to say: back up `/var/lib/steid`. `rsync` is enough.
247
248```sh
249systemctl stop steid
250rsync -a /var/lib/steid/ backup-host:/backups/steid/
251systemctl start steid
252```
253
254Stopping first is the honest version: SQLite in WAL mode leaves `-wal` and `-shm` files,
255and copying them while a write is in flight can capture a torn state. If you would rather
256not stop the service, snapshot the database properly and copy the repositories live —
257they are only written during a push:
258
259```sh
260sqlite3 /var/lib/steid/steid.db ".backup '/tmp/steid-backup.db'"
261rsync -a /tmp/steid-backup.db /var/lib/steid/repos backup-host:/backups/steid/
262```
263
264To restore: install Steid as above, stop it, drop both paths back into `/var/lib/steid`,
265`chown -R steid:steid /var/lib/steid`, start it. Migrations run on boot, so a database
266from an older version is brought forward automatically.
267
268## Building from source
269
270```sh
271cargo install topcoat-cli --version 0.5.0 --locked
272topcoat asset bundle --release
273```
274
275**`cargo build --release` on its own is not enough.** It produces a binary that will not
276boot: `AssetBundle::load()` walks up from the executable looking for
277`assets/manifest.toml`, and `build.rs` does not write one. `topcoat asset bundle` runs
278`cargo build` itself and then writes the bundle to `target/assets`. The binary at
279`target/release/steid` needs that directory beside it.
280
281Requires rustc ≥ 1.95 — Topcoat 0.5 demands it, and on an older toolchain `cargo add
282topcoat` silently resolves to an empty `v0.0.0` placeholder instead of failing.
283
284For development, `topcoat dev` does the bundling for you, and a local `.env` with
285`STEID_INSECURE_COOKIES=true` is needed for the session cookie to survive plain-HTTP
286localhost.
287
288### Cutting a release
289
290```sh
291./release.sh --target x86_64-unknown-linux-musl
292```
293
294Produces `dist/steid-<version>-<target>.tar.gz` and a `.sha256` beside it. The tarball
295extracts to a self-contained directory: the binary, `assets/`, and this README. Building
296a Linux artefact on macOS needs a container (the script uses Docker) or a cross
297toolchain; `--native` builds for the host instead, which is useful for checking the
298artefact layout and not for releasing.
299
300A container image also exists — see [`Dockerfile`](Dockerfile) — but the supported
301artefact is the plain binary.
302
303## Current limitations
304
305Deliberately blunt. Steid is early.
306
307- **Single user.** One account, claimed on first run. No registration, no invites, no
308 organizations yet.
309- **No TLS of its own.** A reverse proxy is mandatory, not recommended — see
310 [requirements](#requirements).
311- **No encryption at rest.** The database and repositories are plain files. Anyone with
312 the disk has everything. Token *values* are hashed, and passwords are Argon2, but
313 repository contents are not encrypted.
314- **Rate limiting covers `/auth/login` and `/auth/setup` only** — 10 attempts a minute
315 per client, with a global backstop. Token authentication on the git routes is *not*
316 limited; a token is 256 bits, so guessing is not the concern there, but unbounded
317 hashing on an open endpoint still is.
318- **Rate limiting keys on forwarded headers, because Topcoat 0.5 does not expose the
319 peer address at all.** Behind a reverse proxy — the supported deployment — that works.
320 Exposed directly to the internet with no proxy, a caller can vary the header and get a
321 fresh budget each time, leaving only the global cap. Run it behind the proxy.
322- **Tokens never expire** and carry no "last used" timestamp, which makes it hard to know
323 which are safe to revoke.
324- **No CI/CD**, no issues, no pull requests, no code review, no SSH transport, no
325 webhooks, no federation.
326- **No writing yet** — posts and markdown are the next milestone, and they are the point
327 of the whole thing.
328- **Light mode is untested.** It is defined; nobody has looked at it.
329- **Not battle-tested.** It has not run under load, has not been audited, and has been
330 deployed by approximately one person. Do not put anything irreplaceable in it that is
331 not also somewhere else.
332
333More, in unflattering detail, in [`plans/current.md`](plans/current.md).
334
335## Project layout
336
337`plans/` is the source of truth for intent, not the code:
338
339| File | Holds |
340|---|---|
341| [`plans/ROADMAP.md`](plans/ROADMAP.md) | vision, stack, the milestone ladder |
342| [`plans/current.md`](plans/current.md) | the active milestone, and every known gap |
343| [`plans/progress.md`](plans/progress.md) | what shipped, and the decisions worth not rediscovering |
344| [`plans/architecture.md`](plans/architecture.md) | layer rules and conventions |
345| [`plans/decisions/`](plans/decisions/) | ADRs |
346
347The code is a single crate in three layers — `domain`, `application`, `infrastructure` —
348with dependencies pointing inward. Every use case takes an `Actor` and authorizes before
349any side effect.