max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+144 insertions,
-16 deletions
| @@ -2263,6 +2263,7 @@ | |||
| 2263 | 2263 | "rand 0.10.2", | |
| 2264 | 2264 | "regex-lite", | |
| 2265 | 2265 | "reqwest", | |
| 2266 | + | "rustls-native-certs", | |
| 2266 | 2267 | "s3-storage", | |
| 2267 | 2268 | "serde", | |
| 2268 | 2269 | "serde_json", | |
| @@ -5013,14 +5014,6 @@ | |||
| 5013 | 5014 | name = "supernote-push" | |
| 5014 | 5015 | version = "0.1.0" | |
| 5015 | 5016 | ||
| 5016 | - | [[patch.unused]] | |
| 5017 | - | name = "kberg" | |
| 5018 | - | version = "0.1.0" | |
| 5019 | - | ||
| 5020 | - | [[patch.unused]] | |
| 5021 | - | name = "painhours" | |
| 5022 | - | version = "0.1.0" | |
| 5023 | - | ||
| 5024 | 5017 | [[patch.unused]] | |
| 5025 | 5018 | name = "synckit-client" | |
| 5026 | 5019 | version = "0.6.0" | |
| @@ -5028,3 +5021,11 @@ | |||
| 5028 | 5021 | [[patch.unused]] | |
| 5029 | 5022 | name = "synckit-config" | |
| 5030 | 5023 | version = "0.1.2" | |
| 5024 | + | ||
| 5025 | + | [[patch.unused]] | |
| 5026 | + | name = "kberg" | |
| 5027 | + | version = "0.1.0" | |
| 5028 | + | ||
| 5029 | + | [[patch.unused]] | |
| 5030 | + | name = "painhours" | |
| 5031 | + | version = "0.1.0" |
| @@ -30,6 +30,11 @@ | |||
| 30 | 30 | ||
| 31 | 31 | # HTTP client / crypto | |
| 32 | 32 | reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } | |
| 33 | + | # Reads the host trust store, which is what reqwest's platform verifier does on | |
| 34 | + | # Linux and what the AWS client behind s3-storage calls directly. Already in the | |
| 35 | + | # tree through both of those; declared here so trust_store can probe the same | |
| 36 | + | # store the outbound paths will use (deploy/README.md). | |
| 37 | + | rustls-native-certs = "0.8" | |
| 33 | 38 | # Pinned to the exact major reqwest resolves so the SSRF pre-check parses the | |
| 34 | 39 | # host with the same parser reqwest connects through (link_preview::validate_url). | |
| 35 | 40 | url = "2" | |
| @@ -84,6 +89,7 @@ | |||
| 84 | 89 | tower-sessions = { workspace = true } | |
| 85 | 90 | tower-sessions-sqlx-store = { workspace = true } | |
| 86 | 91 | reqwest = { workspace = true } | |
| 92 | + | rustls-native-certs = { workspace = true } | |
| 87 | 93 | url = { workspace = true } | |
| 88 | 94 | sha2 = { workspace = true } | |
| 89 | 95 | base64 = { workspace = true } |
| @@ -89,9 +89,14 @@ | |||
| 89 | 89 | deploy problem. mt starts, `/api/health` passes because it only probes the | |
| 90 | 90 | database, and pages serve. The first outbound TLS request is a login attempt, | |
| 91 | 91 | because mt is an OAuth relying party, so the symptom is a login outage with a | |
| 92 | - | certificate error buried in the journal on a node that reports healthy. Check | |
| 93 | - | the bundle when provisioning a node, and keep `ca-certificates` on an update | |
| 94 | - | path rather than assuming it stays current. | |
| 92 | + | certificate error buried in the journal on a node that reports healthy. | |
| 93 | + | ||
| 94 | + | Two things now make that visible. mt probes the store at startup and logs an | |
| 95 | + | error when it comes back empty, and `/api/health` carries a `tls_trust_anchors` | |
| 96 | + | field that PoM asserts, so the condition surfaces as a degraded target rather | |
| 97 | + | than as a support ticket. Neither watches the host bundle for staleness over | |
| 98 | + | time; that is infra's job, and keeping `ca-certificates` on an update path is | |
| 99 | + | still a precondition rather than something mt arranges. | |
| 95 | 100 | ||
| 96 | 101 | Rollback is the MNW server's rollback: the previous bundle still holds the | |
| 97 | 102 | previous mt binary. |
| @@ -19,6 +19,7 @@ | |||
| 19 | 19 | pub mod static_assets; | |
| 20 | 20 | pub mod storage; | |
| 21 | 21 | pub mod templates; | |
| 22 | + | pub mod trust_store; | |
| 22 | 23 | pub mod trusted_proxy; | |
| 23 | 24 | ||
| 24 | 25 | use config::Config; |
| @@ -46,6 +46,12 @@ | |||
| 46 | 46 | let config = Config::from_env(); | |
| 47 | 47 | multithreaded::error_page::init(config.mnw_base_url.clone()); | |
| 48 | 48 | ||
| 49 | + | // Probe the host trust store now rather than on the first outbound request, | |
| 50 | + | // so a host with no usable CA anchors says so in the journal at boot and on | |
| 51 | + | // /api/health, instead of presenting as a login outage on a box that reports | |
| 52 | + | // healthy. The answer is cached, so the health handler does not repeat it. | |
| 53 | + | multithreaded::trust_store::anchors_ok(); | |
| 54 | + | ||
| 49 | 55 | // Optional S3 storage for image uploads | |
| 50 | 56 | let s3 = if let Some(ref s3_config) = config.s3 { | |
| 51 | 57 | match multithreaded::storage::S3Storage::new(s3_config).await { |
| @@ -121,7 +121,14 @@ | |||
| 121 | 121 | ||
| 122 | 122 | [targets.mt.health.expect] | |
| 123 | 123 | status_code = 200 | |
| 124 | - | json_fields = { "status" = "operational", "database" = "true" } | |
| 124 | + | # `tls_trust_anchors` is whether the host CA bundle gave mt any outbound TLS | |
| 125 | + | # anchors. mt ships none of its own, so a thin or stale bundle takes out the | |
| 126 | + | # OAuth token exchange that logs users in, while the box still boots, serves | |
| 127 | + | # pages, and answers 200 here. mt deliberately does not degrade `status` for it | |
| 128 | + | # (a bad bundle is a whole-fleet condition, and failing the load-balancer check | |
| 129 | + | # would turn a login outage into a total one), so this assertion is the only | |
| 130 | + | # thing that makes the condition visible before a user finds it. | |
| 131 | + | json_fields = { "status" = "operational", "database" = "true", "tls_trust_anchors" = "true" } | |
| 125 | 132 | ||
| 126 | 133 | [targets.mt.tls] | |
| 127 | 134 | host = "forums.makenot.work" |
| @@ -520,7 +520,10 @@ | |||
| 520 | 520 | .await | |
| 521 | 521 | .is_ok(); | |
| 522 | 522 | ||
| 523 | - | (health_status(db_ok), Json(health_body(db_ok))) | |
| 523 | + | ( | |
| 524 | + | health_status(db_ok), | |
| 525 | + | Json(health_body(db_ok, crate::trust_store::anchors_ok())), | |
| 526 | + | ) | |
| 524 | 527 | } | |
| 525 | 528 | ||
| 526 | 529 | /// Map DB reachability to the HTTP status. Pure so the status contract can be | |
| @@ -539,7 +542,7 @@ | |||
| 539 | 542 | /// test in this module can exercise it directly. PoM polls this endpoint | |
| 540 | 543 | /// and runs key-by-key assertions from `pom/deploy/pom-hetzner.toml`; the | |
| 541 | 544 | /// guard test validates that every asserted path still resolves here. | |
| 542 | - | fn health_body(db_ok: bool) -> serde_json::Value { | |
| 545 | + | fn health_body(db_ok: bool, trust_anchors_ok: bool) -> serde_json::Value { | |
| 543 | 546 | let status = if db_ok { "operational" } else { "degraded" }; | |
| 544 | 547 | serde_json::json!({ | |
| 545 | 548 | "status": status, | |
| @@ -549,6 +552,13 @@ | |||
| 549 | 552 | // same-semver redeploy, which `version` alone cannot distinguish. | |
| 550 | 553 | "git_sha": option_env!("GIT_HASH").filter(|h| !h.is_empty()), | |
| 551 | 554 | "database": db_ok, | |
| 555 | + | // Whether the host trust store yielded outbound TLS anchors | |
| 556 | + | // (`crate::trust_store`). Deliberately does not move `status` or the | |
| 557 | + | // HTTP code: mt still serves every page that does not leave the box, | |
| 558 | + | // and a bad CA bundle is usually a whole-fleet condition, so failing | |
| 559 | + | // the load-balancer check would turn a login outage into a total one. | |
| 560 | + | // PoM asserts this field, which is what gets it monitored. | |
| 561 | + | "tls_trust_anchors": trust_anchors_ok, | |
| 552 | 562 | }) | |
| 553 | 563 | } | |
| 554 | 564 | ||
| @@ -594,7 +604,7 @@ | |||
| 594 | 604 | #[test] | |
| 595 | 605 | #[ignore = "cross-repo: run by sweep's pom-contract check, which materializes pom"] | |
| 596 | 606 | fn pom_hetzner_health_expectations_resolve() { | |
| 597 | - | let body = health_body(true); | |
| 607 | + | let body = health_body(true, true); | |
| 598 | 608 | pom_contract::assert_health_expectations_resolve( | |
| 599 | 609 | "../pom/deploy/pom-hetzner.toml", | |
| 600 | 610 | "mt", | |
| @@ -608,7 +618,7 @@ | |||
| 608 | 618 | /// than in PoM's exact-match `json_fields`. | |
| 609 | 619 | #[test] | |
| 610 | 620 | fn health_body_carries_version_and_git_sha_keys() { | |
| 611 | - | let body = health_body(true); | |
| 621 | + | let body = health_body(true, true); | |
| 612 | 622 | assert_eq!(body["version"], env!("CARGO_PKG_VERSION")); | |
| 613 | 623 | assert!( | |
| 614 | 624 | body.get("git_sha").is_some(), | |
| @@ -616,6 +626,20 @@ | |||
| 616 | 626 | ); | |
| 617 | 627 | } | |
| 618 | 628 | ||
| 629 | + | /// The trust-anchor field is what PoM watches for a stale or thin host CA | |
| 630 | + | /// bundle, so lock both that it is reported and that it does not move | |
| 631 | + | /// `status`. A box with no anchors still serves every page that does not | |
| 632 | + | /// leave it; degrading the whole target would hide that distinction. | |
| 633 | + | #[test] | |
| 634 | + | fn health_body_reports_trust_anchors_without_moving_status() { | |
| 635 | + | assert_eq!(health_body(true, true)["tls_trust_anchors"], true); | |
| 636 | + | ||
| 637 | + | let body = health_body(true, false); | |
| 638 | + | assert_eq!(body["tls_trust_anchors"], false); | |
| 639 | + | assert_eq!(body["status"], "operational"); | |
| 640 | + | assert_eq!(health_status(true), StatusCode::OK); | |
| 641 | + | } | |
| 642 | + | ||
| 619 | 643 | /// A reachable DB is `200`; an unreachable DB is `503` so status-only probes | |
| 620 | 644 | /// don't read a degraded box as healthy. PoM expects `200` for operational. | |
| 621 | 645 | #[test] |
| @@ -8,5 +8,6 @@ | |||
| 8 | 8 | let body: serde_json::Value = resp.json(); | |
| 9 | 9 | assert_eq!(body["status"], "operational"); | |
| 10 | 10 | assert_eq!(body["database"], true); | |
| 11 | + | assert_eq!(body["tls_trust_anchors"], true); | |
| 11 | 12 | assert!(body["version"].as_str().is_some()); | |
| 12 | 13 | } |
| @@ -1,0 +1,77 @@ | |||
| 1 | + | //! Whether this host has usable outbound TLS trust anchors. | |
| 2 | + | //! | |
| 3 | + | //! mt ships none of its own. `reqwest` verifies through | |
| 4 | + | //! `rustls-platform-verifier`, which on Linux loads the host store with | |
| 5 | + | //! `rustls_native_certs::load_native_certs`, and the AWS client used by | |
| 6 | + | //! `s3-storage` calls the same crate directly. Neither offers bundled roots, so | |
| 7 | + | //! a thin or unreadable store takes out all three outbound paths at once: the | |
| 8 | + | //! OAuth token exchange that logs users in, link previews, and S3 media. | |
| 9 | + | //! | |
| 10 | + | //! Left alone, that failure is invisible until someone tries to log in, on a box | |
| 11 | + | //! that boots fine and passes a database-only health check. Probing the same call | |
| 12 | + | //! the verifier will make turns it into a boot-time log line and an | |
| 13 | + | //! `/api/health` field instead. The precondition itself is documented in | |
| 14 | + | //! `deploy/README.md`. | |
| 15 | + | ||
| 16 | + | use std::sync::OnceLock; | |
| 17 | + | ||
| 18 | + | static ANCHORS_OK: OnceLock<bool> = OnceLock::new(); | |
| 19 | + | ||
| 20 | + | /// Whether the host trust store yielded any usable anchors. | |
| 21 | + | /// | |
| 22 | + | /// Probed once per process and cached, so `main` can force the boot-time log and | |
| 23 | + | /// the health handler can read the answer on every poll without reloading the | |
| 24 | + | /// store. Called from the health handler, which is why it is not `main`-only. | |
| 25 | + | pub fn anchors_ok() -> bool { | |
| 26 | + | *ANCHORS_OK.get_or_init(probe) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// Load the host store and judge it the way the verifier does. | |
| 30 | + | /// | |
| 31 | + | /// Emptiness is the only failure. A store that yields some anchors and some | |
| 32 | + | /// parse errors is what the platform verifier itself accepts (it logs the | |
| 33 | + | /// ignored certificates and builds anyway), so treating partial success as a | |
| 34 | + | /// failure here would report a problem mt does not have. | |
| 35 | + | fn probe() -> bool { | |
| 36 | + | let result = rustls_native_certs::load_native_certs(); | |
| 37 | + | ||
| 38 | + | for error in &result.errors { | |
| 39 | + | tracing::warn!("trust store read error: {error}"); | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | if result.certs.is_empty() { | |
| 43 | + | tracing::error!( | |
| 44 | + | "no CA certificates loaded from the host trust store. Every outbound \ | |
| 45 | + | TLS request will fail, including the OAuth token exchange that logs \ | |
| 46 | + | users in. Install or repair ca-certificates on this host; see \ | |
| 47 | + | deploy/README.md." | |
| 48 | + | ); | |
| 49 | + | false | |
| 50 | + | } else { | |
| 51 | + | tracing::info!( | |
| 52 | + | anchors = result.certs.len(), | |
| 53 | + | "loaded CA certificates from the host trust store" | |
| 54 | + | ); | |
| 55 | + | true | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | #[cfg(test)] | |
| 60 | + | mod tests { | |
| 61 | + | use super::anchors_ok; | |
| 62 | + | ||
| 63 | + | /// Any developer or CI machine has a trust store, so this asserts the probe | |
| 64 | + | /// reads one rather than asserting a hardcoded answer. It would fail on a | |
| 65 | + | /// host that cannot make an outbound HTTPS request at all, which is the | |
| 66 | + | /// condition worth failing on. | |
| 67 | + | #[test] | |
| 68 | + | fn probe_finds_anchors_on_this_host() { | |
| 69 | + | assert!(anchors_ok(), "no CA anchors loaded from the host store"); | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// The answer is cached, so repeated reads must agree and must not reload. | |
| 73 | + | #[test] | |
| 74 | + | fn repeated_reads_agree() { | |
| 75 | + | assert_eq!(anchors_ok(), anchors_ok()); | |
| 76 | + | } | |
| 77 | + | } |