diff --git a/projects/start-os/CHANGELOG.md b/projects/start-os/CHANGELOG.md index eab8d4346..13c04ff0a 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 9e90ba01b..df0a48dbc 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: 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 ea6c6d783..cf5495cd0 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 5797b9bf2..51b5d6501 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 @@ -1,6 +1,7 @@ import { Component, computed, inject, input, signal } from '@angular/core' import { FormsModule } from '@angular/forms' import { ErrorService, i18nPipe } from '@start9labs/shared' +import { T } from '@start9labs/start-core' import { TuiButton, TuiIcon } from '@taiga-ui/core' import { TuiBadge, @@ -21,17 +22,32 @@ import { DomainHealthService } from './domain-health.service' template: ` @if (address(); as address) { - + @if (address.guaAccess !== null) { + + + } @else { + + } 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 } @@ -41,6 +41,23 @@ export type PkgBindingSetAddressEnabledReq = Omit< host: T.HostId // string } +export type ServerBindingSetGuaAccessReq = { + // server.host.binding.set-gua-access + internalPort: 80 + address: T.HostnameInfo + access: T.GuaAccess // disabled | lan | lan-wan +} + +export type PkgBindingSetGuaAccessReq = Omit< + ServerBindingSetGuaAccessReq, + 'internalPort' +> & { + // 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 ef567707a..b86834ea6 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 35eaa0b6d..15459f48f 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 6aa4c72ee..8dd95225e 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 { @@ -2109,10 +2133,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') @@ -2155,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 1efea4fba..7eb097210 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: { diff --git a/shared-libs/crates/start-core/locales/i18n.yaml b/shared-libs/crates/start-core/locales/i18n.yaml index 224f606f7..50cc88924 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 d981f3880..b51508708 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)] @@ -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 `SocketAddrV6`. 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: SocketAddrV6) -> 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( @@ -488,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, } @@ -599,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"), @@ -634,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"), @@ -656,3 +726,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")] + #[ts(as = "HostnameInfo")] + address: CliFromJsonString, + #[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 = address.0; + 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 9e26cdb1c..93a65ce34 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 5f1b4e264..fd3583f95 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, Ipv6Addr, SocketAddr, SocketAddrV6}; use imbl_value::InternedString; use serde::{Deserialize, Serialize}; @@ -66,14 +66,26 @@ 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 `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; + } + Some(SocketAddrV6::new(ip, self.port?, 0, 0)) + } } impl HostnameMetadata { diff --git a/shared-libs/crates/start-core/src/util/serde.rs b/shared-libs/crates/start-core/src/util/serde.rs index 84283f57d..38491e931 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 2dfeff757..ef5e7d10e 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 new file mode 100644 index 000000000..61578bf4f --- /dev/null +++ b/shared-libs/ts-modules/start-core/lib/osBindings/BindingSetGuaAccessParams.ts @@ -0,0 +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: 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 79fb49bda..6141b94d4 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 `SocketAddrV6`. 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 000000000..9f4aafe02 --- /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 84ce03bf3..ba834a6fe 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'