OXYGENHUB
◆ proposal-list-fast 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
1
# Deploying the developer edition on Earth
2
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.
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`.
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`.
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`.
27
8. **Updates**: rsync, build, `sudo install`, `sudo systemctl restart oxygenhub`. `make deploy` in the hub wraps steps 2, 3 and 8.
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`.
30
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
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`,
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
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
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
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` |
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
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.
100
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
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.
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.
127
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
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
```