diff --git a/bots/rhodibot/src/main.rs b/bots/rhodibot/src/main.rs index 2d91c19f..0f5dfe1c 100644 --- a/bots/rhodibot/src/main.rs +++ b/bots/rhodibot/src/main.rs @@ -36,6 +36,16 @@ struct Cli { #[arg(short, long, env = "PORT", default_value = "3000")] port: u16, + /// Address to bind. + /// + /// Loopback by default: the intended deployment fronts this process with a + /// Cloudflare Tunnel (or another reverse proxy) on the same host, so the + /// webhook port has no business being reachable from the network. Set + /// `--bind 0.0.0.0` (or `BIND_ADDR=0.0.0.0`) to serve directly, which is + /// what earlier versions did unconditionally. + #[arg(long, env = "BIND_ADDR", default_value = "127.0.0.1")] + bind: String, + /// GitHub App ID #[arg(long, env = "GITHUB_APP_ID")] app_id: Option, @@ -141,7 +151,7 @@ async fn main() -> Result<()> { .with_state(state); // Start server - let addr = format!("0.0.0.0:{}", cli.port); + let addr = format!("{}:{}", cli.bind, cli.port); let listener = TcpListener::bind(&addr).await?; info!("Listening on {}", addr); diff --git a/deploy/GITHUB-APP-REGISTRATION.adoc b/deploy/GITHUB-APP-REGISTRATION.adoc new file mode 100644 index 00000000..6373458d --- /dev/null +++ b/deploy/GITHUB-APP-REGISTRATION.adoc @@ -0,0 +1,100 @@ += Registering the rhodibot GitHub App +:toc: + +This sheet is filled in by hand in the browser: GitHub has no API to create an +App (the GraphQL schema has no `createGitHubApp` mutation, and the REST API can +only *list* and *read* Apps). Everything else about the deployment is +reproducible from the repository; this page is the one manual step, so it is +written down as a form to fill in rather than a memory to rely on. + +Record the values as you go. Two of them -- the App ID and the private key -- +cannot be recovered in this form later. + +== 1. Where + +https://github.com/settings/apps/new -- a personal App, which is what an owner +of this estate needs. An organisation App is created at +`https://github.com/organizations//settings/apps/new` and behaves the same +otherwise, except that the App's owner controls it. + +== 2. The form + +[cols="1,1,2"] +|=== +| Field | Value | Why +| GitHub App name | `rhodibot` | Must be globally unique across GitHub; if taken, `rhodibot-` +| Homepage URL | `https://github.com/hyperpolymath/gitbot-fleet` | Required by the form; unused by the bot +| Webhook URL | `https://rhodibot./webhook` | The tunnel ingress rule, `/webhook` exactly +| Webhook secret | 32+ random bytes, e.g. `openssl rand -hex 32` | Goes in `rhodibot.env` as `GITHUB_WEBHOOK_SECRET`; without it the origin accepts unsigned deliveries +|=== + +Then, under *Repository permissions*: + +[cols="1,2,2"] +|=== +| Permission | Access | Used for +| Metadata | Read-only | Mandatory once any other permission is set +| Checks | Read and write | `createCheckRun` / `updateCheckRun` -- the compliance result on a commit +| Issues | Read and write | `createIssue` for drift that is not tied to a commit +| Contents | Read-only | Reading repository trees and file contents +| Pull requests | Read-only | Reviewing the PRs the fixers open +|=== + +Nothing else. Every unused permission is a permission that can be abused by a +mistake in this codebase, and the bot has no use for administration, actions, +secrets, or organisation-level access. If a future rule needs more, add it then, +in its own commit, with the reason. + +Under *Subscribe to events*: `push`, `pull_request`, `repository`, +`installation`, `installation_repositories`. The handler ignores everything else +by name (the match in `bots/rhodibot/src/main.rs` is exhaustive in intent: an +unknown event is logged and dropped). + +== 3. The two values you cannot get back + +*App ID* (top of the App's settings page, "About"): + +[source] +---- +GITHUB_APP_ID = ______________ +---- + +*Private key*: "Generate a private key" downloads a `.pem` exactly once. GitHub +stores only the public half; a lost key means generating a new one and +redeploying, which is survivable but avoidable. + +[source] +---- +downloaded at: ______________ +fingerprint: openssl rsa -in rhodibot..private-key.pem -pubout | sha256sum +stored to /etc/fleet/rhodibot-app.pem, 0600 root:root +---- + +The PEM is in PKCS#1 form (`BEGIN RSA PRIVATE KEY`). `AppAuth` also accepts +PKCS#8, so converting it first is optional, not required. + +== 4. Install + +"Install App" -> the account -> *Only select repositories*. Start with one +repository that has no drift, so that the first deliveries produce boring +results and a wrongly-scoped installation is visible immediately. + +Do not choose "All repositories" to begin with: it is the largest possible +blast radius for a bot whose checks have never run against a real installation. + +== 5. Confirm the service picked it up + +[source,bash] +---- +systemctl restart rhodibot +journalctl -u rhodibot -n 20 --no-pager # "GitHub credentials: GitHub App installation tokens" +curl -sS https://rhodibot./health +---- + +== 6. Clean up + +* The key is installed at `/etc/fleet/rhodibot-app.pem`; delete the copy in + `~/Downloads` once the password-manager copy is confirmed. +* Keep the webhook secret in the password manager too: it is shown again in the + App's settings, so it is recoverable, but only to someone with account access. +* `GITHUB_APP_ID` is not secret and belongs in the deployment notes. diff --git a/deploy/RHODIBOT-DEPLOYMENT.adoc b/deploy/RHODIBOT-DEPLOYMENT.adoc new file mode 100644 index 00000000..da861284 --- /dev/null +++ b/deploy/RHODIBOT-DEPLOYMENT.adoc @@ -0,0 +1,139 @@ += Deploying rhodibot behind a Cloudflare Tunnel +:toc: + +Rhodibot needs a public HTTPS endpoint because that is where GitHub delivers +webhooks, but the host it runs on does not need to be reachable. A Cloudflare +Tunnel gives us the first without the second: `cloudflared` dials out to +Cloudflare's edge and forwards requests back down that connection to a loopback +origin. No inbound port, no certificate to renew on this host. + +The pieces: + +[cols="1,2"] +|=== +| `deploy/systemd/rhodibot.service` | the bot itself, bound to `127.0.0.1:3000` +| `deploy/systemd/cloudflared-rhodibot.service` | the tunnel +| `deploy/cloudflared/rhodibot.yml` | ingress: `/webhook` and `/health`, everything else 404 +| `deploy/rhodibot.env.example` | the App credentials, as environment variables +| `deploy/verify-rhodibot.sh` | proof of life, from the outside in +|=== + +== 1. Register the GitHub App first + +The App ID, private key and webhook secret all feed the service, and GitHub will +start delivering webhooks as soon as the App exists. Do +xref:GITHUB-APP-REGISTRATION.adoc[the registration sheet] before this, or accept +a few minutes of failed deliveries while you catch up -- they are redeliverable +from the App's "Advanced" tab, which is only mildly annoying at this scale. + +== 2. Build and install the binary + +[source,bash] +---- +cargo build --locked --release --manifest-path bots/rhodibot/Cargo.toml +install -D -m 0755 bots/rhodibot/target/release/rhodibot /opt/gitbot-fleet/bots/rhodibot/rhodibot +install -d -m 0755 /opt/gitbot-fleet/bots/rhodibot +---- + +== 3. Place the credentials + +[source,bash] +---- +install -d -m 0750 /etc/fleet +install -m 0600 -o root -g root ~/Downloads/rhodibot..private-key.pem /etc/fleet/rhodibot-app.pem +install -m 0640 -o root -g fleet /dev/null /etc/fleet/rhodibot.env +$EDITOR /etc/fleet/rhodibot.env # from deploy/rhodibot.env.example +---- + +The private key is downloadable exactly once. Put it in the password manager +before it is deleted from the download directory. + +Note: `ProtectHome=true` in the unit means the key cannot live under `/home`, +and `ProtectSystem=strict` makes the rest of the filesystem read-only, so +`/etc/fleet` is the only sensible home for it. + +== 4. Create the tunnel + +[source,bash] +---- +cloudflared tunnel login # browser step, picks the zone +cloudflared tunnel create rhodibot # prints the UUID and writes the credentials file +install -D -m 0600 -o cloudflared -g cloudflared \ + ~/.cloudflared/.json /etc/cloudflared/rhodibot.json +install -D -m 0644 \ + deploy/cloudflared/rhodibot.yml /etc/cloudflared/rhodibot.yml +$EDITOR /etc/cloudflared/rhodibot.yml # replace and +cloudflared tunnel ingress validate --config /etc/cloudflared/rhodibot.yml +---- + +Then point DNS at the tunnel and check the routing table before starting +anything: + +[source,bash] +---- +cloudflared tunnel route dns rhodibot rhodibot. +cloudflared tunnel ingress rule https://rhodibot./health +cloudflared tunnel ingress rule https://rhodibot./api/check/x/y # must be http_status:404 +---- + +== 5. Start, in order + +[source,bash] +---- +install -m 0644 deploy/systemd/rhodibot.service deploy/systemd/cloudflared-rhodibot.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now rhodibot.service +journalctl -u rhodibot -n 20 --no-pager # expect: "GitHub credentials: GitHub App installation tokens" +systemctl enable --now cloudflared-rhodibot.service +---- + +If the credential line says `misconfigured`, the App ID and key do not agree -- +the service exits rather than serving unauthenticated traffic, and `systemd` +stops retrying after five attempts. Fix `rhodibot.env` and restart. + +== 6. Verify from the outside + +[source,bash] +---- +RHODIBOT_HOST=rhodibot. ./deploy/verify-rhodibot.sh +---- + +Every line must read `PASS`; `SKIP` means the check could not be made, not that +it passed. The check that matters most is the unsigned `POST /webhook`, which +proves both that the tunnel reaches the origin and that the webhook secret is +set -- without a secret the handler accepts anything, and the tunnel would +forward it to GitHub's own write path. + +== 7. Prove a delivery end to end + +In the App's settings, "Advanced" -> recent deliveries -> pick one -> *Redeliver*. +Then: + +[source,bash] +---- +journalctl -u rhodibot -f +---- + +A `push` delivery should log `Received webhook event: push`. Under ten seconds +of silence means the tunnel is not reaching the origin; see below. + +== Troubleshooting + +[cols="1,2"] +|=== +| 502 from Cloudflare | `rhodibot.service` is not running, or is not on the port the ingress rule names | +| 401 on every delivery | webhook secret in the App and in `rhodibot.env` differ | +| 404 on a valid delivery | the ingress rule does not match the path GitHub used | +| `misconfigured` in the journal | App ID and private key do not belong together; the key must be the PEM as downloaded | +| App requests 403 or 404 | the installation is missing the repository, or the App's permissions were changed and not approved | +| rate limits hit during a sweep | an installation token is being minted per request; check `start-up` logs for the credential mode, and see the caching notes in `bots/rhodibot/src/app_auth.rs` | +|=== + +== Notes + +* The origin is loopback-only (`--bind 127.0.0.1`). To serve without a tunnel, + pass `--bind 0.0.0.0`, and put the webhook secret and a firewall in front of it. +* `/api/check/{owner}/{repo}` is deliberately not published: it spends GitHub + API quota and reports on repositories, and neither is a public service. +* The bot holds no state -- no database, no writable paths -- so backups are + limited to `/etc/fleet` and the App registration. diff --git a/deploy/cloudflared/rhodibot.yml b/deploy/cloudflared/rhodibot.yml new file mode 100644 index 00000000..f361d254 --- /dev/null +++ b/deploy/cloudflared/rhodibot.yml @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# Cloudflare Tunnel ingress for rhodibot. +# +# The tunnel is the *origin* for GitHub's webhook delivery: GitHub must reach a +# public HTTPS endpoint, and this host has no inbound exposure. cloudflared dials +# out to Cloudflare's edge and reverses the flow, so no port is opened on the +# firewall and no certificate is managed here. +# +# Replace these two placeholders before installing (see RHODIBOT-DEPLOYMENT.adoc): +# from `cloudflared tunnel create rhodibot` +# a zone you control on Cloudflare +# +# Verify with: +# cloudflared tunnel ingress validate --config deploy/cloudflared/rhodibot.yml +# cloudflared tunnel ingress rule https://rhodibot./health + +tunnel: +credentials-file: /etc/cloudflared/rhodibot.json + +# rhodibot binds loopback only (see --bind in bots/rhodibot/src/main.rs): the +# origin is a local process, not a public interface. +originRequest: + connectTimeout: 30s + # Webhook deliveries carry a small JSON body; anything larger is not a + # delivery GitHub would send, and there is no reason to buffer it. + noHappyEyeballs: false + +ingress: + # Only the two routes the service needs. GitHub delivers webhooks to + # POST /webhook and nothing else; /health is here so the tunnel can be + # checked without shell access to the host. + - hostname: rhodibot. + path: ^/webhook$ + service: http://127.0.0.1:3000 + + - hostname: rhodibot. + path: ^/health$ + service: http://127.0.0.1:3000 + + # Deliberately *not* exposed: GET /api/check/{owner}/{repo}, which spends + # GitHub API quota and reports on repositories. It is an operator endpoint, + # reachable over an SSH tunnel or from the host, and it stays that way. + # Everything else on this hostname is a 404, including anything added to the + # router later without a matching rule here -- the default is denial. + - hostname: rhodibot. + service: http_status:404 + + # Any other hostname pointing at this tunnel (a stale DNS record, say) also + # gets nothing. + - service: http_status:404 diff --git a/deploy/rhodibot.env.example b/deploy/rhodibot.env.example new file mode 100644 index 00000000..c009c7c5 --- /dev/null +++ b/deploy/rhodibot.env.example @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# Copy to /etc/fleet/rhodibot.env, fill in, then: +# chown root:fleet /etc/fleet/rhodibot.env && chmod 640 /etc/fleet/rhodibot.env +# +# systemd reads this file for rhodibot.service. Nothing in it should be +# world-readable: the webhook secret authenticates deliveries, and the path +# below points at the App's private key. + +# GitHub App ID. Visible on the App's settings page; not a secret. +GITHUB_APP_ID= + +# Path to the App private key, downloaded once when the key is generated. +# The file is write-only-from-GitHub: it cannot be re-downloaded, so keep a +# copy in the password manager before anything else touches the original. +GITHUB_PRIVATE_KEY_PATH=/etc/fleet/rhodibot-app.pem + +# The secret entered under "Webhook secret" in the App's settings. Deliveries +# are signed with it (HMAC-SHA256); without it the service accepts unsigned +# POSTs to /webhook, which the public tunnel would happily forward. +GITHUB_WEBHOOK_SECRET= + +# GitHub Enterprise only. Leave unset for github.com. +# GITHUB_API_URL=https://api.github.com + +# Where the process listens. Loopback, because cloudflared fronts it. +PORT=3000 +BIND_ADDR=127.0.0.1 + +RUST_LOG=rhodibot=info,tower_http=info diff --git a/deploy/systemd/cloudflared-rhodibot.service b/deploy/systemd/cloudflared-rhodibot.service new file mode 100644 index 00000000..ae83098f --- /dev/null +++ b/deploy/systemd/cloudflared-rhodibot.service @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: MPL-2.0 +# Systemd service for the Cloudflare Tunnel that fronts rhodibot. +# +# The tunnel is the public origin: cloudflared holds an outbound connection to +# Cloudflare's edge and forwards requests to the local origin. No inbound port +# is opened and no certificate is provisioned on this host. + +[Unit] +Description=Cloudflare Tunnel for rhodibot +Documentation=https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/ +After=network-online.target rhodibot.service +Wants=network-online.target +# rhodibot.service declares `Before=` this unit; a tunnel with no origin behind +# it would answer 502 to GitHub, which counts as a failed delivery. +Requires=rhodibot.service + +[Service] +Type=simple +User=cloudflared +Group=cloudflared + +# Credentials file is /etc/cloudflared/rhodibot.json, mode 0600 +# cloudflared:cloudflared -- the tunnel's own secret, not a Cloudflare API token. +ExecStart=/usr/local/bin/cloudflared --no-autoupdate --config /etc/cloudflared/rhodibot.yml tunnel run + +Restart=always +RestartSec=5s + +# Hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictNamespaces=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM +CapabilityBoundingSet= + +# Resource limits +LimitNOFILE=65536 +MemoryMax=256M + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/rhodibot.service b/deploy/systemd/rhodibot.service new file mode 100644 index 00000000..5d8b0726 --- /dev/null +++ b/deploy/systemd/rhodibot.service @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: MPL-2.0 +# Systemd service for rhodibot (RSR compliance bot) +# +# Certificate-free origin: this process binds 127.0.0.1 and is reached through +# cloudflared-rhodibot.service. Nothing here listens on a public interface. + +[Unit] +Description=Rhodibot - RSR compliance bot +Documentation=https://github.com/hyperpolymath/gitbot-fleet +After=network-online.target +Wants=network-online.target +# The tunnel is useless without the origin; ordering makes start-up deterministic. +Before=cloudflared-rhodibot.service + +[Service] +Type=simple +User=fleet +Group=fleet +WorkingDirectory=/opt/gitbot-fleet/bots/rhodibot + +# Defaults first, then the file, so /etc/fleet/rhodibot.env wins. +Environment="RUST_LOG=rhodibot=info,tower_http=info" + +# /etc/fleet/rhodibot.env holds, at 0640 root:fleet: +# GITHUB_APP_ID= +# GITHUB_PRIVATE_KEY_PATH=/etc/fleet/rhodibot-app.pem +# GITHUB_WEBHOOK_SECRET= +# RUST_LOG=rhodibot=info,tower_http=info +# `-` makes the file optional, so the service starts and fails loudly in the +# journal rather than refusing to start with no explanation. +EnvironmentFile=-/etc/fleet/rhodibot.env + +ExecStart=/opt/gitbot-fleet/bots/rhodibot/rhodibot --bind 127.0.0.1 + +# Start-up logs one line naming the credential mode -- "GitHub App installation +# tokens", or "misconfigured" followed by a non-zero exit. The credential itself +# is never logged. Check it with: +# journalctl -u rhodibot -n 20 + +Restart=on-failure +RestartSec=5s +# A misconfigured App exits non-zero at start-up. Without a limit, systemd would +# restart it forever and bury the reason in the journal. +StartLimitBurst=5 +StartLimitIntervalSec=60s + +# Hardening +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +ProtectClock=true +ProtectProc=invisible +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM +CapabilityBoundingSet= +# The bot holds no state: the filesystem is read-only, /home is out of reach +# (so the App key cannot live there), and nothing needs writing anywhere. +# The key and this configuration are read from /etc/fleet. + +# Resource limits +LimitNOFILE=65536 +MemoryMax=512M +CPUQuota=100% + +[Install] +WantedBy=multi-user.target diff --git a/deploy/verify-rhodibot.sh b/deploy/verify-rhodibot.sh new file mode 100755 index 00000000..40e0a46c --- /dev/null +++ b/deploy/verify-rhodibot.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# Proof of life for a rhodibot deployment. +# +# Checks the deployment from the outside in: what a visitor can reach, what is +# deliberately unreachable, and what the origin itself reports about its +# credentials. Every check prints PASS, FAIL or SKIP -- SKIP means the check +# could not be made on this host (no systemctl, not root), never that it passed. +# +# RHODIBOT_HOST=rhodibot.example.org sudo -E ./deploy/verify-rhodibot.sh +# +# Exits non-zero if any check fails. + +set -uo pipefail + +HOST="${RHODIBOT_HOST:-}" +ORIGIN="${RHODIBOT_ORIGIN:-http://127.0.0.1:3000}" +KEY_PATH="${RHODIBOT_KEY_PATH:-/etc/fleet/rhodibot-app.pem}" + +fails=0 +pass() { printf ' \033[32mPASS\033[0m %s\n' "$1"; } +fail() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fails=$((fails + 1)); } +skip() { printf ' \033[33mSKIP\033[0m %s\n' "$1"; } + +code() { curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "$@" 2>/dev/null || echo "000"; } + +echo "rhodibot deployment check" +echo " origin: $ORIGIN" +echo " public: ${HOST:+https://$HOST}${HOST:-}" +echo + +echo "origin (the process behind the tunnel)" +health="$(curl -sS --max-time 10 "$ORIGIN/health" 2>/dev/null)" +if [ -n "$health" ]; then + pass "GET /health answers" + mode="$(printf '%s' "$health" | sed -n 's/.*"credentials":"\([^"]*\)".*/\1/p')" + case "$mode" in + misconfigured) fail "/health reports misconfigured credentials" ;; + "") skip "/health carries no credential mode (older build?)" ;; + *) pass "/health reports credentials: $mode" ;; + esac +else + fail "GET /health does not answer at $ORIGIN (is the unit running?)" +fi + +# The origin must not be reachable from anything but this host: a service that +# binds 0.0.0.0 while a tunnel also publishes it is open twice. +# +# Checked against rhodibot's own sockets rather than against the port. Asking +# "who is listening on 3000?" flags any unrelated process that happens to share +# the number; asking "where is rhodibot listening?" is the actual question. +pid="$(pgrep -x rhodibot | head -1)" +if [ -z "$pid" ]; then + skip "rhodibot is not running: cannot check the bind address (run this on the host, with the unit up)" +elif ! command -v ss >/dev/null 2>&1; then + skip "ss not available: cannot check the bind address" +else + addrs="$(ss -ltnpH 2>/dev/null | grep -F "pid=$pid," | awk '{print $4}' | sort -u)" + if [ -z "$addrs" ]; then + skip "no listening sockets visible for pid $pid (needs root?)" + elif printf '%s\n' "$addrs" | grep -qvE '^(127\.0\.0\.1|\[::1\]|::1):'; then + fail "rhodibot (pid $pid) is listening on a non-loopback address: $(printf '%s ' $addrs)" + else + pass "rhodibot (pid $pid) listens on loopback only: $(printf '%s ' $addrs)" + fi +fi + +echo +echo "through the tunnel (what GitHub and the internet see)" +if [ -z "$HOST" ]; then + skip "RHODIBOT_HOST unset: skipping public checks" +else + c="$(code "https://$HOST/health")" + [ "$c" = "200" ] && pass "GET /health -> 200" || fail "GET /health -> $c (expected 200)" + + # An unsigned delivery must be refused. This is the one check that proves + # both that the tunnel reaches the origin *and* that the webhook secret is + # configured: with no secret set, the handler accepts anything. + c="$(code -X POST -H 'content-type: application/json' -d '{}' "https://$HOST/webhook")" + case "$c" in + 401) pass "POST /webhook without a signature -> 401" ;; + 200) fail "POST /webhook without a signature -> 200: GITHUB_WEBHOOK_SECRET is not set" ;; + *) fail "POST /webhook without a signature -> $c (expected 401)" ;; + esac + + echo + echo "boundary (must not be reachable)" + c="$(code "https://$HOST/")" + [ "$c" = "404" ] && pass "GET / -> 404" || fail "GET / -> $c (expected 404)" + + c="$(code "https://$HOST/api/check/hyperpolymath/gitbot-fleet")" + [ "$c" = "404" ] && pass "GET /api/check/... -> 404 (quota-spending endpoint stays private)" \ + || fail "GET /api/check/... -> $c (expected 404)" + + c="$(code "https://$HOST/webhook")" + case "$c" in + 405) pass "GET /webhook -> 405 (path routed, method rejected by the origin)" ;; + 404) pass "GET /webhook -> 404 (blocked at the tunnel)" ;; + *) fail "GET /webhook -> $c (expected 405 or 404)" ;; + esac +fi + +echo +echo "host" +if command -v systemctl >/dev/null 2>&1; then + for unit in rhodibot.service cloudflared-rhodibot.service; do + state="$(systemctl is-active "$unit" 2>/dev/null)" + [ "$state" = "active" ] && pass "$unit is active" || fail "$unit is $state" + done +else + skip "systemctl not available: cannot check the units" +fi + +if [ -e "$KEY_PATH" ]; then + perms="$(stat -c '%a %U:%G' "$KEY_PATH" 2>/dev/null)" + case "$perms" in + 6[04]0\ root:fleet|600\ root:root|640\ root:root) pass "App key $KEY_PATH is $perms" ;; + *) fail "App key $KEY_PATH is $perms (want 600 or 640, owned by root, group fleet)" ;; + esac +else + skip "no App key at $KEY_PATH: the App is not registered on this host yet" +fi + +echo +if [ "$fails" -eq 0 ]; then + echo "no failures" +else + echo "$fails check(s) failed" +fi +exit $((fails > 0))