fix(deploy): correct the runner label, the vhost listen line and the cert paths
Some checks failed
deploy / deploy (push) Failing after 5m31s

Three faults, any one of which would have failed the first deploy — found by
reading the house conventions in ~/git/architecture rather than by running it.

`runs-on: fedora-43-rust` is not a registered runner label. The catalogue in
gitea-runners.md §2 has `rust`; the job would never have been scheduled. Also
collapse build and deploy into one job: the `rust` image descends from
runner-fedora-44 and carries node, ssh and rsync, so the artifact upload and
download — on actions/*-artifact@v3, EOL upstream — bought nothing but a
round-trip and a directory-structure assumption. rpm.lair.cafe's working deploy
is single-job for the same reason.

The nginx vhost bound `listen 443 ssl`. TCP 443 on hanzalova belongs to the
stream SNI router (reverse-proxies.md §4-5, and the live bench.internal.conf
confirms it), so the vhost would never have been reached — the router answers
first with whichever certificate its default branch holds. `nginx -t` passes
either way, which is what makes this worth catching by reading rather than by
deploying. Now `listen 127.0.0.1:14443 ssl proxy_protocol;`.

Cert paths pointed at the host identity cert under /etc/pki/tls. A per-service
name needs its own cert (internal-tls.md §2): the host cert's SAN is bob's FQDN,
not tireless.internal, so verification would have failed. Now
/etc/nginx/tls/{cert,key}/tireless.internal.pem, minted with --san and renewed
by step@tireless.timer.

infra-setup.sh now provisions the ingress rather than describing it: mints the
cert through the JWK provisioner (removing the credential even on failure),
installs the vhost via sites-available + symlink, and registers the
split-horizon record on BOTH routers — a record on one router NXDOMAINs at the
other site. opn-cli has no reconfigure verb, so the apply is a direct API POST;
without it the name resolves only whenever Unbound next happens to reload.

Also fold the stale-bindings check into the workflow (closes the CI half of #8)
and let the runner unit fail without failing the deploy, since it correctly
refuses to start until the interactive login exists.

Refs #9

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
This commit is contained in:
rob thijssen
2026-08-07 16:23:32 +03:00
parent 835e3e98f2
commit 3c7d95edf9
3 changed files with 235 additions and 62 deletions

View File

@@ -2,6 +2,12 @@ name: deploy
# The workflow is the source of infra truth: hosts, ports and paths live here,
# not in a separate manifest (architecture/deployment-gitea-actions.md).
#
# One job, not build + deploy. The `rust` runner image is based on
# runner-fedora-44, so it carries node, npm, ssh and rsync alongside the Rust
# toolchain — everything this needs. Splitting the deploy onto a lighter runner
# is a cost convenience (architecture/gitea-runners.md §3), and it would cost an
# artifact round-trip to buy it. Keeping one job removes that entirely.
on:
push:
@@ -23,8 +29,10 @@ env:
VITE_API_BASE_URL: ""
jobs:
build:
runs-on: fedora-43-rust
deploy:
# `rust` — not `fedora-43`, which has no cargo, and not `fedora-43-rust`,
# which is not a registered label at all (architecture/gitea-runners.md §2).
runs-on: rust
steps:
- uses: actions/checkout@v4
@@ -36,8 +44,19 @@ jobs:
- name: test
run: cargo test --workspace
# The dashboard consumes TypeScript generated from the Rust domain types
# by ts-rs during the test run. If the committed bindings no longer match,
# fail here rather than let the dashboard build against a stale type.
- name: generated bindings are current
run: |
if ! git diff --exit-code dashboard/src/api/generated; then
echo "::error::generated TypeScript is stale."
echo "Run 'cargo test -p tireless-entities' and commit the result."
exit 1
fi
# Static build so a runner newer than the target cannot produce a binary
# the target's glibc rejects (§6 glibc skew).
# the target's glibc rejects (architecture/deployment-gitea-actions.md §6).
- name: build binaries
run: cargo build --release --target x86_64-unknown-linux-musl
@@ -48,30 +67,13 @@ jobs:
npm run lint
npm run build
- uses: actions/upload-artifact@v3
with:
name: tireless
path: |
target/x86_64-unknown-linux-musl/release/tireless-api
target/x86_64-unknown-linux-musl/release/tireless-worker
target/x86_64-unknown-linux-musl/release/tireless
dashboard/dist/
asset/
deploy:
needs: build
runs-on: fedora-43
steps:
- uses: actions/download-artifact@v3
with:
name: tireless
- name: authorise
env:
RSYNC_SSH_KEY: ${{ secrets.RSYNC_SSH_KEY }}
run: |
install -d -m 0700 ~/.ssh
printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci
chmod 0600 ~/.ssh/id_gitea_ci
cat >> ~/.ssh/config <<EOF
printf '%s\n' "$RSYNC_SSH_KEY" | install -m 0600 /dev/stdin ~/.ssh/id_gitea_ci
cat >> ~/.ssh/config <<'EOF'
Host *
IdentityFile ~/.ssh/id_gitea_ci
StrictHostKeyChecking accept-new
@@ -95,11 +97,12 @@ jobs:
- name: ship artifacts
run: |
R="--rsync-path=sudo rsync --mkpath"
rsync $R --chmod 0755 target/x86_64-unknown-linux-musl/release/tireless-api \
B=target/x86_64-unknown-linux-musl/release
rsync $R --chmod 0755 "$B/tireless-api" \
gitea_ci@"$API_HOST":/usr/local/bin/tireless-api
rsync $R --chmod 0755 target/x86_64-unknown-linux-musl/release/tireless-worker \
rsync $R --chmod 0755 "$B/tireless-worker" \
gitea_ci@"$API_HOST":/usr/local/bin/tireless-worker
rsync $R --chmod 0755 target/x86_64-unknown-linux-musl/release/tireless \
rsync $R --chmod 0755 "$B/tireless" \
gitea_ci@"$API_HOST":/usr/local/bin/tireless
rsync $R --chmod 0640 config.toml \
gitea_ci@"$API_HOST":/etc/tireless/config.toml
@@ -115,8 +118,10 @@ jobs:
- name: ship dashboard
run: |
# To the proxy, not to bob — that is where nginx serves it from.
R="--rsync-path=sudo rsync --mkpath"
rsync $R -a --delete dashboard/dist/ \
# restorecon because a webroot not labelled httpd_sys_content_t
# gives nginx a 403 (architecture/reverse-proxies.md §4).
rsync --rsync-path="sudo rsync --mkpath" -a --delete \
--chmod=D755,F644 dashboard/dist/ \
gitea_ci@"$WEB_HOST":"$WEB_ROOT/"
ssh gitea_ci@"$WEB_HOST" "sudo restorecon -R $WEB_ROOT"
@@ -127,7 +132,8 @@ jobs:
sudo restorecon -R /usr/local/bin/tireless-api /usr/local/bin/tireless-worker \
/usr/local/bin/tireless /etc/tireless /var/lib/tireless
# firewalld only learns a freshly-shipped service after a reload (§6).
# firewalld only learns a freshly-shipped service after a reload
# (architecture/deployment-gitea-actions.md §6).
sudo firewall-cmd --reload
zone=\$(sudo firewall-cmd --get-default-zone)
sudo firewall-cmd --zone=\$zone --query-service=tireless-api \
@@ -144,7 +150,12 @@ jobs:
# job returns to the pool (design.md §4.2) — but it does cost the tokens
# already spent. That is the accepted trade for a simple deploy; see
# design.md §10 for why it bites hardest when tireless deploys itself.
sudo systemctl restart tireless-runner.service
#
# It is expected to fail until the interactive Claude Code login has
# been completed as the service account (script/infra-setup.sh step 1);
# a runner with no credentials refuses to start rather than pretending.
sudo systemctl restart tireless-runner.service || \
echo "runner did not start — check 'tireless preflight' and the login"
EOF
- name: health probe
@@ -153,7 +164,7 @@ jobs:
# A loopback probe on bob would pass even if firewalld were closed.
ssh gitea_ci@"$WEB_HOST" \
"curl -fsS http://$API_HOST:$API_PORT/v1/ready"
for unit in tireless-api tireless-poller tireless-runner; do
for unit in tireless-api tireless-poller; do
ssh gitea_ci@"$API_HOST" "systemctl is-active \$unit.service"
done
@@ -162,4 +173,4 @@ jobs:
run: |
ssh gitea_ci@"$API_HOST" \
"journalctl -u tireless-api -u tireless-poller -u tireless-runner \
--since '5 minutes ago' --no-pager"
--since '5 minutes ago' --no-pager" || true

View File

@@ -1,33 +1,40 @@
# tireless — mesh-only vhost on the office proxy (hanzalova.internal).
#
# Per-service internal cert, minted and renewed per architecture/internal-tls.md.
# Static dashboard served directly; /v1 reverse-proxied to the API on bob.
# Serves the built dashboard from this host and reverse-proxies /v1 to the API
# on bob across the mesh. Mesh-only: there is no public counterpart, because
# tireless has no multi-user model and no audience outside the operator
# (doc/plan/design.md §3.4).
#
# Installed by script/infra-setup.sh into sites-available/ with a symlink into
# sites-enabled/; the deploy workflow only ships the SPA into the web root.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name tireless.internal;
ssl_certificate /etc/pki/tls/misc/tireless.internal.pem;
ssl_certificate_key /etc/pki/tls/private/tireless.internal.pem;
# TCP 443 on this host is owned by the stream SNI router
# (streams-enabled/sni-router.conf), which preads the server name and
# forwards to the local https tier on 127.0.0.1:14443 over PROXY protocol.
# A vhost that listens on :443 directly is never reached — the router
# answers first, with whichever certificate its default branch holds.
# `nginx -t` passes either way, so the mistake surfaces only as the wrong
# certificate on an otherwise working handshake.
# See architecture/reverse-proxies.md §4-5.
listen 127.0.0.1:14443 ssl proxy_protocol;
http2 on;
# Quantum-safe where the peer supports it, classical fallback otherwise
# (architecture/generic.md §11).
ssl_protocols TLSv1.3 TLSv1.2;
ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1;
# Per-service internal cert, renewed by step@tireless.timer
# (architecture/internal-tls.md §2-3). Not the host cert: its SAN is bob's
# FQDN, not this service name.
ssl_certificate /etc/nginx/tls/cert/tireless.internal.pem;
ssl_certificate_key /etc/nginx/tls/key/tireless.internal.pem;
ssl_trusted_certificate /etc/pki/ca-trust/source/anchors/root-internal.pem;
ssl_protocols TLSv1.3;
root /var/www/tireless;
index index.html;
# SPA: unknown paths resolve to the shell, which routes client-side.
location / {
try_files $uri $uri/ /index.html;
}
location /v1/ {
proxy_pass http://bob.hanzalova.internal:23296;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -37,4 +44,9 @@ server {
proxy_read_timeout 300s;
proxy_buffering off;
}
# SPA: unknown paths resolve to the shell, which routes client-side.
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -21,6 +21,9 @@ API_PORT="${API_PORT:-23296}"
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; }
@@ -127,10 +130,155 @@ 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
@@ -166,19 +314,21 @@ Remaining one-time steps (operator, on the target host):
The protection rule is what keeps an unattended agent from writing to main.
Verify it rather than assuming it.
3. nginx vhost on the proxy.
The deploy ships the built dashboard to the proxy's web root but does not
install the vhost — that is one-time, and it needs a certificate:
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:
- mint the per-service cert for tireless.internal per
architecture/internal-tls.md;
- copy asset/nginx/tireless.hanzalova.conf to the proxy's conf.d;
- sudo nginx -t && sudo systemctl reload nginx
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"
The vhost serves the dashboard locally and reverse-proxies /v1 to
bob:23296 across the mesh. That is why the API binds 0.0.0.0 rather than
loopback, and why asset/firewalld/ opens the port. Those three facts are one
decision — if you move ingress onto bob, change all three together.
If they disagree, restart nginx rather than reloading it.
4. Postgres role and ident mapping (architecture/generic.md §5).
On magrathea AND frankie: