feat: add mdns hostname metadata variant and fix vhost routing

- Add HostnameMetadata::Mdns variant to distinguish mDNS from private domains
- Mark mDNS addresses as private (public: false) since mDNS is local-only
- Fall back to null SNI entry when hostname not found in vhost mapping
- Simplify public detection in ProxyTarget filter
- Pass hostname to update_addresses for mDNS domain name generation
This commit is contained in:
Aiden McClelland
2026-02-14 15:34:48 -07:00
parent 098d9275f4
commit 3a63f3b840
16 changed files with 105 additions and 45 deletions

View File

@@ -16,16 +16,16 @@ The container runtime communicates with the host via JSON-RPC over Unix socket.
## `/media/startos/` Directory (mounted by host into container) ## `/media/startos/` Directory (mounted by host into container)
| Path | Description | | Path | Description |
|------|-------------| | -------------------- | ----------------------------------------------------- |
| `volumes/<name>/` | Package data volumes (id-mapped, persistent) | | `volumes/<name>/` | Package data volumes (id-mapped, persistent) |
| `assets/` | Read-only assets from s9pk `assets.squashfs` | | `assets/` | Read-only assets from s9pk `assets.squashfs` |
| `images/<name>/` | Container images (squashfs, used for subcontainers) | | `images/<name>/` | Container images (squashfs, used for subcontainers) |
| `images/<name>.env` | Environment variables for image | | `images/<name>.env` | Environment variables for image |
| `images/<name>.json` | Image metadata | | `images/<name>.json` | Image metadata |
| `backup/` | Backup mount point (mounted during backup operations) | | `backup/` | Backup mount point (mounted during backup operations) |
| `rpc/service.sock` | RPC socket (container runtime listens here) | | `rpc/service.sock` | RPC socket (container runtime listens here) |
| `rpc/host.sock` | Host RPC socket (for effects callbacks to host) | | `rpc/host.sock` | Host RPC socket (for effects callbacks to host) |
## S9PK Structure ## S9PK Structure

View File

@@ -89,8 +89,8 @@ export class DockerProcedureContainer extends Drop {
`${packageId}.embassy`, `${packageId}.embassy`,
...new Set( ...new Set(
Object.values(hostInfo?.bindings || {}) Object.values(hostInfo?.bindings || {})
.flatMap((b) => b.addresses.possible) .flatMap((b) => b.addresses.available)
.map((h) => h.hostname.value), .map((h) => h.host),
).values(), ).values(),
] ]
const certChain = await effects.getSslCertificate({ const certChain = await effects.getSslCertificate({

View File

@@ -1245,7 +1245,7 @@ async function updateConfig(
: catchFn( : catchFn(
() => () =>
filled.addressInfo!.filter({ kind: "mdns" })!.hostnames[0] filled.addressInfo!.filter({ kind: "mdns" })!.hostnames[0]
.hostname.value, .host,
) || "" ) || ""
mutConfigValue[key] = url mutConfigValue[key] = url
} }

View File

@@ -1005,9 +1005,10 @@ impl NetworkInterfaceController {
.as_network_mut() .as_network_mut()
.as_gateways_mut() .as_gateways_mut()
.ser(info)?; .ser(info)?;
let hostname = crate::hostname::Hostname(db.as_public().as_server_info().as_hostname().de()?);
let ports = db.as_private().as_available_ports().de()?; let ports = db.as_private().as_available_ports().de()?;
for host in all_hosts(db) { for host in all_hosts(db) {
host?.update_addresses(info, &ports)?; host?.update_addresses(&hostname, info, &ports)?;
} }
Ok(()) Ok(())
}) })

View File

