Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 64 additions & 6 deletions crates/agent/src/caddy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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-<short>-www`: the redirect route of a site's `www.` name.
pub fn site_www_route_id(short: &str) -> anyhow::Result<String> {
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<String> {
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.<domain>` answers `301` to `https://<domain>` 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<serde_json::Value> {
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:<port>` or `unix/<absolute path>` with a conservative
Expand Down Expand Up @@ -125,6 +171,8 @@ pub fn route_json(id: &str, domain: &str, upstream: &str) -> anyhow::Result<serd
pub trait CaddyAdmin: Send + Sync {
/// Create or replace the route `id` (`GET /id` → `PATCH` | `POST`).
async fn upsert_route(&self, id: &str, domain: &str, upstream: &str) -> 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.
Expand Down Expand Up @@ -241,11 +289,10 @@ impl<T: AdminTransport> CaddyClient<T> {
}
}

#[async_trait::async_trait]
impl<T: AdminTransport> CaddyAdmin for CaddyClient<T> {
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<T: AdminTransport> CaddyClient<T> {
/// `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 {
Expand All @@ -261,6 +308,17 @@ impl<T: AdminTransport> CaddyAdmin for CaddyClient<T> {
}
Ok(())
}
}

#[async_trait::async_trait]
impl<T: AdminTransport> CaddyAdmin for CaddyClient<T> {
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) {
Expand Down
34 changes: 31 additions & 3 deletions crates/agent/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
Expand All @@ -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}),
Expand Down
12 changes: 11 additions & 1 deletion crates/agent/src/reconcile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
3 changes: 3 additions & 0 deletions crates/agent/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ pub struct SiteRecord {
pub domain: Option<String>,
#[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)]
Expand Down
60 changes: 58 additions & 2 deletions crates/agent/tests/caddy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
12 changes: 11 additions & 1 deletion crates/agent/tests/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);

Expand Down
22 changes: 22 additions & 0 deletions crates/agent/tests/golden/route-setu-site-abcd1234-www.json
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading