diff --git a/.sqlx/query-283975d29d5e20e97935b047e6e21b3250515d013a5b94d781e0d0a74b7e5643.json b/.sqlx/query-283975d29d5e20e97935b047e6e21b3250515d013a5b94d781e0d0a74b7e5643.json new file mode 100644 index 0000000..9b2d179 --- /dev/null +++ b/.sqlx/query-283975d29d5e20e97935b047e6e21b3250515d013a5b94d781e0d0a74b7e5643.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT site_id, username, password_hash, updated_at FROM site_basic_auth\n WHERE site_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "site_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "site_basic_auth", + "name": "site_id" + } + } + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text", + "origin": { + "Table": { + "table": "site_basic_auth", + "name": "username" + } + } + }, + { + "ordinal": 2, + "name": "password_hash", + "type_info": "Text", + "origin": { + "Table": { + "table": "site_basic_auth", + "name": "password_hash" + } + } + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "site_basic_auth", + "name": "updated_at" + } + } + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "283975d29d5e20e97935b047e6e21b3250515d013a5b94d781e0d0a74b7e5643" +} diff --git a/.sqlx/query-3899586f164bbc4e4870f8fd249bfd481f6862f020588f520999680879472c93.json b/.sqlx/query-3899586f164bbc4e4870f8fd249bfd481f6862f020588f520999680879472c93.json new file mode 100644 index 0000000..a0e0acd --- /dev/null +++ b/.sqlx/query-3899586f164bbc4e4870f8fd249bfd481f6862f020588f520999680879472c93.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM site_basic_auth WHERE site_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "3899586f164bbc4e4870f8fd249bfd481f6862f020588f520999680879472c93" +} diff --git a/.sqlx/query-a3c0c816deffa02b368fc1426338a7c4a8534576181016d86797412df21c8b0b.json b/.sqlx/query-a3c0c816deffa02b368fc1426338a7c4a8534576181016d86797412df21c8b0b.json new file mode 100644 index 0000000..c283924 --- /dev/null +++ b/.sqlx/query-a3c0c816deffa02b368fc1426338a7c4a8534576181016d86797412df21c8b0b.json @@ -0,0 +1,67 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO site_basic_auth (site_id, username, password_hash, updated_at)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (site_id) DO UPDATE\n SET username = EXCLUDED.username,\n password_hash = EXCLUDED.password_hash,\n updated_at = EXCLUDED.updated_at\n RETURNING site_id, username, password_hash, updated_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "site_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "site_basic_auth", + "name": "site_id" + } + } + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text", + "origin": { + "Table": { + "table": "site_basic_auth", + "name": "username" + } + } + }, + { + "ordinal": 2, + "name": "password_hash", + "type_info": "Text", + "origin": { + "Table": { + "table": "site_basic_auth", + "name": "password_hash" + } + } + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "site_basic_auth", + "name": "updated_at" + } + } + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "a3c0c816deffa02b368fc1426338a7c4a8534576181016d86797412df21c8b0b" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index a89c162..24fee55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to Setu. Release notes are cut from the section matching the tag. +## 0.1.7 โ€” Unreleased + +### Added + +- **Per-site HTTP basic auth.** `PUT /api/v1/sites/{id}/basic-auth` with a + user name and password puts a login in front of one site; `DELETE` + removes it and `GET` shows the user name. Caddy enforces it on the + site's route. Only a bcrypt hash is stored, and no response or log + carries the password or the hash. A change runs as a `route_update` job + and leaves the site's status alone. + ## 0.1.6 โ€” 2026-10-23 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index e466d11..a35cff6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,6 +218,18 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bcrypt" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a" +dependencies = [ + "base64 0.23.1", + "blowfish", + "getrandom 0.4.3", + "subtle", +] + [[package]] name = "bitflags" version = "2.13.2" @@ -254,6 +266,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher 0.5.2", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -303,7 +325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -326,7 +348,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", "chacha20 0.9.1", - "cipher", + "cipher 0.4.4", "poly1305", "zeroize", ] @@ -352,10 +374,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.6", - "inout", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.6", +] + [[package]] name = "clap" version = "4.6.7" @@ -707,7 +739,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1286,6 +1318,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a580c13f0ffc335ef42dd3f6b1a41b726051c5e1656d9a3aa6fddb3c5dfbbf9" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -1714,7 +1755,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1972,7 +2013,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2210,6 +2251,7 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "bcrypt", "chrono", "clap", "flate2", @@ -2718,10 +2760,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3370,7 +3412,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3682,27 +3724,27 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "cdb02c3132d251ab1606e9fc913a7da6c6ed08cc263df15edf082e1dc497d157" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.3.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +checksum = "1c8ea04668f5de8d8d51051714ba560c9536519cadcdb8829bba4fe2ad159b03" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.1.1+zstd.1.5.7" +version = "2.1.2+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 0eb4bb4..41ecf9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ tracing = { version = "0.1" } tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } utoipa = { version = "5", features = ["axum_extras", "chrono"] } argon2 = { version = "0.5" } +bcrypt = { version = "0.19", default-features = false, features = ["std"] } chacha20poly1305 = { version = "0.10" } rand = { version = "0.9" } base64 = { version = "0.22" } diff --git a/crates/agent/src/caddy.rs b/crates/agent/src/caddy.rs index 405152e..1c2a08a 100644 --- a/crates/agent/src/caddy.rs +++ b/crates/agent/src/caddy.rs @@ -100,6 +100,38 @@ fn valid_upstream(upstream: &str) -> bool { /// The route object (golden, see `tests/golden/route-*.json`). pub fn route_json(id: &str, domain: &str, upstream: &str) -> anyhow::Result { + route_json_with_auth(id, domain, upstream, None) +} + +/// Letters, digits and `._-`, 1..=64: the basic-auth user name. +fn valid_auth_user(user: &str) -> bool { + (1..=64).contains(&user.len()) + && user + .chars() + .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) +} + +/// A bcrypt hash as Caddy's `hash-password` writes it: `$2a$`/`$2b$`/`$2y$`, +/// two-digit cost, 53 characters of the bcrypt alphabet (60 in all). +fn valid_bcrypt(hash: &str) -> bool { + hash.len() == 60 + && ["$2a$", "$2b$", "$2y$"].iter().any(|p| hash.starts_with(p)) + && hash.as_bytes()[6] == b'$' + && hash[4..6].chars().all(|c| c.is_ascii_digit()) + && hash[7..] + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '/') +} + +/// [`route_json`] with an optional HTTP basic-auth `(user, bcrypt hash)` +/// in front of the proxy (site routes only; the hash, never the password, +/// is what reaches Caddy). +pub fn route_json_with_auth( + id: &str, + domain: &str, + upstream: &str, + auth: Option<(&str, &str)>, +) -> anyhow::Result { if !valid_route_id(id) { anyhow::bail!("invalid route id {id:?}"); } @@ -109,13 +141,33 @@ pub fn route_json(id: &str, domain: &str, upstream: &str) -> anyhow::Result anyhow::Result anyhow::Result<()>; + async fn upsert_route(&self, id: &str, domain: &str, upstream: &str) -> anyhow::Result<()> { + self.upsert_route_auth(id, domain, upstream, None).await + } + /// [`CaddyAdmin::upsert_route`] with an optional basic-auth `(user, hash)`. + async fn upsert_route_auth( + &self, + id: &str, + domain: &str, + upstream: &str, + auth: Option<(&str, &str)>, + ) -> anyhow::Result<()>; /// Delete route `id`; a missing route is success. async fn remove_route(&self, id: &str) -> anyhow::Result<()>; /// `@id`s of the routes of server `setu`, in order. @@ -243,8 +305,14 @@ impl CaddyClient { #[async_trait::async_trait] impl CaddyAdmin for CaddyClient { - async fn upsert_route(&self, id: &str, domain: &str, upstream: &str) -> anyhow::Result<()> { - let route = route_json(id, domain, upstream)?; + async fn upsert_route_auth( + &self, + id: &str, + domain: &str, + upstream: &str, + auth: Option<(&str, &str)>, + ) -> anyhow::Result<()> { + let route = route_json_with_auth(id, domain, upstream, auth)?; let body = serde_json::to_vec(&route)?; let path = format!("/id/{id}"); let (status, resp) = self.transport.request("GET", &path, None).await?; diff --git a/crates/agent/src/dispatch.rs b/crates/agent/src/dispatch.rs index 62636b3..b9b77f8 100644 --- a/crates/agent/src/dispatch.rs +++ b/crates/agent/src/dispatch.rs @@ -138,6 +138,7 @@ where site_short_id, domain, port, + basic_auth, } => { let id = match crate::caddy::site_route_id(site_short_id) { Ok(id) => id, @@ -149,10 +150,24 @@ where if let Err(e) = rt.update_state(site_short_id, |r| { r.domain = Some(domain.clone()); r.port = *port; + r.basic_auth = basic_auth + .as_ref() + .map(|a| (a.username.clone(), a.password_hash.clone())); }) { return err(cmd_id, "set_route failed", e); } - match rt.caddy().upsert_route(&id, domain, &upstream).await { + match rt + .caddy() + .upsert_route_auth( + &id, + domain, + &upstream, + basic_auth + .as_ref() + .map(|a| (a.username.as_str(), a.password_hash.as_str())), + ) + .await + { Ok(()) => ok( cmd_id, serde_json::json!({"domain": domain, "route_id": id}), diff --git a/crates/agent/src/reconcile.rs b/crates/agent/src/reconcile.rs index 1454e5d..df228c8 100644 --- a/crates/agent/src/reconcile.rs +++ b/crates/agent/src/reconcile.rs @@ -60,7 +60,14 @@ where } wanted.insert(id.clone()); if let Err(e) = caddy - .upsert_route(&id, domain, &site_upstream(site.port)) + .upsert_route_auth( + &id, + domain, + &site_upstream(site.port), + site.basic_auth + .as_ref() + .map(|(u, h)| (u.as_str(), h.as_str())), + ) .await { tracing::warn!(route = %id, error = %e, "reconcile: cannot upsert route"); diff --git a/crates/agent/src/state.rs b/crates/agent/src/state.rs index 785742f..a42b784 100644 --- a/crates/agent/src/state.rs +++ b/crates/agent/src/state.rs @@ -31,6 +31,9 @@ pub struct SiteRecord { pub domain: Option, #[serde(default)] pub port: u16, + /// `(user, bcrypt hash)` recorded by `SetRoute`; reconcile puts it back. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub basic_auth: Option<(String, String)>, #[serde(default)] pub version: String, #[serde(default)] diff --git a/crates/agent/tests/caddy.rs b/crates/agent/tests/caddy.rs index 2e25b81..2a3b1bd 100644 --- a/crates/agent/tests/caddy.rs +++ b/crates/agent/tests/caddy.rs @@ -8,8 +8,8 @@ use std::path::PathBuf; use setu_agent::caddy::{ - route_json, site_route_id, site_upstream, CaddyAdmin, CaddySocket, FakeAdmin, FakeCaddy, - PANEL_ROUTE_ID, ROUTES_PATH, + route_json, route_json_with_auth, site_route_id, site_upstream, CaddyAdmin, CaddySocket, + FakeAdmin, FakeCaddy, PANEL_ROUTE_ID, ROUTES_PATH, }; const SHORT: &str = "abcd1234"; @@ -313,3 +313,43 @@ async fn caddy_socket_against_real_caddy_when_available() { vec![PANEL_ROUTE_ID.to_string()] ); } + +#[test] +fn caddy_route_with_basic_auth_is_golden_and_validated() { + let hash = "$2a$14$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234"; + let route = route_json_with_auth( + &site_route_id(SHORT).expect("id"), + "a.example.test", + &site_upstream(20001), + Some(("alice", hash)), + ) + .expect("auth route"); + assert_eq!( + serde_json::to_string_pretty(&route).expect("json") + "\n", + include_str!("golden/route-setu-site-abcd1234-auth.json") + ); + // No credential: identical to the plain route. + assert_eq!( + route_json_with_auth("setu-site-abcd1234", "a.test", "127.0.0.1:1", None).expect("plain"), + route_json("setu-site-abcd1234", "a.test", "127.0.0.1:1").expect("plain") + ); + // Hostile names and non-bcrypt hashes never reach the config. + for (u, h) in [ + ("", hash), + ("a\"b", hash), + ("a b", hash), + ("alice", ""), + ("alice", "plaintext"), + ("alice", "$2a$14$x\"y"), + ] { + assert!( + route_json_with_auth("setu-site-abcd1234", "a.test", "127.0.0.1:1", Some((u, h))) + .is_err(), + "{u:?} {h:?}" + ); + } + // The panel route never takes a credential. + assert!( + route_json_with_auth("setu-panel", "a.test", "127.0.0.1:1", Some(("alice", hash))).is_err() + ); +} diff --git a/crates/agent/tests/commands.rs b/crates/agent/tests/commands.rs index 72eb154..842df1c 100644 --- a/crates/agent/tests/commands.rs +++ b/crates/agent/tests/commands.rs @@ -227,6 +227,7 @@ async fn every_command_kind_is_handled() { site_short_id: s(), domain: "example.com".to_string(), port: servers.health_port, + basic_auth: None, }, Command::Backup { site: s(), @@ -486,6 +487,7 @@ async fn route_commands_key_off_the_site_short_id() { site_short_id: "abcd1234".to_string(), domain: "a.example.test".to_string(), port: 20001, + basic_auth: None, }; let r = setu_agent::execute("c1", &set, &rt).await; assert!(r.ok, "{:?}", r.error); @@ -506,6 +508,7 @@ async fn route_commands_key_off_the_site_short_id() { site_short_id: String::new(), domain: "b.example.test".to_string(), port: 20002, + basic_auth: None, }; assert!(!setu_agent::execute("c2", &empty_short, &rt).await.ok); diff --git a/crates/agent/tests/golden/route-setu-site-abcd1234-auth.json b/crates/agent/tests/golden/route-setu-site-abcd1234-auth.json new file mode 100644 index 0000000..6f796aa --- /dev/null +++ b/crates/agent/tests/golden/route-setu-site-abcd1234-auth.json @@ -0,0 +1,35 @@ +{ + "@id": "setu-site-abcd1234", + "handle": [ + { + "handler": "authentication", + "providers": { + "http_basic": { + "accounts": [ + { + "password": "$2a$14$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234", + "username": "alice" + } + ], + "realm": "a.example.test" + } + } + }, + { + "handler": "reverse_proxy", + "upstreams": [ + { + "dial": "127.0.0.1:20001" + } + ] + } + ], + "match": [ + { + "host": [ + "a.example.test" + ] + } + ], + "terminal": true +} diff --git a/crates/agent/tests/reconcile.rs b/crates/agent/tests/reconcile.rs index f57b1de..6f9c388 100644 --- a/crates/agent/tests/reconcile.rs +++ b/crates/agent/tests/reconcile.rs @@ -11,6 +11,7 @@ fn record(domain: Option<&str>, port: u16) -> SiteRecord { domain: domain.map(str::to_string), port, version: "1.0.0".into(), + basic_auth: None, db: None, } } @@ -186,3 +187,38 @@ async fn reconcile_keeps_panel_route_first() { "panel first, sites kept in order" ); } + +#[tokio::test] +async fn reconcile_restores_basic_auth_and_drops_it_when_cleared() { + let dir = tempfile::tempdir().expect("tempdir"); + let p = Paths::new(Some(dir.path().to_path_buf())); + let config = AgentConfig { + sites_root: p.sites(), + state_path: p.state(), + ..AgentConfig::default() + }; + let hash = "$2a$14$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234"; + let mut state = State::default(); + let mut rec = record(Some("a.example.test"), 20001); + rec.basic_auth = Some(("alice".into(), hash.into())); + state.sites.insert("aaaaaaaa".into(), rec); + let (caddy, helper, postgres) = (FakeCaddy::new(), FakeHelper::new(), FakePostgres::new()); + + setu_agent::reconcile::run(&state, &config, &helper, &caddy, &postgres) + .await + .expect("reconcile"); + let routes = caddy.admin().routes(); + let r = route(&routes, "setu-site-aaaaaaaa").expect("a"); + assert_eq!(r["handle"][0]["handler"], "authentication"); + assert_eq!(r["handle"][1]["handler"], "reverse_proxy"); + + // Auth cleared in state: the route goes back to the plain proxy. + state.sites.get_mut("aaaaaaaa").unwrap().basic_auth = None; + setu_agent::reconcile::run(&state, &config, &helper, &caddy, &postgres) + .await + .expect("reconcile"); + let routes = caddy.admin().routes(); + let r = route(&routes, "setu-site-aaaaaaaa").expect("a"); + assert_eq!(r["handle"].as_array().unwrap().len(), 1); + assert_eq!(r["handle"][0]["handler"], "reverse_proxy"); +} diff --git a/crates/agent/tests/state.rs b/crates/agent/tests/state.rs index 03465d7..4452958 100644 --- a/crates/agent/tests/state.rs +++ b/crates/agent/tests/state.rs @@ -35,6 +35,7 @@ fn state_round_trips_and_caps_results() { port: 20001, version: "1.2.3".into(), db: Some("site_abcd1234".into()), + basic_auth: None, } }) .expect("site"); diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 982aca9..f3ce9a8 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -33,6 +33,7 @@ chrono.workspace = true url.workspace = true clap.workspace = true argon2.workspace = true +bcrypt.workspace = true utoipa.workspace = true reqwest.workspace = true sha2.workspace = true diff --git a/crates/api/src/rest/basic_auth.rs b/crates/api/src/rest/basic_auth.rs new file mode 100644 index 0000000..375e501 --- /dev/null +++ b/crates/api/src/rest/basic_auth.rs @@ -0,0 +1,252 @@ +//! `GET/PUT/DELETE /api/v1/sites/{id}/basic-auth` (0.1.7): HTTP basic +//! auth in front of one site, enforced by Caddy. The password is hashed +//! with bcrypt (the format Caddy reads) and only the hash is stored; no +//! response ever carries a password or a hash. A change enqueues a +//! `route_update` job that rewrites the site's Caddy route. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use setu_common::AppError; +use utoipa::ToSchema; + +use crate::auth::{audit, require_role, ClientIp, Role, Session}; +use crate::state::AppState; + +use super::sites::require_no_running_job; +use super::sites::JobOut; + +/// bcrypt cost: 12 in release builds; the minimum under `debug_assertions` +/// so the test suite stays fast. +const COST: u32 = if cfg!(debug_assertions) { 4 } else { 12 }; + +/// Longest password bcrypt reads (bytes). +const MAX_PASSWORD: usize = 72; + +/// A site's basic-auth setting: the user name only. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct BasicAuthOut { + pub username: String, + pub updated_at: chrono::DateTime, +} + +/// `GET /sites/{id}/basic-auth`: the setting, or `null` when the site is open. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct BasicAuthGetOut { + pub basic_auth: Option, +} + +/// `PUT /sites/{id}/basic-auth` body. +#[derive(Deserialize, ToSchema)] +pub struct BasicAuthPutRequest { + /// Letters, digits and `._-`, 1 to 64. + pub username: String, + /// 8 to 72 bytes, no control characters. Never stored or returned. + pub password: String, +} + +// Holds a password: `Debug` never prints it. +impl std::fmt::Debug for BasicAuthPutRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BasicAuthPutRequest") + .field("username", &self.username) + .field("password", &"[redacted]") + .finish() + } +} + +/// `PUT`/`DELETE` result: the setting after the change and the route job. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct BasicAuthJobResponse { + pub basic_auth: Option, + pub job: JobOut, +} + +fn out(row: &setu_db::SiteBasicAuth) -> BasicAuthOut { + BasicAuthOut { + username: row.username.clone(), + updated_at: row.updated_at, + } +} + +/// The rules for a user name and password; the message never echoes the password. +pub fn validate(username: &str, password: &str) -> Result<(), AppError> { + let name_ok = (1..=64).contains(&username.len()) + && username + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')); + if !name_ok { + return Err(AppError::validation( + "username must be 1 to 64 letters, digits, '.', '_' or '-'", + )); + } + if !(8..=MAX_PASSWORD).contains(&password.len()) { + return Err(AppError::validation("password must be 8 to 72 bytes long")); + } + if password.chars().any(char::is_control) { + return Err(AppError::validation( + "password must not contain control characters", + )); + } + Ok(()) +} + +/// bcrypt hash on the blocking pool (it is CPU-bound). +async fn hash_blocking(password: &str) -> Result { + let password = password.to_string(); + tokio::task::spawn_blocking(move || bcrypt::hash_with_result(password, COST)) + .await + .map_err(|e| { + tracing::error!("basic-auth hashing task failed: {e}"); + AppError::internal() + })? + .map(|h| h.format_for_version(bcrypt::Version::TwoB)) + .map_err(|e| { + tracing::error!("basic-auth hashing failed: {e}"); + AppError::internal() + }) +} + +async fn enqueue_route(state: &AppState, site: &setu_db::Site) -> Result { + let job = state + .db + .jobs() + .enqueue("route_update", Some(&site.id), Some(&site.server_id), None) + .await?; + Ok(JobOut::from_row(&job)) +} + +fn require_settled(site: &setu_db::Site) -> Result<(), AppError> { + if site.status == "running" || site.status == "stopped" { + Ok(()) + } else { + Err(AppError::conflict(format!( + "site cannot change its basic auth from status {}", + site.status + ))) + } +} + +/// The site's basic-auth user name (viewer and above). +#[utoipa::path( + get, + path = "/api/v1/sites/{id}/basic-auth", + params(("id" = String, Path, description = "Site id")), + responses( + (status = 200, body = BasicAuthGetOut), + (status = 401, body = super::ErrorBody), + (status = 404, body = super::ErrorBody), + ) +)] +pub async fn get( + State(state): State, + session: Session, + Path(id): Path, +) -> Result { + require_role(&session, Role::Viewer).await?; + let site = state.db.sites().get(&id).await?; + let row = state.db.basic_auths().get(&site.id).await?; + Ok(Json(BasicAuthGetOut { + basic_auth: row.as_ref().map(out), + })) +} + +/// Set or replace the site's basic auth (operator and above); enqueues a +/// route update. +#[utoipa::path( + put, + path = "/api/v1/sites/{id}/basic-auth", + params(("id" = String, Path, description = "Site id")), + request_body = BasicAuthPutRequest, + responses( + (status = 202, body = BasicAuthJobResponse), + (status = 400, body = super::ErrorBody), + (status = 401, body = super::ErrorBody), + (status = 403, body = super::ErrorBody), + (status = 404, body = super::ErrorBody), + (status = 409, body = super::ErrorBody), + ) +)] +pub async fn put( + State(state): State, + session: Session, + ip: ClientIp, + Path(id): Path, + super::ApiJson(body): super::ApiJson, +) -> Result { + let caller = require_role(&session, Role::Operator).await?; + let site = state.db.sites().get(&id).await?; + validate(&body.username, &body.password)?; + require_settled(&site)?; + require_no_running_job(&state.db, &site.id).await?; + let hash = hash_blocking(&body.password).await?; + let row = state + .db + .basic_auths() + .set(&site.id, &body.username, &hash, state.clock.now()) + .await?; + let job = enqueue_route(&state, &site).await?; + audit( + &state.db, + Some(&caller.id), + "site.basic_auth_set", + Some(&site.id), + ip.as_deref(), + ) + .await; + Ok(( + StatusCode::ACCEPTED, + Json(BasicAuthJobResponse { + basic_auth: Some(out(&row)), + job, + }), + )) +} + +/// Remove the site's basic auth (operator and above); 404 when none. +#[utoipa::path( + delete, + path = "/api/v1/sites/{id}/basic-auth", + params(("id" = String, Path, description = "Site id")), + responses( + (status = 202, body = BasicAuthJobResponse), + (status = 401, body = super::ErrorBody), + (status = 403, body = super::ErrorBody), + (status = 404, body = super::ErrorBody), + (status = 409, body = super::ErrorBody), + ) +)] +pub async fn delete( + State(state): State, + session: Session, + ip: ClientIp, + Path(id): Path, +) -> Result { + let caller = require_role(&session, Role::Operator).await?; + let site = state.db.sites().get(&id).await?; + require_settled(&site)?; + require_no_running_job(&state.db, &site.id).await?; + if !state.db.basic_auths().clear(&site.id).await? { + return Err(AppError::not_found("this site has no basic auth")); + } + let job = enqueue_route(&state, &site).await?; + audit( + &state.db, + Some(&caller.id), + "site.basic_auth_clear", + Some(&site.id), + ip.as_deref(), + ) + .await; + Ok(( + StatusCode::ACCEPTED, + Json(BasicAuthJobResponse { + basic_auth: None, + job, + }), + )) +} diff --git a/crates/api/src/rest/docs.rs b/crates/api/src/rest/docs.rs index 652b1c6..d862242 100644 --- a/crates/api/src/rest/docs.rs +++ b/crates/api/src/rest/docs.rs @@ -17,6 +17,7 @@ use super::auth::{LoginRequest, OperatorOut}; use super::backups::{ BackupJobResponse, BackupListOut, BackupOut, ResetGetOut, ResetOut, ResetPutRequest, }; +use super::basic_auth::{BasicAuthGetOut, BasicAuthJobResponse, BasicAuthOut, BasicAuthPutRequest}; use super::jobs::{AuditEntryOut, AuditListOut, AuditQuery, JobListOut, JobsQuery}; use super::operators::{CreateOperatorRequest, OperatorListOut, SetPasswordRequest}; use super::panel::SelfBackupOut; @@ -87,6 +88,9 @@ use super::{ErrorBody, ErrorDetail, HealthOut, ReadyOut}; super::backups::list, super::backups::create, super::backups::restore, + super::basic_auth::get, + super::basic_auth::put, + super::basic_auth::delete, super::backups::reset_get, super::backups::reset_put, super::backups::reset_delete, @@ -159,6 +163,10 @@ use super::{ErrorBody, ErrorDetail, HealthOut, ReadyOut}; EnvGetOut, EnvPutRequest, BackupOut, + BasicAuthGetOut, + BasicAuthJobResponse, + BasicAuthOut, + BasicAuthPutRequest, ResetGetOut, ResetOut, ResetPutRequest, diff --git a/crates/api/src/rest/mod.rs b/crates/api/src/rest/mod.rs index d368bb9..9c130d0 100644 --- a/crates/api/src/rest/mod.rs +++ b/crates/api/src/rest/mod.rs @@ -4,6 +4,7 @@ pub mod agent; pub mod apps; pub mod auth; pub mod backups; +pub mod basic_auth; pub mod docs; pub mod jobs; pub mod operators; @@ -108,6 +109,12 @@ pub fn router(state: AppState) -> Router { ) .route("/sites/{id}/backups/{bid}", delete(backups::delete)) .route("/sites/{id}/backups/{bid}/restore", post(backups::restore)) + .route( + "/sites/{id}/basic-auth", + get(basic_auth::get) + .put(basic_auth::put) + .delete(basic_auth::delete), + ) .route( "/sites/{id}/reset", get(backups::reset_get) diff --git a/crates/api/tests/sites.rs b/crates/api/tests/sites.rs index 406451e..7bd9a23 100644 --- a/crates/api/tests/sites.rs +++ b/crates/api/tests/sites.rs @@ -2127,3 +2127,99 @@ async fn a_reset_schedule_is_set_read_guarded_and_stopped() { let again = owner.delete(&url).send().await.expect("stop"); assert_eq!(again.status(), reqwest::StatusCode::NOT_FOUND); } + +#[tokio::test] +async fn basic_auth_is_set_hashed_sent_to_the_agent_guarded_and_removed() { + let tdb = TestDb::new().await; + let srv = TestServer::spawn(tdb, FakeAgent::new()).await; + let owner = srv.login_as_owner().await; + let viewer = srv.login_as("viewer").await; + let (site_id, _server_id) = running_site(&srv, &owner, "bauth").await; + let url = srv.url(&format!("/api/v1/sites/{site_id}/basic-auth")); + + let none: Value = viewer + .get(&url) + .send() + .await + .expect("get") + .json() + .await + .expect("json"); + assert_eq!(none["basic_auth"], Value::Null); + let denied = viewer + .put(&url) + .json(&json!({"username": "alice", "password": "correct-horse"})) + .send() + .await + .expect("put"); + assert_eq!(denied.status(), reqwest::StatusCode::FORBIDDEN); + for body in [ + json!({"username": "", "password": "correct-horse"}), + json!({"username": "a b", "password": "correct-horse"}), + json!({"username": "alice", "password": "short"}), + json!({"username": "alice", "password": "x".repeat(73)}), + json!({"username": "alice", "password": "with\u{7}bell-char"}), + ] { + let bad = owner.put(&url).json(&body).send().await.expect("put"); + assert_eq!(bad.status(), reqwest::StatusCode::BAD_REQUEST, "{body}"); + let text = bad.text().await.unwrap_or_default(); + assert!( + !text.contains("correct-horse") && !text.contains("with"), + "{text}" + ); + } + let gone = owner.delete(&url).send().await.expect("delete"); + assert_eq!( + gone.status(), + reqwest::StatusCode::NOT_FOUND, + "nothing to remove yet" + ); + + let set = owner + .put(&url) + .json(&json!({"username": "alice", "password": "correct-horse"})) + .send() + .await + .expect("put"); + assert_eq!(set.status(), reqwest::StatusCode::ACCEPTED); + let text = set.text().await.expect("body"); + assert!( + !text.contains("correct-horse") && !text.contains("$2"), + "{text}" + ); + let body: Value = serde_json::from_str(&text).expect("json"); + assert_eq!(body["basic_auth"]["username"], "alice"); + let job = body["job"]["id"].as_str().expect("job id").to_string(); + let done = poll_job(&srv, &owner, &job).await.expect("job"); + assert_eq!(done["status"], "done", "{done}"); + + let routes = srv.agent.commands_of("set_route"); + let last = routes.last().expect("a set_route command"); + let auth = &last["basic_auth"]; + assert_eq!(auth["username"], "alice"); + let hash = auth["password_hash"].as_str().expect("hash"); + assert!(hash.starts_with("$2b$") && hash.len() == 60, "{hash}"); + assert!(bcrypt::verify("correct-horse", hash).expect("verify")); + + let got: Value = viewer + .get(&url) + .send() + .await + .expect("get") + .json() + .await + .expect("json"); + assert_eq!(got["basic_auth"]["username"], "alice"); + assert!(!got.to_string().contains("$2"), "{got}"); + + let del = owner.delete(&url).send().await.expect("delete"); + assert_eq!(del.status(), reqwest::StatusCode::ACCEPTED); + let body: Value = del.json().await.expect("json"); + let job = body["job"]["id"].as_str().expect("job id").to_string(); + let done = poll_job(&srv, &owner, &job).await.expect("job"); + assert_eq!(done["status"], "done", "{done}"); + let routes = srv.agent.commands_of("set_route"); + assert!(routes.last().expect("route")["basic_auth"].is_null()); + let again = owner.delete(&url).send().await.expect("delete"); + assert_eq!(again.status(), reqwest::StatusCode::NOT_FOUND); +} diff --git a/crates/db/migrations/0005_site_basic_auth.sql b/crates/db/migrations/0005_site_basic_auth.sql new file mode 100644 index 0000000..92cee09 --- /dev/null +++ b/crates/db/migrations/0005_site_basic_auth.sql @@ -0,0 +1,16 @@ +-- Per-site HTTP basic auth (0.1.7): one user name and a bcrypt hash of the +-- password per site, enforced by Caddy in front of the app. The password +-- itself is never stored. Dropping the site drops the row. +CREATE TABLE site_basic_auth ( + site_id TEXT PRIMARY KEY REFERENCES sites (id) ON DELETE CASCADE, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- The route_update job (rewrites a site's Caddy route from the table above). +ALTER TABLE jobs DROP CONSTRAINT jobs_kind_check; +ALTER TABLE jobs ADD CONSTRAINT jobs_kind_check CHECK (kind IN + ('provision_site', 'update_site', 'backup_site', 'restore_site', + 'delete_site', 'delete_backup', 'env_update', 'restart_site', + 'stop_site', 'start_site', 'route_update')); diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 34e4416..5f76876 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -13,10 +13,10 @@ pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); pub use repo::{ AgentCommand, Allocation, App, AppSource, AppSources, AppVersion, Apps, Audit, AuditEntry, - Backup, Backups, Commands, CreateError, HeartbeatInput, HeartbeatOutcome, Job, Jobs, JoinError, - JoinInput, LatestMetric, Metrics, NewPlan, NewSite, Operator, Operators, Plan, Plans, Resets, - Server, ServerStatus, Servers, Session, Sessions, Settings, Site, SiteReset, Sites, WaitError, - NOTIFY_CHANNEL, SELF_BACKUP, SETUP_TOKEN_HASH, TLS_INTERNAL, + Backup, Backups, BasicAuths, Commands, CreateError, HeartbeatInput, HeartbeatOutcome, Job, + Jobs, JoinError, JoinInput, LatestMetric, Metrics, NewPlan, NewSite, Operator, Operators, Plan, + Plans, Resets, Server, ServerStatus, Servers, Session, Sessions, Settings, Site, SiteBasicAuth, + SiteReset, Sites, WaitError, NOTIFY_CHANNEL, SELF_BACKUP, SETUP_TOKEN_HASH, TLS_INTERNAL, }; pub use setu_common::{AppError, ErrorCode}; @@ -117,6 +117,10 @@ impl Db { AppSources::new(&self.pool) } + pub fn basic_auths(&self) -> BasicAuths<'_> { + BasicAuths::new(&self.pool) + } + pub fn resets(&self) -> Resets<'_> { Resets::new(&self.pool) } diff --git a/crates/db/src/repo/basic_auth.rs b/crates/db/src/repo/basic_auth.rs new file mode 100644 index 0000000..615ed93 --- /dev/null +++ b/crates/db/src/repo/basic_auth.rs @@ -0,0 +1,87 @@ +//! `site_basic_auth` repo (0.1.7): the HTTP basic-auth user name and +//! bcrypt hash of a site. The password is never stored. + +use chrono::{DateTime, Utc}; +use sqlx::PgPool; + +use crate::{map_db_error, AppError}; + +/// A site's basic-auth credentials (the hash, not the password). +#[derive(Clone, PartialEq, Eq)] +pub struct SiteBasicAuth { + pub site_id: String, + pub username: String, + pub password_hash: String, + pub updated_at: DateTime, +} + +// `Debug` never prints the hash. +impl std::fmt::Debug for SiteBasicAuth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SiteBasicAuth") + .field("site_id", &self.site_id) + .field("username", &self.username) + .field("password_hash", &"[redacted]") + .field("updated_at", &self.updated_at) + .finish() + } +} + +pub struct BasicAuths<'a> { + pool: &'a PgPool, +} + +impl<'a> BasicAuths<'a> { + pub fn new(pool: &'a PgPool) -> Self { + Self { pool } + } + + /// The site's credentials, if it has any. + pub async fn get(&self, site_id: &str) -> Result, AppError> { + sqlx::query_as!( + SiteBasicAuth, + "SELECT site_id, username, password_hash, updated_at FROM site_basic_auth + WHERE site_id = $1", + site_id, + ) + .fetch_optional(self.pool) + .await + .map_err(map_db_error) + } + + /// Sets (or replaces) the site's credentials. + pub async fn set( + &self, + site_id: &str, + username: &str, + password_hash: &str, + now: DateTime, + ) -> Result { + sqlx::query_as!( + SiteBasicAuth, + "INSERT INTO site_basic_auth (site_id, username, password_hash, updated_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (site_id) DO UPDATE + SET username = EXCLUDED.username, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at + RETURNING site_id, username, password_hash, updated_at", + site_id, + username, + password_hash, + now, + ) + .fetch_one(self.pool) + .await + .map_err(map_db_error) + } + + /// Removes the site's credentials; `false` when it had none. + pub async fn clear(&self, site_id: &str) -> Result { + let done = sqlx::query!("DELETE FROM site_basic_auth WHERE site_id = $1", site_id) + .execute(self.pool) + .await + .map_err(map_db_error)?; + Ok(done.rows_affected() > 0) + } +} diff --git a/crates/db/src/repo/jobs.rs b/crates/db/src/repo/jobs.rs index fbf7e9c..65b5472 100644 --- a/crates/db/src/repo/jobs.rs +++ b/crates/db/src/repo/jobs.rs @@ -24,6 +24,7 @@ pub const JOB_KINDS: &[&str] = &[ "restart_site", "stop_site", "start_site", + "route_update", ]; /// One unit of background work. `payload` may carry sealed values and diff --git a/crates/db/src/repo/mod.rs b/crates/db/src/repo/mod.rs index eba4514..f869b9a 100644 --- a/crates/db/src/repo/mod.rs +++ b/crates/db/src/repo/mod.rs @@ -2,6 +2,7 @@ pub mod app_sources; pub mod apps; pub mod audit; pub mod backups; +pub mod basic_auth; pub mod commands; pub mod jobs; pub mod metrics; @@ -17,6 +18,7 @@ pub use app_sources::{AppSource, AppSources}; pub use apps::{App, AppVersion, Apps}; pub use audit::{Audit, AuditEntry}; pub use backups::{Backup, Backups}; +pub use basic_auth::{BasicAuths, SiteBasicAuth}; pub use commands::{AgentCommand, Commands, WaitError, NOTIFY_CHANNEL}; pub use jobs::{Job, Jobs}; pub use metrics::{LatestMetric, Metrics}; diff --git a/crates/jobs/src/lib.rs b/crates/jobs/src/lib.rs index f8c400b..b367193 100644 --- a/crates/jobs/src/lib.rs +++ b/crates/jobs/src/lib.rs @@ -2,7 +2,7 @@ //! //! Each public `run` function drives one job kind to completion: //! [`provision`], [`update`], [`backup`], [`restore`], [`delete`], -//! [`delete_backup`], [`env_update`], [`lifecycle`], with [`runner`] +//! [`delete_backup`], [`env_update`], [`lifecycle`], [`route_update`], with [`runner`] //! claiming jobs from the Postgres queue. Every step runs through //! [`JobCtx::step`]: it stores the step as the job's cursor, then logs //! `step : ok` / `step : error ` (into `jobs.log` when @@ -36,6 +36,7 @@ pub mod env_update; pub mod lifecycle; pub mod provision; pub mod restore; +pub mod route_update; pub mod runner; pub mod scheduler; pub mod update; diff --git a/crates/jobs/src/provision.rs b/crates/jobs/src/provision.rs index 7e1877b..01846e5 100644 --- a/crates/jobs/src/provision.rs +++ b/crates/jobs/src/provision.rs @@ -154,6 +154,7 @@ async fn machine(ctx: &JobCtx, site: &Site, steps: &Steps) -> Result<(), (&'stat site_short_id: short.clone(), domain: site.domain.clone(), port: prep.spec.port, + basic_auth: None, } ); steps diff --git a/crates/jobs/src/route_update.rs b/crates/jobs/src/route_update.rs new file mode 100644 index 0000000..29c7592 --- /dev/null +++ b/crates/jobs/src/route_update.rs @@ -0,0 +1,45 @@ +//! `route_update` (0.1.7): the site's Caddy route is rewritten from the +//! stored basic-auth credentials (set, replaced or removed through +//! `PUT`/`DELETE /sites/{id}/basic-auth`). One agent step, `set_route`, +//! carrying the stored user name and bcrypt hash (never a password). The +//! site keeps its status: a failed route change leaves the old route in +//! place and fails only the job. + +use setu_common::AppError; +use setu_proto::{BasicAuth, Command}; + +use crate::JobCtx; + +/// The `SetRoute` command for a site from its stored credentials. +pub async fn route_command(ctx: &JobCtx, site_id: &str) -> Result { + let site = ctx.db.sites().get(site_id).await?; + let auth = ctx.db.basic_auths().get(site_id).await?; + let port = u16::try_from(site.port) + .map_err(|_| AppError::validation(format!("site {site_id} has an invalid port")))?; + Ok(Command::SetRoute { + site_short_id: site.short_id, + domain: site.domain, + port, + basic_auth: auth.map(|a| BasicAuth { + username: a.username, + password_hash: a.password_hash, + }), + }) +} + +/// Run the job for `site_id`. +pub async fn run(ctx: &JobCtx, site_id: &str) -> Result<(), AppError> { + let site = ctx.db.sites().get(site_id).await?; + if site.status != "running" && site.status != "stopped" { + return Err(AppError::validation(format!( + "site {site_id} cannot change its route from status {}", + site.status + ))); + } + ctx.step("set_route", async { + let cmd = route_command(ctx, site_id).await?; + ctx.agent_ok(&site.server_id, cmd).await.map(drop) + }) + .await?; + ctx.step("finish", async { Ok(()) }).await +} diff --git a/crates/jobs/src/runner.rs b/crates/jobs/src/runner.rs index c980a91..34bd8f9 100644 --- a/crates/jobs/src/runner.rs +++ b/crates/jobs/src/runner.rs @@ -229,6 +229,7 @@ impl Worker { let changes = open_changes(&self.sealer, &job.payload)?; crate::env_update::run(&ctx, &site("env_update")?, &changes).await } + "route_update" => crate::route_update::run(&ctx, &site("route_update")?).await, kind @ ("restart_site" | "stop_site" | "start_site") => { let op = crate::lifecycle::Op::from_job_kind(kind).ok_or_else(|| { AppError::validation(format!("unknown lifecycle kind {kind}")) diff --git a/crates/jobs/tests/route_update.rs b/crates/jobs/tests/route_update.rs new file mode 100644 index 0000000..39b3fb1 --- /dev/null +++ b/crates/jobs/tests/route_update.rs @@ -0,0 +1,64 @@ +// Test crate: a panic is the failure report (R146). +#![allow(clippy::unwrap_used, clippy::expect_used)] +//! `route_update` over [`FakeAgent`]: the route carries the stored +//! credentials, and a site that is not settled is refused. + +mod common; + +use common::{ctx_with_logs, manifest_toml, setup}; +use setu_jobs::{provision, route_update, testing::FakeAgent}; + +#[tokio::test] +async fn the_route_carries_the_stored_credentials_then_none() { + let (_test_db, fx) = setup(&manifest_toml("0.1.0")).await; + let agent = FakeAgent::new(); + let (ctx, logs) = ctx_with_logs(fx.db.clone(), &agent); + provision::run(&ctx, &fx.site_id).await.expect("provision"); + logs.lock().unwrap().clear(); + + let hash = format!("$2b$12${}", "a".repeat(53)); + fx.db + .basic_auths() + .set(&fx.site_id, "alice", &hash, chrono::Utc::now()) + .await + .unwrap(); + route_update::run(&ctx, &fx.site_id).await.expect("route"); + let last = agent.commands_of("set_route").pop().unwrap(); + assert_eq!(last["basic_auth"]["username"], "alice"); + assert_eq!(last["basic_auth"]["password_hash"], hash.as_str()); + assert_eq!( + *logs.lock().unwrap(), + ["step set_route: ok", "step finish: ok"].map(str::to_string) + ); + + assert!(fx.db.basic_auths().clear(&fx.site_id).await.unwrap()); + route_update::run(&ctx, &fx.site_id).await.expect("route"); + assert!(agent.commands_of("set_route").pop().unwrap()["basic_auth"].is_null()); +} + +#[tokio::test] +async fn an_agent_failure_fails_the_job_and_keeps_the_site_status() { + let (_test_db, fx) = setup(&manifest_toml("0.1.0")).await; + let agent = FakeAgent::new(); + let (ctx, _logs) = ctx_with_logs(fx.db.clone(), &agent); + provision::run(&ctx, &fx.site_id).await.expect("provision"); + agent.fail_next("set_route"); + route_update::run(&ctx, &fx.site_id) + .await + .expect_err("agent failed"); + assert_eq!( + fx.db.sites().get(&fx.site_id).await.unwrap().status, + "running" + ); +} + +#[tokio::test] +async fn a_site_that_is_not_settled_is_refused() { + let (_test_db, fx) = setup(&manifest_toml("0.1.0")).await; + let agent = FakeAgent::new(); + let (ctx, _logs) = ctx_with_logs(fx.db.clone(), &agent); + let err = route_update::run(&ctx, &fx.site_id) + .await + .expect_err("still provisioning"); + assert!(err.message.contains("cannot change its route"), "{err}"); +} diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index 3217344..4ea44ac 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -94,6 +94,23 @@ impl fmt::Debug for EnvVar { } } +/// HTTP basic-auth for one site: the user name and a bcrypt hash (never +/// the password). `Debug` redacts the hash. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct BasicAuth { + pub username: String, + pub password_hash: String, +} + +impl fmt::Debug for BasicAuth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BasicAuth") + .field("username", &self.username) + .field("password_hash", &"[redacted]") + .finish() + } +} + /// Everything the agent needs to materialise one site. No paths, no /// user, no env file: the agent derives them from `short_id` (spec ยง4). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] @@ -165,6 +182,9 @@ pub enum Command { site_short_id: String, domain: String, port: u16, + /// Absent (and not serialised) for a site without basic auth. + #[serde(default, skip_serializing_if = "Option::is_none")] + basic_auth: Option, }, RemoveRoute { site_short_id: String, @@ -556,6 +576,7 @@ mod tests { site_short_id: short(), domain: "example.com".to_string(), port: 20001, + basic_auth: None, }, "set_route", ), diff --git a/openapi.json b/openapi.json index 4c15ddb..f25a164 100644 --- a/openapi.json +++ b/openapi.json @@ -2570,6 +2570,218 @@ } } }, + "/api/v1/sites/{id}/basic-auth": { + "get": { + "tags": [ + "super::basic_auth" + ], + "summary": "The site's basic-auth user name (viewer and above).", + "operationId": "get", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Site id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasicAuthGetOut" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + } + }, + "put": { + "tags": [ + "super::basic_auth" + ], + "summary": "Set or replace the site's basic auth (operator and above); enqueues a\nroute update.", + "operationId": "put", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Site id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasicAuthPutRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasicAuthJobResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + } + }, + "delete": { + "tags": [ + "super::basic_auth" + ], + "summary": "Remove the site's basic auth (operator and above); 404 when none.", + "operationId": "delete", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Site id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasicAuthJobResponse" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + } + } + }, "/api/v1/sites/{id}/env": { "get": { "tags": [ @@ -3706,6 +3918,95 @@ } } }, + "BasicAuth": { + "type": "object", + "description": "HTTP basic-auth for one site: the user name and a bcrypt hash (never\nthe password). `Debug` redacts the hash.", + "required": [ + "username", + "password_hash" + ], + "properties": { + "password_hash": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "BasicAuthGetOut": { + "type": "object", + "description": "`GET /sites/{id}/basic-auth`: the setting, or `null` when the site is open.", + "properties": { + "basic_auth": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BasicAuthOut" + } + ] + } + } + }, + "BasicAuthJobResponse": { + "type": "object", + "description": "`PUT`/`DELETE` result: the setting after the change and the route job.", + "required": [ + "job" + ], + "properties": { + "basic_auth": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BasicAuthOut" + } + ] + }, + "job": { + "$ref": "#/components/schemas/JobOut" + } + } + }, + "BasicAuthOut": { + "type": "object", + "description": "A site's basic-auth setting: the user name only.", + "required": [ + "username", + "updated_at" + ], + "properties": { + "updated_at": { + "type": "string", + "format": "date-time" + }, + "username": { + "type": "string" + } + } + }, + "BasicAuthPutRequest": { + "type": "object", + "description": "`PUT /sites/{id}/basic-auth` body.", + "required": [ + "username", + "password" + ], + "properties": { + "password": { + "type": "string", + "description": "8 to 72 bytes, no control characters. Never stored or returned." + }, + "username": { + "type": "string", + "description": "Letters, digits and `._-`, 1 to 64." + } + } + }, "BinaryFormat": { "type": "string", "description": "What a [`BinaryRef`] download is.", @@ -4015,6 +4316,17 @@ "kind" ], "properties": { + "basic_auth": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BasicAuth", + "description": "Absent (and not serialised) for a site without basic auth." + } + ] + }, "domain": { "type": "string" },