@@ -10,6 +10,7 @@ use ts_rs::TS;
use crate::GatewayId; use crate::GatewayId;
use crate::context::{CliContext, RpcContext}; use crate::context::{CliContext, RpcContext};
use crate::db::model::DatabaseModel; use crate::db::model::DatabaseModel;
use crate::hostname::Hostname;
use crate::net::acme::AcmeProvider; use crate::net::acme::AcmeProvider;
use crate::net::host::{HostApiKind, all_hosts}; use crate::net::host::{HostApiKind, all_hosts};
use crate::prelude::*; use crate::prelude::*;
@@ -194,9 +195,10 @@ pub async fn add_public_domain<Kind: HostApiKind>(
.as_public_domains_mut() .as_public_domains_mut()
.insert(&fqdn, &PublicDomainConfig { acme, gateway })?; .insert(&fqdn, &PublicDomainConfig { acme, gateway })?;
handle_duplicates(db)?; handle_duplicates(db)?;
let hostname = Hostname(db.as_public().as_server_info().as_hostname().de()?);
let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?; let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?;
let ports = db.as_private().as_available_ports().de()?; let ports = db.as_private().as_available_ports().de()?;
Kind::host_for(&inheritance, db)?.update_addresses(&gateways, &ports) Kind::host_for(&inheritance, db)?.update_addresses(&hostname, &gateways, &ports)
}) })
.await .await
.result?; .result?;
@@ -225,9 +227,10 @@ pub async fn remove_public_domain<Kind: HostApiKind>(
Kind::host_for(&inheritance, db)? Kind::host_for(&inheritance, db)?
.as_public_domains_mut() .as_public_domains_mut()
.remove(&fqdn)?; .remove(&fqdn)?;
let hostname = Hostname(db.as_public().as_server_info().as_hostname().de()?);
let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?; let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?;
let ports = db.as_private().as_available_ports().de()?; let ports = db.as_private().as_available_ports().de()?;
Kind::host_for(&inheritance, db)?.update_addresses(&gateways, &ports) Kind::host_for(&inheritance, db)?.update_addresses(&hostname, &gateways, &ports)
}) })
.await .await
.result?; .result?;
@@ -255,9 +258,10 @@ pub async fn add_private_domain<Kind: HostApiKind>(
.upsert(&fqdn, || Ok(BTreeSet::new()))? .upsert(&fqdn, || Ok(BTreeSet::new()))?
.mutate(|d| Ok(d.insert(gateway)))?; .mutate(|d| Ok(d.insert(gateway)))?;
handle_duplicates(db)?; handle_duplicates(db)?;
let hostname = Hostname(db.as_public().as_server_info().as_hostname().de()?);
let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?; let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?;
let ports = db.as_private().as_available_ports().de()?; let ports = db.as_private().as_available_ports().de()?;
Kind::host_for(&inheritance, db)?.update_addresses(&gateways, &ports) Kind::host_for(&inheritance, db)?.update_addresses(&hostname, &gateways, &ports)
}) })
.await .await
.result?; .result?;
@@ -276,9 +280,10 @@ pub async fn remove_private_domain<Kind: HostApiKind>(
Kind::host_for(&inheritance, db)? Kind::host_for(&inheritance, db)?
.as_private_domains_mut() .as_private_domains_mut()
.mutate(|d| Ok(d.remove(&domain)))?; .mutate(|d| Ok(d.remove(&domain)))?;
let hostname = Hostname(db.as_public().as_server_info().as_hostname().de()?);
let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?; let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?;
let ports = db.as_private().as_available_ports().de()?; let ports = db.as_private().as_available_ports().de()?;
Kind::host_for(&inheritance, db)?.update_addresses(&gateways, &ports) Kind::host_for(&inheritance, db)?.update_addresses(&hostname, &gateways, &ports)
}) })
.await .await
.result?; .result?;

View File

