From 2a3671581246663bba6d68b8dedbbd0be8e3a241 Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:15:36 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(net):=20IPv6=20GUA=20exposure=20tri-st?= =?UTF-8?q?ate=20(disabled/lan/lan-wan)=20=E2=80=94=20model=20+=20RPC=20+?= =?UTF-8?q?=20derivation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An IPv6 global-unicast address is a single globally-routable address, so a plain on/off toggle can't express LAN-only (firewalled) vs WAN-exposed. Give GUAs a tri-state instead, defaulting to LAN (behavior-preserving — GUAs are LAN-only source-filtered today). - GuaAccess { Disabled, Lan, LanWan } + gua_access map on DerivedAddressInfo - HostnameInfo::gua() (global-unicast detection), access_for/is_wan, GUA-aware enabled() - set-gua-access RPC (mirrors the removed set_range_gateway_access) + i18n x5 - net_controller derivation (SSL/vhost + non-SSL) classifies WAN via is_wan(), so a LAN+WAN GUA flows into the public set — vhost already binds the GUA and source-filters LAN per ProxyTarget::filter() - ts-bindings regenerated; model unit tests Follow-up commits (this PR): PCP-v6 pinhole, non-SSL v6 forwarding (lxcbr0 IPv6 + ip6 nft table), UI tri-state control, docs/CHANGELOG. --- .../crates/start-core/locales/i18n.yaml | 14 ++ .../crates/start-core/src/net/host/binding.rs | 188 +++++++++++++++++- .../start-core/src/net/net_controller.rs | 18 +- .../start-core/src/net/service_interface.rs | 26 ++- .../osBindings/BindingSetGuaAccessParams.ts | 8 + .../lib/osBindings/DerivedAddressInfo.ts | 6 + .../start-core/lib/osBindings/GuaAccess.ts | 20 ++ .../start-core/lib/osBindings/index.ts | 2 + 8 files changed, 266 insertions(+), 16 deletions(-) create mode 100644 shared-libs/ts-modules/start-core/lib/osBindings/BindingSetGuaAccessParams.ts create mode 100644 shared-libs/ts-modules/start-core/lib/osBindings/GuaAccess.ts diff --git a/shared-libs/crates/start-core/locales/i18n.yaml b/shared-libs/crates/start-core/locales/i18n.yaml index 224f606f7e..50cc88924c 100644 --- a/shared-libs/crates/start-core/locales/i18n.yaml +++ b/shared-libs/crates/start-core/locales/i18n.yaml @@ -2928,6 +2928,13 @@ help.arg.address: fr_FR: "Adresse réseau" pl_PL: "Adres sieciowy" +help.arg.gua-access: + en_US: "IPv6 GUA exposure: disabled, lan, or lan-wan" + de_DE: "IPv6-GUA-Freigabe: disabled, lan oder lan-wan" + es_ES: "Exposición de GUA IPv6: disabled, lan o lan-wan" + fr_FR: "Exposition de la GUA IPv6 : disabled, lan ou lan-wan" + pl_PL: "Ekspozycja GUA IPv6: disabled, lan lub lan-wan" + help.arg.allow-model-mismatch: en_US: "Allow database model mismatch" de_DE: "Datenbankmodell-Abweichung erlauben" @@ -5962,6 +5969,13 @@ about.set-range-address-enabled-for-binding: fr_FR: "Définir une adresse de passerelle activée pour une liaison de plage de ports" pl_PL: "Ustaw adres bramy jako włączony dla powiązania zakresu portów" +about.set-gua-access-for-binding: + en_US: "Set the LAN / LAN+WAN exposure of an IPv6 global-unicast address for a binding" + de_DE: "LAN-/LAN+WAN-Freigabe einer globalen IPv6-Unicast-Adresse für eine Bindung festlegen" + es_ES: "Establecer la exposición LAN / LAN+WAN de una dirección unicast global IPv6 para un vínculo" + fr_FR: "Définir l'exposition LAN / LAN+WAN d'une adresse unicast globale IPv6 pour une liaison" + pl_PL: "Ustaw ekspozycję LAN / LAN+WAN globalnego adresu unicast IPv6 dla powiązania" + about.set-country: en_US: "Set the country" de_DE: "Das Land festlegen" diff --git a/shared-libs/crates/start-core/src/net/host/binding.rs b/shared-libs/crates/start-core/src/net/host/binding.rs index d981f38807..33598ed1bf 100644 --- a/shared-libs/crates/start-core/src/net/host/binding.rs +++ b/shared-libs/crates/start-core/src/net/host/binding.rs @@ -48,6 +48,44 @@ impl FromStr for BindId { } } +/// Per-GUA exposure chosen by the operator. Unlike every other address (an +/// on/off toggle), an IPv6 global-unicast address is a single globally-routable +/// address that can be reachable LAN-only or also from the WAN, so it carries a +/// tri-state: +/// +/// - `Disabled` — not bound / rejected. +/// - `Lan` (default) — reachable on-link, source-filtered to the gateway's +/// subnet (traffic from outside the subnet is rejected). No WAN exposure. +/// - `LanWan` — also reachable from the WAN; the host attempts an IPv6 PCP +/// pinhole on the gateway. +/// +/// Mirrors the WAN-opt-in posture of single-port public IPv4 (disabled by +/// default), but with an explicit LAN middle state because a GUA — unlike a +/// private LAN IP — is globally routable, so "on" must distinguish LAN-only +/// (firewalled) from WAN-exposed. +#[derive( + Clone, + Copy, + Debug, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Deserialize, + Serialize, + TS, + clap::ValueEnum, +)] +#[ts(export)] +#[serde(rename_all = "kebab-case")] +pub enum GuaAccess { + Disabled, + #[default] + Lan, + LanWan, +} + #[derive(Debug, Default, Clone, Deserialize, Serialize, TS, HasModel)] #[serde(rename_all = "camelCase")] #[ts(export)] @@ -57,14 +95,35 @@ pub struct DerivedAddressInfo { pub enabled: BTreeSet, /// User override: disable these addresses (only for domains and private IP & port) pub disabled: BTreeSet<(InternedString, u16)>, + /// User override: per-GUA exposure (only for IPv6 global-unicast addresses), + /// keyed by the GUA's `SocketAddr`. Absent ⇒ [`GuaAccess::Lan`]. + #[serde(default)] + pub gua_access: BTreeMap, /// COMPUTED: NetServiceData::update — all possible addresses for this binding pub available: BTreeSet, } impl DerivedAddressInfo { + /// Operator's exposure choice for an IPv6 GUA; untouched GUAs default to + /// [`GuaAccess::Lan`]. + pub fn access_for(&self, gua: SocketAddr) -> GuaAccess { + self.gua_access.get(&gua).copied().unwrap_or_default() + } + + /// True if `addr` should be exposed to the WAN (no source filter + an + /// upstream pinhole attempt): any `public` address, or a GUA the operator + /// set to [`GuaAccess::LanWan`]. + pub fn is_wan(&self, addr: &HostnameInfo) -> bool { + addr.public + || addr + .gua() + .map_or(false, |g| self.access_for(g) == GuaAccess::LanWan) + } + /// Returns addresses that are currently enabled after applying overrides. - /// Default: public IPs are disabled, everything else is enabled. - /// Explicit `enabled`/`disabled` overrides take precedence. + /// Default: public IPs are disabled, GUAs are LAN-only, everything else is + /// enabled. Explicit `enabled` / `disabled` / `gua_access` overrides take + /// precedence. pub fn enabled(&self) -> BTreeSet<&HostnameInfo> { self.available .iter() @@ -72,6 +131,9 @@ impl DerivedAddressInfo { if h.is_internal() { // lo / lxcbr0 are always reachable and never operator-disablable. true + } else if let Some(gua) = h.gua() { + // GUAs carry a tri-state: Disabled removes them, LAN/LAN+WAN keep them. + self.access_for(gua) != GuaAccess::Disabled } else if h.public && h.metadata.is_ip() { // Public IPs: disabled by default, explicitly enabled via SocketAddr h.to_socket_addr().map_or( @@ -468,6 +530,15 @@ pub fn binding() .with_about("about.set-range-address-enabled-for-binding") .with_call_remote::(), ) + .subcommand( + "set-gua-access", + from_fn_async(set_gua_access::) + .with_metadata("sync_db", Value::Bool(true)) + .with_inherited(Kind::inheritance) + .no_display() + .with_about("about.set-gua-access-for-binding") + .with_call_remote::(), + ) } pub async fn list_bindings( @@ -656,3 +727,116 @@ pub async fn set_range_address_enabled( Ok(()) } +#[derive(Deserialize, Serialize, Parser, TS)] +#[group(skip)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct BindingSetGuaAccessParams { + #[arg(help = "help.arg.internal-port")] + internal_port: u16, + #[arg(long, help = "help.arg.address")] + address: String, + #[arg(long, help = "help.arg.gua-access")] + access: GuaAccess, +} + +/// Set the Disabled / LAN / LAN+WAN exposure for a single IPv6 GUA on a binding. +/// `Lan` is the default, so setting `Lan` clears the entry. The GUA tri-state +/// only applies to single-port bindings — port ranges are IPv4-only. Errors if +/// `address` is not an IPv6 global-unicast address. +pub async fn set_gua_access( + ctx: RpcContext, + BindingSetGuaAccessParams { + internal_port, + address, + access, + }: BindingSetGuaAccessParams, + inheritance: Kind::Inheritance, +) -> Result<(), Error> { + let address: HostnameInfo = + serde_json::from_str(&address).with_kind(ErrorKind::Deserialization)?; + let gua = address.gua().ok_or_else(|| { + Error::new( + eyre!("address is not an IPv6 global-unicast address"), + ErrorKind::InvalidRequest, + ) + })?; + ctx.db + .mutate(|db| { + Kind::host_for(&inheritance, db)? + .as_bindings_mut() + .mutate(|b| { + let bind = b.get_mut(&internal_port).or_not_found(internal_port)?; + if access == GuaAccess::default() { + bind.addresses.gua_access.remove(&gua); + } else { + bind.addresses.gua_access.insert(gua, access); + } + Ok(()) + }) + }) + .await + .result?; + Ok(()) +} + + +#[cfg(test)] +mod test { + use super::*; + use crate::GatewayId; + use crate::net::service_interface::{HostnameInfo, HostnameMetadata}; + + fn ipv6_addr(host: &str, port: u16) -> HostnameInfo { + HostnameInfo { + ssl: true, + public: false, + hostname: InternedString::intern(host), + port: Some(port), + metadata: HostnameMetadata::Ipv6 { + gateway: GatewayId::from(InternedString::intern("eth0")), + scope_id: 0, + }, + } + } + + #[test] + fn gua_detection() { + assert!(ipv6_addr("2001:db8::1", 443).gua().is_some()); // global unicast + assert!(ipv6_addr("fd00::1", 443).gua().is_none()); // ULA + assert!(ipv6_addr("fe80::1", 443).gua().is_none()); // link-local + assert!(ipv6_addr("::1", 443).gua().is_none()); // loopback + // An IPv4 address is never a GUA, even at a global-looking string. + let v4 = HostnameInfo { + hostname: InternedString::intern("1.2.3.4"), + metadata: HostnameMetadata::Ipv4 { + gateway: GatewayId::from(InternedString::intern("eth0")), + }, + ..ipv6_addr("2001:db8::1", 443) + }; + assert!(v4.gua().is_none()); + } + + #[test] + fn gua_tristate_enabled_and_wan() { + let gua = ipv6_addr("2001:db8::1", 443); + let key = gua.gua().unwrap(); + let mut info = DerivedAddressInfo::default(); + info.available.insert(gua.clone()); + + // Default ⇒ LAN: reachable but not WAN-exposed. + assert_eq!(info.access_for(key), GuaAccess::Lan); + assert!(info.enabled().contains(&gua)); + assert!(!info.is_wan(&gua)); + + // LAN+WAN ⇒ reachable and WAN-exposed. + info.gua_access.insert(key, GuaAccess::LanWan); + assert!(info.enabled().contains(&gua)); + assert!(info.is_wan(&gua)); + + // Disabled ⇒ removed from the enabled set. + info.gua_access.insert(key, GuaAccess::Disabled); + assert!(!info.enabled().contains(&gua)); + assert!(!info.is_wan(&gua)); + } +} 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 9e26cdb1c2..93a65ce34a 100644 --- a/shared-libs/crates/start-core/src/net/net_controller.rs +++ b/shared-libs/crates/start-core/src/net/net_controller.rs @@ -364,19 +364,21 @@ impl NetServiceData { }; if let Some(assigned_ssl_port) = bind.net.assigned_ssl_port { - // Collect private IPs from enabled private addresses' gateways + // Collect private IPs from enabled LAN-only addresses' gateways + // (a GUA set to LAN+WAN is WAN, so it lands in the public set). let server_private_ips: BTreeSet = enabled_addresses .iter() - .filter(|a| !a.public) + .filter(|a| !bind.addresses.is_wan(a)) .flat_map(|a| a.metadata.gateways()) .filter_map(|gw| net_ifaces.get(gw).and_then(|info| info.ip_info.as_ref())) .flat_map(|ip_info| ip_info.subnets.iter().map(|s| s.addr())) .collect(); - // Collect public gateways from enabled public IP addresses + // Collect public gateways from enabled WAN IP addresses + // (public IPv4 WAN, or a GUA the operator set to LAN+WAN). let server_public_gateways: BTreeSet = enabled_addresses .iter() - .filter(|a| a.public && a.metadata.is_ip()) + .filter(|a| bind.addresses.is_wan(a) && a.metadata.is_ip()) .flat_map(|a| a.metadata.gateways()) .cloned() .collect(); @@ -455,15 +457,15 @@ impl NetServiceData { let external = bind.net.assigned_port.or_not_found("assigned lan port")?; let fwd_public: BTreeSet = enabled_addresses .iter() - .filter(|a| a.public) + .filter(|a| bind.addresses.is_wan(a)) .flat_map(|a| a.metadata.gateways()) .cloned() .collect(); // Declare which address makes each gateway public, so a stray // auto-port-map can be traced back to the exposure driving it. - for a in enabled_addresses.iter().filter(|a| a.public) { + for a in enabled_addresses.iter().filter(|a| bind.addresses.is_wan(a)) { tracing::debug!( - "port {external}: public address {} (ip={}) on gateway(s) {:?}", + "port {external}: WAN address {} (ip={}) on gateway(s) {:?}", a.hostname, a.metadata.is_ip(), a.metadata.gateways().collect::>(), @@ -471,7 +473,7 @@ impl NetServiceData { } let fwd_private: BTreeSet = enabled_addresses .iter() - .filter(|a| !a.public) + .filter(|a| !bind.addresses.is_wan(a)) .flat_map(|a| a.metadata.gateways()) .filter_map(|gw| net_ifaces.get(gw).and_then(|i| i.ip_info.as_ref())) .flat_map(|ip| ip.subnets.iter().map(|s| s.addr())) diff --git a/shared-libs/crates/start-core/src/net/service_interface.rs b/shared-libs/crates/start-core/src/net/service_interface.rs index 5f1b4e2640..eae62b701b 100644 --- a/shared-libs/crates/start-core/src/net/service_interface.rs +++ b/shared-libs/crates/start-core/src/net/service_interface.rs @@ -1,5 +1,5 @@ use std::collections::BTreeSet; -use std::net::SocketAddr; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use imbl_value::InternedString; use serde::{Deserialize, Serialize}; @@ -66,14 +66,28 @@ impl HostnameInfo { /// reach the service; they are never operator-disablable, and a binding with /// no exported interface is restricted to them. pub fn is_internal(&self) -> bool { - match self.hostname.parse::() { - Ok(std::net::IpAddr::V4(v4)) => { - v4.is_loopback() || v4 == std::net::Ipv4Addr::from(crate::HOST_IP) - } - Ok(std::net::IpAddr::V6(v6)) => v6.is_loopback(), + match self.hostname.parse::() { + Ok(IpAddr::V4(v4)) => v4.is_loopback() || v4 == Ipv4Addr::from(crate::HOST_IP), + Ok(IpAddr::V6(v6)) => v6.is_loopback(), Err(_) => false, } } + + /// If this address is an IPv6 **global unicast** address (a GUA — not + /// loopback / ULA / link-local), return it as a `SocketAddr`. GUAs are the + /// only addresses that carry the Disabled / LAN / LAN+WAN tri-state. + pub fn gua(&self) -> Option { + if !matches!(self.metadata, HostnameMetadata::Ipv6 { .. }) { + return None; + } + let IpAddr::V6(ip) = self.hostname.parse::().ok()? else { + return None; + }; + if crate::net::utils::ipv6_is_local(ip) { + return None; + } + Some(SocketAddr::new(IpAddr::V6(ip), self.port?)) + } } impl HostnameMetadata { diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/BindingSetGuaAccessParams.ts b/shared-libs/ts-modules/start-core/lib/osBindings/BindingSetGuaAccessParams.ts new file mode 100644 index 0000000000..72e766e00a --- /dev/null +++ b/shared-libs/ts-modules/start-core/lib/osBindings/BindingSetGuaAccessParams.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GuaAccess } from './GuaAccess' + +export type BindingSetGuaAccessParams = { + internalPort: number + address: string + access: GuaAccess +} diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/DerivedAddressInfo.ts b/shared-libs/ts-modules/start-core/lib/osBindings/DerivedAddressInfo.ts index 79fb49bda4..c391b399b8 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/DerivedAddressInfo.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/DerivedAddressInfo.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GuaAccess } from './GuaAccess' import type { HostnameInfo } from './HostnameInfo' export type DerivedAddressInfo = { @@ -10,6 +11,11 @@ export type DerivedAddressInfo = { * User override: disable these addresses (only for domains and private IP & port) */ disabled: Array<[string, number]> + /** + * User override: per-GUA exposure (only for IPv6 global-unicast addresses), + * keyed by the GUA's `SocketAddr`. Absent ⇒ [`GuaAccess::Lan`]. + */ + guaAccess: { [key: string]: GuaAccess } /** * COMPUTED: NetServiceData::update — all possible addresses for this binding */ diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/GuaAccess.ts b/shared-libs/ts-modules/start-core/lib/osBindings/GuaAccess.ts new file mode 100644 index 0000000000..9f4aafe02e --- /dev/null +++ b/shared-libs/ts-modules/start-core/lib/osBindings/GuaAccess.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Per-GUA exposure chosen by the operator. Unlike every other address (an + * on/off toggle), an IPv6 global-unicast address is a single globally-routable + * address that can be reachable LAN-only or also from the WAN, so it carries a + * tri-state: + * + * - `Disabled` — not bound / rejected. + * - `Lan` (default) — reachable on-link, source-filtered to the gateway's + * subnet (traffic from outside the subnet is rejected). No WAN exposure. + * - `LanWan` — also reachable from the WAN; the host attempts an IPv6 PCP + * pinhole on the gateway. + * + * Mirrors the WAN-opt-in posture of single-port public IPv4 (disabled by + * default), but with an explicit LAN middle state because a GUA — unlike a + * private LAN IP — is globally routable, so "on" must distinguish LAN-only + * (firewalled) from WAN-exposed. + */ +export type GuaAccess = 'disabled' | 'lan' | 'lan-wan' diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/index.ts b/shared-libs/ts-modules/start-core/lib/osBindings/index.ts index 84ce03bf3f..ba834a6fe3 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/index.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/index.ts @@ -49,6 +49,7 @@ export { BindParams } from './BindParams' export { BindRangeParams } from './BindRangeParams' export { BindingRanges } from './BindingRanges' export { BindingSetAddressEnabledParams } from './BindingSetAddressEnabledParams' +export { BindingSetGuaAccessParams } from './BindingSetGuaAccessParams' export { Bindings } from './Bindings' export { Blake3Commitment } from './Blake3Commitment' export { BlockDev } from './BlockDev' @@ -135,6 +136,7 @@ export { GetUsersParams } from './GetUsersParams' export { GigaBytes } from './GigaBytes' export { GitHash } from './GitHash' export { Governor } from './Governor' +export { GuaAccess } from './GuaAccess' export { Guid } from './Guid' export { HardwareRequirements } from './HardwareRequirements' export { HealthCheckId } from './HealthCheckId' From 5931cc770794263081f7f51ae844d83de8c1ebf5 Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:52:13 +0000 Subject: [PATCH 2/6] =?UTF-8?q?refactor(net):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20SocketAddrV6=20GUA=20key=20+=20CliFromJsonString=20?= =?UTF-8?q?param?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @dr-bonez's review on #3368: - `gua_access` keyed by `SocketAddrV6` (type-precise — GUAs are always v6); `HostnameInfo::gua()` returns `SocketAddrV6`. - New `CliFromJsonString`: a param that is structured `T` over the JSON-RPC wire (serde passthrough — no double-encoding) but a JSON-string arg on the CLI (clap `ValueParser` parses the JSON). Replaces the `address: String` + `serde_json::from_str` pattern in both `set_gua_access` and the inherited `set_address_enabled`. Pair with `#[ts(as = "HostnameInfo")]` so the binding shows the object. - UI: `set-address-enabled` now sends the `HostnameInfo` object instead of a JSON string (api.types, both toggle callers, mock). - ts-bindings regenerated. --- .../addresses/gateway/actions.component.ts | 5 +-- .../addresses/gateway/item.component.ts | 5 +-- .../web/ui/src/app/services/api/api.types.ts | 2 +- .../services/api/embassy-mock-api.service.ts | 3 +- .../crates/start-core/src/net/host/binding.rs | 25 ++++++------ .../start-core/src/net/service_interface.rs | 8 ++-- .../crates/start-core/src/util/serde.rs | 40 +++++++++++++++++++ .../BindingSetAddressEnabledParams.ts | 3 +- .../osBindings/BindingSetGuaAccessParams.ts | 3 +- .../lib/osBindings/DerivedAddressInfo.ts | 2 +- 10 files changed, 67 insertions(+), 29 deletions(-) diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/actions.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/actions.component.ts index ea6c6d783f..cf5495cd07 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/actions.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/actions.component.ts @@ -253,14 +253,13 @@ export class GatewayActionsComponent { if (!iface) return const enabled = !addr.enabled - const addressJson = JSON.stringify(addr.hostnameInfo) const loader = this.loader.open('Saving').subscribe() try { if (this.packageId()) { const params = { internalPort: iface.addressInfo.internalPort, - address: addressJson, + address: addr.hostnameInfo, enabled, package: this.packageId(), host: iface.addressInfo.hostId, @@ -275,7 +274,7 @@ export class GatewayActionsComponent { } else { await this.api.serverBindingSetAddressEnabled({ internalPort: 80, - address: addressJson, + address: addr.hostnameInfo, enabled, }) } diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/item.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/item.component.ts index 5797b9bf29..3a51cd808a 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/item.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/item.component.ts @@ -289,14 +289,13 @@ export class GatewayItemComponent { this.toggling.set(true) const enabled = !addr.enabled - const addressJson = JSON.stringify(addr.hostnameInfo) const loader = this.loader.open('Saving').subscribe() try { if (this.packageId()) { const params = { internalPort: iface.addressInfo.internalPort, - address: addressJson, + address: addr.hostnameInfo, enabled, package: this.packageId(), host: iface.addressInfo.hostId, @@ -311,7 +310,7 @@ export class GatewayItemComponent { } else { await this.api.serverBindingSetAddressEnabled({ internalPort: 80, - address: addressJson, + address: addr.hostnameInfo, enabled, }) } diff --git a/projects/start-os/web/ui/src/app/services/api/api.types.ts b/projects/start-os/web/ui/src/app/services/api/api.types.ts index 935e7678f9..edf4357422 100644 --- a/projects/start-os/web/ui/src/app/services/api/api.types.ts +++ b/projects/start-os/web/ui/src/app/services/api/api.types.ts @@ -27,7 +27,7 @@ export type FollowServerLogsReq = Omit export type ServerBindingSetAddressEnabledReq = { // server.host.binding.set-address-enabled internalPort: 80 - address: string // JSON-serialized HostnameInfo + address: T.HostnameInfo enabled: boolean | null // null = reset to default } diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts index 6aa4c72eef..e6106efb06 100644 --- a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts @@ -2109,10 +2109,9 @@ export class MockApiService extends ApiService { private mockSetAddressEnabled( basePath: string, - addressJson: string, + h: T.HostnameInfo, enabled: boolean | null, ): void { - const h: T.HostnameInfo = JSON.parse(addressJson) const isPublicIp = h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6') diff --git a/shared-libs/crates/start-core/src/net/host/binding.rs b/shared-libs/crates/start-core/src/net/host/binding.rs index 33598ed1bf..b515087087 100644 --- a/shared-libs/crates/start-core/src/net/host/binding.rs +++ b/shared-libs/crates/start-core/src/net/host/binding.rs @@ -1,5 +1,5 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::net::SocketAddr; +use std::net::{SocketAddr, SocketAddrV6}; use std::str::FromStr; use clap::Parser; @@ -20,7 +20,7 @@ use crate::net::service_interface::{ use crate::net::vhost::AlpnInfo; use crate::prelude::*; use crate::util::FromStrParser; -use crate::util::serde::{HandlerExtSerde, display_serializable}; +use crate::util::serde::{CliFromJsonString, HandlerExtSerde, display_serializable}; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)] #[ts(export)] @@ -96,9 +96,9 @@ pub struct DerivedAddressInfo { /// User override: disable these addresses (only for domains and private IP & port) pub disabled: BTreeSet<(InternedString, u16)>, /// User override: per-GUA exposure (only for IPv6 global-unicast addresses), - /// keyed by the GUA's `SocketAddr`. Absent ⇒ [`GuaAccess::Lan`]. + /// keyed by the GUA's `SocketAddrV6`. Absent ⇒ [`GuaAccess::Lan`]. #[serde(default)] - pub gua_access: BTreeMap, + pub gua_access: BTreeMap, /// COMPUTED: NetServiceData::update — all possible addresses for this binding pub available: BTreeSet, } @@ -106,7 +106,7 @@ pub struct DerivedAddressInfo { impl DerivedAddressInfo { /// Operator's exposure choice for an IPv6 GUA; untouched GUAs default to /// [`GuaAccess::Lan`]. - pub fn access_for(&self, gua: SocketAddr) -> GuaAccess { + pub fn access_for(&self, gua: SocketAddrV6) -> GuaAccess { self.gua_access.get(&gua).copied().unwrap_or_default() } @@ -559,7 +559,8 @@ pub struct BindingSetAddressEnabledParams { #[arg(help = "help.arg.internal-port")] internal_port: u16, #[arg(long, help = "help.arg.address")] - address: String, + #[ts(as = "HostnameInfo")] + address: CliFromJsonString, #[arg(long, help = "help.arg.binding-enabled")] enabled: Option, } @@ -670,8 +671,7 @@ pub async fn set_address_enabled( inheritance: Kind::Inheritance, ) -> Result<(), Error> { let enabled = enabled.unwrap_or(true); - let address: HostnameInfo = - serde_json::from_str(&address).with_kind(ErrorKind::Deserialization)?; + let address = address.0; if !enabled && address.is_internal() { return Err(Error::new( eyre!("loopback / bridge (internal) addresses cannot be disabled"), @@ -705,8 +705,7 @@ pub async fn set_range_address_enabled( inheritance: Kind::Inheritance, ) -> Result<(), Error> { let enabled = enabled.unwrap_or(true); - let address: HostnameInfo = - serde_json::from_str(&address).with_kind(ErrorKind::Deserialization)?; + let address = address.0; if !enabled && address.is_internal() { return Err(Error::new( eyre!("loopback / bridge (internal) addresses cannot be disabled"), @@ -735,7 +734,8 @@ pub struct BindingSetGuaAccessParams { #[arg(help = "help.arg.internal-port")] internal_port: u16, #[arg(long, help = "help.arg.address")] - address: String, + #[ts(as = "HostnameInfo")] + address: CliFromJsonString, #[arg(long, help = "help.arg.gua-access")] access: GuaAccess, } @@ -753,8 +753,7 @@ pub async fn set_gua_access( }: BindingSetGuaAccessParams, inheritance: Kind::Inheritance, ) -> Result<(), Error> { - let address: HostnameInfo = - serde_json::from_str(&address).with_kind(ErrorKind::Deserialization)?; + let address = address.0; let gua = address.gua().ok_or_else(|| { Error::new( eyre!("address is not an IPv6 global-unicast address"), diff --git a/shared-libs/crates/start-core/src/net/service_interface.rs b/shared-libs/crates/start-core/src/net/service_interface.rs index eae62b701b..d72b701737 100644 --- a/shared-libs/crates/start-core/src/net/service_interface.rs +++ b/shared-libs/crates/start-core/src/net/service_interface.rs @@ -1,5 +1,5 @@ use std::collections::BTreeSet; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV6}; use imbl_value::InternedString; use serde::{Deserialize, Serialize}; @@ -74,9 +74,9 @@ impl HostnameInfo { } /// If this address is an IPv6 **global unicast** address (a GUA — not - /// loopback / ULA / link-local), return it as a `SocketAddr`. GUAs are the + /// loopback / ULA / link-local), return it as a `SocketAddrV6`. GUAs are the /// only addresses that carry the Disabled / LAN / LAN+WAN tri-state. - pub fn gua(&self) -> Option { + pub fn gua(&self) -> Option { if !matches!(self.metadata, HostnameMetadata::Ipv6 { .. }) { return None; } @@ -86,7 +86,7 @@ impl HostnameInfo { if crate::net::utils::ipv6_is_local(ip) { return None; } - Some(SocketAddr::new(IpAddr::V6(ip), self.port?)) + Some(SocketAddrV6::new(ip, self.port?, 0, 0)) } } diff --git a/shared-libs/crates/start-core/src/util/serde.rs b/shared-libs/crates/start-core/src/util/serde.rs index 84283f57d4..38491e9312 100644 --- a/shared-libs/crates/start-core/src/util/serde.rs +++ b/shared-libs/crates/start-core/src/util/serde.rs @@ -1088,6 +1088,46 @@ impl Deref for Base64 { } } +/// A parameter that arrives as structured `T` over the JSON-RPC wire, but as a +/// JSON **string** argument on the CLI. serde is a pure passthrough to `T` (the +/// wire carries `T` directly — no double-encoding), while the clap `ValueParser` +/// parses the string argument as JSON. Pair with `#[ts(as = "T")]` on the field +/// so the generated binding shows `T`, not a string. +#[derive(Debug, Clone)] +pub struct CliFromJsonString(pub T); +impl Deref for CliFromJsonString { + type Target = T; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl Serialize for CliFromJsonString { + fn serialize(&self, serializer: S) -> Result { + self.0.serialize(serializer) + } +} +impl<'de, T: Deserialize<'de>> Deserialize<'de> for CliFromJsonString { + fn deserialize>(deserializer: D) -> Result { + T::deserialize(deserializer).map(Self) + } +} +impl FromStr for CliFromJsonString { + type Err = Error; + fn from_str(s: &str) -> Result { + Ok(Self( + serde_json::from_str(s).with_kind(ErrorKind::Deserialization)?, + )) + } +} +impl ValueParserFactory + for CliFromJsonString +{ + type Parser = FromStrParser; + fn value_parser() -> Self::Parser { + FromStrParser::new() + } +} + #[derive(Clone, Debug)] pub struct Regex(regex::Regex); impl From for regex::Regex { diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/BindingSetAddressEnabledParams.ts b/shared-libs/ts-modules/start-core/lib/osBindings/BindingSetAddressEnabledParams.ts index 2dfeff7574..ef5e7d10e0 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/BindingSetAddressEnabledParams.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/BindingSetAddressEnabledParams.ts @@ -1,7 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HostnameInfo } from './HostnameInfo' export type BindingSetAddressEnabledParams = { internalPort: number - address: string + address: HostnameInfo enabled: boolean | null } diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/BindingSetGuaAccessParams.ts b/shared-libs/ts-modules/start-core/lib/osBindings/BindingSetGuaAccessParams.ts index 72e766e00a..61578bf4fe 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/BindingSetGuaAccessParams.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/BindingSetGuaAccessParams.ts @@ -1,8 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { GuaAccess } from './GuaAccess' +import type { HostnameInfo } from './HostnameInfo' export type BindingSetGuaAccessParams = { internalPort: number - address: string + address: HostnameInfo access: GuaAccess } diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/DerivedAddressInfo.ts b/shared-libs/ts-modules/start-core/lib/osBindings/DerivedAddressInfo.ts index c391b399b8..6141b94d41 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/DerivedAddressInfo.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/DerivedAddressInfo.ts @@ -13,7 +13,7 @@ export type DerivedAddressInfo = { disabled: Array<[string, number]> /** * User override: per-GUA exposure (only for IPv6 global-unicast addresses), - * keyed by the GUA's `SocketAddr`. Absent ⇒ [`GuaAccess::Lan`]. + * keyed by the GUA's `SocketAddrV6`. Absent ⇒ [`GuaAccess::Lan`]. */ guaAccess: { [key: string]: GuaAccess } /** From e9e35fa7c37e9266384fefff71c9af4854d7dd23 Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:14:23 +0000 Subject: [PATCH 3/6] feat(ui): IPv6 GUA exposure tri-state control Render IPv6 GUA addresses on the service Interfaces page with a Disabled / LAN / LAN+WAN selector instead of the on/off toggle every other address uses, wired to the new set-gua-access RPC. - interface.service.ts: client-side GUA detection (matches the backend's ipv6_is_local complement), `GatewayAddress.guaAccess`, GUA-aware `isEnabled`. - item.component.ts: tri-state + @if (address.guaAccess !== null) { + + + } @else { + + } & { + // package.host.binding.set-gua-access + internalPort: number + package: T.PackageId // string + host: T.HostId // string +} + // package domains export type PkgAddPublicDomainReq = T.AddPublicDomainParams & { diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts index ef567707a6..b86834ea66 100644 --- a/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts +++ b/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts @@ -18,9 +18,11 @@ import { PkgAddPrivateDomainReq, PkgAddPublicDomainReq, PkgBindingSetAddressEnabledReq, + PkgBindingSetGuaAccessReq, PkgRemovePrivateDomainReq, PkgRemovePublicDomainReq, ServerBindingSetAddressEnabledReq, + ServerBindingSetGuaAccessReq, ServerState, WebsocketConfig, } from './api.types' @@ -360,6 +362,14 @@ export abstract class ApiService { params: PkgBindingSetAddressEnabledReq, ): Promise + abstract serverBindingSetGuaAccess( + params: ServerBindingSetGuaAccessReq, + ): Promise + + abstract pkgBindingSetGuaAccess( + params: PkgBindingSetGuaAccessReq, + ): Promise + abstract pkgAddPublicDomain( params: PkgAddPublicDomainReq, ): Promise diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts index 35eaa0b6d8..15459f48f9 100644 --- a/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts +++ b/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts @@ -31,9 +31,11 @@ import { PkgAddPrivateDomainReq, PkgAddPublicDomainReq, PkgBindingSetAddressEnabledReq, + PkgBindingSetGuaAccessReq, PkgRemovePrivateDomainReq, PkgRemovePublicDomainReq, ServerBindingSetAddressEnabledReq, + ServerBindingSetGuaAccessReq, ServerState, WebsocketConfig, } from './api.types' @@ -682,6 +684,24 @@ export class LiveApiService extends ApiService { }) } + async serverBindingSetGuaAccess( + params: ServerBindingSetGuaAccessReq, + ): Promise { + return this.rpcRequest({ + method: 'server.host.binding.set-gua-access', + params, + }) + } + + async pkgBindingSetGuaAccess( + params: PkgBindingSetGuaAccessReq, + ): Promise { + return this.rpcRequest({ + method: 'package.host.binding.set-gua-access', + params, + }) + } + async pkgAddPublicDomain( params: PkgAddPublicDomainReq, ): Promise { diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts index e6106efb06..8dd95225e9 100644 --- a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts @@ -43,9 +43,11 @@ import { PkgAddPrivateDomainReq, PkgAddPublicDomainReq, PkgBindingSetAddressEnabledReq, + PkgBindingSetGuaAccessReq, PkgRemovePrivateDomainReq, PkgRemovePublicDomainReq, ServerBindingSetAddressEnabledReq, + ServerBindingSetGuaAccessReq, ServerState, WebsocketConfig, } from './api.types' @@ -1662,6 +1664,28 @@ export class MockApiService extends ApiService { return null } + async serverBindingSetGuaAccess( + params: ServerBindingSetGuaAccessReq, + ): Promise { + await pauseFor(2000) + + const basePath = `/serverInfo/network/host/bindings/${params.internalPort}/addresses` + this.mockSetGuaAccess(basePath, params.address, params.access) + + return null + } + + async pkgBindingSetGuaAccess( + params: PkgBindingSetGuaAccessReq, + ): Promise { + await pauseFor(2000) + + const basePath = `/packageData/${params.package}/hosts/${params.host}/bindings/${params.internalPort}/addresses` + this.mockSetGuaAccess(basePath, params.address, params.access) + + return null + } + async pkgAddPublicDomain( params: PkgAddPublicDomainReq, ): Promise { @@ -2154,6 +2178,30 @@ export class MockApiService extends ApiService { } } + private mockSetGuaAccess( + basePath: string, + h: T.HostnameInfo, + access: T.GuaAccess, + ): void { + if (h.metadata.kind !== 'ipv6' || h.port === null) return + + const key = `[${h.hostname}]:${h.port}` + const current = this.mockData(basePath) as T.DerivedAddressInfo + const guaAccess = { ...(current.guaAccess ?? {}) } + + // LAN is the default, so it clears the entry. + if (access === 'lan') { + delete guaAccess[key] + } else { + guaAccess[key] = access + } + + current.guaAccess = guaAccess + this.mockRevision([ + { op: PatchOp.ADD, path: `${basePath}/guaAccess`, value: guaAccess }, + ]) + } + private mockData(path: string): any { const parts = path.split('/').filter(Boolean) let obj: any = mockPatchData diff --git a/projects/start-os/web/ui/src/app/services/api/mock-patch.ts b/projects/start-os/web/ui/src/app/services/api/mock-patch.ts index 1efea4fba9..7eb0972106 100644 --- a/projects/start-os/web/ui/src/app/services/api/mock-patch.ts +++ b/projects/start-os/web/ui/src/app/services/api/mock-patch.ts @@ -41,6 +41,7 @@ export const mockPatchData: DataModel = { addresses: { enabled: [], disabled: [], + guaAccess: {}, available: [ { ssl: true, @@ -460,6 +461,7 @@ export const mockPatchData: DataModel = { addresses: { enabled: ['203.0.113.45:42443'], disabled: [], + guaAccess: {}, available: [ { ssl: true, @@ -610,6 +612,7 @@ export const mockPatchData: DataModel = { addresses: { enabled: ['203.0.113.45:49152'], disabled: [], + guaAccess: {}, available: [ { ssl: false, @@ -704,6 +707,7 @@ export const mockPatchData: DataModel = { addresses: { enabled: [], disabled: [], + guaAccess: {}, available: [ { ssl: false, @@ -779,6 +783,7 @@ export const mockPatchData: DataModel = { addresses: { enabled: [], disabled: [], + guaAccess: {}, available: [], }, options: { From 558a8cd109755ee08b3d2d4e7f438fa182437a93 Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:34:27 +0000 Subject: [PATCH 4/6] docs(start-os): document IPv6 GUA exposure tri-state Interfaces page + CHANGELOG: the Disabled / LAN / LAN+WAN control for IPv6 global-unicast addresses (default LAN, LAN+WAN attempts a PCP pinhole). --- projects/start-os/CHANGELOG.md | 11 +++++++++++ projects/start-os/docs/src/interfaces.md | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/projects/start-os/CHANGELOG.md b/projects/start-os/CHANGELOG.md index eab8d43463..13c04ff0a9 100644 --- a/projects/start-os/CHANGELOG.md +++ b/projects/start-os/CHANGELOG.md @@ -8,6 +8,17 @@ Full per-release notes are published on the [GitHub releases page](https://github.com/Start9Labs/start-technologies/releases). This file tracks notable changes since the move to the monorepo. +## [Unreleased] + +### Added + +- **IPv6 GUA exposure tri-state.** On a service interface, an IPv6 global-unicast + address (GUA) now offers a **Disabled / LAN / LAN+WAN** control instead of an + on/off toggle. **LAN** (the default) keeps it reachable on the local network + only — traffic from outside the subnet is rejected; **LAN+WAN** also exposes it + to the Internet and attempts an automatic gateway pinhole (PCP). IPv6 ULAs and + IPv4 are unchanged. + ## [0.4.0-beta.10] ### Added diff --git a/projects/start-os/docs/src/interfaces.md b/projects/start-os/docs/src/interfaces.md index 9e90ba01b3..df0a48dbc1 100644 --- a/projects/start-os/docs/src/interfaces.md +++ b/projects/start-os/docs/src/interfaces.md @@ -22,7 +22,7 @@ Each table has the following columns: | Column | Description | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Toggle** | Enable or disable the address. This directly affects iptables and firewall rules — disabling an address blocks traffic to it. Public IPv4 addresses are off by default. All other addresses are on by default. | +| **Toggle** | Enable or disable the address. This directly affects iptables and firewall rules — disabling an address blocks traffic to it. Public IPv4 addresses are off by default. All other addresses are on by default. **IPv6 global-unicast (GUA) addresses use a three-way control instead** — see the note below. | | **Access** | **Public** or **Private**. Public addresses are reachable from the Internet. Private addresses are only reachable on the LAN or via VPN. | | **Type** | The address type: `IPv4`, `IPv6`, `Domain`, or `mDNS` (mDNS is only available on router gateways). | | **Certificate Authority** | Who signs the SSL certificate for this address: **Root CA** (your server's own CA), **Let's Encrypt** (publicly trusted), or **None** (non-SSL, e.g. plain HTTP). | @@ -32,6 +32,15 @@ Each table has the following columns: > [!NOTE] > The Settings button appears for addresses that require external configuration: [public domains](clearnet.md) (DNS + port forwarding), [private domains](private-domains.md) (DNS), and [public IP addresses](public-ip.md) (port forwarding). +> [!NOTE] +> Unlike a private LAN address, an IPv6 **global-unicast address (GUA)** is a single globally-routable address — so instead of an on/off toggle it offers three states: +> +> - **Disabled** — not reachable. +> - **LAN** (default) — reachable on the local network only; traffic from outside your subnet is rejected. +> - **LAN+WAN** — also reachable from the Internet. StartOS attempts to open the matching pinhole on your gateway automatically (via PCP); if your gateway doesn't support it you may need to allow inbound traffic to that address and port manually. +> +> This only applies to IPv6 GUAs. IPv6 ULAs (private) stay a simple toggle, and IPv4 keeps its separate LAN and WAN address rows. + ### Adding Domains You can add domains to a gateway table by clicking "Add Domain" on the gateway and choosing either: From 00392fa69f1929d42cae8684f49350880eea607c Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:12:45 +0000 Subject: [PATCH 5/6] refactor(net): parse GUA hostname directly as Ipv6Addr Per @dr-bonez: parse the hostname straight to Ipv6Addr instead of IpAddr + matching IpAddr::V6. The parse is itself the discriminant, so the redundant Ipv6-metadata guard is dropped. --- .../crates/start-core/src/net/service_interface.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/shared-libs/crates/start-core/src/net/service_interface.rs b/shared-libs/crates/start-core/src/net/service_interface.rs index d72b701737..e3c1011224 100644 --- a/shared-libs/crates/start-core/src/net/service_interface.rs +++ b/shared-libs/crates/start-core/src/net/service_interface.rs @@ -1,5 +1,5 @@ use std::collections::BTreeSet; -use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV6}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV6}; use imbl_value::InternedString; use serde::{Deserialize, Serialize}; @@ -77,12 +77,7 @@ impl HostnameInfo { /// loopback / ULA / link-local), return it as a `SocketAddrV6`. GUAs are the /// only addresses that carry the Disabled / LAN / LAN+WAN tri-state. pub fn gua(&self) -> Option { - if !matches!(self.metadata, HostnameMetadata::Ipv6 { .. }) { - return None; - } - let IpAddr::V6(ip) = self.hostname.parse::().ok()? else { - return None; - }; + let ip = self.hostname.parse::().ok()?; if crate::net::utils::ipv6_is_local(ip) { return None; } From df10f6a79aeb3c4593a1b1cd7912bd05e706017e Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:42:10 +0000 Subject: [PATCH 6/6] refactor(net): keep the Ipv6 metadata guard in gua() Per @dr-bonez: restore the `matches!(metadata, Ipv6 { .. })` guard; only the IpAddr::V6 parse dance was the redundant part. --- shared-libs/crates/start-core/src/net/service_interface.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shared-libs/crates/start-core/src/net/service_interface.rs b/shared-libs/crates/start-core/src/net/service_interface.rs index e3c1011224..fd3583f956 100644 --- a/shared-libs/crates/start-core/src/net/service_interface.rs +++ b/shared-libs/crates/start-core/src/net/service_interface.rs @@ -77,6 +77,9 @@ impl HostnameInfo { /// loopback / ULA / link-local), return it as a `SocketAddrV6`. GUAs are the /// only addresses that carry the Disabled / LAN / LAN+WAN tri-state. pub fn gua(&self) -> Option { + if !matches!(self.metadata, HostnameMetadata::Ipv6 { .. }) { + return None; + } let ip = self.hostname.parse::().ok()?; if crate::net::utils::ipv6_is_local(ip) { return None;