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) {
+
+
+ {{ 'Disabled' | i18n }}
+ {{ 'LAN' | i18n }}
+ {{ 'LAN+WAN' | i18n }}
+
+ } @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'