@@ -13,7 +13,8 @@ use ts_rs::TS;
use crate::context::RpcContext; use crate::context::RpcContext;
use crate::db::model::DatabaseModel; use crate::db::model::DatabaseModel;
use crate::db::model::public::NetworkInterfaceInfo; use crate::db::model::public::{NetworkInterfaceInfo, NetworkInterfaceType};
use crate::hostname::Hostname;
use crate::net::forward::AvailablePorts; use crate::net::forward::AvailablePorts;
use crate::net::host::address::{HostAddress, PublicDomainConfig, address_api}; use crate::net::host::address::{HostAddress, PublicDomainConfig, address_api};
use crate::net::host::binding::{BindInfo, BindOptions, Bindings, binding}; use crate::net::host::binding::{BindInfo, BindOptions, Bindings, binding};
@@ -66,10 +67,13 @@ impl Host {
impl Model<Host> { impl Model<Host> {
pub fn update_addresses( pub fn update_addresses(
&mut self, &mut self,
mdns: &Hostname,
gateways: &OrdMap<GatewayId, NetworkInterfaceInfo>, gateways: &OrdMap<GatewayId, NetworkInterfaceInfo>,
available_ports: &AvailablePorts, available_ports: &AvailablePorts,
) -> Result<(), Error> { ) -> Result<(), Error> {
let this = self.destructure_mut(); let this = self.destructure_mut();
// ips
for (_, bind) in this.bindings.as_entries_mut()? { for (_, bind) in this.bindings.as_entries_mut()? {
let net = bind.as_net().de()?; let net = bind.as_net().de()?;
let opt = bind.as_options().de()?; let opt = bind.as_options().de()?;
@@ -143,6 +147,46 @@ impl Model<Host> {
} }
} }
} }
// mdns
let mdns_host = mdns.local_domain_name();
let mdns_gateways: BTreeSet<GatewayId> = gateways
.iter()
.filter(|(_, g)| {
matches!(
g.ip_info.as_ref().and_then(|i| i.device_type),
Some(NetworkInterfaceType::Ethernet | NetworkInterfaceType::Wireless)
)
})
.map(|(id, _)| id.clone())
.collect();
if let Some(port) = net.assigned_port.filter(|_| {
opt.secure
.map_or(true, |s| !(s.ssl && opt.add_ssl.is_some()))
}) {
available.insert(HostnameInfo {
ssl: opt.secure.map_or(false, |s| s.ssl),
public: false,
host: mdns_host.clone(),
port: Some(port),
metadata: HostnameMetadata::Mdns {
gateways: mdns_gateways.clone(),
},
});
}
if let Some(mut port) = net.assigned_ssl_port {
available.insert(HostnameInfo {
ssl: true,
public: false,
host: mdns_host,
port: Some(port),
metadata: HostnameMetadata::Mdns {
gateways: mdns_gateways,
},
});
}
// public domains
for (domain, info) in this.public_domains.de()? { for (domain, info) in this.public_domains.de()? {
let metadata = HostnameMetadata::PublicDomain { let metadata = HostnameMetadata::PublicDomain {
gateway: info.gateway.clone(), gateway: info.gateway.clone(),
@@ -173,12 +217,14 @@ impl Model<Host> {
available.insert(HostnameInfo { available.insert(HostnameInfo {
ssl: true, ssl: true,
public: true, public: true,
host: domain.clone(), host: domain,
port: Some(port), port: Some(port),
metadata, metadata,
}); });
} }
} }
// private domains
for (domain, domain_gateways) in this.private_domains.de()? { for (domain, domain_gateways) in this.private_domains.de()? {
if let Some(port) = net.assigned_port.filter(|_| { if let Some(port) = net.assigned_port.filter(|_| {
opt.secure opt.secure
@@ -213,7 +259,7 @@ impl Model<Host> {
available.insert(HostnameInfo { available.insert(HostnameInfo {
ssl: true, ssl: true,
public: true, public: true,
host: domain.clone(), host: domain,
port: Some(port), port: Some(port),
metadata: HostnameMetadata::PrivateDomain { metadata: HostnameMetadata::PrivateDomain {
gateways: domain_gateways, gateways: domain_gateways,

View File

@@ -539,10 +539,11 @@ impl NetService {
.as_network() .as_network()
.as_gateways() .as_gateways()
.de()?; .de()?;
let hostname = Hostname(db.as_public().as_server_info().as_hostname().de()?);
let mut ports = db.as_private().as_available_ports().de()?; let mut ports = db.as_private().as_available_ports().de()?;
let host = host_for(db, pkg_id.as_ref(), &id)?; let host = host_for(db, pkg_id.as_ref(), &id)?;
host.add_binding(&mut ports, internal_port, options)?; host.add_binding(&mut ports, internal_port, options)?;
host.update_addresses(&gateways, &ports)?; host.update_addresses(&hostname, &gateways, &ports)?;
db.as_private_mut().as_available_ports_mut().ser(&ports)?; db.as_private_mut().as_available_ports_mut().ser(&ports)?;
Ok(()) Ok(())
}) })
@@ -563,6 +564,7 @@ impl NetService {
.as_network() .as_network()
.as_gateways() .as_gateways()
.de()?; .de()?;
let hostname = Hostname(db.as_public().as_server_info().as_hostname().de()?);
let ports = db.as_private().as_available_ports().de()?; let ports = db.as_private().as_available_ports().de()?;
if let Some(ref pkg_id) = pkg_id { if let Some(ref pkg_id) = pkg_id {
for (host_id, host) in db for (host_id, host) in db
@@ -584,7 +586,7 @@ impl NetService {
} }
Ok(()) Ok(())
})?; })?;
host.update_addresses(&gateways, &ports)?; host.update_addresses(&hostname, &gateways, &ports)?;
} }
} else { } else {
let host = db let host = db
@@ -603,7 +605,7 @@ impl NetService {
} }
Ok(()) Ok(())
})?; })?;
host.update_addresses(&gateways, &ports)?; host.update_addresses(&hostname, &gateways, &ports)?;
} }
Ok(()) Ok(())
}) })

