From 4e638fb58e1dc0727f930e08309501e899dabd8f Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Tue, 10 Feb 2026 17:38:51 -0700 Subject: [PATCH] feat: implement preferred port allocation and per-address enable/disable - Add AvailablePorts::try_alloc() with SSL tracking (BTreeMap) - Add DerivedAddressInfo on BindInfo with private_disabled/public_enabled/possible sets - Add Bindings wrapper with Map impl for patchdb indexed access - Flatten HostAddress from single-variant enum to struct - Replace set-gateway-enabled RPC with set-address-enabled - Remove hostname_info from Host; computed addresses now in BindInfo.addresses.possible - Compute possible addresses inline in NetServiceData::update() - Update DB migration, SDK types, frontend, and container-runtime --- CLAUDE.md | 18 + agents/TODO.md | 123 +--- .../DockerProcedureContainer.ts | 13 +- .../Systems/SystemForEmbassy/index.ts | 4 +- core/src/db/model/public.rs | 54 +- core/src/net/forward.rs | 48 +- core/src/net/gateway.rs | 12 +- core/src/net/host/address.rs | 49 +- core/src/net/host/binding.rs | 193 ++++-- core/src/net/host/mod.rs | 11 +- core/src/net/net_controller.rs | 618 +++++++++--------- core/src/net/service_interface.rs | 6 +- core/src/net/tunnel.rs | 8 +- core/src/service/effects/net/ssl.rs | 8 +- core/src/version/v0_4_0_alpha_20.rs | 89 +-- sdk/base/lib/osBindings/BindInfo.ts | 8 +- ...s.ts => BindingSetAddressEnabledParams.ts} | 5 +- sdk/base/lib/osBindings/Bindings.ts | 4 + sdk/base/lib/osBindings/DerivedAddressInfo.ts | 17 + sdk/base/lib/osBindings/Host.ts | 9 +- sdk/base/lib/osBindings/NetInfo.ts | 3 - sdk/base/lib/osBindings/index.ts | 4 +- sdk/base/lib/util/getServiceInterface.ts | 19 +- .../interfaces/gateways.component.ts | 30 +- .../interfaces/interface.service.ts | 29 +- .../services/routes/interface.component.ts | 4 +- .../routes/startos-ui/startos-ui.component.ts | 4 +- .../ui/src/app/services/api/api.fixures.ts | 156 +++-- .../ui/src/app/services/api/api.types.ts | 18 +- .../app/services/api/embassy-api.service.ts | 12 +- .../services/api/embassy-live-api.service.ts | 16 +- .../services/api/embassy-mock-api.service.ts | 60 +- .../ui/src/app/services/api/mock-patch.ts | 296 ++++----- 33 files changed, 996 insertions(+), 952 deletions(-) rename sdk/base/lib/osBindings/{BindingGatewaySetEnabledParams.ts => BindingSetAddressEnabledParams.ts} (58%) create mode 100644 sdk/base/lib/osBindings/Bindings.ts create mode 100644 sdk/base/lib/osBindings/DerivedAddressInfo.ts diff --git a/CLAUDE.md b/CLAUDE.md index 22d94db31..2ed0a6368 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,24 @@ make update-startbox REMOTE=start9@ # Fastest iteration (binary + UI) make test-core # Run Rust tests ``` +### Verifying code changes + +When making changes across multiple layers (Rust, SDK, web, container-runtime), verify in this order: + +1. **Rust**: `cargo check -p start-os` — verifies core compiles +2. **TS bindings**: `make ts-bindings` — regenerates TypeScript types from Rust `#[ts(export)]` structs + - Runs `./core/build/build-ts.sh` to export ts-rs types to `core/bindings/` + - Syncs `core/bindings/` → `sdk/base/lib/osBindings/` via rsync + - If you manually edit files in `sdk/base/lib/osBindings/`, you must still rebuild the SDK (step 3) +3. **SDK bundle**: `cd sdk && make baseDist dist` — compiles SDK source into packages + - `baseDist/` is consumed by `/web` (via `@start9labs/start-sdk-base`) + - `dist/` is consumed by `/container-runtime` (via `@start9labs/start-sdk`) + - Web and container-runtime reference the **built** SDK, not source files +4. **Web type check**: `cd web && npm run check` — type-checks all Angular projects +5. **Container runtime type check**: `cd container-runtime && npm run check` — type-checks the runtime + +**Important**: Editing `sdk/base/lib/osBindings/*.ts` alone is NOT sufficient — you must rebuild the SDK bundle (step 3) before web/container-runtime can see the changes. + ## Architecture ### Core (`/core`) diff --git a/agents/TODO.md b/agents/TODO.md index 34adf08c6..cabae12fb 100644 --- a/agents/TODO.md +++ b/agents/TODO.md @@ -33,114 +33,39 @@ Pending tasks for AI agents. Remove items when completed. `preferred_external_port: 443` won't get 443 as its `assigned_ssl_port` (it's taken), but it CAN still have domain vhost entries on port 443 — SNI routes by hostname. - #### 1. Preferred Port Allocation for Ownership (`forward.rs`, `binding.rs`) + #### 1. Preferred Port Allocation for Ownership ✅ DONE - Expand `AvailablePorts` to support trying a preferred port before falling back to the dynamic range: + `AvailablePorts::try_alloc(port) -> Option` added to `forward.rs`. `BindInfo::new()` and + `BindInfo::update()` attempt the preferred port first, falling back to dynamic-range allocation. - - Add `try_alloc(port) -> Option`: Attempts to exclusively allocate a specific port. Returns - `None` if the port is already allocated or restricted. - - Enforce the restricted port list (currently noted in `vhost.rs:89`: `<=1024, >=32768, 5355, 5432, - 9050, 6010, 9051, 5353`) — skip the preferred port if restricted, except for ports the OS itself - uses (80, 443). - - No SSL-vs-non-SSL distinction or refcounting needed at this layer — ownership is always exclusive. - SSL port sharing for domains is handled entirely by the VHostController via SNI. + #### 2. Per-Address Enable/Disable ✅ DONE - Modify `BindInfo::new()` and `BindInfo::update()` to attempt the preferred port first: + Gateway-level `private_disabled`/`public_enabled` on `NetInfo` replaced with per-address + `DerivedAddressInfo` on `BindInfo`. `hostname_info` removed from `Host` — computed addresses now + live in `BindInfo.addresses.possible`. - ``` - assigned_ssl_port = try_alloc(ssl.preferred_external_port) - .unwrap_or(dynamic_pool.alloc()) - assigned_port = try_alloc(options.preferred_external_port) - .unwrap_or(dynamic_pool.alloc()) - ``` - - After this change, `assigned_ssl_port` may match the preferred port if it was available, or fall back - to the dynamic range as before. - - #### 2. Per-Address Enable/Disable (replaces gateway overrides) - - **Current model being removed**: `NetInfo` has `private_disabled: OrdSet` and - `public_enabled: OrdSet` — gateway-level toggles where private gateways are enabled by - default and public gateways are disabled by default. The `set-gateway-enabled` RPC endpoint and the - `InterfaceFilter` impl on `NetInfo` use these sets. This model is unintuitive because users think in - terms of individual addresses, not gateways. - - **New model**: Per-address enable/disable using `DerivedAddressInfo` on `BindInfo`. Instead of - gateway-level toggles, users toggle individual addresses. The `hostnameInfo` field moves from `Host` - to `BindInfo.addresses` (as the computed `possible` set). - - **`DerivedAddressInfo` struct** (added to `BindInfo`): + **`DerivedAddressInfo` struct** (on `BindInfo`): ```rust pub struct DerivedAddressInfo { - /// User-controlled: private-gateway addresses the user has disabled pub private_disabled: BTreeSet, - /// User-controlled: public-gateway addresses the user has enabled pub public_enabled: BTreeSet, - /// COMPUTED by update(): all possible addresses for this binding - pub possible: BTreeSet, + pub possible: BTreeSet, // COMPUTED by update() } ``` - `DerivedAddressInfo::enabled()` returns `possible` filtered by the two sets: private addresses are - enabled by default (disabled if in `private_disabled`), public addresses are disabled by default - (enabled if in `public_enabled`). Requires `HostnameInfo` to derive `Ord` for `BTreeSet` usage. + `DerivedAddressInfo::enabled()` returns `possible` filtered by the two sets. `HostnameInfo` derives + `Ord` for `BTreeSet` usage. `AddressFilter` (implementing `InterfaceFilter`) derives enabled + gateway set from `DerivedAddressInfo` for vhost/forward filtering. - **How disabling works per address type**: + **RPC endpoint**: `set-gateway-enabled` replaced with `set-address-enabled` (on both + `server.host.binding` and `package.host.binding`). - The enforcement mechanism varies by address type because different addresses are reached through - different network paths: + **How disabling works per address type** (enforcement deferred to Section 3): - - **WAN IP:port** (public gateway IP addresses): Disabled via **source-IP gating** in the vhost - layer (Section 3). Public and private traffic share the same port listener, so we can't just - remove the vhost entry — that would also block private traffic. Instead, the vhost target is - tagged with which source-IP classes it accepts. When a WAN IP address is disabled, the vhost - target rejects connections whose source IP matches the gateway (i.e., NAT'd internet traffic) - or falls outside the gateway's LAN subnets. LAN traffic to the same port is unaffected. - - **LAN IP:port** (private gateway IP addresses): Also enforced via **source-IP gating**. When - disabled, the vhost target rejects connections from LAN subnets on that gateway. This is the - inverse of the WAN case — same mechanism, different source-IP class. + - **WAN/LAN IP:port**: Will be enforced via **source-IP gating** in the vhost layer (Section 3). - **Hostname-based addresses** (`.local`, domains): Disabled by **not creating the vhost/SNI - entry** for that hostname. Since hostname-based routing uses SNI (SSL) or Host header (HTTP), - removing the entry means the hostname simply doesn't resolve to a backend. No traffic reaches - the service for that hostname. - - **Backend changes**: - - - **Remove from `NetInfo`**: Delete the `private_disabled` and `public_enabled` fields entirely. - `NetInfo` becomes just `{ assigned_port: Option, assigned_ssl_port: Option }`. - - **Add `addresses: DerivedAddressInfo` to `BindInfo`**: User-controlled sets (`private_disabled`, - `public_enabled`) are preserved across updates; `possible` is recomputed by `update()`. - - **Remove `hostname_info` from `Host`**: Computed addresses now live in `BindInfo.addresses.possible` - instead of being a top-level field on `Host` that was never persisted to the DB. - - **Default behavior preserved**: Private-gateway addresses default to enabled, public-gateway - addresses default to disabled, via the `enabled()` method on `DerivedAddressInfo`. - - **Remove `set-gateway-enabled`** RPC endpoint from `binding.rs`. - - **Remove `InterfaceFilter` impl for `NetInfo`**: The per-gateway filter logic is replaced by - per-address filtering derived from `DerivedAddressInfo`. - - **New RPC endpoint** (`binding.rs`): - - Following the existing `HostApiKind` pattern, replace `set-gateway-enabled` with: - - - **`set-address-enabled`** — Toggle an individual address on or off. - - ```ts - interface BindingSetAddressEnabledParams { - internalPort: number - address: HostnameInfo // identifies the address directly (no separate AddressId type needed) - enabled: boolean | null // null = reset to default - } - ``` - - Mutates `BindInfo.addresses.private_disabled` / `.public_enabled` based on the address's `public` - field. If `public == true` and enabled, add to `public_enabled`; if disabled, remove. If - `public == false` and enabled, remove from `private_disabled`; if disabled, add. Uses `sync_db` - metadata. - - This yields two RPC methods: - - `server.host.binding.set-address-enabled` - - `package.host.binding.set-address-enabled` + entry** for that hostname. #### 3. Eliminate the Port 5443 Hack: Source-IP-Based WAN Blocking (`vhost.rs`, `net_controller.rs`) @@ -206,7 +131,7 @@ Pending tasks for AI agents. Remove items when completed. ##### View Page - Displays all computed addresses for the interface (from `hostname_info`) as a flat list. For each + Displays all computed addresses for the interface (from `BindInfo.addresses`) as a flat list. For each address, show: URL, type (IPv4, IPv6, .local, domain), access level (public/private), gateway name, SSL indicator, enable/disable state, port forward info for public addresses, and a test button for reachability (see Section 7). @@ -282,13 +207,13 @@ Pending tasks for AI agents. Remove items when completed. | File | Role | |------|------| - | `core/src/net/forward.rs` | `AvailablePorts` — port pool allocation | - | `core/src/net/host/binding.rs` | `BindInfo`/`NetInfo`/`DerivedAddressInfo` — remove gateway overrides, add per-address enable/disable sets, new RPC endpoints | - | `core/src/net/net_controller.rs:259` | `NetServiceData::update()` — compute `enabled` on `HostnameInfo`, vhost/forward/DNS reconciliation, 5443 hack removal | + | `core/src/net/forward.rs` | `AvailablePorts` — port pool allocation, `try_alloc()` for preferred ports | + | `core/src/net/host/binding.rs` | `Bindings` (Map wrapper for patchdb), `BindInfo`/`NetInfo`/`DerivedAddressInfo`/`AddressFilter` — per-address enable/disable, `set-address-enabled` RPC | + | `core/src/net/net_controller.rs:259` | `NetServiceData::update()` — computes `DerivedAddressInfo.possible`, vhost/forward/DNS reconciliation, 5443 hack removal | | `core/src/net/vhost.rs` | `VHostController` / `ProxyTarget` — source-IP gating for public/private | - | `core/src/net/gateway.rs` | `InterfaceFilter` — remove `NetInfo` impl, simplify | - | `core/src/net/service_interface.rs` | `HostnameInfo` — add `Ord` derives for use in `BTreeSet` | - | `core/src/net/host/address.rs` | Existing domain/onion CRUD endpoints (no changes needed) | + | `core/src/net/gateway.rs` | `InterfaceFilter` trait and filter types (`AddressFilter`, `PublicFilter`, etc.) | + | `core/src/net/service_interface.rs` | `HostnameInfo` — derives `Ord` for `BTreeSet` usage | + | `core/src/net/host/address.rs` | `HostAddress` (flattened struct), domain CRUD endpoints | | `sdk/base/lib/interfaces/Host.ts` | SDK `MultiHost.bindPort()` — no changes needed | | `core/src/db/model/public.rs` | Public DB model — port forward mapping | diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts index b6fd6f033..1a8975317 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts @@ -82,17 +82,14 @@ export class DockerProcedureContainer extends Drop { }), ) } else if (volumeMount.type === "certificate") { + const hostInfo = await effects.getHostInfo({ + hostId: volumeMount["interface-id"], + }) const hostnames = [ `${packageId}.embassy`, ...new Set( - Object.values( - ( - await effects.getHostInfo({ - hostId: volumeMount["interface-id"], - }) - )?.hostnameInfo || {}, - ) - .flatMap((h) => h) + Object.values(hostInfo?.bindings || {}) + .flatMap((b) => b.addresses.possible) .map((h) => h.hostname.value), ).values(), ] diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts index 82dc037f9..cb7585ac9 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts @@ -1244,8 +1244,8 @@ async function updateConfig( ? "" : catchFn( () => - filled.addressInfo!.filter({ kind: "mdns" })! - .hostnames[0].hostname.value, + filled.addressInfo!.filter({ kind: "mdns" })!.hostnames[0] + .hostname.value, ) || "" mutConfigValue[key] = url } diff --git a/core/src/db/model/public.rs b/core/src/db/model/public.rs index d038577a6..cd2dc2169 100644 --- a/core/src/db/model/public.rs +++ b/core/src/db/model/public.rs @@ -20,7 +20,7 @@ use crate::db::model::Database; use crate::db::model::package::AllPackageData; use crate::net::acme::AcmeProvider; use crate::net::host::Host; -use crate::net::host::binding::{AddSslOptions, BindInfo, BindOptions, NetInfo}; +use crate::net::host::binding::{AddSslOptions, BindInfo, BindOptions, Bindings, DerivedAddressInfo, NetInfo}; use crate::net::utils::ipv6_is_local; use crate::net::vhost::AlpnInfo; use crate::prelude::*; @@ -63,35 +63,35 @@ impl Public { post_init_migration_todos: BTreeMap::new(), network: NetworkInfo { host: Host { - bindings: [( - 80, - BindInfo { - enabled: false, - options: BindOptions { - preferred_external_port: 80, - add_ssl: Some(AddSslOptions { - preferred_external_port: 443, - add_x_forwarded_headers: false, - alpn: Some(AlpnInfo::Specified(vec![ - MaybeUtf8String("h2".into()), - MaybeUtf8String("http/1.1".into()), - ])), - }), - secure: None, + bindings: Bindings( + [( + 80, + BindInfo { + enabled: false, + options: BindOptions { + preferred_external_port: 80, + add_ssl: Some(AddSslOptions { + preferred_external_port: 443, + add_x_forwarded_headers: false, + alpn: Some(AlpnInfo::Specified(vec![ + MaybeUtf8String("h2".into()), + MaybeUtf8String("http/1.1".into()), + ])), + }), + secure: None, + }, + net: NetInfo { + assigned_port: None, + assigned_ssl_port: Some(443), + }, + addresses: DerivedAddressInfo::default(), }, - net: NetInfo { - assigned_port: None, - assigned_ssl_port: Some(443), - private_disabled: OrdSet::new(), - public_enabled: OrdSet::new(), - }, - }, - )] - .into_iter() - .collect(), + )] + .into_iter() + .collect(), + ), public_domains: BTreeMap::new(), private_domains: BTreeSet::new(), - hostname_info: BTreeMap::new(), }, wifi: WifiInfo { enabled: true, diff --git a/core/src/net/forward.rs b/core/src/net/forward.rs index b18ed7f1b..7cf5e7985 100644 --- a/core/src/net/forward.rs +++ b/core/src/net/forward.rs @@ -4,8 +4,8 @@ use std::sync::{Arc, Weak}; use std::time::Duration; use futures::channel::oneshot; -use id_pool::IdPool; use iddqd::{IdOrdItem, IdOrdMap}; +use rand::Rng; use imbl::OrdMap; use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn_async}; use serde::{Deserialize, Serialize}; @@ -23,25 +23,49 @@ use crate::util::serde::{HandlerExtSerde, display_serializable}; use crate::util::sync::Watch; pub const START9_BRIDGE_IFACE: &str = "lxcbr0"; -pub const FIRST_DYNAMIC_PRIVATE_PORT: u16 = 49152; +const EPHEMERAL_PORT_START: u16 = 49152; +// vhost.rs:89 — not allowed: <=1024, >=32768, 5355, 5432, 9050, 6010, 9051, 5353 +const RESTRICTED_PORTS: &[u16] = &[5353, 5355, 5432, 6010, 9050, 9051]; + +fn is_restricted(port: u16) -> bool { + port <= 1024 || RESTRICTED_PORTS.contains(&port) +} #[derive(Debug, Deserialize, Serialize)] -pub struct AvailablePorts(IdPool); +pub struct AvailablePorts(BTreeMap); impl AvailablePorts { pub fn new() -> Self { - Self(IdPool::new_ranged(FIRST_DYNAMIC_PRIVATE_PORT..u16::MAX)) + Self(BTreeMap::new()) } - pub fn alloc(&mut self) -> Result { - self.0.request_id().ok_or_else(|| { - Error::new( - eyre!("{}", t!("net.forward.no-dynamic-ports-available")), - ErrorKind::Network, - ) - }) + pub fn alloc(&mut self, ssl: bool) -> Result { + let mut rng = rand::rng(); + for _ in 0..1000 { + let port = rng.random_range(EPHEMERAL_PORT_START..u16::MAX); + if !self.0.contains_key(&port) { + self.0.insert(port, ssl); + return Ok(port); + } + } + Err(Error::new( + eyre!("{}", t!("net.forward.no-dynamic-ports-available")), + ErrorKind::Network, + )) + } + /// Try to allocate a specific port. Returns Some(port) if available, None if taken/restricted. + pub fn try_alloc(&mut self, port: u16, ssl: bool) -> Option { + if is_restricted(port) || self.0.contains_key(&port) { + return None; + } + self.0.insert(port, ssl); + Some(port) + } + /// Returns whether a given allocated port is SSL. + pub fn is_ssl(&self, port: u16) -> bool { + self.0.get(&port).copied().unwrap_or(false) } pub fn free(&mut self, ports: impl IntoIterator) { for port in ports { - self.0.return_id(port).unwrap_or_default(); + self.0.remove(&port); } } } diff --git a/core/src/net/gateway.rs b/core/src/net/gateway.rs index 6079efd76..688892f85 100644 --- a/core/src/net/gateway.rs +++ b/core/src/net/gateway.rs @@ -1685,19 +1685,9 @@ where #[test] fn test_filter() { - use crate::net::host::binding::NetInfo; let wg1 = "wg1".parse::().unwrap(); assert!(!InterfaceFilter::filter( - &AndFilter( - NetInfo { - private_disabled: [wg1.clone()].into_iter().collect(), - public_enabled: Default::default(), - assigned_port: None, - assigned_ssl_port: None, - }, - AndFilter(IdFilter(wg1.clone()), PublicFilter { public: false }), - ) - .into_dyn(), + &AndFilter(IdFilter(wg1.clone()), PublicFilter { public: false }).into_dyn(), &wg1, &NetworkInterfaceInfo { name: None, diff --git a/core/src/net/host/address.rs b/core/src/net/host/address.rs index 59f74fdb8..33c32073b 100644 --- a/core/src/net/host/address.rs +++ b/core/src/net/host/address.rs @@ -16,15 +16,11 @@ use crate::prelude::*; use crate::util::serde::{HandlerExtSerde, display_serializable}; #[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -#[serde(rename_all_fields = "camelCase")] -#[serde(tag = "kind")] -pub enum HostAddress { - Domain { - address: InternedString, - public: Option, - private: bool, - }, +#[serde(rename_all = "camelCase")] +pub struct HostAddress { + pub address: InternedString, + pub public: Option, + pub private: bool, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] @@ -151,29 +147,18 @@ pub fn address_api() let mut table = Table::new(); table.add_row(row![bc => "ADDRESS", "PUBLIC", "ACME PROVIDER"]); - for address in &res { - match address { - HostAddress::Domain { - address, - public: Some(PublicDomainConfig { gateway, acme }), - private, - } => { - table.add_row(row![ - address, - &format!( - "{} ({gateway})", - if *private { "YES" } else { "ONLY" } - ), - acme.as_ref().map(|a| a.0.as_str()).unwrap_or("NONE") - ]); - } - HostAddress::Domain { - address, - public: None, - .. - } => { - table.add_row(row![address, &format!("NO"), "N/A"]); - } + for entry in &res { + if let Some(PublicDomainConfig { gateway, acme }) = &entry.public { + table.add_row(row![ + entry.address, + &format!( + "{} ({gateway})", + if entry.private { "YES" } else { "ONLY" } + ), + acme.as_ref().map(|a| a.0.as_str()).unwrap_or("NONE") + ]); + } else { + table.add_row(row![entry.address, &format!("NO"), "N/A"]); } } diff --git a/core/src/net/host/binding.rs b/core/src/net/host/binding.rs index 8862e2bda..3c78c4338 100644 --- a/core/src/net/host/binding.rs +++ b/core/src/net/host/binding.rs @@ -3,16 +3,17 @@ use std::str::FromStr; use clap::Parser; use clap::builder::ValueParserFactory; -use imbl::OrdSet; use rpc_toolkit::{Context, Empty, HandlerArgs, HandlerExt, ParentHandler, from_fn_async}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use crate::context::{CliContext, RpcContext}; use crate::db::model::public::NetworkInterfaceInfo; +use crate::db::prelude::Map; use crate::net::forward::AvailablePorts; use crate::net::gateway::InterfaceFilter; use crate::net::host::HostApiKind; +use crate::net::service_interface::HostnameInfo; use crate::net::vhost::AlpnInfo; use crate::prelude::*; use crate::util::FromStrParser; @@ -45,51 +46,137 @@ impl FromStr for BindId { } } -#[derive(Debug, Deserialize, Serialize, TS)] +#[derive(Debug, Default, Clone, Deserialize, Serialize, TS, HasModel)] #[serde(rename_all = "camelCase")] #[ts(export)] +#[model = "Model"] +pub struct DerivedAddressInfo { + /// User-controlled: private-gateway addresses the user has disabled + pub private_disabled: BTreeSet, + /// User-controlled: public-gateway addresses the user has enabled + pub public_enabled: BTreeSet, + /// COMPUTED: NetServiceData::update — all possible addresses for this binding + pub possible: BTreeSet, +} + +impl DerivedAddressInfo { + /// Returns addresses that are currently enabled. + /// Private addresses are enabled by default (disabled if in private_disabled). + /// Public addresses are disabled by default (enabled if in public_enabled). + pub fn enabled(&self) -> BTreeSet<&HostnameInfo> { + self.possible + .iter() + .filter(|h| { + if h.public { + self.public_enabled.contains(h) + } else { + !self.private_disabled.contains(h) + } + }) + .collect() + } + + /// Derive a gateway-level InterfaceFilter from the enabled addresses. + /// A gateway passes the filter if it has any enabled address for this binding. + pub fn gateway_filter(&self) -> AddressFilter { + let enabled_gateways: BTreeSet = self + .enabled() + .into_iter() + .map(|h| h.gateway.id.clone()) + .collect(); + AddressFilter(enabled_gateways) + } +} + +/// Gateway-level filter derived from DerivedAddressInfo. +/// Passes if the gateway has at least one enabled address. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct AddressFilter(pub BTreeSet); +impl InterfaceFilter for AddressFilter { + fn filter(&self, id: &GatewayId, info: &NetworkInterfaceInfo) -> bool { + info.ip_info.is_some() && self.0.contains(id) + } +} + +#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)] +#[model = "Model"] +#[ts(export)] +pub struct Bindings(pub BTreeMap); + +impl Map for Bindings { + type Key = u16; + type Value = BindInfo; + fn key_str(key: &Self::Key) -> Result, Error> { + Self::key_string(key) + } + fn key_string(key: &Self::Key) -> Result { + Ok(InternedString::from_display(key)) + } +} + +impl std::ops::Deref for Bindings { + type Target = BTreeMap; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for Bindings { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[derive(Debug, Deserialize, Serialize, HasModel, TS)] +#[serde(rename_all = "camelCase")] +#[model = "Model"] +#[ts(export)] pub struct BindInfo { pub enabled: bool, pub options: BindOptions, pub net: NetInfo, + pub addresses: DerivedAddressInfo, } #[derive(Clone, Debug, Deserialize, Serialize, TS, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "camelCase")] #[ts(export)] pub struct NetInfo { - #[ts(as = "BTreeSet::")] - #[serde(default)] - pub private_disabled: OrdSet, - #[ts(as = "BTreeSet::")] - #[serde(default)] - pub public_enabled: OrdSet, pub assigned_port: Option, pub assigned_ssl_port: Option, } +impl InterfaceFilter for NetInfo { + fn filter(&self, _id: &GatewayId, info: &NetworkInterfaceInfo) -> bool { + info.ip_info.is_some() + } +} + impl BindInfo { pub fn new(available_ports: &mut AvailablePorts, options: BindOptions) -> Result { let mut assigned_port = None; let mut assigned_ssl_port = None; - if options.add_ssl.is_some() { - assigned_ssl_port = Some(available_ports.alloc()?); + if let Some(ssl) = &options.add_ssl { + assigned_ssl_port = available_ports + .try_alloc(ssl.preferred_external_port, true) + .or_else(|| Some(available_ports.alloc(true).ok()?)); } if options .secure .map_or(true, |s| !(s.ssl && options.add_ssl.is_some())) { - assigned_port = Some(available_ports.alloc()?); + assigned_port = available_ports + .try_alloc(options.preferred_external_port, false) + .or_else(|| Some(available_ports.alloc(false).ok()?)); } Ok(Self { enabled: true, options, net: NetInfo { - private_disabled: OrdSet::new(), - public_enabled: OrdSet::new(), assigned_port, assigned_ssl_port, }, + addresses: DerivedAddressInfo::default(), }) } pub fn update( @@ -97,7 +184,11 @@ impl BindInfo { available_ports: &mut AvailablePorts, options: BindOptions, ) -> Result { - let Self { net: mut lan, .. } = self; + let Self { + net: mut lan, + addresses, + .. + } = self; if options .secure .map_or(true, |s| !(s.ssl && options.add_ssl.is_some())) @@ -105,19 +196,26 @@ impl BindInfo { { lan.assigned_port = if let Some(port) = lan.assigned_port.take() { Some(port) + } else if let Some(port) = + available_ports.try_alloc(options.preferred_external_port, false) + { + Some(port) } else { - Some(available_ports.alloc()?) + Some(available_ports.alloc(false)?) }; } else { if let Some(port) = lan.assigned_port.take() { available_ports.free([port]); } } - if options.add_ssl.is_some() { + if let Some(ssl) = &options.add_ssl { lan.assigned_ssl_port = if let Some(port) = lan.assigned_ssl_port.take() { Some(port) + } else if let Some(port) = available_ports.try_alloc(ssl.preferred_external_port, true) + { + Some(port) } else { - Some(available_ports.alloc()?) + Some(available_ports.alloc(true)?) }; } else { if let Some(port) = lan.assigned_ssl_port.take() { @@ -128,22 +226,17 @@ impl BindInfo { enabled: true, options, net: lan, + addresses: DerivedAddressInfo { + private_disabled: addresses.private_disabled, + public_enabled: addresses.public_enabled, + possible: BTreeSet::new(), + }, }) } pub fn disable(&mut self) { self.enabled = false; } } -impl InterfaceFilter for NetInfo { - fn filter(&self, id: &GatewayId, info: &NetworkInterfaceInfo) -> bool { - info.ip_info.is_some() - && if info.public() { - self.public_enabled.contains(id) - } else { - !self.private_disabled.contains(id) - } - } -} #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, TS)] #[ts(export)] @@ -188,7 +281,7 @@ pub fn binding() let mut table = Table::new(); table.add_row(row![bc => "INTERNAL PORT", "ENABLED", "EXTERNAL PORT", "EXTERNAL SSL PORT"]); - for (internal, info) in res { + for (internal, info) in res.iter() { table.add_row(row![ internal, info.enabled, @@ -213,12 +306,12 @@ pub fn binding() .with_call_remote::(), ) .subcommand( - "set-gateway-enabled", - from_fn_async(set_gateway_enabled::) + "set-address-enabled", + from_fn_async(set_address_enabled::) .with_metadata("sync_db", Value::Bool(true)) .with_inherited(Kind::inheritance) .no_display() - .with_about("about.set-gateway-enabled-for-binding") + .with_about("about.set-address-enabled-for-binding") .with_call_remote::(), ) } @@ -227,7 +320,7 @@ pub async fn list_bindings( ctx: RpcContext, _: Empty, inheritance: Kind::Inheritance, -) -> Result, Error> { +) -> Result { Kind::host_for(&inheritance, &mut ctx.db.peek().await)? .as_bindings() .de() @@ -236,50 +329,44 @@ pub async fn list_bindings( #[derive(Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] -pub struct BindingGatewaySetEnabledParams { +pub struct BindingSetAddressEnabledParams { #[arg(help = "help.arg.internal-port")] internal_port: u16, - #[arg(help = "help.arg.gateway-id")] - gateway: GatewayId, + #[arg(long, help = "help.arg.address")] + address: String, #[arg(long, help = "help.arg.binding-enabled")] enabled: Option, } -pub async fn set_gateway_enabled( +pub async fn set_address_enabled( ctx: RpcContext, - BindingGatewaySetEnabledParams { + BindingSetAddressEnabledParams { internal_port, - gateway, + address, enabled, - }: BindingGatewaySetEnabledParams, + }: BindingSetAddressEnabledParams, inheritance: Kind::Inheritance, ) -> Result<(), Error> { let enabled = enabled.unwrap_or(true); - let gateway_public = ctx - .net_controller - .net_iface - .watcher - .ip_info() - .get(&gateway) - .or_not_found(&gateway)? - .public(); + let address: HostnameInfo = + serde_json::from_str(&address).with_kind(ErrorKind::Deserialization)?; ctx.db .mutate(|db| { Kind::host_for(&inheritance, db)? .as_bindings_mut() .mutate(|b| { - let net = &mut b.get_mut(&internal_port).or_not_found(internal_port)?.net; - if gateway_public { + let bind = b.get_mut(&internal_port).or_not_found(internal_port)?; + if address.public { if enabled { - net.public_enabled.insert(gateway); + bind.addresses.public_enabled.insert(address.clone()); } else { - net.public_enabled.remove(&gateway); + bind.addresses.public_enabled.remove(&address); } } else { if enabled { - net.private_disabled.remove(&gateway); + bind.addresses.private_disabled.remove(&address); } else { - net.private_disabled.insert(gateway); + bind.addresses.private_disabled.insert(address.clone()); } } Ok(()) diff --git a/core/src/net/host/mod.rs b/core/src/net/host/mod.rs index 6fdd65d92..ef06313dd 100644 --- a/core/src/net/host/mod.rs +++ b/core/src/net/host/mod.rs @@ -13,8 +13,7 @@ use crate::context::RpcContext; use crate::db::model::DatabaseModel; use crate::net::forward::AvailablePorts; use crate::net::host::address::{HostAddress, PublicDomainConfig, address_api}; -use crate::net::host::binding::{BindInfo, BindOptions, binding}; -use crate::net::service_interface::HostnameInfo; +use crate::net::host::binding::{BindInfo, BindOptions, Bindings, binding}; use crate::prelude::*; use crate::{HostId, PackageId}; @@ -26,11 +25,9 @@ pub mod binding; #[model = "Model"] #[ts(export)] pub struct Host { - pub bindings: BTreeMap, + pub bindings: Bindings, pub public_domains: BTreeMap, pub private_domains: BTreeSet, - /// COMPUTED: NetService::update - pub hostname_info: BTreeMap>, // internal port -> Hostnames } impl AsRef for Host { @@ -45,7 +42,7 @@ impl Host { pub fn addresses<'a>(&'a self) -> impl Iterator + 'a { self.public_domains .iter() - .map(|(address, config)| HostAddress::Domain { + .map(|(address, config)| HostAddress { address: address.clone(), public: Some(config.clone()), private: self.private_domains.contains(address), @@ -54,7 +51,7 @@ impl Host { self.private_domains .iter() .filter(|a| !self.public_domains.contains_key(*a)) - .map(|address| HostAddress::Domain { + .map(|address| HostAddress { address: address.clone(), public: None, private: true, diff --git a/core/src/net/net_controller.rs b/core/src/net/net_controller.rs index 06cc087b5..56c30ed4e 100644 --- a/core/src/net/net_controller.rs +++ b/core/src/net/net_controller.rs @@ -18,8 +18,8 @@ use crate::hostname::Hostname; use crate::net::dns::DnsController; use crate::net::forward::{InterfacePortForwardController, START9_BRIDGE_IFACE, add_iptables_rule}; use crate::net::gateway::{ - AndFilter, DynInterfaceFilter, IdFilter, InterfaceFilter, NetworkInterfaceController, OrFilter, - PublicFilter, SecureFilter, + AndFilter, AnyFilter, DynInterfaceFilter, IdFilter, InterfaceFilter, + NetworkInterfaceController, OrFilter, PublicFilter, SecureFilter, }; use crate::net::host::address::HostAddress; use crate::net::host::binding::{AddSslOptions, BindId, BindOptions}; @@ -202,7 +202,7 @@ impl NetServiceData { .as_entries_mut()? { host.as_bindings_mut().mutate(|b| { - for (internal_port, info) in b { + for (internal_port, info) in b.iter_mut() { if !except.contains(&BindId { id: host_id.clone(), internal_port: *internal_port, @@ -233,7 +233,7 @@ impl NetServiceData { .as_network_mut() .as_host_mut(); host.as_bindings_mut().mutate(|b| { - for (internal_port, info) in b { + for (internal_port, info) in b.iter_mut() { if !except.contains(&BindId { id: HostId::default(), internal_port: *internal_port, @@ -251,11 +251,15 @@ impl NetServiceData { } } - async fn update(&mut self, ctrl: &NetController, id: HostId, host: Host) -> Result<(), Error> { + async fn update( + &mut self, + ctrl: &NetController, + id: HostId, + mut host: Host, + ) -> Result<(), Error> { let mut forwards: BTreeMap = BTreeMap::new(); let mut vhosts: BTreeMap<(Option, u16), ProxyTarget> = BTreeMap::new(); let mut private_dns: BTreeSet = BTreeSet::new(); - let mut hostname_info: BTreeMap> = BTreeMap::new(); let binds = self.binds.entry(id.clone()).or_default(); let peek = ctrl.db.peek().await; @@ -264,324 +268,327 @@ impl NetServiceData { let server_info = peek.as_public().as_server_info(); let net_ifaces = ctrl.net_iface.watcher.ip_info(); let hostname = server_info.as_hostname().de()?; - for (port, bind) in &host.bindings { + let host_addresses: Vec<_> = host.addresses().collect(); + for (port, bind) in host.bindings.iter_mut() { if !bind.enabled { continue; } - if bind.net.assigned_port.is_some() || bind.net.assigned_ssl_port.is_some() { - let mut hostnames = BTreeSet::new(); - if let Some(ssl) = &bind.options.add_ssl { - let external = bind - .net - .assigned_ssl_port - .or_not_found("assigned ssl port")?; - let addr = (self.ip, *port).into(); - let connect_ssl = if let Some(alpn) = ssl.alpn.clone() { - Err(alpn) + if bind.net.assigned_port.is_none() && bind.net.assigned_ssl_port.is_none() { + continue; + } + let mut hostnames = BTreeSet::new(); + let mut gw_filter = AnyFilter( + [PublicFilter { public: false }.into_dyn()] + .into_iter() + .chain( + bind.addresses + .public_enabled + .iter() + .map(|a| a.gateway.id.clone()) + .collect::>() + .into_iter() + .map(IdFilter) + .map(InterfaceFilter::into_dyn), + ) + .collect(), + ); + if let Some(ssl) = &bind.options.add_ssl { + let external = bind + .net + .assigned_ssl_port + .or_not_found("assigned ssl port")?; + let addr = (self.ip, *port).into(); + let connect_ssl = if let Some(alpn) = ssl.alpn.clone() { + Err(alpn) + } else { + if bind.options.secure.as_ref().map_or(false, |s| s.ssl) { + Ok(()) } else { - if bind.options.secure.as_ref().map_or(false, |s| s.ssl) { - Ok(()) - } else { - Err(AlpnInfo::Reflect) - } - }; - for hostname in ctrl.server_hostnames.iter().cloned() { - vhosts.insert( - (hostname, external), - ProxyTarget { - filter: bind.net.clone().into_dyn(), - acme: None, - addr, - add_x_forwarded_headers: ssl.add_x_forwarded_headers, - connect_ssl: connect_ssl - .clone() - .map(|_| ctrl.tls_client_config.clone()), - }, - ); + Err(AlpnInfo::Reflect) } - for address in host.addresses() { - match address { - HostAddress::Domain { - address, - public, - private, - } => { - if hostnames.insert(address.clone()) { - let address = Some(address.clone()); - if ssl.preferred_external_port == 443 { - if let Some(public) = &public { - vhosts.insert( - (address.clone(), 5443), - ProxyTarget { - filter: AndFilter( - bind.net.clone(), - AndFilter( - IdFilter(public.gateway.clone()), - PublicFilter { public: false }, - ), - ) - .into_dyn(), - acme: public.acme.clone(), - addr, - add_x_forwarded_headers: ssl - .add_x_forwarded_headers, - connect_ssl: connect_ssl - .clone() - .map(|_| ctrl.tls_client_config.clone()), - }, - ); - vhosts.insert( - (address.clone(), 443), - ProxyTarget { - filter: AndFilter( - bind.net.clone(), - if private { - OrFilter( - IdFilter(public.gateway.clone()), - PublicFilter { public: false }, - ) - .into_dyn() - } else { - AndFilter( - IdFilter(public.gateway.clone()), - PublicFilter { public: true }, - ) - .into_dyn() - }, - ) - .into_dyn(), - acme: public.acme.clone(), - addr, - add_x_forwarded_headers: ssl - .add_x_forwarded_headers, - connect_ssl: connect_ssl - .clone() - .map(|_| ctrl.tls_client_config.clone()), - }, - ); - } else { - vhosts.insert( - (address.clone(), 443), - ProxyTarget { - filter: AndFilter( - bind.net.clone(), - PublicFilter { public: false }, - ) - .into_dyn(), - acme: None, - addr, - add_x_forwarded_headers: ssl - .add_x_forwarded_headers, - connect_ssl: connect_ssl - .clone() - .map(|_| ctrl.tls_client_config.clone()), - }, - ); - } - } else { - if let Some(public) = public { - vhosts.insert( - (address.clone(), external), - ProxyTarget { - filter: AndFilter( - bind.net.clone(), - if private { - OrFilter( - IdFilter(public.gateway.clone()), - PublicFilter { public: false }, - ) - .into_dyn() - } else { - IdFilter(public.gateway.clone()) - .into_dyn() - }, - ) - .into_dyn(), - acme: public.acme.clone(), - addr, - add_x_forwarded_headers: ssl - .add_x_forwarded_headers, - connect_ssl: connect_ssl - .clone() - .map(|_| ctrl.tls_client_config.clone()), - }, - ); - } else { - vhosts.insert( - (address.clone(), external), - ProxyTarget { - filter: AndFilter( - bind.net.clone(), - PublicFilter { public: false }, - ) - .into_dyn(), - acme: None, - addr, - add_x_forwarded_headers: ssl - .add_x_forwarded_headers, - connect_ssl: connect_ssl - .clone() - .map(|_| ctrl.tls_client_config.clone()), - }, - ); - } - } - } - } - } - } - } - if bind - .options - .secure - .map_or(true, |s| !(s.ssl && bind.options.add_ssl.is_some())) - { - let external = bind.net.assigned_port.or_not_found("assigned lan port")?; - forwards.insert( - external, - ( - SocketAddrV4::new(self.ip, *port), - AndFilter( - SecureFilter { - secure: bind.options.secure.is_some(), - }, - bind.net.clone(), - ) - .into_dyn(), - ), + }; + for hostname in ctrl.server_hostnames.iter().cloned() { + vhosts.insert( + (hostname, external), + ProxyTarget { + filter: gw_filter.clone().into_dyn(), + acme: None, + addr, + add_x_forwarded_headers: ssl.add_x_forwarded_headers, + connect_ssl: connect_ssl + .clone() + .map(|_| ctrl.tls_client_config.clone()), + }, // TODO: allow public traffic? ); } - let mut bind_hostname_info: Vec = - hostname_info.remove(port).unwrap_or_default(); - for (gateway_id, info) in net_ifaces - .iter() - .filter(|(_, info)| { - info.ip_info.as_ref().map_or(false, |i| { - !matches!(i.device_type, Some(NetworkInterfaceType::Bridge)) - }) - }) - .filter(|(id, info)| bind.net.filter(id, info)) + for HostAddress { + address, + public, + private, + } in host_addresses.iter().cloned() { - let gateway = GatewayInfo { - id: gateway_id.clone(), - name: info - .name - .clone() - .or_else(|| info.ip_info.as_ref().map(|i| i.name.clone())) - .unwrap_or_else(|| gateway_id.clone().into()), - public: info.public(), - }; - let port = bind.net.assigned_port.filter(|_| { - bind.options.secure.map_or(false, |s| { - !(s.ssl && bind.options.add_ssl.is_some()) || info.secure() - }) - }); - if !info.public() - && info.ip_info.as_ref().map_or(false, |i| { - i.device_type != Some(NetworkInterfaceType::Wireguard) - }) - { - bind_hostname_info.push(HostnameInfo { - gateway: gateway.clone(), - public: false, - hostname: IpHostname::Local { - value: InternedString::from_display(&{ - let hostname = &hostname; - lazy_format!("{hostname}.local") - }), - port, - ssl_port: bind.net.assigned_ssl_port, - }, - }); - } - for address in host.addresses() { - if let HostAddress::Domain { - address, - public, - private, - } = address - { - if public.is_none() { - private_dns.insert(address.clone()); + if hostnames.insert(address.clone()) { + let address = Some(address.clone()); + if ssl.preferred_external_port == 443 { + if let Some(public) = &public { + vhosts.insert( + (address.clone(), 5443), + ProxyTarget { + filter: AndFilter( + bind.net.clone(), + AndFilter( + IdFilter(public.gateway.clone()), + PublicFilter { public: false }, + ), + ) + .into_dyn(), + acme: public.acme.clone(), + addr, + add_x_forwarded_headers: ssl.add_x_forwarded_headers, + connect_ssl: connect_ssl + .clone() + .map(|_| ctrl.tls_client_config.clone()), + }, + ); + vhosts.insert( + (address.clone(), 443), + ProxyTarget { + filter: AndFilter( + bind.net.clone(), + if private { + OrFilter( + IdFilter(public.gateway.clone()), + PublicFilter { public: false }, + ) + .into_dyn() + } else { + AndFilter( + IdFilter(public.gateway.clone()), + PublicFilter { public: true }, + ) + .into_dyn() + }, + ) + .into_dyn(), + acme: public.acme.clone(), + addr, + add_x_forwarded_headers: ssl.add_x_forwarded_headers, + connect_ssl: connect_ssl + .clone() + .map(|_| ctrl.tls_client_config.clone()), + }, + ); + } else { + vhosts.insert( + (address.clone(), 443), + ProxyTarget { + filter: AndFilter( + bind.net.clone(), + PublicFilter { public: false }, + ) + .into_dyn(), + acme: None, + addr, + add_x_forwarded_headers: ssl.add_x_forwarded_headers, + connect_ssl: connect_ssl + .clone() + .map(|_| ctrl.tls_client_config.clone()), + }, + ); } - let private = private && !info.public(); - let public = - public.as_ref().map_or(false, |p| &p.gateway == gateway_id); - if public || private { - if bind - .options - .add_ssl - .as_ref() - .map_or(false, |ssl| ssl.preferred_external_port == 443) - { - bind_hostname_info.push(HostnameInfo { - gateway: gateway.clone(), - public, - hostname: IpHostname::Domain { - value: address.clone(), - port: None, - ssl_port: Some(443), - }, - }); - } else { - bind_hostname_info.push(HostnameInfo { - gateway: gateway.clone(), - public, - hostname: IpHostname::Domain { - value: address.clone(), - port, - ssl_port: bind.net.assigned_ssl_port, - }, - }); - } + } else { + if let Some(public) = public { + vhosts.insert( + (address.clone(), external), + ProxyTarget { + filter: AndFilter( + bind.net.clone(), + if private { + OrFilter( + IdFilter(public.gateway.clone()), + PublicFilter { public: false }, + ) + .into_dyn() + } else { + IdFilter(public.gateway.clone()).into_dyn() + }, + ) + .into_dyn(), + acme: public.acme.clone(), + addr, + add_x_forwarded_headers: ssl.add_x_forwarded_headers, + connect_ssl: connect_ssl + .clone() + .map(|_| ctrl.tls_client_config.clone()), + }, + ); + } else { + vhosts.insert( + (address.clone(), external), + ProxyTarget { + filter: AndFilter( + bind.net.clone(), + PublicFilter { public: false }, + ) + .into_dyn(), + acme: None, + addr, + add_x_forwarded_headers: ssl.add_x_forwarded_headers, + connect_ssl: connect_ssl + .clone() + .map(|_| ctrl.tls_client_config.clone()), + }, + ); } } } - if let Some(ip_info) = &info.ip_info { - let public = info.public(); - if let Some(wan_ip) = ip_info.wan_ip { - bind_hostname_info.push(HostnameInfo { + } + } + if bind + .options + .secure + .map_or(true, |s| !(s.ssl && bind.options.add_ssl.is_some())) + { + let external = bind.net.assigned_port.or_not_found("assigned lan port")?; + forwards.insert( + external, + ( + SocketAddrV4::new(self.ip, *port), + AndFilter( + SecureFilter { + secure: bind.options.secure.is_some(), + }, + bind.net.clone(), + ) + .into_dyn(), + ), + ); + } + bind.addresses.possible.clear(); + for (gateway_id, info) in net_ifaces + .iter() + .filter(|(_, info)| { + info.ip_info.as_ref().map_or(false, |i| { + !matches!(i.device_type, Some(NetworkInterfaceType::Bridge)) + }) + }) + .filter(|(id, info)| bind.net.filter(id, info)) + { + let gateway = GatewayInfo { + id: gateway_id.clone(), + name: info + .name + .clone() + .or_else(|| info.ip_info.as_ref().map(|i| i.name.clone())) + .unwrap_or_else(|| gateway_id.clone().into()), + public: info.public(), + }; + let port = bind.net.assigned_port.filter(|_| { + bind.options.secure.map_or(false, |s| { + !(s.ssl && bind.options.add_ssl.is_some()) || info.secure() + }) + }); + if !info.public() + && info.ip_info.as_ref().map_or(false, |i| { + i.device_type != Some(NetworkInterfaceType::Wireguard) + }) + { + bind.addresses.possible.insert(HostnameInfo { + gateway: gateway.clone(), + public: false, + hostname: IpHostname::Local { + value: InternedString::from_display(&{ + let hostname = &hostname; + lazy_format!("{hostname}.local") + }), + port, + ssl_port: bind.net.assigned_ssl_port, + }, + }); + } + for HostAddress { + address, + public, + private, + } in host_addresses.iter().cloned() + { + if public.is_none() { + private_dns.insert(address.clone()); + } + let private = private && !info.public(); + let public = public.as_ref().map_or(false, |p| &p.gateway == gateway_id); + if public || private { + if bind + .options + .add_ssl + .as_ref() + .map_or(false, |ssl| ssl.preferred_external_port == 443) + { + bind.addresses.possible.insert(HostnameInfo { gateway: gateway.clone(), - public: true, - hostname: IpHostname::Ipv4 { - value: wan_ip, + public, + hostname: IpHostname::Domain { + value: address.clone(), + port: None, + ssl_port: Some(443), + }, + }); + } else { + bind.addresses.possible.insert(HostnameInfo { + gateway: gateway.clone(), + public, + hostname: IpHostname::Domain { + value: address.clone(), port, ssl_port: bind.net.assigned_ssl_port, }, }); } - for ipnet in &ip_info.subnets { - match ipnet { - IpNet::V4(net) => { - if !public { - bind_hostname_info.push(HostnameInfo { - gateway: gateway.clone(), - public, - hostname: IpHostname::Ipv4 { - value: net.addr(), - port, - ssl_port: bind.net.assigned_ssl_port, - }, - }); - } - } - IpNet::V6(net) => { - bind_hostname_info.push(HostnameInfo { + } + } + if let Some(ip_info) = &info.ip_info { + let public = info.public(); + if let Some(wan_ip) = ip_info.wan_ip { + bind.addresses.possible.insert(HostnameInfo { + gateway: gateway.clone(), + public: true, + hostname: IpHostname::Ipv4 { + value: wan_ip, + port, + ssl_port: bind.net.assigned_ssl_port, + }, + }); + } + for ipnet in &ip_info.subnets { + match ipnet { + IpNet::V4(net) => { + if !public { + bind.addresses.possible.insert(HostnameInfo { gateway: gateway.clone(), - public: public && !ipv6_is_local(net.addr()), - hostname: IpHostname::Ipv6 { + public, + hostname: IpHostname::Ipv4 { value: net.addr(), - scope_id: ip_info.scope_id, port, ssl_port: bind.net.assigned_ssl_port, }, }); } } + IpNet::V6(net) => { + bind.addresses.possible.insert(HostnameInfo { + gateway: gateway.clone(), + public: public && !ipv6_is_local(net.addr()), + hostname: IpHostname::Ipv6 { + value: net.addr(), + scope_id: ip_info.scope_id, + port, + ssl_port: bind.net.assigned_ssl_port, + }, + }); + } } } } - hostname_info.insert(*port, bind_hostname_info); } } @@ -673,9 +680,26 @@ impl NetServiceData { } ctrl.dns.gc_private_domains(&rm)?; + let res = ctrl + .db + .mutate(|db| { + let bindings = host_for(db, self.id.as_ref(), &id)?.as_bindings_mut(); + for (port, bind) in host.bindings.0 { + if let Some(b) = bindings.as_idx_mut(&port) { + b.as_addresses_mut() + .as_possible_mut() + .ser(&bind.addresses.possible)?; + } + } + Ok(()) + }) + .await; + res.result?; if let Some(pkg_id) = self.id.as_ref() { - if let Some(cbs) = ctrl.callbacks.get_host_info(&(pkg_id.clone(), id)) { - cbs.call(vector![]).await?; + if res.revision.is_some() { + if let Some(cbs) = ctrl.callbacks.get_host_info(&(pkg_id.clone(), id)) { + cbs.call(vector![]).await?; + } } } Ok(()) diff --git a/core/src/net/service_interface.rs b/core/src/net/service_interface.rs index 6d04ae891..1eae3ebca 100644 --- a/core/src/net/service_interface.rs +++ b/core/src/net/service_interface.rs @@ -6,7 +6,7 @@ use ts_rs::TS; use crate::{GatewayId, HostId, ServiceInterfaceId}; -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)] #[ts(export)] #[serde(rename_all = "camelCase")] pub struct HostnameInfo { @@ -20,7 +20,7 @@ impl HostnameInfo { } } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)] #[ts(export)] #[serde(rename_all = "camelCase")] pub struct GatewayInfo { @@ -29,7 +29,7 @@ pub struct GatewayInfo { pub public: bool, } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)] #[ts(export)] #[serde(rename_all = "camelCase")] #[serde(rename_all_fields = "camelCase")] diff --git a/core/src/net/tunnel.rs b/core/src/net/tunnel.rs index f3b505850..8635475e1 100644 --- a/core/src/net/tunnel.rs +++ b/core/src/net/tunnel.rs @@ -175,8 +175,12 @@ pub async fn remove_tunnel( let host = host?; host.as_bindings_mut().mutate(|b| { Ok(b.values_mut().for_each(|v| { - v.net.private_disabled.remove(&id); - v.net.public_enabled.remove(&id); + v.addresses + .private_disabled + .retain(|h| h.gateway.id != id); + v.addresses + .public_enabled + .retain(|h| h.gateway.id != id); })) })?; } diff --git a/core/src/service/effects/net/ssl.rs b/core/src/service/effects/net/ssl.rs index 317fb50c8..7de40aed6 100644 --- a/core/src/service/effects/net/ssl.rs +++ b/core/src/service/effects/net/ssl.rs @@ -60,10 +60,10 @@ pub async fn get_ssl_certificate( .into_iter() .chain(m.as_private_domains().de()?) .chain( - m.as_hostname_info() + m.as_bindings() .de()? .values() - .flatten() + .flat_map(|b| b.addresses.possible.iter().cloned()) .map(|h| h.to_san_hostname()), ) .collect::>()) @@ -184,10 +184,10 @@ pub async fn get_ssl_key( .into_iter() .chain(m.as_private_domains().de()?) .chain( - m.as_hostname_info() + m.as_bindings() .de()? .values() - .flatten() + .flat_map(|b| b.addresses.possible.iter().cloned()) .map(|h| h.to_san_hostname()), ) .collect::>()) diff --git a/core/src/version/v0_4_0_alpha_20.rs b/core/src/version/v0_4_0_alpha_20.rs index 3320af322..3da7caf5c 100644 --- a/core/src/version/v0_4_0_alpha_20.rs +++ b/core/src/version/v0_4_0_alpha_20.rs @@ -57,29 +57,6 @@ impl VersionT for Version { } } - // Remove onion entries from hostnameInfo in server host - migrate_hostname_info( - db.get_mut("public") - .and_then(|p| p.get_mut("serverInfo")) - .and_then(|s| s.get_mut("network")) - .and_then(|n| n.get_mut("host")), - ); - - // Remove onion entries from hostnameInfo in all package hosts - if let Some(packages) = db - .get_mut("public") - .and_then(|p| p.get_mut("packageData")) - .and_then(|p| p.as_object_mut()) - { - for (_, package) in packages.iter_mut() { - if let Some(hosts) = package.get_mut("hosts").and_then(|h| h.as_object_mut()) { - for (_, host) in hosts.iter_mut() { - migrate_hostname_info(Some(host)); - } - } - } - } - // Remove onion store from private keyStore if let Some(key_store) = db .get_mut("private") @@ -89,6 +66,29 @@ impl VersionT for Version { key_store.remove("onion"); } + // Migrate server host: remove hostnameInfo, add addresses to bindings, clean net + migrate_host( + db.get_mut("public") + .and_then(|p| p.get_mut("serverInfo")) + .and_then(|s| s.get_mut("network")) + .and_then(|n| n.get_mut("host")), + ); + + // Migrate all package hosts + if let Some(packages) = db + .get_mut("public") + .and_then(|p| p.get_mut("packageData")) + .and_then(|p| p.as_object_mut()) + { + for (_, package) in packages.iter_mut() { + if let Some(hosts) = package.get_mut("hosts").and_then(|h| h.as_object_mut()) { + for (_, host) in hosts.iter_mut() { + migrate_host(Some(host)); + } + } + } + } + Ok(Value::Null) } fn down(self, _db: &mut Value) -> Result<(), Error> { @@ -96,20 +96,35 @@ impl VersionT for Version { } } -fn migrate_hostname_info(host: Option<&mut Value>) { - if let Some(hostname_info) = host - .and_then(|h| h.get_mut("hostnameInfo")) - .and_then(|h| h.as_object_mut()) - { - for (_, infos) in hostname_info.iter_mut() { - if let Some(arr) = infos.as_array_mut() { - // Remove onion entries - arr.retain(|info| info.get("kind").and_then(|k| k.as_str()) != Some("onion")); - // Strip "kind" field from remaining entries (HostnameInfo flattened from enum to struct) - for info in arr.iter_mut() { - if let Some(obj) = info.as_object_mut() { - obj.remove("kind"); - } +fn migrate_host(host: Option<&mut Value>) { + let Some(host) = host.and_then(|h| h.as_object_mut()) else { + return; + }; + + // Remove hostnameInfo from host + host.remove("hostnameInfo"); + + // For each binding: add "addresses" field, remove gateway-level fields from "net" + if let Some(bindings) = host.get_mut("bindings").and_then(|b| b.as_object_mut()) { + for (_, binding) in bindings.iter_mut() { + if let Some(binding_obj) = binding.as_object_mut() { + // Add addresses if not present + if !binding_obj.contains_key("addresses") { + binding_obj.insert( + "addresses".into(), + serde_json::json!({ + "privateDisabled": [], + "publicEnabled": [], + "possible": [] + }) + .into(), + ); + } + + // Remove gateway-level privateDisabled/publicEnabled from net + if let Some(net) = binding_obj.get_mut("net").and_then(|n| n.as_object_mut()) { + net.remove("privateDisabled"); + net.remove("publicEnabled"); } } } diff --git a/sdk/base/lib/osBindings/BindInfo.ts b/sdk/base/lib/osBindings/BindInfo.ts index eba1fe446..d83c6948f 100644 --- a/sdk/base/lib/osBindings/BindInfo.ts +++ b/sdk/base/lib/osBindings/BindInfo.ts @@ -1,5 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { BindOptions } from './BindOptions' +import type { DerivedAddressInfo } from './DerivedAddressInfo' import type { NetInfo } from './NetInfo' -export type BindInfo = { enabled: boolean; options: BindOptions; net: NetInfo } +export type BindInfo = { + enabled: boolean + options: BindOptions + net: NetInfo + addresses: DerivedAddressInfo +} diff --git a/sdk/base/lib/osBindings/BindingGatewaySetEnabledParams.ts b/sdk/base/lib/osBindings/BindingSetAddressEnabledParams.ts similarity index 58% rename from sdk/base/lib/osBindings/BindingGatewaySetEnabledParams.ts rename to sdk/base/lib/osBindings/BindingSetAddressEnabledParams.ts index fb90cdaa7..2dfeff757 100644 --- a/sdk/base/lib/osBindings/BindingGatewaySetEnabledParams.ts +++ b/sdk/base/lib/osBindings/BindingSetAddressEnabledParams.ts @@ -1,8 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { GatewayId } from './GatewayId' -export type BindingGatewaySetEnabledParams = { +export type BindingSetAddressEnabledParams = { internalPort: number - gateway: GatewayId + address: string enabled: boolean | null } diff --git a/sdk/base/lib/osBindings/Bindings.ts b/sdk/base/lib/osBindings/Bindings.ts new file mode 100644 index 000000000..ef921868e --- /dev/null +++ b/sdk/base/lib/osBindings/Bindings.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BindInfo } from './BindInfo' + +export type Bindings = { [key: number]: BindInfo } diff --git a/sdk/base/lib/osBindings/DerivedAddressInfo.ts b/sdk/base/lib/osBindings/DerivedAddressInfo.ts new file mode 100644 index 000000000..2c83191fd --- /dev/null +++ b/sdk/base/lib/osBindings/DerivedAddressInfo.ts @@ -0,0 +1,17 @@ +// 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 DerivedAddressInfo = { + /** + * User-controlled: private-gateway addresses the user has disabled + */ + privateDisabled: Array + /** + * User-controlled: public-gateway addresses the user has enabled + */ + publicEnabled: Array + /** + * COMPUTED: NetServiceData::update — all possible addresses for this binding + */ + possible: Array +} diff --git a/sdk/base/lib/osBindings/Host.ts b/sdk/base/lib/osBindings/Host.ts index 1cbe0b84e..cecc24475 100644 --- a/sdk/base/lib/osBindings/Host.ts +++ b/sdk/base/lib/osBindings/Host.ts @@ -1,14 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { BindInfo } from './BindInfo' -import type { HostnameInfo } from './HostnameInfo' +import type { Bindings } from './Bindings' import type { PublicDomainConfig } from './PublicDomainConfig' export type Host = { - bindings: { [key: number]: BindInfo } + bindings: Bindings publicDomains: { [key: string]: PublicDomainConfig } privateDomains: Array - /** - * COMPUTED: NetService::update - */ - hostnameInfo: { [key: number]: Array } } diff --git a/sdk/base/lib/osBindings/NetInfo.ts b/sdk/base/lib/osBindings/NetInfo.ts index 4483f81b8..90abe2cd8 100644 --- a/sdk/base/lib/osBindings/NetInfo.ts +++ b/sdk/base/lib/osBindings/NetInfo.ts @@ -1,9 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { GatewayId } from './GatewayId' export type NetInfo = { - privateDisabled: Array - publicEnabled: Array assignedPort: number | null assignedSslPort: number | null } diff --git a/sdk/base/lib/osBindings/index.ts b/sdk/base/lib/osBindings/index.ts index 3f02322d8..18cca9c9a 100644 --- a/sdk/base/lib/osBindings/index.ts +++ b/sdk/base/lib/osBindings/index.ts @@ -36,7 +36,8 @@ export { BackupTargetFS } from './BackupTargetFS' export { Base64 } from './Base64' export { BindId } from './BindId' export { BindInfo } from './BindInfo' -export { BindingGatewaySetEnabledParams } from './BindingGatewaySetEnabledParams' +export { BindingSetAddressEnabledParams } from './BindingSetAddressEnabledParams' +export { Bindings } from './Bindings' export { BindOptions } from './BindOptions' export { BindParams } from './BindParams' export { Blake3Commitment } from './Blake3Commitment' @@ -64,6 +65,7 @@ export { Dependencies } from './Dependencies' export { DependencyMetadata } from './DependencyMetadata' export { DependencyRequirement } from './DependencyRequirement' export { DepInfo } from './DepInfo' +export { DerivedAddressInfo } from './DerivedAddressInfo' export { Description } from './Description' export { DesiredStatus } from './DesiredStatus' export { DestroySubcontainerFsParams } from './DestroySubcontainerFsParams' diff --git a/sdk/base/lib/util/getServiceInterface.ts b/sdk/base/lib/util/getServiceInterface.ts index cc80e3aec..bc22e70c6 100644 --- a/sdk/base/lib/util/getServiceInterface.ts +++ b/sdk/base/lib/util/getServiceInterface.ts @@ -1,6 +1,12 @@ import { PackageId, ServiceInterfaceId, ServiceInterfaceType } from '../types' import { knownProtocols } from '../interfaces/Host' -import { AddressInfo, Host, Hostname, HostnameInfo } from '../types' +import { + AddressInfo, + DerivedAddressInfo, + Host, + Hostname, + HostnameInfo, +} from '../types' import { Effects } from '../Effects' import { DropGenerator, DropPromise } from './Drop' import { IpAddress, IPV6_LINK_LOCAL } from './ip' @@ -220,6 +226,14 @@ function filterRec( return hostnames } +function enabledAddresses(addr: DerivedAddressInfo): HostnameInfo[] { + return addr.possible.filter((h) => + h.public + ? addr.publicEnabled.some((e) => deepEqual(e, h)) + : !addr.privateDisabled.some((d) => deepEqual(d, h)), + ) +} + export const filledAddress = ( host: Host, addressInfo: AddressInfo, @@ -229,7 +243,8 @@ export const filledAddress = ( const u = toUrls(h) return [u.url, u.sslUrl].filter((u) => u !== null) } - const hostnames = host.hostnameInfo[addressInfo.internalPort] ?? [] + const binding = host.bindings[addressInfo.internalPort] + const hostnames = binding ? enabledAddresses(binding.addresses) : [] function filledAddressFromHostnames( hostnames: HostnameInfo[], diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/gateways.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/gateways.component.ts index 077fa87bd..1a3a4d632 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/gateways.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/gateways.component.ts @@ -83,32 +83,8 @@ export class InterfaceGatewaysComponent { readonly gateways = input.required() - async onToggle(gateway: InterfaceGateway) { - const addressInfo = this.interface.value()!.addressInfo - const pkgId = this.interface.packageId() - - const loader = this.loader.open().subscribe() - - try { - if (pkgId) { - await this.api.pkgBindingToggleGateway({ - gateway: gateway.id, - enabled: !gateway.enabled, - internalPort: addressInfo.internalPort, - host: addressInfo.hostId, - package: pkgId, - }) - } else { - await this.api.serverBindingToggleGateway({ - gateway: gateway.id, - enabled: !gateway.enabled, - internalPort: 80, - }) - } - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } + async onToggle(_gateway: InterfaceGateway) { + // TODO: Replace with per-address toggle UI (Section 6 frontend overhaul). + // Gateway-level toggle replaced by set-address-enabled RPC. } } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts index 21bc013b7..6e05c02d9 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts @@ -252,18 +252,23 @@ export class InterfaceService { serviceInterface: T.ServiceInterface, host: T.Host, ): T.HostnameInfo[] { - let hostnameInfo = - host.hostnameInfo[serviceInterface.addressInfo.internalPort] - return ( - hostnameInfo?.filter( - h => - this.config.accessType === 'localhost' || - !( - (h.hostname.kind === 'ipv6' && - utils.IPV6_LINK_LOCAL.contains(h.hostname.value)) || - h.gateway.id === 'lo' - ), - ) || [] + const binding = + host.bindings[serviceInterface.addressInfo.internalPort] + if (!binding) return [] + const addr = binding.addresses + const enabled = addr.possible.filter(h => + h.public + ? addr.publicEnabled.some(e => utils.deepEqual(e, h)) + : !addr.privateDisabled.some(d => utils.deepEqual(d, h)), + ) + return enabled.filter( + h => + this.config.accessType === 'localhost' || + !( + (h.hostname.kind === 'ipv6' && + utils.IPV6_LINK_LOCAL.contains(h.hostname.value)) || + h.gateway.id === 'lo' + ), ) } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts index 0fa1b4766..03ec3d3ee 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts @@ -135,8 +135,8 @@ export default class ServiceInterfaceRoute { gateways.map(g => ({ enabled: (g.public - ? binding?.net.publicEnabled.includes(g.id) - : !binding?.net.privateDisabled.includes(g.id)) ?? false, + ? binding?.addresses.publicEnabled.some(a => a.gateway.id === g.id) + : !binding?.addresses.privateDisabled.some(a => a.gateway.id === g.id)) ?? false, ...g, })) || [], publicDomains: getPublicDomains(host.publicDomains, gateways), diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts index 08ba78d94..6d2246f98 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts @@ -96,8 +96,8 @@ export default class StartOsUiComponent { gateways: gateways.map(g => ({ enabled: (g.public - ? binding?.net.publicEnabled.includes(g.id) - : !binding?.net.privateDisabled.includes(g.id)) ?? false, + ? binding?.addresses.publicEnabled.some(a => a.gateway.id === g.id) + : !binding?.addresses.privateDisabled.some(a => a.gateway.id === g.id)) ?? false, ...g, })), publicDomains: getPublicDomains(network.host.publicDomains, gateways), diff --git a/web/projects/ui/src/app/services/api/api.fixures.ts b/web/projects/ui/src/app/services/api/api.fixures.ts index a5280f5f4..06f482e99 100644 --- a/web/projects/ui/src/app/services/api/api.fixures.ts +++ b/web/projects/ui/src/app/services/api/api.fixures.ts @@ -2126,8 +2126,74 @@ export namespace Mock { net: { assignedPort: 80, assignedSslPort: 443, - publicEnabled: [], + }, + addresses: { privateDisabled: [], + publicEnabled: [], + possible: [ + { + gateway: { id: 'eth0', name: 'Ethernet', public: false }, + public: false, + hostname: { + kind: 'local', + value: 'adjective-noun.local', + port: null, + sslPort: 1234, + }, + }, + { + gateway: { id: 'wlan0', name: 'Wireless', public: false }, + public: false, + hostname: { + kind: 'local', + value: 'adjective-noun.local', + port: null, + sslPort: 1234, + }, + }, + { + gateway: { id: 'wlan0', name: 'Wireless', public: false }, + public: false, + hostname: { + kind: 'ipv4', + value: '192.168.10.11', + port: null, + sslPort: 1234, + }, + }, + { + gateway: { id: 'wlan0', name: 'Wireless', public: false }, + public: false, + hostname: { + kind: 'ipv4', + value: '10.0.0.2', + port: null, + sslPort: 1234, + }, + }, + { + gateway: { id: 'eth0', name: 'Ethernet', public: false }, + public: false, + hostname: { + kind: 'ipv6', + value: '[fe80:cd00:0000:0cde:1257:0000:211e:72cd]', + scopeId: 2, + port: null, + sslPort: 1234, + }, + }, + { + gateway: { id: 'wlan0', name: 'Wireless', public: false }, + public: false, + hostname: { + kind: 'ipv6', + value: '[fe80:cd00:0000:0cde:1257:0000:211e:1234]', + scopeId: 3, + port: null, + sslPort: 1234, + }, + }, + ], }, options: { addSsl: null, @@ -2138,78 +2204,6 @@ export namespace Mock { }, publicDomains: {}, privateDomains: [], - hostnameInfo: { - 80: [ - { - kind: 'ip', - gateway: { id: 'eth0', name: 'Ethernet', public: false }, - public: false, - hostname: { - kind: 'local', - value: 'adjective-noun.local', - port: null, - sslPort: 1234, - }, - }, - { - kind: 'ip', - gateway: { id: 'wlan0', name: 'Wireless', public: false }, - public: false, - hostname: { - kind: 'local', - value: 'adjective-noun.local', - port: null, - sslPort: 1234, - }, - }, - { - kind: 'ip', - gateway: { id: 'wlan0', name: 'Wireless', public: false }, - public: false, - hostname: { - kind: 'ipv4', - value: '192.168.10.11', - port: null, - sslPort: 1234, - }, - }, - { - kind: 'ip', - gateway: { id: 'wlan0', name: 'Wireless', public: false }, - public: false, - hostname: { - kind: 'ipv4', - value: '10.0.0.2', - port: null, - sslPort: 1234, - }, - }, - { - kind: 'ip', - gateway: { id: 'eth0', name: 'Ethernet', public: false }, - public: false, - hostname: { - kind: 'ipv6', - value: '[fe80:cd00:0000:0cde:1257:0000:211e:72cd]', - scopeId: 2, - port: null, - sslPort: 1234, - }, - }, - { - kind: 'ip', - gateway: { id: 'wlan0', name: 'Wireless', public: false }, - public: false, - hostname: { - kind: 'ipv6', - value: '[fe80:cd00:0000:0cde:1257:0000:211e:1234]', - scopeId: 3, - port: null, - sslPort: 1234, - }, - }, - ], - }, }, bcdefgh: { bindings: { @@ -2218,8 +2212,11 @@ export namespace Mock { net: { assignedPort: 8332, assignedSslPort: null, - publicEnabled: [], + }, + addresses: { privateDisabled: [], + publicEnabled: [], + possible: [], }, options: { addSsl: null, @@ -2230,9 +2227,6 @@ export namespace Mock { }, publicDomains: {}, privateDomains: [], - hostnameInfo: { - 8332: [], - }, }, cdefghi: { bindings: { @@ -2241,8 +2235,11 @@ export namespace Mock { net: { assignedPort: 8333, assignedSslPort: null, - publicEnabled: [], + }, + addresses: { privateDisabled: [], + publicEnabled: [], + possible: [], }, options: { addSsl: null, @@ -2253,9 +2250,6 @@ export namespace Mock { }, publicDomains: {}, privateDomains: [], - hostnameInfo: { - 8333: [], - }, }, }, storeExposedDependents: [], diff --git a/web/projects/ui/src/app/services/api/api.types.ts b/web/projects/ui/src/app/services/api/api.types.ts index 85abac31a..73d42ee68 100644 --- a/web/projects/ui/src/app/services/api/api.types.ts +++ b/web/projects/ui/src/app/services/api/api.types.ts @@ -281,13 +281,13 @@ export namespace RR { } export type RemoveAcmeRes = null - export type ServerBindingToggleGatewayReq = { - // server.host.binding.set-gateway-enabled - gateway: T.GatewayId + export type ServerBindingSetAddressEnabledReq = { + // server.host.binding.set-address-enabled internalPort: 80 - enabled: boolean + address: string // JSON-serialized HostnameInfo + enabled: boolean | null // null = reset to default } - export type ServerBindingToggleGatewayRes = null + export type ServerBindingSetAddressEnabledRes = null export type OsUiAddPublicDomainReq = { // server.host.address.domain.public.add @@ -315,16 +315,16 @@ export namespace RR { } export type OsUiRemovePrivateDomainRes = null - export type PkgBindingToggleGatewayReq = Omit< - ServerBindingToggleGatewayReq, + export type PkgBindingSetAddressEnabledReq = Omit< + ServerBindingSetAddressEnabledReq, 'internalPort' > & { - // package.host.binding.set-gateway-enabled + // package.host.binding.set-address-enabled internalPort: number package: T.PackageId // string host: T.HostId // string } - export type PkgBindingToggleGatewayRes = null + export type PkgBindingSetAddressEnabledRes = null export type PkgAddPublicDomainReq = OsUiAddPublicDomainReq & { // package.host.address.domain.public.add diff --git a/web/projects/ui/src/app/services/api/embassy-api.service.ts b/web/projects/ui/src/app/services/api/embassy-api.service.ts index c55dc8441..ee79644f8 100644 --- a/web/projects/ui/src/app/services/api/embassy-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-api.service.ts @@ -336,9 +336,9 @@ export abstract class ApiService { abstract removeAcme(params: RR.RemoveAcmeReq): Promise - abstract serverBindingToggleGateway( - params: RR.ServerBindingToggleGatewayReq, - ): Promise + abstract serverBindingSetAddressEnabled( + params: RR.ServerBindingSetAddressEnabledReq, + ): Promise abstract osUiAddPublicDomain( params: RR.OsUiAddPublicDomainReq, @@ -356,9 +356,9 @@ export abstract class ApiService { params: RR.OsUiRemovePrivateDomainReq, ): Promise - abstract pkgBindingToggleGateway( - params: RR.PkgBindingToggleGatewayReq, - ): Promise + abstract pkgBindingSetAddressEnabled( + params: RR.PkgBindingSetAddressEnabledReq, + ): Promise abstract pkgAddPublicDomain( params: RR.PkgAddPublicDomainReq, diff --git a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts index 00524e2ef..ad7085c40 100644 --- a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -607,11 +607,11 @@ export class LiveApiService extends ApiService { }) } - async serverBindingToggleGateway( - params: RR.ServerBindingToggleGatewayReq, - ): Promise { + async serverBindingSetAddressEnabled( + params: RR.ServerBindingSetAddressEnabledReq, + ): Promise { return this.rpcRequest({ - method: 'server.host.binding.set-gateway-enabled', + method: 'server.host.binding.set-address-enabled', params, }) } @@ -652,11 +652,11 @@ export class LiveApiService extends ApiService { }) } - async pkgBindingToggleGateway( - params: RR.PkgBindingToggleGatewayReq, - ): Promise { + async pkgBindingSetAddressEnabled( + params: RR.PkgBindingSetAddressEnabledReq, + ): Promise { return this.rpcRequest({ - method: 'package.host.binding.set-gateway-enabled', + method: 'package.host.binding.set-address-enabled', params, }) } diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index e91a732e5..3c3e7a1d7 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -1348,20 +1348,12 @@ export class MockApiService extends ApiService { return null } - async serverBindingToggleGateway( - params: RR.ServerBindingToggleGatewayReq, - ): Promise { + async serverBindingSetAddressEnabled( + params: RR.ServerBindingSetAddressEnabledReq, + ): Promise { await pauseFor(2000) - const patch = [ - { - op: PatchOp.REPLACE, - path: `/serverInfo/network/host/bindings/${params.internalPort}/net/publicEnabled`, - value: params.enabled ? [params.gateway] : [], - }, - ] - this.mockRevision(patch) - + // Mock: no-op since address enable/disable modifies DerivedAddressInfo sets return null } @@ -1380,10 +1372,9 @@ export class MockApiService extends ApiService { }, { op: PatchOp.ADD, - path: `/serverInfo/host/hostnameInfo/80/0`, + path: `/serverInfo/network/host/bindings/80/addresses/possible/0`, value: { - kind: 'ip', - gatewayId: 'eth0', + gateway: { id: 'eth0', name: 'Ethernet', public: false }, public: true, hostname: { kind: 'domain', @@ -1412,7 +1403,7 @@ export class MockApiService extends ApiService { }, { op: PatchOp.REMOVE, - path: `/serverInfo/host/hostnameInfo/80/0`, + path: `/serverInfo/network/host/bindings/80/addresses/possible/0`, }, ] this.mockRevision(patch) @@ -1433,10 +1424,9 @@ export class MockApiService extends ApiService { }, { op: PatchOp.ADD, - path: `/serverInfo/host/hostnameInfo/80/0`, + path: `/serverInfo/network/host/bindings/80/addresses/possible/0`, value: { - kind: 'ip', - gatewayId: 'eth0', + gateway: { id: 'eth0', name: 'Ethernet', public: false }, public: false, hostname: { kind: 'domain', @@ -1466,7 +1456,7 @@ export class MockApiService extends ApiService { }, { op: PatchOp.REMOVE, - path: `/serverInfo/host/hostnameInfo/80/0`, + path: `/serverInfo/network/host/bindings/80/addresses/possible/0`, }, ] this.mockRevision(patch) @@ -1474,20 +1464,12 @@ export class MockApiService extends ApiService { return null } - async pkgBindingToggleGateway( - params: RR.PkgBindingToggleGatewayReq, - ): Promise { + async pkgBindingSetAddressEnabled( + params: RR.PkgBindingSetAddressEnabledReq, + ): Promise { await pauseFor(2000) - const patch = [ - { - op: PatchOp.REPLACE, - path: `/packageData/${params.package}/hosts/${params.host}/bindings/${params.internalPort}/net/privateDisabled`, - value: params.enabled ? [] : [params.gateway], - }, - ] - this.mockRevision(patch) - + // Mock: no-op since address enable/disable modifies DerivedAddressInfo sets return null } @@ -1506,10 +1488,9 @@ export class MockApiService extends ApiService { }, { op: PatchOp.ADD, - path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`, + path: `/packageData/${params.package}/hosts/${params.host}/bindings/80/addresses/possible/0`, value: { - kind: 'ip', - gatewayId: 'eth0', + gateway: { id: 'eth0', name: 'Ethernet', public: false }, public: true, hostname: { kind: 'domain', @@ -1538,7 +1519,7 @@ export class MockApiService extends ApiService { }, { op: PatchOp.REMOVE, - path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`, + path: `/packageData/${params.package}/hosts/${params.host}/bindings/80/addresses/possible/0`, }, ] this.mockRevision(patch) @@ -1559,10 +1540,9 @@ export class MockApiService extends ApiService { }, { op: PatchOp.ADD, - path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`, + path: `/packageData/${params.package}/hosts/${params.host}/bindings/80/addresses/possible/0`, value: { - kind: 'ip', - gatewayId: 'eth0', + gateway: { id: 'eth0', name: 'Ethernet', public: false }, public: false, hostname: { kind: 'domain', @@ -1592,7 +1572,7 @@ export class MockApiService extends ApiService { }, { op: PatchOp.REMOVE, - path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`, + path: `/packageData/${params.package}/hosts/${params.host}/bindings/80/addresses/possible/0`, }, ] this.mockRevision(patch) diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index 6effc3827..c1d0c793a 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -38,8 +38,74 @@ export const mockPatchData: DataModel = { net: { assignedPort: null, assignedSslPort: 443, - publicEnabled: [], + }, + addresses: { privateDisabled: [], + publicEnabled: [], + possible: [ + { + gateway: { id: 'eth0', name: 'Ethernet', public: false }, + public: false, + hostname: { + kind: 'local', + value: 'adjective-noun.local', + port: null, + sslPort: 443, + }, + }, + { + gateway: { id: 'wlan0', name: 'Wireless', public: false }, + public: false, + hostname: { + kind: 'local', + value: 'adjective-noun.local', + port: null, + sslPort: 443, + }, + }, + { + gateway: { id: 'eth0', name: 'Ethernet', public: false }, + public: false, + hostname: { + kind: 'ipv4', + value: '10.0.0.1', + port: null, + sslPort: 443, + }, + }, + { + gateway: { id: 'wlan0', name: 'Wireless', public: false }, + public: false, + hostname: { + kind: 'ipv4', + value: '10.0.0.2', + port: null, + sslPort: 443, + }, + }, + { + gateway: { id: 'eth0', name: 'Ethernet', public: false }, + public: false, + hostname: { + kind: 'ipv6', + value: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd', + scopeId: 2, + port: null, + sslPort: 443, + }, + }, + { + gateway: { id: 'wlan0', name: 'Wireless', public: false }, + public: false, + hostname: { + kind: 'ipv6', + value: 'fe80::cd00:0000:0cde:1257:0000:211e:1234', + scopeId: 3, + port: null, + sslPort: 443, + }, + }, + ], }, options: { preferredExternalPort: 80, @@ -54,78 +120,6 @@ export const mockPatchData: DataModel = { }, publicDomains: {}, privateDomains: [], - hostnameInfo: { - 80: [ - { - kind: 'ip', - gateway: { id: 'eth0', name: 'Ethernet', public: false }, - public: false, - hostname: { - kind: 'local', - value: 'adjective-noun.local', - port: null, - sslPort: 443, - }, - }, - { - kind: 'ip', - gateway: { id: 'wlan0', name: 'Wireless', public: false }, - public: false, - hostname: { - kind: 'local', - value: 'adjective-noun.local', - port: null, - sslPort: 443, - }, - }, - { - kind: 'ip', - gateway: { id: 'eth0', name: 'Ethernet', public: false }, - public: false, - hostname: { - kind: 'ipv4', - value: '10.0.0.1', - port: null, - sslPort: 443, - }, - }, - { - kind: 'ip', - gateway: { id: 'wlan0', name: 'Wireless', public: false }, - public: false, - hostname: { - kind: 'ipv4', - value: '10.0.0.2', - port: null, - sslPort: 443, - }, - }, - { - kind: 'ip', - gateway: { id: 'eth0', name: 'Ethernet', public: false }, - public: false, - hostname: { - kind: 'ipv6', - value: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd', - scopeId: 2, - port: null, - sslPort: 443, - }, - }, - { - kind: 'ip', - gateway: { id: 'wlan0', name: 'Wireless', public: false }, - public: false, - hostname: { - kind: 'ipv6', - value: 'fe80::cd00:0000:0cde:1257:0000:211e:1234', - scopeId: 3, - port: null, - sslPort: 443, - }, - }, - ], - }, }, gateways: { eth0: { @@ -503,8 +497,74 @@ export const mockPatchData: DataModel = { net: { assignedPort: 80, assignedSslPort: 443, - publicEnabled: [], + }, + addresses: { privateDisabled: [], + publicEnabled: [], + possible: [ + { + gateway: { id: 'eth0', name: 'Ethernet', public: false }, + public: false, + hostname: { + kind: 'local', + value: 'adjective-noun.local', + port: null, + sslPort: 1234, + }, + }, + { + gateway: { id: 'wlan0', name: 'Wireless', public: false }, + public: false, + hostname: { + kind: 'local', + value: 'adjective-noun.local', + port: null, + sslPort: 1234, + }, + }, + { + gateway: { id: 'eth0', name: 'Ethernet', public: false }, + public: false, + hostname: { + kind: 'ipv4', + value: '10.0.0.1', + port: null, + sslPort: 1234, + }, + }, + { + gateway: { id: 'wlan0', name: 'Wireless', public: false }, + public: false, + hostname: { + kind: 'ipv4', + value: '10.0.0.2', + port: null, + sslPort: 1234, + }, + }, + { + gateway: { id: 'eth0', name: 'Ethernet', public: false }, + public: false, + hostname: { + kind: 'ipv6', + value: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd', + scopeId: 2, + port: null, + sslPort: 1234, + }, + }, + { + gateway: { id: 'wlan0', name: 'Wireless', public: false }, + public: false, + hostname: { + kind: 'ipv6', + value: 'fe80::cd00:0000:0cde:1257:0000:211e:1234', + scopeId: 3, + port: null, + sslPort: 1234, + }, + }, + ], }, options: { addSsl: null, @@ -515,78 +575,6 @@ export const mockPatchData: DataModel = { }, publicDomains: {}, privateDomains: [], - hostnameInfo: { - 80: [ - { - kind: 'ip', - gateway: { id: 'eth0', name: 'Ethernet', public: false }, - public: false, - hostname: { - kind: 'local', - value: 'adjective-noun.local', - port: null, - sslPort: 1234, - }, - }, - { - kind: 'ip', - gateway: { id: 'wlan0', name: 'Wireless', public: false }, - public: false, - hostname: { - kind: 'local', - value: 'adjective-noun.local', - port: null, - sslPort: 1234, - }, - }, - { - kind: 'ip', - gateway: { id: 'eth0', name: 'Ethernet', public: false }, - public: false, - hostname: { - kind: 'ipv4', - value: '10.0.0.1', - port: null, - sslPort: 1234, - }, - }, - { - kind: 'ip', - gateway: { id: 'wlan0', name: 'Wireless', public: false }, - public: false, - hostname: { - kind: 'ipv4', - value: '10.0.0.2', - port: null, - sslPort: 1234, - }, - }, - { - kind: 'ip', - gateway: { id: 'eth0', name: 'Ethernet', public: false }, - public: false, - hostname: { - kind: 'ipv6', - value: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd', - scopeId: 2, - port: null, - sslPort: 1234, - }, - }, - { - kind: 'ip', - gateway: { id: 'wlan0', name: 'Wireless', public: false }, - public: false, - hostname: { - kind: 'ipv6', - value: 'fe80::cd00:0000:0cde:1257:0000:211e:1234', - scopeId: 3, - port: null, - sslPort: 1234, - }, - }, - ], - }, }, bcdefgh: { bindings: { @@ -595,8 +583,11 @@ export const mockPatchData: DataModel = { net: { assignedPort: 8332, assignedSslPort: null, - publicEnabled: [], + }, + addresses: { privateDisabled: [], + publicEnabled: [], + possible: [], }, options: { addSsl: null, @@ -607,9 +598,6 @@ export const mockPatchData: DataModel = { }, publicDomains: {}, privateDomains: [], - hostnameInfo: { - 8332: [], - }, }, cdefghi: { bindings: { @@ -618,8 +606,11 @@ export const mockPatchData: DataModel = { net: { assignedPort: 8333, assignedSslPort: null, - publicEnabled: [], + }, + addresses: { privateDisabled: [], + publicEnabled: [], + possible: [], }, options: { addSsl: null, @@ -630,9 +621,6 @@ export const mockPatchData: DataModel = { }, publicDomains: {}, privateDomains: [], - hostnameInfo: { - 8333: [], - }, }, }, storeExposedDependents: [],