Files
tireless/script/infra-setup.sh
rob thijssen 9deeaaa9c2
All checks were successful
deploy / deploy (push) Successful in 5m35s
feat(infra): provision the poller identity
Stage 2 writes the first label, and nothing about the label protocol can be
dogfooded until an identity exists that is allowed to. Provisioned now rather
than deferred.

  account   tireless-poller (tireless-poller@lair.cafe), no ssh key — it never
            clones anything
  team      lair/tireless-poller: Issues=write, PullRequests=read, Code=none,
            everything else none, scoped to named repos
  token     write:issue, read:repository, read:user

This is the one place an org team earns its keep. Collaborator permissions in
Gitea are repo-wide, so granting issue write that way would hand this account
code push too — the unit-level team grants labelling without it.

The units now load different environment files, and that is not tidiness. The
runner spawns coding agents as subprocesses, and subprocesses inherit the
environment, so a label-capable token in the runner's environment is a token
every agent run can read. poller.env is loaded only by tireless-poller.service,
which spawns nothing.

Be precise about what that buys: the separation is between processes, not users.
Both units run as `tireless`, so the runner's uid can still read poller.env even
though its process never loads it. An agent would have to go looking rather than
find it handed over in `env` — a real difference, not a boundary. Closing it
means separate service accounts or LoadCredential=; recorded as a stage 8 item so
it is a known gap rather than an assumed guarantee.

Verified each identity can do its own job and not the other's:

              tireless   tireless-poller
  add label      403          200
  fork repo      works        403
  read issues    200          200

One measurement caveat recorded in §6.4: the poller can still read repository
contents despite Code=none, because lair/tireless is public. The unit permission
bites on private repos — do not read that as the grant being wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
2026-08-07 18:31:59 +03:00