View File

@@ -32,6 +32,9 @@ pub enum HostnameMetadata {
gateway: GatewayId, gateway: GatewayId,
scope_id: u32, scope_id: u32,
}, },
Mdns {
gateways: BTreeSet<GatewayId>,
},
PrivateDomain { PrivateDomain {
gateways: BTreeSet<GatewayId>, gateways: BTreeSet<GatewayId>,
}, },
@@ -67,7 +70,9 @@ impl HostnameMetadata {
Self::Ipv4 { gateway } Self::Ipv4 { gateway }
| Self::Ipv6 { gateway, .. } | Self::Ipv6 { gateway, .. }
| Self::PublicDomain { gateway } => Box::new(std::iter::once(gateway)), | Self::PublicDomain { gateway } => Box::new(std::iter::once(gateway)),
Self::PrivateDomain { gateways } => Box::new(gateways.iter()), Self::PrivateDomain { gateways } | Self::Mdns { gateways } => {
Box::new(gateways.iter())
}
Self::Plugin { .. } => Box::new(std::iter::empty()), Self::Plugin { .. } => Box::new(std::iter::empty()),
} }
} }

View File

@@ -175,13 +175,14 @@ pub async fn remove_tunnel(
ctx.db ctx.db
.mutate(|db| { .mutate(|db| {
let hostname = crate::hostname::Hostname(db.as_public().as_server_info().as_hostname().de()?);
let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?; let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?;
let ports = db.as_private().as_available_ports().de()?; let ports = db.as_private().as_available_ports().de()?;
for host in all_hosts(db) { for host in all_hosts(db) {
let host = host?; let host = host?;
host.as_public_domains_mut() host.as_public_domains_mut()
.mutate(|p| Ok(p.retain(|_, v| v.gateway != id)))?; .mutate(|p| Ok(p.retain(|_, v| v.gateway != id)))?;
host.update_addresses(&gateways, &ports)?; host.update_addresses(&hostname, &gateways, &ports)?;
} }
Ok(()) Ok(())
@@ -193,6 +194,7 @@ pub async fn remove_tunnel(
ctx.db ctx.db
.mutate(|db| { .mutate(|db| {
let hostname = crate::hostname::Hostname(db.as_public().as_server_info().as_hostname().de()?);
let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?; let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?;
let ports = db.as_private().as_available_ports().de()?; let ports = db.as_private().as_available_ports().de()?;
for host in all_hosts(db) { for host in all_hosts(db) {
@@ -204,7 +206,7 @@ pub async fn remove_tunnel(
d.retain(|_, gateways| !gateways.is_empty()); d.retain(|_, gateways| !gateways.is_empty());
Ok(()) Ok(())
})?; })?;
host.update_addresses(&gateways, &ports)?; host.update_addresses(&hostname, &gateways, &ports)?;
} }
Ok(()) Ok(())

View File

@@ -278,8 +278,7 @@ impl Accept for VHostBindListener {
cx: &mut std::task::Context<'_>, cx: &mut std::task::Context<'_>,
) -> Poll<Result<(Self::Metadata, AcceptStream), Error>> { ) -> Poll<Result<(Self::Metadata, AcceptStream), Error>> {
// Update listeners when ip_info or bind_reqs change // Update listeners when ip_info or bind_reqs change
while self.ip_info.poll_changed(cx).is_ready() while self.ip_info.poll_changed(cx).is_ready() || self.bind_reqs.poll_changed(cx).is_ready()
|| self.bind_reqs.poll_changed(cx).is_ready()
{ {
let reqs = self.bind_reqs.read_and_mark_seen(); let reqs = self.bind_reqs.read_and_mark_seen();
let listeners = &mut self.listeners; let listeners = &mut self.listeners;
@@ -506,10 +505,8 @@ where
}; };
let src = tcp.peer_addr.ip(); let src = tcp.peer_addr.ip();
// Public if: source is a gateway/router IP (NAT'd internet), // Public: source is outside all known subnets (direct internet)
// or source is outside all known subnets (direct internet) let is_public = !ip_info.subnets.iter().any(|s| s.contains(&src));
let is_public = ip_info.lan_ip.contains(&src)
|| !ip_info.subnets.iter().any(|s| s.contains(&src));
if is_public { if is_public {
self.public.contains(&gw.id) self.public.contains(&gw.id)
@@ -695,6 +692,7 @@ where
let (target, rc) = self.0.peek(|m| { let (target, rc) = self.0.peek(|m| {
m.get(&hello.server_name().map(InternedString::from)) m.get(&hello.server_name().map(InternedString::from))
.or_else(|| m.get(&None))
.into_iter() .into_iter()
.flatten() .flatten()
.filter(|(_, rc)| rc.strong_count() > 0) .filter(|(_, rc)| rc.strong_count() > 0)

View File

@@ -240,7 +240,7 @@ impl LocaleString {
pub fn localize(&mut self) { pub fn localize(&mut self) {
self.localize_for(&*rust_i18n::locale()); self.localize_for(&*rust_i18n::locale());
} }
pub fn localized(mut self) -> String { pub fn localized(self) -> String {
self.localized_for(&*rust_i18n::locale()) self.localized_for(&*rust_i18n::locale())
} }
} }

View File

@@ -5,6 +5,7 @@ import type { PackageId } from './PackageId'
export type HostnameMetadata = export type HostnameMetadata =
| { kind: 'ipv4'; gateway: GatewayId } | { kind: 'ipv4'; gateway: GatewayId }
| { kind: 'ipv6'; gateway: GatewayId; scopeId: number } | { kind: 'ipv6'; gateway: GatewayId; scopeId: number }
| { kind: 'mdns'; gateways: Array<GatewayId> }
| { kind: 'private-domain'; gateways: Array<GatewayId> } | { kind: 'private-domain'; gateways: Array<GatewayId> }
| { kind: 'public-domain'; gateway: GatewayId } | { kind: 'public-domain'; gateway: GatewayId }
| { kind: 'plugin'; package: PackageId } | { kind: 'plugin'; package: PackageId }

View File

@@ -49,7 +49,7 @@ type VisibilityFilter<V extends 'public' | 'private'> = V extends 'public'
: never : never
type KindFilter<K extends FilterKinds> = K extends 'mdns' type KindFilter<K extends FilterKinds> = K extends 'mdns'
? ?
| (HostnameInfo & { metadata: { kind: 'private-domain' } }) | (HostnameInfo & { metadata: { kind: 'mdns' } })
| KindFilter<Exclude<K, 'mdns'>> | KindFilter<Exclude<K, 'mdns'>>
: K extends 'domain' : K extends 'domain'
? ?
@@ -199,9 +199,7 @@ function filterRec(
hostnames = hostnames.filter( hostnames = hostnames.filter(
(h) => (h) =>
invert !== invert !==
((kind.has('mdns') && ((kind.has('mdns') && h.metadata.kind === 'mdns') ||
h.metadata.kind === 'private-domain' &&
h.host.endsWith('.local')) ||
(kind.has('domain') && (kind.has('domain') &&
(h.metadata.kind === 'private-domain' || (h.metadata.kind === 'private-domain' ||
h.metadata.kind === 'public-domain')) || h.metadata.kind === 'public-domain')) ||

View File

@@ -30,6 +30,7 @@ function getGatewayIds(h: T.HostnameInfo): string[] {
case 'ipv6': case 'ipv6':
case 'public-domain': case 'public-domain':
return [h.metadata.gateway] return [h.metadata.gateway]
case 'mdns':
case 'private-domain': case 'private-domain':
return h.metadata.gateways return h.metadata.gateways
case 'plugin': case 'plugin':
@@ -45,8 +46,10 @@ function getAddressType(h: T.HostnameInfo): string {
return 'IPv6' return 'IPv6'
case 'public-domain': case 'public-domain':
return 'Public Domain' return 'Public Domain'
case 'mdns':
return 'mDNS'
case 'private-domain': case 'private-domain':
return h.host.endsWith('.local') ? 'mDNS' : 'Private Domain' return 'Private Domain'
case 'plugin': case 'plugin':
return 'Plugin' return 'Plugin'
} }
@@ -84,8 +87,7 @@ export class InterfaceService {
const isDomain = const isDomain =
h.metadata.kind === 'private-domain' || h.metadata.kind === 'private-domain' ||
h.metadata.kind === 'public-domain' h.metadata.kind === 'public-domain'
const isMdns = const isMdns = h.metadata.kind === 'mdns'
h.metadata.kind === 'private-domain' && h.host.endsWith('.local')
const address: GatewayAddress = { const address: GatewayAddress = {
enabled, enabled,

View File

@@ -2137,7 +2137,7 @@ export namespace Mock {
host: 'adjective-noun.local', host: 'adjective-noun.local',
port: 1234, port: 1234,
metadata: { metadata: {
kind: 'private-domain', kind: 'mdns',
gateways: ['eth0', 'wlan0'], gateways: ['eth0', 'wlan0'],
}, },
}, },

View File

@@ -49,7 +49,7 @@ export const mockPatchData: DataModel = {
host: 'adjective-noun.local', host: 'adjective-noun.local',
port: 443, port: 443,
metadata: { metadata: {
kind: 'private-domain', kind: 'mdns',
gateways: ['eth0', 'wlan0'], gateways: ['eth0', 'wlan0'],
}, },
}, },
@@ -515,7 +515,7 @@ export const mockPatchData: DataModel = {
host: 'adjective-noun.local', host: 'adjective-noun.local',
port: 443, port: 443,
metadata: { metadata: {
kind: 'private-domain', kind: 'mdns',
gateways: ['eth0'], gateways: ['eth0'],
}, },
}, },
@@ -622,7 +622,7 @@ export const mockPatchData: DataModel = {
host: 'adjective-noun.local', host: 'adjective-noun.local',
port: 8332, port: 8332,
metadata: { metadata: {
kind: 'private-domain', kind: 'mdns',
gateways: ['eth0'], gateways: ['eth0'],
}, },
}, },