From 1250103a91abb615132247925f48d5217cc7d696 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Mon, 21 Sep 2026 17:20:39 -0600 Subject: [PATCH 1/2] fix(net): answer TLS for the names a plugin exports 0ff264313 made the vhost lookup exact: a name is served only if it has an entry of its own. It registered mDNS names as entries to match, but the loop that decides which names a host answers to still admitted only PublicDomain, PrivateDomain and Mdns. A hostname a URL plugin exported never got an entry, and the fallback to the port's `*` entry that used to cover it was gone, so every SSL .onion on an OS-terminated binding ended its handshake in `unrecognized name`. 0.4.0.1 still has the fallback and is unaffected. A plugin's name is served on the binding's assigned SSL port, since its provider forwards to the binding's bridge address whatever port the name advertises, and on the private leg whatever `public` the export carries. Its accept set is the binding's loopback and bridge SSL addresses alone: a plugin hostname has no gateways, and its provider connects from its own container. The regression never shipped, so the changelog amends the unreleased entry for the exact lookup rather than adding a Fixed line. --- projects/start-os/CHANGELOG.md | 5 +- .../start-core/src/net/net_controller.rs | 104 +++++++++++++++--- 2 files changed, 91 insertions(+), 18 deletions(-) diff --git a/projects/start-os/CHANGELOG.md b/projects/start-os/CHANGELOG.md index 2a38263616..44402466a0 100644 --- a/projects/start-os/CHANGELOG.md +++ b/projects/start-os/CHANGELOG.md @@ -427,8 +427,9 @@ for the detail behind its highlights. your dashboard, along with a certificate for that name signed by your server's Root CA. Logging in was never possible under those names, so the page could not be used for anything, but it should not have been reachable. Your server - now serves its `.local` address, the domains you have assigned to it, and - direct connections to its IP address. + now serves its `.local` address, the domains you have assigned to it, the + addresses a service such as Tor has added for it, and direct connections to + its IP address. - **Image upgrades verify their checksum again.** `upgrade` compared the image's blake3 hash only when it was given a second positional argument, which no diff --git a/shared-libs/crates/start-core/src/net/net_controller.rs b/shared-libs/crates/start-core/src/net/net_controller.rs index b3d7c29282..ed18c9e424 100644 --- a/shared-libs/crates/start-core/src/net/net_controller.rs +++ b/shared-libs/crates/start-core/src/net/net_controller.rs @@ -316,6 +316,30 @@ fn ssl_vhost_public_v4<'a>( } /// LAN addresses a binding's SSL `*` vhost answers on: its enabled SSL-port IPs. +/// Where a plugin's provider reaches this binding: it forwards from its own +/// container, over loopback or the bridge. +fn ssl_vhost_internal_ips<'a>( + enabled_addresses: impl IntoIterator, +) -> BTreeSet { + enabled_addresses + .into_iter() + .filter(|a| a.ssl && a.is_internal()) + .filter_map(|a| a.hostname.parse().ok()) + .collect() +} + +/// The `(port, is the public leg)` a name is served on. A plugin's name arrives +/// on the binding's own SSL port, whatever port the name advertises. +fn named_vhost_leg(addr_info: &HostnameInfo, assigned_ssl_port: u16) -> Option<(u16, bool)> { + match &addr_info.metadata { + HostnameMetadata::PublicDomain { .. } + | HostnameMetadata::PrivateDomain { .. } + | HostnameMetadata::Mdns { .. } => Some((addr_info.port?, addr_info.public)), + HostnameMetadata::Plugin { .. } => Some((assigned_ssl_port, false)), + _ => None, + } +} + fn ssl_vhost_private_ips<'a>( enabled_addresses: impl IntoIterator, ) -> BTreeSet { @@ -518,35 +542,36 @@ impl NetServiceData { // entry carries its own gateways, so a public domain accepts WAN // even where the bare IP is disabled. // - // The mDNS name is registered here like any other: the vhost - // controller serves a name only if it has an entry, and this is - // where the set of names a host answers to is decided. It is never - // public, so it contributes no upstream port map. + // The mDNS name and a plugin's names are registered here like any + // other: the vhost controller serves a name only if it has an + // entry, and this is where the set of names a host answers to is + // decided. Neither is public, so neither contributes an upstream + // port map. let passthrough = bind.options.add_ssl.is_none(); for addr_info in &enabled_addresses { if !addr_info.ssl { continue; } - match &addr_info.metadata { - HostnameMetadata::PublicDomain { .. } - | HostnameMetadata::PrivateDomain { .. } - | HostnameMetadata::Mdns { .. } => {} - _ => continue, - } - let domain = &addr_info.hostname; - let Some(domain_ssl_port) = addr_info.port else { + let Some((domain_ssl_port, public)) = + named_vhost_leg(addr_info, assigned_ssl_port) + else { continue; }; - let key = (Some(domain.clone()), domain_ssl_port, addr_info.public); + let domain = &addr_info.hostname; + let key = (Some(domain.clone()), domain_ssl_port, public); let target = vhosts.entry(key).or_insert_with(|| ProxyTarget { public_v4: BTreeSet::new(), public_v6: BTreeSet::new(), public_v6_gateways: BTreeSet::new(), - private: BTreeSet::new(), + private: if matches!(addr_info.metadata, HostnameMetadata::Plugin { .. }) { + ssl_vhost_internal_ips(enabled_addresses.iter().copied()) + } else { + BTreeSet::new() + }, // The public leg's alone, so a name served both ways // keeps its LAN side. A passthrough never intermediates // ACME — the backend is the ACME client. - acme: if passthrough || !addr_info.public { + acme: if passthrough || !public { None } else { host_addresses @@ -568,7 +593,7 @@ impl NetServiceData { passthrough, preserve_source_ip: passthrough, }); - if addr_info.public { + if public { // A public domain is dual-stack (A + AAAA): public on its // gateways' bare IPv4 and on each of their GUAs. let gws: BTreeSet = @@ -1645,6 +1670,53 @@ mod tests { ); } + #[test] + fn a_plugin_name_is_served_on_the_bindings_ssl_port_over_internal_ips_alone() { + let eth = GatewayId::from(InternedString::intern("eth0")); + let row = |host: &str, ssl, public, port, metadata| HostnameInfo { + ssl, + public, + hostname: InternedString::intern(host), + port: Some(port), + metadata, + }; + let ipv4 = || HostnameMetadata::Ipv4 { + gateway: eth.clone(), + }; + let onion = row( + "example.onion", + true, + true, + 443, + HostnameMetadata::Plugin { + package_id: "tor".parse().unwrap(), + remove_action: None, + overflow_actions: Vec::new(), + info: imbl_value::Value::Null, + }, + ); + let domain = row( + "example.com", + true, + true, + 443, + HostnameMetadata::PublicDomain { + gateway: eth.clone(), + }, + ); + let bridge = row("10.0.3.1", true, false, 49443, ipv4()); + let bridge_plain = row("10.0.3.1", false, false, 49080, ipv4()); + let lan = row("192.0.2.10", true, false, 49443, ipv4()); + + assert_eq!(named_vhost_leg(&onion, 49443), Some((49443, false))); + assert_eq!(named_vhost_leg(&domain, 49443), Some((443, true))); + assert_eq!(named_vhost_leg(&lan, 49443), None); + assert_eq!( + ssl_vhost_internal_ips([&onion, &bridge, &bridge_plain, &lan]), + BTreeSet::from(["10.0.3.1".parse::().unwrap()]) + ); + } + #[test] fn a_forwarded_port_admits_its_enabled_lan_ips_alone() { let eth = GatewayId::from(InternedString::intern("eth0")); From 832b8165b96a61e3bc456a0704ccbe2d7b0f7312 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Tue, 22 Sep 2026 13:47:15 -0600 Subject: [PATCH 2/2] fix(net): every named vhost accepts the bridge, including a name two rows share A plugin name that is also a private domain on the same port shares one vhost entry with it. The private-domain row sorts first and made the entry, so the plugin-only bridge accept set was never applied and the provider's connection was rejected. Every named entry now starts from the binding's loopback and bridge SSL addresses, whatever row makes it. The named-vhost assembly moves into add_named_vhosts so the shared-name case has a regression test, and the ssl_vhost_private_ips doc comment the previous commit separated from its function goes back. --- .../start-core/src/net/net_controller.rs | 180 +++++++++++++----- 1 file changed, 136 insertions(+), 44 deletions(-) diff --git a/shared-libs/crates/start-core/src/net/net_controller.rs b/shared-libs/crates/start-core/src/net/net_controller.rs index ed18c9e424..7d727522c7 100644 --- a/shared-libs/crates/start-core/src/net/net_controller.rs +++ b/shared-libs/crates/start-core/src/net/net_controller.rs @@ -315,9 +315,8 @@ fn ssl_vhost_public_v4<'a>( .collect() } -/// LAN addresses a binding's SSL `*` vhost answers on: its enabled SSL-port IPs. -/// Where a plugin's provider reaches this binding: it forwards from its own -/// container, over loopback or the bridge. +/// The loopback and bridge addresses of a binding's SSL port. Every name the +/// binding serves accepts them. fn ssl_vhost_internal_ips<'a>( enabled_addresses: impl IntoIterator, ) -> BTreeSet { @@ -340,6 +339,50 @@ fn named_vhost_leg(addr_info: &HostnameInfo, assigned_ssl_port: u16) -> Option<( } } +/// Adds an entry for each name the binding serves over TLS, or merges the +/// name's gateways into an entry another row already made. +fn add_named_vhosts( + vhosts: &mut BTreeMap, + enabled_addresses: &BTreeSet<&HostnameInfo>, + assigned_ssl_port: u16, + net_ifaces: &OrdMap, + new_target: impl Fn(&HostnameInfo, bool) -> ProxyTarget, +) { + let internal = ssl_vhost_internal_ips(enabled_addresses.iter().copied()); + for addr_info in enabled_addresses { + if !addr_info.ssl { + continue; + } + let Some((port, public)) = named_vhost_leg(addr_info, assigned_ssl_port) else { + continue; + }; + let key = (Some(addr_info.hostname.clone()), port, public); + let ProxyTarget { + public_v4, + public_v6, + private: accepts, + .. + } = vhosts.entry(key).or_insert_with(|| ProxyTarget { + private: internal.clone(), + ..new_target(addr_info, public) + }); + if public { + // A public domain is dual-stack (A + AAAA): public on its + // gateways' bare IPv4 and on each of their GUAs. + let gws: BTreeSet = addr_info.metadata.gateways().cloned().collect(); + public_v4.extend(gws.iter().cloned()); + public_v6.extend(crate::net::utils::gua_ips(net_ifaces, &gws)); + } else { + for gw in addr_info.metadata.gateways() { + if let Some(ip_info) = net_ifaces.get(gw).and_then(|i| i.ip_info.as_ref()) { + accepts.extend(ip_info.subnets.iter().map(|s| s.addr())); + } + } + } + } +} + +/// LAN addresses a binding's SSL `*` vhost answers on: its enabled SSL-port IPs. fn ssl_vhost_private_ips<'a>( enabled_addresses: impl IntoIterator, ) -> BTreeSet { @@ -546,28 +589,19 @@ impl NetServiceData { // other: the vhost controller serves a name only if it has an // entry, and this is where the set of names a host answers to is // decided. Neither is public, so neither contributes an upstream - // port map. + // port map. Every name accepts the bridge, where other services + // and a plugin's provider reach it. let passthrough = bind.options.add_ssl.is_none(); - for addr_info in &enabled_addresses { - if !addr_info.ssl { - continue; - } - let Some((domain_ssl_port, public)) = - named_vhost_leg(addr_info, assigned_ssl_port) - else { - continue; - }; - let domain = &addr_info.hostname; - let key = (Some(domain.clone()), domain_ssl_port, public); - let target = vhosts.entry(key).or_insert_with(|| ProxyTarget { + add_named_vhosts( + &mut vhosts, + &enabled_addresses, + assigned_ssl_port, + &net_ifaces, + |addr_info, public| ProxyTarget { public_v4: BTreeSet::new(), public_v6: BTreeSet::new(), public_v6_gateways: BTreeSet::new(), - private: if matches!(addr_info.metadata, HostnameMetadata::Plugin { .. }) { - ssl_vhost_internal_ips(enabled_addresses.iter().copied()) - } else { - BTreeSet::new() - }, + private: BTreeSet::new(), // The public leg's alone, so a name served both ways // keeps its LAN side. A passthrough never intermediates // ACME — the backend is the ACME client. @@ -576,7 +610,7 @@ impl NetServiceData { } else { host_addresses .iter() - .find(|a| a.address == *domain) + .find(|a| a.address == addr_info.hostname) .and_then(|a| a.public.as_ref()) .and_then(|p| p.acme.clone()) }, @@ -592,28 +626,8 @@ impl NetServiceData { alpn: alpn.clone(), passthrough, preserve_source_ip: passthrough, - }); - if public { - // A public domain is dual-stack (A + AAAA): public on its - // gateways' bare IPv4 and on each of their GUAs. - let gws: BTreeSet = - addr_info.metadata.gateways().cloned().collect(); - target.public_v4.extend(gws.iter().cloned()); - target - .public_v6 - .extend(crate::net::utils::gua_ips(&net_ifaces, &gws)); - } else { - for gw in addr_info.metadata.gateways() { - if let Some(info) = net_ifaces.get(gw) { - if let Some(ip_info) = &info.ip_info { - for subnet in &ip_info.subnets { - target.private.insert(subnet.addr()); - } - } - } - } - } - } + }, + ); } // Direct forward — the plaintext port, the only external port with @@ -1717,6 +1731,84 @@ mod tests { ); } + #[test] + fn every_named_vhost_accepts_the_bridge_even_when_names_share_an_entry() { + let eth = GatewayId::from(InternedString::intern("eth0")); + let row = |host: &str, public, port, metadata| HostnameInfo { + ssl: true, + public, + hostname: InternedString::intern(host), + port: Some(port), + metadata, + }; + let plugin = || HostnameMetadata::Plugin { + package_id: "tor".parse().unwrap(), + remove_action: None, + overflow_actions: Vec::new(), + info: imbl_value::Value::Null, + }; + let private_domain = || HostnameMetadata::PrivateDomain { + gateways: BTreeSet::from([eth.clone()]), + }; + let bridge = row( + "10.0.3.1", + false, + 49443, + HostnameMetadata::Ipv4 { + gateway: eth.clone(), + }, + ); + let named = |rows: &[&HostnameInfo]| { + let enabled: BTreeSet<&HostnameInfo> = rows.iter().copied().chain([&bridge]).collect(); + let mut vhosts = BTreeMap::new(); + add_named_vhosts(&mut vhosts, &enabled, 49443, &OrdMap::new(), |_, _| { + ProxyTarget { + public_v4: BTreeSet::new(), + public_v6: BTreeSet::new(), + public_v6_gateways: BTreeSet::new(), + private: BTreeSet::new(), + acme: None, + addr: "10.0.3.2:443".parse().unwrap(), + addr_v6: None, + add_x_forwarded_headers: false, + auth: None, + connect_ssl: None, + alpn: None, + passthrough: false, + preserve_source_ip: false, + } + }); + vhosts + }; + let accepts = |vhosts: &BTreeMap, name: &str, public| { + let ProxyTarget { private, .. } = + &vhosts[&(Some(InternedString::intern(name)), 49443, public)]; + private.contains(&"10.0.3.1".parse::().unwrap()) + }; + + let onion = row("example.onion", true, 443, plugin()); + let shared_onion = row("box.example", true, 443, plugin()); + let shared_domain = row("box.example", false, 49443, private_domain()); + let private = row("lan.example", false, 49443, private_domain()); + let public = row( + "www.example", + true, + 49443, + HostnameMetadata::PublicDomain { + gateway: eth.clone(), + }, + ); + + assert!(accepts(&named(&[&onion]), "example.onion", false)); + assert!(accepts(&named(&[&private]), "lan.example", false)); + assert!(accepts(&named(&[&public]), "www.example", true)); + assert!(accepts( + &named(&[&shared_domain, &shared_onion]), + "box.example", + false + )); + } + #[test] fn a_forwarded_port_admits_its_enabled_lan_ips_alone() { let eth = GatewayId::from(InternedString::intern("eth0"));