From 341043e887af47e3bc3e4e6c735fd5cd2715e721 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:24:46 +0000 Subject: [PATCH 1/2] agent, proto, jobs: www redirect for apex domains An apex domain also serves www. with a 301 to https:// keeping the URI (own Caddy route setu-site--www, so Caddy gets its certificate). set_route carries www_redirect (default true, an older control plane's commands stay on); the agent records the opt-out in its state and reconcile rebuilds or removes the route. Goldens and tests for the route builder, the upsert and reconcile. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- CHANGELOG.md | 13 ++++ crates/agent/src/caddy.rs | 70 +++++++++++++++++-- crates/agent/src/dispatch.rs | 34 ++++++++- crates/agent/src/reconcile.rs | 12 +++- crates/agent/src/state.rs | 3 + crates/agent/tests/caddy.rs | 60 +++++++++++++++- crates/agent/tests/commands.rs | 12 +++- .../golden/route-setu-site-abcd1234-www.json | 22 ++++++ crates/agent/tests/reconcile.rs | 50 +++++++++++++ crates/agent/tests/state.rs | 1 + crates/jobs/src/provision.rs | 1 + crates/proto/src/lib.rs | 9 +++ 12 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 crates/agent/tests/golden/route-setu-site-abcd1234-www.json diff --git a/CHANGELOG.md b/CHANGELOG.md index a89c162..eccc70c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to Setu. Release notes are cut from the section matching the tag. +## Unreleased + +### Added + +- **`www.` redirect for apex domains.** A site on `example.com` also serves + `www.example.com`: the agent's Caddy config adds the `www` host (Caddy + fetches its certificate) with a `301` to `https://example.com{uri}`. The + agent understands an opt-out (`www_redirect: false` in `set_route`); the + control plane sends it on for every site for now, and the per-site setting + and the panel line are not wired yet. A subdomain or a `www.` name gets no + redirect (the apex check is a two-label rule plus `co.uk`-style second + levels, not the public suffix list). + ## 0.1.6 — 2026-10-23 ### Fixed diff --git a/crates/agent/src/caddy.rs b/crates/agent/src/caddy.rs index 405152e..78d8dd1 100644 --- a/crates/agent/src/caddy.rs +++ b/crates/agent/src/caddy.rs @@ -80,7 +80,53 @@ pub fn valid_route_id(id: &str) -> bool { id == PANEL_ROUTE_ID || id .strip_prefix("setu-site-") - .is_some_and(setu_common::is_short_id) + .is_some_and(|rest| setu_common::is_short_id(rest.strip_suffix("-www").unwrap_or(rest))) +} + +/// `setu-site--www`: the redirect route of a site's `www.` name. +pub fn site_www_route_id(short: &str) -> anyhow::Result { + Ok(format!("{}-www", site_route_id(short)?)) +} + +/// Second-level labels that act as a public suffix under a country code +/// (`example.co.uk`): a small fixed list, not the public suffix list. +const SECOND_LEVEL: &[&str] = &["co", "com", "org", "net", "gov", "ac", "edu"]; + +/// The `www.` name served beside `domain` when `domain` is an apex +/// (`example.com`, `example.co.uk`); `None` for a subdomain or a name +/// that already starts with `www.`. +pub fn www_name(domain: &str) -> Option { + let labels: Vec<&str> = domain.split('.').collect(); + let apex = match labels.as_slice() { + [name, tld] => !name.is_empty() && tld.len() >= 2, + [name, sld, cc] => { + !name.is_empty() && SECOND_LEVEL.contains(sld) && cc.len() == 2 && *name != "www" + } + _ => false, + }; + (apex && labels[0] != "www" && valid_domain(domain)).then(|| format!("www.{domain}")) +} + +/// The redirect route (golden, see `tests/golden/route-setu-site-*-www.json`): +/// `www.` answers `301` to `https://` keeping the URI. Naming +/// the host in a matcher is what makes Caddy get its certificate. +pub fn www_route_json(id: &str, domain: &str) -> anyhow::Result { + if !valid_route_id(id) || !id.ends_with("-www") { + anyhow::bail!("invalid route id {id:?}"); + } + let Some(www) = www_name(domain) else { + anyhow::bail!("{domain:?} has no www name"); + }; + Ok(serde_json::json!({ + "@id": id, + "match": [{ "host": [www] }], + "handle": [{ + "handler": "static_response", + "status_code": 301, + "headers": { "Location": [format!("https://{domain}{{http.request.uri}}")] }, + }], + "terminal": true, + })) } /// `127.0.0.1:` or `unix/` with a conservative @@ -125,6 +171,8 @@ pub fn route_json(id: &str, domain: &str, upstream: &str) -> anyhow::Result anyhow::Result<()>; + /// Create or replace the `www.` → apex redirect route `id`. + async fn upsert_www_route(&self, id: &str, domain: &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. @@ -241,11 +289,10 @@ 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)?; - let body = serde_json::to_vec(&route)?; +impl CaddyClient { + /// `GET /id` then `PATCH` (exists) or `POST` (missing) the route. + async fn put_route(&self, id: &str, route: &serde_json::Value) -> anyhow::Result<()> { + let body = serde_json::to_vec(route)?; let path = format!("/id/{id}"); let (status, resp) = self.transport.request("GET", &path, None).await?; match status { @@ -261,6 +308,17 @@ impl CaddyAdmin for CaddyClient { } Ok(()) } +} + +#[async_trait::async_trait] +impl CaddyAdmin for CaddyClient { + async fn upsert_route(&self, id: &str, domain: &str, upstream: &str) -> anyhow::Result<()> { + self.put_route(id, &route_json(id, domain, upstream)?).await + } + + async fn upsert_www_route(&self, id: &str, domain: &str) -> anyhow::Result<()> { + self.put_route(id, &www_route_json(id, domain)?).await + } async fn remove_route(&self, id: &str) -> anyhow::Result<()> { if !valid_route_id(id) { diff --git a/crates/agent/src/dispatch.rs b/crates/agent/src/dispatch.rs index 62636b3..dba8d7a 100644 --- a/crates/agent/src/dispatch.rs +++ b/crates/agent/src/dispatch.rs @@ -138,6 +138,7 @@ where site_short_id, domain, port, + www_redirect, } => { let id = match crate::caddy::site_route_id(site_short_id) { Ok(id) => id, @@ -149,13 +150,32 @@ where if let Err(e) = rt.update_state(site_short_id, |r| { r.domain = Some(domain.clone()); r.port = *port; + r.no_www = !*www_redirect; }) { return err(cmd_id, "set_route failed", e); } - match rt.caddy().upsert_route(&id, domain, &upstream).await { + if let Err(e) = rt.caddy().upsert_route(&id, domain, &upstream).await { + return err(cmd_id, "set_route failed", e); + } + // The `www.` name of an apex domain redirects to it; opting out + // (or a domain that has none) removes a redirect left from before. + let www_id = match crate::caddy::site_www_route_id(site_short_id) { + Ok(id) => id, + Err(e) => return err(cmd_id, "set_route failed", e), + }; + let www = if *www_redirect { + crate::caddy::www_name(domain) + } else { + None + }; + let applied = match &www { + Some(_) => rt.caddy().upsert_www_route(&www_id, domain).await, + None => rt.caddy().remove_route(&www_id).await, + }; + match applied { Ok(()) => ok( cmd_id, - serde_json::json!({"domain": domain, "route_id": id}), + serde_json::json!({"domain": domain, "route_id": id, "www": www}), "set_route ok", ), Err(e) => err(cmd_id, "set_route failed", e), @@ -176,7 +196,15 @@ where return err(cmd_id, "remove_route failed", e); } } - match rt.caddy().remove_route(&id).await { + let removed = match crate::caddy::site_www_route_id(site_short_id) { + Ok(www_id) => rt.caddy().remove_route(&www_id).await, + Err(e) => Err(e), + }; + let removed = match removed { + Ok(()) => rt.caddy().remove_route(&id).await, + Err(e) => Err(e), + }; + match removed { 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..11594ef 100644 --- a/crates/agent/src/reconcile.rs +++ b/crates/agent/src/reconcile.rs @@ -17,7 +17,9 @@ use std::collections::BTreeSet; use setu_proto::{InventoryReport, UnitState}; -use crate::caddy::{site_route_id, site_upstream, CaddyAdmin, PANEL_ROUTE_ID}; +use crate::caddy::{ + site_route_id, site_upstream, site_www_route_id, www_name, CaddyAdmin, PANEL_ROUTE_ID, +}; use crate::config::AgentConfig; use crate::helper::Verb; use crate::helper_client::Helper; @@ -65,6 +67,14 @@ where { tracing::warn!(route = %id, error = %e, "reconcile: cannot upsert route"); } + if let (false, Some(_), Ok(www_id)) = + (site.no_www, www_name(domain), site_www_route_id(short)) + { + wanted.insert(www_id.clone()); + if let Err(e) = caddy.upsert_www_route(&www_id, domain).await { + tracing::warn!(route = %www_id, error = %e, "reconcile: cannot upsert www route"); + } + } } if let Err(e) = caddy.move_route_first(PANEL_ROUTE_ID).await { tracing::warn!(error = %e, "reconcile: cannot move the panel route first"); diff --git a/crates/agent/src/state.rs b/crates/agent/src/state.rs index 785742f..c641d13 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, + /// The operator opted out of the `www.` → apex redirect. + #[serde(default)] + pub no_www: bool, #[serde(default)] pub version: String, #[serde(default)] diff --git a/crates/agent/tests/caddy.rs b/crates/agent/tests/caddy.rs index 2e25b81..aaf5ad8 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, site_route_id, site_upstream, site_www_route_id, valid_route_id, www_name, + www_route_json, CaddyAdmin, CaddySocket, FakeAdmin, FakeCaddy, PANEL_ROUTE_ID, ROUTES_PATH, }; const SHORT: &str = "abcd1234"; @@ -313,3 +313,59 @@ async fn caddy_socket_against_real_caddy_when_available() { vec![PANEL_ROUTE_ID.to_string()] ); } + +#[test] +fn www_name_exists_for_apex_domains_only() { + for (domain, want) in [ + ("example.com", Some("www.example.com")), + ("example.co.uk", Some("www.example.co.uk")), + ("www.example.com", None), + ("blog.example.com", None), + ("a.b.example.com", None), + ("localhost", None), + ("a.example.test", None), + ] { + assert_eq!(www_name(domain).as_deref(), want, "{domain}"); + } +} + +#[test] +fn the_www_route_is_a_301_to_the_apex_and_is_golden() { + let id = site_www_route_id(SHORT).expect("id"); + assert_eq!(id, "setu-site-abcd1234-www"); + assert!(valid_route_id(&id)); + assert!(!valid_route_id("setu-site-abcd1234-wwx")); + let route = www_route_json(&id, "example.test").expect("route"); + assert_eq!( + serde_json::to_string_pretty(&route).expect("json") + "\n", + include_str!("golden/route-setu-site-abcd1234-www.json") + ); + assert!( + www_route_json(&id, "a.example.test").is_err(), + "no www name" + ); + assert!(www_route_json("setu-site-abcd1234", "example.test").is_err()); +} + +#[tokio::test] +async fn the_www_route_is_upserted_beside_the_site_route() { + let caddy = FakeCaddy::new(); + let id = site_route_id(SHORT).expect("id"); + let www = site_www_route_id(SHORT).expect("id"); + caddy + .upsert_route(&id, "example.test", &site_upstream(20001)) + .await + .expect("site"); + caddy + .upsert_www_route(&www, "example.test") + .await + .expect("www"); + caddy + .upsert_www_route(&www, "example.test") + .await + .expect("again"); + let routes = caddy.admin().routes(); + assert_eq!(routes.len(), 2, "no duplicate: {routes:?}"); + caddy.remove_route(&www).await.expect("remove"); + assert_eq!(caddy.admin().routes().len(), 1); +} diff --git a/crates/agent/tests/commands.rs b/crates/agent/tests/commands.rs index 72eb154..483a013 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, + www_redirect: true, }, Command::Backup { site: s(), @@ -342,7 +343,14 @@ async fn every_command_kind_is_handled() { let inv: InventoryReport = serde_json::from_value(result.output.clone()).expect("inventory"); assert_eq!(inv.sites_on_disk, [short]); - assert_eq!(inv.routes, [format!("setu-site-{short}")]); + // example.com is an apex: its www redirect route is listed too. + assert_eq!( + inv.routes, + [ + format!("setu-site-{short}"), + format!("setu-site-{short}-www") + ] + ); assert_eq!(inv.databases, ["site_00000001"]); assert_eq!(inv.units.len(), 1); assert_eq!(inv.units[0].short_id, short); @@ -486,6 +494,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, + www_redirect: true, }; let r = setu_agent::execute("c1", &set, &rt).await; assert!(r.ok, "{:?}", r.error); @@ -506,6 +515,7 @@ async fn route_commands_key_off_the_site_short_id() { site_short_id: String::new(), domain: "b.example.test".to_string(), port: 20002, + www_redirect: true, }; assert!(!setu_agent::execute("c2", &empty_short, &rt).await.ok); diff --git a/crates/agent/tests/golden/route-setu-site-abcd1234-www.json b/crates/agent/tests/golden/route-setu-site-abcd1234-www.json new file mode 100644 index 0000000..d82e45c --- /dev/null +++ b/crates/agent/tests/golden/route-setu-site-abcd1234-www.json @@ -0,0 +1,22 @@ +{ + "@id": "setu-site-abcd1234-www", + "handle": [ + { + "handler": "static_response", + "headers": { + "Location": [ + "https://example.test{http.request.uri}" + ] + }, + "status_code": 301 + } + ], + "match": [ + { + "host": [ + "www.example.test" + ] + } + ], + "terminal": true +} diff --git a/crates/agent/tests/reconcile.rs b/crates/agent/tests/reconcile.rs index f57b1de..7dfec06 100644 --- a/crates/agent/tests/reconcile.rs +++ b/crates/agent/tests/reconcile.rs @@ -12,6 +12,7 @@ fn record(domain: Option<&str>, port: u16) -> SiteRecord { port, version: "1.0.0".into(), db: None, + no_www: false, } } @@ -186,3 +187,52 @@ async fn reconcile_keeps_panel_route_first() { "panel first, sites kept in order" ); } + +/// An apex domain gets its `www.` redirect rebuilt, an opted-out site and a +/// subdomain get none, and the redirect of a vanished site is removed. +#[tokio::test] +async fn reconcile_serves_the_www_redirect_of_apex_domains_only() { + 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 mut state = State::default(); + state + .sites + .insert("aaaaaaaa".into(), record(Some("example.test"), 20001)); + let mut off = record(Some("other.test"), 20002); + off.no_www = true; + state.sites.insert("bbbbbbbb".into(), off); + state + .sites + .insert("cccccccc".into(), record(Some("blog.example.test"), 20003)); + let caddy = FakeCaddy::new(); + // A redirect left by a site the state no longer has. + caddy + .upsert_www_route("setu-site-zzzzzzzz-www", "gone.test") + .await + .expect("seed"); + let (postgres, helper) = (FakePostgres::new(), FakeHelper::new()); + for _ in 0..2 { + setu_agent::reconcile::run(&state, &config, &helper, &caddy, &postgres) + .await + .expect("reconcile"); + let routes = caddy.admin().routes(); + let www = route(&routes, "setu-site-aaaaaaaa-www").expect("www route"); + assert_eq!(www["match"][0]["host"][0], "www.example.test"); + assert_eq!(www["handle"][0]["status_code"], 301); + assert!( + route(&routes, "setu-site-bbbbbbbb-www").is_none(), + "opted out" + ); + assert!( + route(&routes, "setu-site-cccccccc-www").is_none(), + "subdomain" + ); + assert!(route(&routes, "setu-site-zzzzzzzz-www").is_none(), "stale"); + assert_eq!(routes.len(), 4, "3 sites + 1 www: {routes:?}"); + } +} diff --git a/crates/agent/tests/state.rs b/crates/agent/tests/state.rs index 03465d7..09bd5ba 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()), + no_www: false, } }) .expect("site"); diff --git a/crates/jobs/src/provision.rs b/crates/jobs/src/provision.rs index 7e1877b..2410ee7 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, + www_redirect: true, } ); steps diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index 3217344..9bd32bd 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -165,6 +165,10 @@ pub enum Command { site_short_id: String, domain: String, port: u16, + /// Serve `www.` with a 301 to the apex (apex domains only). + /// Absent in commands queued by an older control plane: on. + #[serde(default = "default_true")] + www_redirect: bool, }, RemoveRoute { site_short_id: String, @@ -207,6 +211,10 @@ pub const DEFAULT_TIMEOUT: Duration = Duration::from_mins(2); /// Added to `startup_grace_s` for `health`. pub const HEALTH_SLACK: Duration = Duration::from_secs(30); +fn default_true() -> bool { + true +} + impl Command { /// The snake-case `kind` tag (the JSON discriminator). pub fn kind(&self) -> &'static str { @@ -556,6 +564,7 @@ mod tests { site_short_id: short(), domain: "example.com".to_string(), port: 20001, + www_redirect: true, }, "set_route", ), From 80e833d0939e6b5de18e0445c965293c0c309838 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:44:06 +0000 Subject: [PATCH 2/2] api: regenerate openapi.json for the set_route www_redirect field Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- openapi.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openapi.json b/openapi.json index 4c15ddb..1cd4de7 100644 --- a/openapi.json +++ b/openapi.json @@ -4031,6 +4031,10 @@ }, "site_short_id": { "type": "string" + }, + "www_redirect": { + "type": "boolean", + "description": "Serve `www.` with a 301 to the apex (apex domains only).\nAbsent in commands queued by an older control plane: on." } } },