Deploying the developer edition on Earth
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.
The host
| Name | earth (earth.andymccall.xyz, IPv6 ULA; 192.168.1.6 on the LAN) |
| OS | Ubuntu 26.04.1 LTS, x86_64, 4 cores, 31 GiB RAM |
| Disk | / 251 GB (95 GB free), /home 1 TB (944 GB free) |
| 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 |
| Not installed | Rust toolchain, sqlite3 CLI, nginx or caddy, certbot |
| Access | ssh -i ~/.ssh/earth_claude claude@earth; claude has passwordless sudo |
Ports 7300 (HTTP) and 7343 (HTTPS) are free and are the hub's.
Steps
- Toolchain (as
claude):curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal, thensudo apt-get install -y build-essential pkg-config git. - 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 pushto the hub itself is the ongoing path. - Build:
cd ~/src/oxygenhub && cargo build --release. - 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). - Service:
/etc/systemd/system/oxygenhub.servicerunningoxygenhub serve --data /home/oxygenhub/dataasoxygenhub,Restart=on-failure,ProtectSystem=strict,ReadWritePaths=/home/oxygenhub/data,NoNewPrivileges=yes;sudo systemctl enable --now oxygenhub. - TLS: the developer edition generates a self-signed certificate at
init. Download the certificate fromhttps://earth.andymccall.xyz:7343/.well-known/oxyhub-ca.pem(or over plain HTTP on port 7300) and point clients at it withoxy config remote.<name>.ca <file>orOXY_CA. - First repositories: on the development machine,
oxy remote add earth https://earth.andymccall.xyz:7343/andymccall/oxygenin each ofoxygen,oxygen-vscode,oxygenhub, thenoxy push earth. - Updates: rsync, build,
sudo install,sudo systemctl restart oxygenhub.make deployin the hub wraps steps 2, 3 and 8.
Where things are on Earth
| Binaries | /usr/local/bin/oxygenhub, /usr/local/bin/oxy |
| Sources | /home/claude/src/oxygen, /home/claude/src/oxygenhub (rsync targets; the hub itself is the canonical remote) |
| Data | /home/oxygenhub/data (hub.toml, hub.db, keys/, repos/andymccall/*) |
| Push token | /home/oxygenhub/push-token (also in hub.toml); each local checkout has it as remote.earth.token |
| 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 |
| Service | sudo systemctl status oxygenhub, sudo journalctl -u oxygenhub |
HTTPS only (oxygenhub#218)
When base_url is https://…, the hub takes nothing over plain http
through a proxy: a request that X-Forwarded-Proto (or Cloudflare's
CF-Visitor) reports as http is redirected to the same path on
base_url (GET and HEAD) or refused with 400 (anything else), and every
cookie it sets is Secure. Requests made to the hub directly, such as a
runner on the same host using http://127.0.0.1:7300, carry no such
header and are served as before. In front, Cloudflare's Always Use HTTPS
is on (2026-10-15). Strict-Transport-Security is not sent yet: it waits
for the security headers (#222) and the site owner's decision, because
browsers remember it for months.
Guessing is slowed (oxygenhub#221)
Password sign-in, the two-factor code step, device codes and password
reset requests count failures per account and per client address
(behind_proxy = true takes the address from the tunnel's
CF-Connecting-IP, so set it when the hub is behind one). An account
gets five free failures and an address twenty (people share addresses);
after that each failure doubles the wait before the next try, from two
seconds up to a fifteen-minute lockout, and the page says when to try
again. A reset request counts as a try whatever it matched, so the
answer still gives nothing away. A quiet hour forgets the count and a
successful sign-in clears the account's. Each wait that starts is in the
audit log (login.throttled, device.throttled,
password.reset_throttled) with the address. The counts live in memory,
so a restart clears them.
Outbound requests (oxygenhub#219)
Webhooks and AI assistants call addresses people give, so the hub only
calls public addresses: it resolves the host, refuses if any address
it resolves to is loopback, private, shared (CGNAT), link-local (the
cloud metadata address among them), multicast or an IPv6 local range,
pins the connection to the addresses it checked, and follows no
redirects. A refused webhook delivery records "not sent: …" with the
reason; an assistant says why it cannot be reached. To use a service on
your own network on purpose (a local Ollama), a site administrator lists
it in hub.toml:
[outbound]
allow = ["http://127.0.0.1:11434"] # scheme://host (any port) or scheme://host:port