OXYGENHUB
◆ v0.9.1 oxygenhub / docs / deploy.md
9.6 KiB · Markdown · last changed deploy.md: the socket listens where server.bind says; Earth has it now 18 days ago renderedsource blame history raw
93be9ce4 Andy McCall 1
# Deploying the developer edition on Earth
2
baebdd18 Andy McCall ↶ 3
Companion to `spec.md` §17.5. Status: **installed and running** since 2026-09-27 as the systemd service `oxygenhub` under the `oxygenhub` system user, data in `/home/oxygenhub/data`, HTTP on 7300 and HTTPS on 7343 with a self-signed certificate. `oxygen`, `oxygen-vscode` and `oxygenhub` are hosted under `andymccall/` and each checkout has an `earth` remote.
93be9ce4 Andy McCall 4
5
## The host
6
7
| | |
8
|---|---|
9
| Name | `earth` (`earth.andymccall.xyz`, IPv6 ULA; `192.168.1.6` on the LAN) |
10
| OS | Ubuntu 26.04.1 LTS, x86_64, 4 cores, 31 GiB RAM |
11
| Disk | `/` 251 GB (95 GB free), `/home` 1 TB (944 GB free) |
12
| Already running | sshd :22, a Python service :6400, `wren-nest` :7842, retroplay-manager in Docker (:8080, :6670) with Postgres and a tetriblox server, two GitHub Actions runners (`doomsdayonecom`), Docker, containerd |
13
| Not installed | Rust toolchain, sqlite3 CLI, nginx or caddy, certbot |
14
| Access | `ssh -i ~/.ssh/earth_claude claude@earth`; `claude` has passwordless sudo |
15
16
Ports 7300 (HTTP) and 7343 (HTTPS) are free and are the hub's.
17
18
## Steps
19
20
1. **Toolchain** (as `claude`): `curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal`, then `sudo apt-get install -y build-essential pkg-config git`.
21
2. **Source**: from the development machine, `rsync -a --delete --exclude target --exclude node_modules ~/development/oxygen ~/development/oxygenhub claude@earth:~/src/`. Oxygen is never pushed to Git; rsync is the bootstrap, and after the hub runs, `oxy push` to the hub itself is the ongoing path.
22
3. **Build**: `cd ~/src/oxygenhub && cargo build --release`.
23
4. **System user and data**: `sudo useradd --system --home /home/oxygenhub --create-home oxygenhub`, `sudo install -o oxygenhub -g oxygenhub -m 0755 target/release/oxygenhub /usr/local/bin/`, `sudo -u oxygenhub oxygenhub init --data /home/oxygenhub/data --admin andymccall` (prints the first admin's one-time password).
24
5. **Service**: `/etc/systemd/system/oxygenhub.service` running `oxygenhub serve --data /home/oxygenhub/data` as `oxygenhub`, `Restart=on-failure`, `ProtectSystem=strict`, `ReadWritePaths=/home/oxygenhub/data`, `NoNewPrivileges=yes`; `sudo systemctl enable --now oxygenhub`.
8f3d2ccf Andy McCall ↶ 25
6. **TLS**: the developer edition generates a self-signed certificate at `init`. Download the certificate from `https://earth.andymccall.xyz:7343/.well-known/oxyhub-ca.pem` (or over plain HTTP on port 7300) and point clients at it with `oxy config remote.<name>.ca <file>` or `OXY_CA`.
d91780ee Andy McCall ↶ 26
7. **First repositories**: on the development machine, `oxy remote add earth https://oxygenhub.io/andymccall/oxygen` in each of `oxygen`, `oxygen-vscode`, `oxygenhub`, then `oxy push earth`.
baebdd18 Andy McCall ↶ 27
8. **Updates**: rsync, build, `sudo install`, `sudo systemctl restart oxygenhub`. `make deploy` in the hub wraps steps 2, 3 and 8.
d336629f Andy McCall ↶ 28
9. **Public address** (2026-09-30): a Cloudflare tunnel on Earth serves the hub as `https://oxygenhub.io`, with a publicly trusted certificate, and `base_url` in `hub.toml` is that address, so every link the hub writes uses it. Checkouts push there with no CA set, and subrepo URLs name it too.
29
10. **Localhost only** (2026-09-30): the tunnel connects to `127.0.0.1:7300`, so the hub binds `127.0.0.1:7300` and `127.0.0.1:7343` and nothing on the LAN reaches it directly; `behind_proxy = true` (the hub does not yet use client addresses). The Flow runner on Earth polls `http://127.0.0.1:7300`. Backups: `hub.toml.before-localhost`, `~/flow/flow.toml.before-localhost`.
baebdd18 Andy McCall ↶ 30
c92a9949 Andy McCall ↶ 31
## Restarts without errors (oxygenhub#233)
32
33
A deploy restarts the hub. Since #233, on SIGTERM (what `systemctl restart`
34
sends) the hub stops taking connections, ends its live event streams
35
(browsers reconnect by themselves) and gives the requests in flight up to
36
30 seconds to finish. Requests that arrive *between* the old process and the
37
new one are refused unless systemd holds the listening socket across the
38
restart: socket activation. The hub serves a socket systemd passes
39
(`LISTEN_FDS`) and binds `server.bind` itself otherwise.
40
41
To turn it on, add `/etc/systemd/system/oxygenhub.socket`:
42
43
```ini
44
[Unit]
45
Description=Oxygenhub's listening socket, held across restarts
46
47
[Socket]
48
ListenStream=0.0.0.0:7300
49
# A restart's queue: connections wait here, not refused.
50
Backlog=512
51
NoDelay=yes
52
53
[Install]
54
WantedBy=sockets.target
55
```
56
9aee52d3 Andy McCall ↶ 57
`ListenStream` is the address `server.bind` names in `hub.toml` (Earth's is `127.0.0.1:7300`, behind cloudflared). Then give `oxygenhub.service` `Requires=oxygenhub.socket`,
c92a9949 Andy McCall ↶ 58
`After=oxygenhub.socket` and `TimeoutStopSec=40`, and run:
59
60
```sh
61
sudo systemctl daemon-reload
62
sudo systemctl stop oxygenhub
63
sudo systemctl enable --now oxygenhub.socket
64
sudo systemctl start oxygenhub
65
```
66
9aee52d3 Andy McCall ↶ 67
On Earth the three service lines are a drop-in, `/etc/systemd/system/oxygenhub.service.d/socket.conf` (installed 2026-10-17; a restart under 10 requests a second then answered all 120 with 200).
68
c92a9949 Andy McCall ↶ 69
The service's log then says "http on the socket systemd holds". From then on
70
`systemctl restart oxygenhub` keeps the socket, so nobody sees a refused
71
connection or a Cloudflare 502. HTTPS (7343, the developer edition's
72
self-signed listener) still binds itself.
73
baebdd18 Andy McCall ↶ 74
## Where things are on Earth
75
76
| | |
77
|---|---|
78
| Binaries | `/usr/local/bin/oxygenhub`, `/usr/local/bin/oxy` |
79
| Sources | `/home/claude/src/oxygen`, `/home/claude/src/oxygenhub` (rsync targets; the hub itself is the canonical remote) |
80
| Data | `/home/oxygenhub/data` (`hub.toml`, `hub.db`, `keys/`, `repos/andymccall/*`) |
81
| Push token | `/home/oxygenhub/push-token` (also in `hub.toml`); each local checkout has it as `remote.earth.token` |
82
| Certificate | `/home/oxygenhub/data/keys/tls.crt`, served at `/.well-known/oxyhub-ca.pem`; local copy `~/.config/oxy/earth-ca.pem` set as `remote.earth.ca` |
83
| Service | `sudo systemctl status oxygenhub`, `sudo journalctl -u oxygenhub` |
9a8e3b44 Andy McCall ↶ 84
85
## HTTPS only (oxygenhub#218)
86
87
When `base_url` is `https://…`, the hub takes nothing over plain http
88
through a proxy: a request that `X-Forwarded-Proto` (or Cloudflare's
89
`CF-Visitor`) reports as http is redirected to the same path on
90
`base_url` (GET and HEAD) or refused with 400 (anything else), and every
91
cookie it sets is `Secure`. Requests made to the hub directly, such as a
92
runner on the same host using `http://127.0.0.1:7300`, carry no such
93
header and are served as before. In front, Cloudflare's Always Use HTTPS
2cbf6f34 Andy McCall ↶ 94
is on (2026-10-15). Strict-Transport-Security comes from `[server] hsts_max_age` (seconds;
95
0, the default, sends none; never over plain http; oxygenhub#231).
96
Browsers remember it, so raise it in stages: 300 first, a week
97
(604800) after a few quiet days, then a year (31536000). Add
98
`includeSubDomains` only once every subdomain is HTTPS, and preload last.
99
oxygenhub.io is at 300 from 2026-10-16.
9a777fec Andy McCall ↶ 100
d74ce22f Andy McCall ↶ 101
## Security headers (oxygenhub#222)
102
103
Every response carries `X-Content-Type-Options: nosniff`,
104
`Referrer-Policy: strict-origin-when-cross-origin` and a
105
`Permissions-Policy` that turns off the camera, microphone, location and
106
the like; pages also carry `X-Frame-Options: DENY` and a Content Security
107
Policy (`frame-ancestors 'none'`, scripts and forms from the hub only,
108
WebAssembly for the backdrop). The policy is **report-only** for now:
109
browsers report what it would block to `/csp-report`, and the hub logs
110
each distinct violation once an hour (`csp would block: …`). The pages
111
still use inline scripts and handlers, which it reports; once they have
112
moved into `hub.js` and the log is quiet, `ENFORCE` in
113
`crates/hub/src/headers.rs` switches it on. Responses with their own
114
policy (report frames, a job's HTML artifact) keep it. HSTS is not sent:
115
it waits for the site owner's decision.
116
3dd6582d Andy McCall ↶ 117
## security.txt (oxygenhub#224)
118
119
Administration › Security contact sets where vulnerabilities are reported
120
(an email address or an https page) and, optionally, a policy page. The
121
hub then serves `/.well-known/security.txt` (and `/security.txt`) per
122
RFC 9116, with an `Expires` date that is always just under a year ahead,
123
so it never goes stale. With no contact set, both answer 404.
2cbf6f34 Andy McCall ↶ 124
`/security-contact` is the page to point the contact at: the reporting
125
address and Markdown text from the same panel (oxygenhub#230), with
126
sensible text until it is written.
3dd6582d Andy McCall ↶ 127
aa0bfbd1 Andy McCall ↶ 128
## Guessing is slowed (oxygenhub#221)
129
130
Password sign-in, the two-factor code step, device codes and password
131
reset requests count failures per account and per client address
132
(`behind_proxy = true` takes the address from the tunnel's
133
`CF-Connecting-IP`, so set it when the hub is behind one). An account
134
gets five free failures and an address twenty (people share addresses);
135
after that each failure doubles the wait before the next try, from two
136
seconds up to a fifteen-minute lockout, and the page says when to try
137
again. A reset request counts as a try whatever it matched, so the
138
answer still gives nothing away. A quiet hour forgets the count and a
139
successful sign-in clears the account's. Each wait that starts is in the
140
audit log (`login.throttled`, `device.throttled`,
141
`password.reset_throttled`) with the address. The counts live in memory,
142
so a restart clears them.
143
9a777fec Andy McCall ↶ 144
## Outbound requests (oxygenhub#219)
145
146
Webhooks and AI assistants call addresses people give, so the hub only
147
calls **public** addresses: it resolves the host, refuses if any address
148
it resolves to is loopback, private, shared (CGNAT), link-local (the
149
cloud metadata address among them), multicast or an IPv6 local range,
150
pins the connection to the addresses it checked, and follows no
151
redirects. A refused webhook delivery records "not sent: …" with the
152
reason; an assistant says why it cannot be reached. To use a service on
153
your own network on purpose (a local Ollama), a site administrator lists
154
it in `hub.toml`:
155
156
```toml
157
[outbound]
158
allow = ["http://127.0.0.1:11434"]   # scheme://host (any port) or scheme://host:port
159
```