Skip to content
Merged
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
5 changes: 3 additions & 2 deletions projects/start-os/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
252 changes: 208 additions & 44 deletions shared-libs/crates/start-core/src/net/net_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,73 @@ fn ssl_vhost_public_v4<'a>(
.collect()
}

/// 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<Item = &'a HostnameInfo>,
) -> BTreeSet<IpAddr> {
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,
}
}

/// 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<VHostKey, ProxyTarget>,
enabled_addresses: &BTreeSet<&HostnameInfo>,
assigned_ssl_port: u16,
net_ifaces: &OrdMap<GatewayId, NetworkInterfaceInfo>,
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<GatewayId> = 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<Item = &'a HostnameInfo>,
Expand Down Expand Up @@ -518,40 +585,32 @@ 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. 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;
}
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 {
continue;
};
let key = (Some(domain.clone()), domain_ssl_port, addr_info.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: BTreeSet::new(),
// The public leg's alone, so a name served both ways
Comment thread
dr-bonez marked this conversation as resolved.
// 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
.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())
},
Expand All @@ -567,28 +626,8 @@ impl NetServiceData {
alpn: alpn.clone(),
passthrough,
preserve_source_ip: passthrough,
});
if addr_info.public {
// A public domain is dual-stack (A + AAAA): public on its
// gateways' bare IPv4 and on each of their GUAs.
let gws: BTreeSet<GatewayId> =
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
Expand Down Expand Up @@ -1645,6 +1684,131 @@ 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::<IpAddr>().unwrap()])
);
}

#[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<VHostKey, ProxyTarget>, name: &str, public| {
let ProxyTarget { private, .. } =
&vhosts[&(Some(InternedString::intern(name)), 49443, public)];
private.contains(&"10.0.3.1".parse::<IpAddr>().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"));
Expand Down
Loading