411 lines
19 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# One-time host provisioning for tireless.
#
# Run by an operator from a workstation with full sudo — NOT by CI. See
# architecture/deployment-gitea-actions.md §2. Idempotent: re-running with no
# changes is a no-op beyond file copies.
#
# Per architecture/generic.md §7 this script never suppresses errors. Where a
# command may legitimately fail (a service not yet installed), the failure is
# handled explicitly and visibly.
set -euo pipefail
APP=tireless
API_HOST="${API_HOST:-bob.hanzalova.internal}"
API_PORT="${API_PORT:-23296}"
# Ingress runs on the office proxy, not on bob (doc/plan/design.md §6.2). The
# dashboard is served from there and /v1 is reverse-proxied across the mesh, so
# the proxy needs its own (much smaller) deploy grant.
WEB_HOST="${WEB_HOST:-hanzalova.internal}"
WEB_ROOT="${WEB_ROOT:-/var/www/tireless}"
RUNNER_PUBKEY="${RUNNER_PUBKEY:-$HOME/.ssh/id_gitea_ci.pub}"
# JWK provisioner password for the internal CA, used only to mint the first
# cert (architecture/internal-tls.md §4). Never left on a host.
PROVISIONER_PW="${PROVISIONER_PW:-$HOME/.step/secrets/provisioner}"
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m warn\033[0m %s\n' "$*" >&2; }
fatal() { printf '\033[1;31mfatal\033[0m %s\n' "$*" >&2; exit 1; }
[[ -f $RUNNER_PUBKEY ]] || fatal "runner public key not found at $RUNNER_PUBKEY.
The keypair is maintained at ~/.ssh/id_gitea_ci on roosta and is shared by every
project's deploy. Copy it — do not generate a new one."
# ---------------------------------------------------------------------------
# 1. gitea_ci account, key, journal access, scoped sudoers
# ---------------------------------------------------------------------------
provision_host() {
local host="$1"
info "provisioning $host"
if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$host" true; then
warn "$host unreachable; skipping (re-run once it is back)"
return 0
fi
ssh "$host" 'sudo useradd --system --create-home --home-dir /var/lib/gitea_ci \
--shell /usr/sbin/nologin gitea_ci || echo "gitea_ci already exists"'
ssh "$host" 'sudo install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh'
rsync --rsync-path 'sudo rsync' --chown gitea_ci:gitea_ci --chmod 0600 \
"$RUNNER_PUBKEY" "$host:/var/lib/gitea_ci/.ssh/authorized_keys"
ssh "$host" 'sudo usermod -aG systemd-journal gitea_ci'
# Scoped sudoers — exactly the commands the deploy runs, nothing broader.
# Named <app>_gitea_ci so other apps on this host keep their own drop-in.
ssh "$host" "sudo tee /etc/sudoers.d/${APP}_gitea_ci >/dev/null" <<SUDOERS
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/tireless-api
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/tireless-worker
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/tireless
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/tireless/config.toml
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/tireless.conf
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/tireless-api.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/tireless-poller.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/tireless-runner.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/tireless-api.xml
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /var/www/tireless/
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart tireless-api.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart tireless-poller.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart tireless-runner.service
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/tireless-api /usr/local/bin/tireless-worker /usr/local/bin/tireless /etc/tireless
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -l
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -a -t http_port_t -p tcp ${API_PORT}
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --get-default-zone
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone=* --query-service=tireless-api
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --permanent --zone=* --add-service=tireless-api
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone=* --add-service=tireless-api
SUDOERS
ssh "$host" "sudo visudo -cf /etc/sudoers.d/${APP}_gitea_ci"
# ------------------------------------------------------------------------
# 2. Service account, directories, cert ACL
# ------------------------------------------------------------------------
ssh "$host" 'sudo install -d -o root -g root -m 0755 /etc/tireless'
# /var/lib/tireless is deliberately NOT created here: the service account
# does not exist until the first deploy runs systemd-sysusers, so this always
# raced. The units declare StateDirectory=tireless, which makes systemd create,
# own and label it at first start.
# The service account needs to read the host key for mTLS to Postgres (§11).
ssh "$host" 'sudo setfacl -m u:tireless:r "/etc/pki/tls/private/$(hostname -f).pem" || \
echo "deferred: tireless user does not exist yet"'
# SELinux: the API binds a non-standard port, which must be labelled before
# the first start or the bind is denied (§10).
ssh "$host" "sudo semanage port -l | grep -qE '^http_port_t.*\\b${API_PORT}\\b' \
&& echo 'port ${API_PORT} already labelled' \
|| sudo semanage port -a -t http_port_t -p tcp ${API_PORT}"
info "$host provisioned"
}
provision_host "$API_HOST"
# ---------------------------------------------------------------------------
# 3. Proxy host: web root and a deploy grant scoped to it alone
# ---------------------------------------------------------------------------
provision_web_host() {
local host="$1"
info "provisioning ingress on $host"
if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$host" true; then
warn "$host unreachable; skipping (re-run once it is back)"
return 0
fi
ssh "$host" 'sudo useradd --system --create-home --home-dir /var/lib/gitea_ci \
--shell /usr/sbin/nologin gitea_ci || echo "gitea_ci already exists"'
ssh "$host" 'sudo install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh'
rsync --rsync-path 'sudo rsync' --chown gitea_ci:gitea_ci --chmod 0600 \
"$RUNNER_PUBKEY" "$host:/var/lib/gitea_ci/.ssh/authorized_keys"
ssh "$host" "sudo install -d -o root -g root -m 0755 $WEB_ROOT"
# Deliberately narrower than the API host's grant: the proxy only ever
# receives static files. It gets no systemctl, no binaries, no config.
ssh "$host" "sudo tee /etc/sudoers.d/${APP}_web_gitea_ci >/dev/null" <<SUDOERS
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * ${WEB_ROOT}/
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R ${WEB_ROOT}
SUDOERS
ssh "$host" "sudo visudo -cf /etc/sudoers.d/${APP}_web_gitea_ci"
mint_service_cert "$host" "$APP"
install_vhost "$host"
info "$host ingress provisioned"
}
# ---------------------------------------------------------------------------
# Per-service internal cert (architecture/internal-tls.md §4)
#
# The step@ timer only *renews*; the first cert is minted through the JWK
# provisioner, whose password is shipped to the host only for as long as the
# mint takes and removed even when it fails.
# ---------------------------------------------------------------------------
mint_service_cert() {
local host="$1" name="$2"
local cert="/etc/nginx/tls/cert/${name}.internal.pem"
local key="/etc/nginx/tls/key/${name}.internal.pem"
local root=/etc/pki/ca-trust/source/anchors/root-internal.pem
local state
state=$(ssh "$host" "[ -f $cert ] && sudo step certificate verify $cert \
--roots $root >/dev/null 2>&1 && echo valid || echo missing")
if [[ $state == valid ]]; then
info "$name.internal cert already valid on $host"
else
[[ -f $PROVISIONER_PW ]] || fatal "provisioner password not found at $PROVISIONER_PW"
info "minting $name.internal cert on $host"
rsync -az --rsync-path='sudo rsync' --chmod=0600 \
"$PROVISIONER_PW" "$host:/tmp/${name}-provisioner"
# --san is mandatory: modern clients ignore CN, and a CN-only cert fails
# with "no alternative certificate subject name matches target hostname".
ssh "$host" "
sudo install -d -m 0755 /etc/nginx/tls/cert /etc/nginx/tls/key
rc=0
sudo step ca certificate --force \
--provisioner lair \
--provisioner-password-file /tmp/${name}-provisioner \
--ca-url https://ca.internal \
--root $root \
--san ${name}.internal \
${name}.internal $cert $key || rc=\$?
sudo rm -f /tmp/${name}-provisioner
[ \$rc -eq 0 ] || { echo 'mint failed' >&2; exit \$rc; }
sudo chown root:root $cert $key
sudo chmod 644 $cert
sudo chmod 640 $key
sudo setfacl -m u:nginx:r $key"
fi
# Harmless before the first mint — ExecCondition makes it a no-op.
ssh "$host" "sudo systemctl enable --now step@${name}.timer"
}
# ---------------------------------------------------------------------------
# nginx vhost (architecture/reverse-proxies.md §4)
# ---------------------------------------------------------------------------
install_vhost() {
local host="$1"
local conf="tireless.internal.conf"
ssh "$host" "sudo install -d -o root -g root -m 0755 $WEB_ROOT"
# A webroot not labelled httpd_sys_content_t gives nginx a 403. Deploys
# inherit the directory's type, so labelling it once here is enough.
ssh "$host" "sudo restorecon -R $WEB_ROOT"
rsync --rsync-path='sudo rsync' --chmod 0644 \
"$(dirname "$0")/../asset/nginx/tireless.hanzalova.conf" \
"$host:/etc/nginx/sites-available/$conf"
ssh "$host" "sudo ln -sfn ../sites-available/$conf /etc/nginx/sites-enabled/$conf"
# `nginx -t` parses without binding, so it cannot see a port owned across
# http{} and stream{}. It is necessary, not sufficient — hence the served-cert
# check below rather than trusting the reload (internal-tls.md §3).
ssh "$host" "sudo nginx -t" || fatal "nginx config test failed on $host"
ssh "$host" "sudo systemctl reload nginx"
}
# ---------------------------------------------------------------------------
# Split-horizon DNS, on BOTH routers
#
# A mesh host queries its own site's router, so a record added to only one
# router resolves at that site and NXDOMAINs everywhere else — including the
# operator's workstation if it is on the other site.
# ---------------------------------------------------------------------------
register_dns() {
local proxy_ip
proxy_ip=$(getent hosts "$WEB_HOST" | awk '{print $1; exit}')
[[ -n $proxy_ip ]] || fatal "cannot resolve $WEB_HOST to give the routers an address"
for site in kosherinata hanzalova; do
local cfg="$HOME/.opn-cli/${site}.yml"
if [[ ! -f $cfg ]]; then
warn "no opn-cli config for $site; register tireless.internal there by hand"
continue
fi
info "registering tireless.internal -> $proxy_ip on $site"
# Idempotent: skip the create when the override is already there, because a
# second create silently makes a *duplicate* override rather than failing,
# and two overrides for one name is a confusing thing to debug later.
#
# The listing is a padded ASCII table: `| uuid | enabled | hostname |
# domain | rr | …`, so hostname is field 4 and domain field 5 once split on
# the pipe. Trim the padding before comparing.
if opn-cli --config "$cfg" unbound host list 2>/dev/null | awk -F'|' '
NF > 5 {
h = $4; d = $5; gsub(/[[:space:]]/, "", h); gsub(/[[:space:]]/, "", d)
if (h == "tireless" && d == "internal") found = 1
}
END { exit !found }'; then
info " override already present on $site"
else
opn-cli --config "$cfg" unbound host create \
--hostname tireless --domain internal --rr A --server "$proxy_ip"
fi
# `create` only *saves* the override; Unbound keeps serving the old zone
# until the service is reconfigured. opn-cli exposes no reconfigure verb
# (it has only host/alias/domain), so call the API endpoint directly —
# otherwise the name resolves whenever Unbound next happens to reload,
# which looks like success on a slow enough check.
apply_unbound "$cfg" "$site"
done
}
# POST /api/unbound/service/reconfigure, reading credentials from the opn-cli
# config so there is one place they live.
apply_unbound() {
local cfg="$1" site="$2"
local url key secret verify
url=$(python3 -c "import yaml;print(yaml.safe_load(open('$cfg'))['url'])")
key=$(python3 -c "import yaml;print(yaml.safe_load(open('$cfg'))['api_key'])")
secret=$(python3 -c "import yaml;print(yaml.safe_load(open('$cfg'))['api_secret'])")
verify=$(python3 -c "import yaml;print(yaml.safe_load(open('$cfg')).get('ssl_verify',True))")
local curl_opts=(-fsS -u "$key:$secret" -X POST -H 'Content-Type: application/json' -d '{}')
[[ $verify == True ]] || curl_opts+=(-k)
if curl "${curl_opts[@]}" "$url/unbound/service/reconfigure" >/dev/null; then
info " unbound reconfigured on $site"
else
warn "unbound reconfigure failed on $site — apply the pending change in the
OPNsense UI, or tireless.internal will resolve only at the other site"
fi
}
provision_web_host "$WEB_HOST"
register_dns
# ---------------------------------------------------------------------------
# 4. Manual steps that cannot be automated
# ---------------------------------------------------------------------------
cat <<'MANUAL'
Remaining one-time steps (operator, on the target host):
1. Claude Code subscription login.
The OAuth flow is interactive and must be completed *as the service account*,
because Claude Code reads credentials from $HOME:
sudo -u tireless -H bash -c 'cd /var/lib/tireless && \
npx -y @anthropic-ai/claude-code@2.1.220'
# then: /login, and complete the browser flow
The `cd` is load-bearing. `-H` sets HOME, but sudo leaves the working
directory where you invoked it — typically your own 0700 home, which
`tireless` cannot read. npx then fails spawning its `sh -c claude` there:
npm error Error: spawn sh EACCES
npm error path: '/home/grenade'
which reads like a broken install rather than a directory permission.
`bash -c` rather than `sudo -iu tireless`, because the account's shell is
nologin by design and a login shell will not start.
Keep the pinned version in step with `CLAUDE_PACKAGE` in
crates/tireless-agent/src/claude.rs — the runner npx's that exact version,
and logging in with a different one warms the wrong cache.
This writes /var/lib/tireless/.claude.json. The token refreshes in place,
which is why the unit declares StateDirectory=tireless (systemd creates
it, owns it as the service account, and keeps it writable).
Skip this only if you intend to run pay-as-you-go, in which case put
ANTHROPIC_API_KEY in /etc/tireless/tireless.env instead. Do not do both:
the API key silently wins, and the subscription goes unused.
2. Gitea bot account. (Done for git.lair.cafe on 2026-08-07.)
A dedicated `tireless` user, not your own account, holding two credentials:
- ssh key at /var/lib/tireless/.ssh/id_ed25519 (0600 tireless:tireless),
registered on the account, for git transport. The private half is kept at
~/.ssh/id_tireless on the operator workstation.
- API token scoped `write:issue`, `write:repository`, `read:user`, in
/etc/tireless/tireless.env as GITEA_TOKEN (0640 root:tireless), for
issues and pull requests.
Seed known_hosts for the forge at the same time. An unattended git must not
prompt, and StrictHostKeyChecking=accept-new would trust whatever answered.
GRANT THIS ACCOUNT NOTHING. No collaborator role, no team membership, no
permission on any repo beyond public read. An ordinary authenticated user can
already fork, push to its own fork, open a cross-repo pull request, comment
on an issue and create an issue -- which is the whole of what the runner does.
For each repo tireless should work on, the only step is:
- fork it to the `tireless` user.
Confirm, rather than assume:
push to the fork succeeds
cross-repo PR from the fork succeeds
push to the upstream repo "User permission denied for writing."
deleting the repo refused
What is NOT needed: branch protection for the bot. It has no push permission
at all, so there is no rule to forget on the next repo you add. Keep branch
protection for your own workflow if you want it; it is no longer what stands
between an unattended agent and `main`.
LABELLING IS A SEPARATE IDENTITY: `tireless-poller`. (Done 2026-08-07.)
Applying a label is the one §2.2 capability an unprivileged account cannot do,
and it must not be bought by granting the runner code write.
- account `tireless-poller` (tireless-poller@lair.cafe), no ssh key -- it
never clones anything;
- org team `lair/tireless-poller`: Issues=write, PullRequests=read,
Code=none, everything else none, scoped to named repos;
- API token scoped `write:issue`, `read:repository`, `read:user`, in
/etc/tireless/poller.env (0640 root:tireless).
The env file is SEPARATE from the runner's on purpose. The runner spawns
coding agents as subprocesses, and subprocesses inherit the environment, so a
label-capable token in the runner's environment is one every agent run can
read. tireless-poller.service loads poller.env; tireless-runner.service loads
tireless.env. Do not merge them.
Confirm the split, rather than assume it:
runner token, add label 403
poller token, add label 200
poller token, fork a repo 403
Beware when you build stage 2: creating an issue WITH labels as an
unprivileged user returns 201 and silently drops the labels. No error, no
warning, an issue that never gets picked up. The runner creates children bare
and records their intended labels in Postgres; the poller applies them.
3. Confirm the proxy is serving the cert that is on disk.
The vhost, its cert and the split-horizon DNS were installed above, but
`systemctl reload nginx` exits 0 whatever nginx does with the SIGHUP — and
these certs live 24 hours, so a reload that silently did not land shows up
as an expired cert tomorrow, with every file on disk looking current
(architecture/internal-tls.md §3). Verify by serial, not expiry:
served=$(echo | openssl s_client -servername tireless.internal \
-connect hanzalova.internal:443 2>/dev/null \
| openssl x509 -noout -serial)
disk=$(ssh hanzalova.internal "sudo openssl x509 -noout -serial \
-in /etc/nginx/tls/cert/tireless.internal.pem")
[ "$served" = "$disk" ] || echo "stale: $served vs $disk"
If they disagree, restart nginx rather than reloading it.
4. Postgres role and ident mapping (architecture/generic.md §5).
On magrathea AND frankie:
- create role `tireless_rw`, and a `tireless` database;
- drop /var/lib/pgsql/18/data/pg_ident.conf.d/<this-host-fqdn>.conf
containing: cert_cn <this-host-fqdn> tireless_rw
- sudo systemctl reload postgresql-18
Both servers, or a failover locks tireless out.
MANUAL
info "infra-setup complete"