From bcc23c8f3674d5a344441bac50d40914869b24a6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:03:58 +0000 Subject: [PATCH 1/2] feat(rhodibot): authenticate the REST client as a GitHub App App authentication existed but nothing used it: GitHubClient still read a single GITHUB_TOKEN, so every repository request went out as one identity. Credentials are now resolved once, in the client constructor: - app_id + private key -> GitHub App. Each request is scoped to the repository it concerns, because installation tokens do not carry across repositories. - otherwise GITHUB_TOKEN, otherwise anonymous (unchanged behaviour). A half-configured App is refused rather than downgraded. Setting app_id with no key (or a key with no app_id) leaves the client "misconfigured": readiness() fails, so main.rs exits at start-up instead of serving traffic, and authorize() refuses to send the request at all. Quietly falling back to anonymous access would turn a config mistake into a permissions puzzle at the first webhook. /health now names the credential mode so an operator can see what was picked up. Two defects that only bite at fleet scale are fixed in AppAuth while wiring it: the token cache held one installation, so a sweep across owners re-minted a token on nearly every request; and the installation lookup - an API call billed to the App's own much smaller rate-limit budget - was repeated per request. Both are now keyed caches. file_exists reports a boolean by contract, so an authentication failure is logged and reported as absent rather than silently mass-reporting non-compliance. Tests: 5 new unit tests (100 total, 50 unit + 50 integration), including the handshake end to end against a mock server that answers only to the installation token, reuse across requests, and the two misconfiguration paths. --- bots/rhodibot/src/app_auth.rs | 66 +++++-- bots/rhodibot/src/github.rs | 343 ++++++++++++++++++++++++++++++---- bots/rhodibot/src/main.rs | 18 +- 3 files changed, 372 insertions(+), 55 deletions(-) diff --git a/bots/rhodibot/src/app_auth.rs b/bots/rhodibot/src/app_auth.rs index 59f6a843..b9245055 100644 --- a/bots/rhodibot/src/app_auth.rs +++ b/bots/rhodibot/src/app_auth.rs @@ -25,6 +25,7 @@ //! five minutes early, so a request never races the expiry. //! - Errors carry a status code and a context string, never a credential. +use std::collections::HashMap; use std::sync::Mutex; use anyhow::{Context, Result, anyhow}; @@ -51,7 +52,6 @@ const TOKEN_REFRESH_MARGIN_SECS: i64 = 300; /// A cached installation token and the moment it stops being usable. #[derive(Clone)] struct CachedToken { - installation_id: u64, token: String, expires_at: DateTime, } @@ -76,7 +76,19 @@ pub struct AppAuth { key_pair: RsaKeyPair, client: reqwest::Client, api_url: String, - cached: Mutex>, + /// Installation tokens by installation ID. + /// + /// A single slot would thrash: an App is installed once per owner, so a + /// sweep across owners would re-mint a token on nearly every request. + /// Tokens are per installation and last an hour. + tokens: Mutex>, + /// `owner/repo` to installation ID. + /// + /// The lookup is an API call billed to the App's own rate-limit budget, + /// which is far smaller than an installation's, so it is remembered rather + /// than repeated per request. Bounded by the number of repositories the App + /// is installed on. + installations: Mutex>, } impl AppAuth { @@ -96,7 +108,8 @@ impl AppAuth { key_pair, client: reqwest::Client::new(), api_url: api_url.trim_end_matches('/').to_string(), - cached: Mutex::new(None), + tokens: Mutex::new(HashMap::new()), + installations: Mutex::new(HashMap::new()), }) } @@ -116,6 +129,16 @@ impl AppAuth { /// This is how a webhook delivery learns which installation to act as, /// since the payload's `installation.id` is not always present. pub async fn installation_for_repository(&self, owner: &str, repo: &str) -> Result { + let key = format!("{owner}/{repo}"); + if let Some(installation_id) = self + .installations + .lock() + .ok() + .and_then(|cache| cache.get(&key).copied()) + { + return Ok(installation_id); + } + let jwt = self.app_jwt()?; let url = format!("{}/repos/{owner}/{repo}/installation", self.api_url); let response = self @@ -138,6 +161,11 @@ impl AppAuth { .json() .await .context("parsing the installation lookup response")?; + + if let Ok(mut cache) = self.installations.lock() { + cache.insert(key, installation.id); + } + Ok(installation.id) } @@ -176,23 +204,22 @@ impl AppAuth { .await .context("parsing the installation token response")?; - if let Ok(mut cache) = self.cached.lock() { - *cache = Some(CachedToken { + if let Ok(mut cache) = self.tokens.lock() { + cache.insert( installation_id, - token: body.token.clone(), - expires_at: body.expires_at, - }); + CachedToken { + token: body.token.clone(), + expires_at: body.expires_at, + }, + ); } Ok(body.token) } fn cached_token(&self, installation_id: u64) -> Option { - let cache = self.cached.lock().ok()?; - let entry = cache.as_ref()?; - if entry.installation_id != installation_id { - return None; - } + let cache = self.tokens.lock().ok()?; + let entry = cache.get(&installation_id)?; let remaining = entry.expires_at.signed_duration_since(Utc::now()); if remaining <= ChronoDuration::seconds(TOKEN_REFRESH_MARGIN_SECS) { return None; @@ -326,8 +353,10 @@ fn der_len(len: usize) -> Vec { out } +/// Compiles only under test, but is reachable from the tests of the other +/// modules in this crate, which need a usable key to configure an App. #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use ring::signature::{RSA_PKCS1_2048_8192_SHA256, RsaPublicKeyComponents}; @@ -411,6 +440,15 @@ mod tests { format!("{begin}\n{}\n{end}\n", wrapped.join("\n")) } + /// The test key as a PEM, in the PKCS#1 form GitHub hands out. + /// + /// Exposed to the rest of the crate (tests only) so that tests of the + /// client wiring can configure a GitHub App without duplicating the key. + pub(crate) fn test_private_key_pem() -> String { + let der = hex::decode(TEST_KEY_PKCS1_DER_HEX).expect("hex"); + test_key_pem("RSA PRIVATE KEY", &der) + } + #[test] fn pkcs1_wrapping_matches_the_canonical_pkcs8_encoding() { // Byte-identical to `openssl pkcs8 -topk8 -nocrypt` for the same key: diff --git a/bots/rhodibot/src/github.rs b/bots/rhodibot/src/github.rs index 803a2642..faccabef 100644 --- a/bots/rhodibot/src/github.rs +++ b/bots/rhodibot/src/github.rs @@ -2,45 +2,150 @@ //! GitHub API client module //! +//! # Authentication +//! +//! Credentials are resolved once, in [`GitHubClient::new`]: +//! +//! - a **GitHub App**, when `app_id` and a private key are both configured. +//! Each request is then scoped to the repository it concerns: the app JWT is +//! exchanged for an installation token for that repository (see +//! [`crate::app_auth`]), because installation tokens do not carry across +//! repositories. +//! - a **static token** from `GITHUB_TOKEN`, which is a single identity and +//! does not expire. +//! - otherwise anonymous, which works on public repositories under a punitive +//! rate limit. +//! +//! A half-configured App is *not* allowed to fall back to the anonymous path: +//! the client reports itself unready (see [`GitHubClient::readiness`]) and +//! refuses to send authenticated requests, so a misconfiguration surfaces at +//! start-up instead of as a permissions puzzle later. +//! //! # Security considerations //! -//! - The GitHub token is read from `GITHUB_TOKEN` environment variable and -//! passed only to `bearer_auth()`. It is never logged, serialized, or -//! included in error messages. +//! - Credentials are passed only to `bearer_auth()`. They are never logged, +//! serialized, or included in error messages. //! - File paths passed to content APIs are validated against path traversal. -use anyhow::Result; +use std::sync::Arc; + +use anyhow::{Result, bail}; use reqwest::Client; use serde::{Deserialize, Serialize}; +use tracing::error; +use crate::app_auth::AppAuth; use crate::config::Config; use crate::sanitize; +/// How the client authenticates to the GitHub API. +enum Credentials { + /// A `GITHUB_TOKEN`-style token: one identity, one lifetime, no exchange. + /// `None` means unauthenticated, which is allowed but rate-limited hard. + Static(Option), + /// A GitHub App. The app JWT is signed per call and exchanged for an + /// installation token scoped to the repository being touched; [`AppAuth`] + /// caches both the lookup and the token. + App(Arc), + /// App credentials were configured but cannot be used. Requests fail + /// instead of quietly falling back to anonymous access, which would + /// surface later as a puzzling permissions problem somewhere else. + Invalid(String), +} + /// GitHub API client pub struct GitHubClient { client: Client, base_url: String, - token: Option, + credentials: Credentials, } impl GitHubClient { - /// Create a new GitHub client + /// Create a new GitHub client. + /// + /// Credentials come from configuration: a GitHub App when an app ID and a + /// private key are both present, otherwise `GITHUB_TOKEN`, otherwise + /// anonymous. Call [`Self::readiness`] at start-up so a half-configured App + /// is rejected before it serves traffic. pub fn new(config: &Config) -> Self { Self { client: Client::new(), base_url: config.github_api_url.clone(), - token: std::env::var("GITHUB_TOKEN").ok(), + credentials: Self::resolve_credentials(config), + } + } + + /// Decide how to authenticate. Touches no network. + fn resolve_credentials(config: &Config) -> Credentials { + match (config.app_id, config.private_key.as_deref()) { + (Some(app_id), Some(private_key)) => { + match AppAuth::new(app_id, private_key, &config.github_api_url) { + Ok(app_auth) => Credentials::App(Arc::new(app_auth)), + Err(error) => Credentials::Invalid(format!( + "GitHub App credentials are unusable: {error:#}" + )), + } + } + (Some(_), None) => Credentials::Invalid( + "GITHUB_APP_ID is set but no private key was provided".to_string(), + ), + (None, Some(_)) => Credentials::Invalid( + "a GitHub App private key was provided but GITHUB_APP_ID is missing".to_string(), + ), + (None, None) => Credentials::Static(std::env::var("GITHUB_TOKEN").ok()), + } + } + + /// Fail when this client cannot authenticate. + /// + /// Called at start-up: an unusable App configuration should stop the + /// process, not every webhook that arrives afterwards. + pub fn readiness(&self) -> Result<()> { + match &self.credentials { + Credentials::Invalid(reason) => bail!("{reason}"), + _ => Ok(()), + } + } + + /// Describe the credential in use, for start-up logging. Carries no + /// credential material. + pub fn credential_mode(&self) -> &'static str { + match &self.credentials { + Credentials::Static(Some(_)) => "static token from GITHUB_TOKEN", + Credentials::Static(None) => "anonymous (public repositories only)", + Credentials::App(_) => "GitHub App installation tokens", + Credentials::Invalid(_) => "misconfigured", + } + } + + /// Attach credentials to a request about one repository. + /// + /// Installation tokens are per repository, so the token is minted for the + /// repository this request is about rather than reused across the estate. + async fn authorize( + &self, + request: reqwest::RequestBuilder, + owner: &str, + repo: &str, + ) -> Result { + match &self.credentials { + Credentials::Static(Some(token)) => Ok(request.bearer_auth(token)), + Credentials::Static(None) => Ok(request), + Credentials::App(app_auth) => { + let installation = app_auth.installation_for_repository(owner, repo).await?; + let token = app_auth.installation_token(installation).await?; + Ok(request.bearer_auth(token)) + } + Credentials::Invalid(reason) => { + bail!("refusing to send an unauthenticated request: {reason}") + } } } /// Get repository information pub async fn get_repository(&self, owner: &str, repo: &str) -> Result { let url = format!("{}/repos/{}/{}", self.base_url, owner, repo); - let mut request = self.client.get(&url); - - if let Some(ref token) = self.token { - request = request.bearer_auth(token); - } + let request = self.authorize(self.client.get(&url), owner, repo).await?; let response = request .header("Accept", "application/vnd.github+json") @@ -61,12 +166,11 @@ impl GitHubClient { path: &str, ) -> Result> { sanitize::validate_file_path(path)?; - let url = format!("{}/repos/{}/{}/contents/{}", self.base_url, owner, repo, path); - let mut request = self.client.get(&url); - - if let Some(ref token) = self.token { - request = request.bearer_auth(token); - } + let url = format!( + "{}/repos/{}/{}/contents/{}", + self.base_url, owner, repo, path + ); + let request = self.authorize(self.client.get(&url), owner, repo).await?; let response = request .header("Accept", "application/vnd.github+json") @@ -84,12 +188,20 @@ impl GitHubClient { if sanitize::validate_file_path(path).is_err() { return false; } - let url = format!("{}/repos/{}/{}/contents/{}", self.base_url, owner, repo, path); - let mut request = self.client.head(&url); - - if let Some(ref token) = self.token { - request = request.bearer_auth(token); - } + let url = format!( + "{}/repos/{}/{}/contents/{}", + self.base_url, owner, repo, path + ); + let request = match self.authorize(self.client.head(&url), owner, repo).await { + Ok(request) => request, + Err(error) => { + // This function reports a boolean by contract, but "cannot + // authenticate" is not "the file is absent" -- reporting it as + // absence would mass-report non-compliance. Say so loudly. + error!("cannot authenticate a file-existence check: {error:#}"); + return false; + } + }; request .header("User-Agent", "rhodibot") @@ -104,12 +216,11 @@ impl GitHubClient { /// The `path` parameter is validated against path traversal before use. pub async fn get_file_content(&self, owner: &str, repo: &str, path: &str) -> Result { sanitize::validate_file_path(path)?; - let url = format!("{}/repos/{}/{}/contents/{}", self.base_url, owner, repo, path); - let mut request = self.client.get(&url); - - if let Some(ref token) = self.token { - request = request.bearer_auth(token); - } + let url = format!( + "{}/repos/{}/{}/contents/{}", + self.base_url, owner, repo, path + ); + let request = self.authorize(self.client.get(&url), owner, repo).await?; let response = request .header("Accept", "application/vnd.github.raw+json") @@ -141,11 +252,7 @@ impl GitHubClient { .map_err(|e| anyhow::anyhow!("{}", e))?; let url = format!("{}/repos/{}/{}/issues", self.base_url, owner, repo); - let mut request = self.client.post(&url); - - if let Some(ref token) = self.token { - request = request.bearer_auth(token); - } + let request = self.authorize(self.client.post(&url), owner, repo).await?; let payload = CreateIssue { title: title.to_string(), @@ -178,11 +285,7 @@ impl GitHubClient { .map_err(|e| anyhow::anyhow!("{}", e))?; let url = format!("{}/repos/{}/{}/check-runs", self.base_url, owner, repo); - let mut request = self.client.post(&url); - - if let Some(ref token) = self.token { - request = request.bearer_auth(token); - } + let request = self.authorize(self.client.post(&url), owner, repo).await?; let response = request .header("Accept", "application/vnd.github+json") @@ -265,3 +368,163 @@ pub struct CheckRun { pub name: String, pub status: String, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_auth::tests::test_private_key_pem; + use chrono::Duration; + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + /// A config aimed at the mock server, carrying no credentials of its own. + fn config_for(server: &MockServer) -> Config { + Config { + app_id: None, + private_key: None, + webhook_secret: None, + github_api_url: server.uri(), + } + } + + /// Mount the two calls an App makes before it can act: finding the + /// installation, then exchanging a JWT for a token. + async fn mount_app_handshake(server: &MockServer, installation_id: u64, token: &str) { + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/installation")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": installation_id + }))) + .mount(server) + .await; + + Mock::given(method("POST")) + .and(path(format!( + "/app/installations/{installation_id}/access_tokens" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "token": token, + "expires_at": (chrono::Utc::now() + Duration::hours(1)).to_rfc3339(), + }))) + .mount(server) + .await; + } + + /// A file check that answers only to the expected token. + async fn mount_file_check(server: &MockServer, expected_token: &str) { + Mock::given(method("HEAD")) + .and(path("/repos/acme/widgets/contents/README.adoc")) + .and(header( + "authorization", + format!("Bearer {expected_token}").as_str(), + )) + .respond_with(ResponseTemplate::new(200)) + .mount(server) + .await; + } + + #[tokio::test] + async fn app_credentials_are_exchanged_for_a_repository_scoped_token() { + let server = MockServer::start().await; + mount_app_handshake(&server, 42, "ghs_installation").await; + // Answers only when the request carries the installation token, so a + // failure here means the app JWT leaked through to the repository API. + mount_file_check(&server, "ghs_installation").await; + + let mut config = config_for(&server); + config.app_id = Some(1234); + config.private_key = Some(test_private_key_pem()); + + let client = GitHubClient::new(&config); + assert!(client.readiness().is_ok()); + assert_eq!(client.credential_mode(), "GitHub App installation tokens"); + assert!(client.file_exists("acme", "widgets", "README.adoc").await); + } + + #[tokio::test] + async fn the_installation_lookup_and_token_are_reused_across_requests() { + let server = MockServer::start().await; + mount_app_handshake(&server, 42, "ghs_installation").await; + mount_file_check(&server, "ghs_installation").await; + + let mut config = config_for(&server); + config.app_id = Some(1234); + config.private_key = Some(test_private_key_pem()); + let client = GitHubClient::new(&config); + + assert!(client.file_exists("acme", "widgets", "README.adoc").await); + assert!(client.file_exists("acme", "widgets", "README.adoc").await); + + let requests = server.received_requests().await.expect("recorded"); + let lookups = requests + .iter() + .filter(|request| request.url.path() == "/repos/acme/widgets/installation") + .count(); + let exchanges = requests + .iter() + .filter(|request| request.url.path().ends_with("/access_tokens")) + .count(); + assert_eq!( + lookups, 1, + "the installation lookup is an API call; it must not repeat per request" + ); + assert_eq!(exchanges, 1, "a valid installation token must be reused"); + } + + #[tokio::test] + async fn a_half_configured_app_is_refused_rather_than_downgraded() { + let server = MockServer::start().await; + let mut config = config_for(&server); + config.app_id = Some(1234); // deliberately no private key + + let client = GitHubClient::new(&config); + assert_eq!(client.credential_mode(), "misconfigured"); + let error = client + .readiness() + .expect_err("an App without a key is unready"); + assert!( + format!("{error:#}").contains("no private key"), + "unexpected message: {error:#}" + ); + + assert!(!client.file_exists("acme", "widgets", "README.adoc").await); + assert!( + server + .received_requests() + .await + .expect("recorded") + .is_empty(), + "an unauthenticated request must not be sent as a fallback" + ); + } + + #[tokio::test] + async fn an_unusable_private_key_is_reported_at_start_up() { + let server = MockServer::start().await; + let mut config = config_for(&server); + config.app_id = Some(1234); + config.private_key = Some("this is not a key".to_string()); + + let client = GitHubClient::new(&config); + let error = client.readiness().expect_err("an unusable key is unready"); + assert!( + format!("{error:#}").contains("unusable"), + "unexpected message: {error:#}" + ); + } + + #[tokio::test] + async fn a_static_token_is_still_used_for_repository_requests() { + let server = MockServer::start().await; + mount_file_check(&server, "ghs_static").await; + + // Set directly: the static path reads the environment, and mutating + // process-wide env vars from a parallel test suite is a race. + let mut client = GitHubClient::new(&config_for(&server)); + client.credentials = Credentials::Static(Some("ghs_static".to_string())); + + assert!(client.readiness().is_ok()); + assert_eq!(client.credential_mode(), "static token from GITHUB_TOKEN"); + assert!(client.file_exists("acme", "widgets", "README.adoc").await); + } +} diff --git a/bots/rhodibot/src/main.rs b/bots/rhodibot/src/main.rs index 59924212..2d91c19f 100644 --- a/bots/rhodibot/src/main.rs +++ b/bots/rhodibot/src/main.rs @@ -21,6 +21,7 @@ use tower_http::trace::TraceLayer; use tracing::{info, warn}; use rhodibot::config; +use rhodibot::github::GitHubClient; use rhodibot::rsr; use rhodibot::webhook; @@ -77,6 +78,10 @@ enum Command { #[derive(Clone)] struct AppState { config: Arc, + /// How the bot authenticates, reported by `/health`. Never credential + /// material -- a mode name, so an operator can see whether the App + /// credentials were picked up. + credential_mode: &'static str, } #[tokio::main] @@ -114,8 +119,16 @@ async fn main() -> Result<()> { info!("Starting Rhodibot v{}", env!("CARGO_PKG_VERSION")); + // Refuse to start with credentials that cannot work. A half-configured App + // must not quietly become an anonymous client: that turns into a + // permissions puzzle at the first webhook instead of a clear failure now. + let credentials = GitHubClient::new(&config); + credentials.readiness()?; + info!("GitHub credentials: {}", credentials.credential_mode()); + let state = AppState { config: Arc::new(config), + credential_mode: credentials.credential_mode(), }; // Build router @@ -187,11 +200,12 @@ async fn run_check(config: &Config, owner: &str, repo: &str, format: &str) -> Re } /// Health check endpoint -async fn health_check() -> impl IntoResponse { +async fn health_check(State(state): State) -> impl IntoResponse { Json(HealthResponse { status: "healthy".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), name: "rhodibot".to_string(), + credentials: state.credential_mode.to_string(), }) } @@ -200,6 +214,8 @@ struct HealthResponse { status: String, version: String, name: String, + /// e.g. "GitHub App installation tokens". Named, never the credential. + credentials: String, } /// Webhook handler for GitHub events From df9e43372465506fac031e639b1b6f4cf1670609 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:15:57 +0000 Subject: [PATCH 2/2] feat(rhodibot): deploy behind a Cloudflare Tunnel, loopback origin Adds the deployment the App credentials were built for: a tunnel config whose ingress publishes POST /webhook and GET /health and 404s everything else, the two systemd units, an environment-file template, an App-registration sheet, and a verification script. The origin now binds 127.0.0.1 by default instead of 0.0.0.0. A tunnel-fronted service that also listens on every interface is published twice, and the second publication has no webhook secret in front of it. `--bind 0.0.0.0` restores the previous behaviour for anyone serving directly. /api/check/{owner}/{repo} is deliberately not routed: it spends GitHub API quota and reports on repositories, so it stays an operator endpoint. deploy/verify-rhodibot.sh checks the deployment from the outside in -- origin health and credential mode, that the port is loopback-only, that the tunnel reaches it, that an unsigned delivery is refused with 401 (which also proves the webhook secret is set: without one the handler accepts anything), and that the private endpoints 404. PASS/FAIL/SKIP are distinguished, because a check that could not be made is not a check that passed. --- bots/rhodibot/src/main.rs | 12 +- deploy/GITHUB-APP-REGISTRATION.adoc | 100 ++++++++++++++ deploy/RHODIBOT-DEPLOYMENT.adoc | 139 ++++++++++++++++++++ deploy/cloudflared/rhodibot.yml | 51 +++++++ deploy/rhodibot.env.example | 30 +++++ deploy/systemd/cloudflared-rhodibot.service | 51 +++++++ deploy/systemd/rhodibot.service | 78 +++++++++++ deploy/verify-rhodibot.sh | 131 ++++++++++++++++++ 8 files changed, 591 insertions(+), 1 deletion(-) create mode 100644 deploy/GITHUB-APP-REGISTRATION.adoc create mode 100644 deploy/RHODIBOT-DEPLOYMENT.adoc create mode 100644 deploy/cloudflared/rhodibot.yml create mode 100644 deploy/rhodibot.env.example create mode 100644 deploy/systemd/cloudflared-rhodibot.service create mode 100644 deploy/systemd/rhodibot.service create mode 100755 deploy/verify-rhodibot.sh 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))