feat: implement preferred port allocation and per-address enable/disable

- Add AvailablePorts::try_alloc() with SSL tracking (BTreeMap<u16, bool>)
- 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
This commit is contained in:
Aiden McClelland
2026-02-10 17:38:51 -07:00
parent 73274ef6e0
commit 4e638fb58e
33 changed files with 996 additions and 952 deletions

View File

@@ -29,6 +29,24 @@ make update-startbox REMOTE=start9@<ip> # 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`)

View File

@@ -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<u16>` 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<u16>`: 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<GatewayId>` and
`public_enabled: OrdSet<GatewayId>` — 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<HostnameInfo>,
/// User-controlled: public-gateway addresses the user has enabled
pub public_enabled: BTreeSet<HostnameInfo>,
/// COMPUTED by update(): all possible addresses for this binding
pub possible: BTreeSet<HostnameInfo>,
pub possible: BTreeSet<HostnameInfo>, // 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<u16>, assigned_ssl_port: Option<u16> }`.
- **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 |

View File

@@ -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(),
]

View File

@@ -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
}

View File

@@ -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,7 +63,8 @@ impl Public {
post_init_migration_todos: BTreeMap::new(),
network: NetworkInfo {
host: Host {
bindings: [(
bindings: Bindings(
[(
80,
BindInfo {
enabled: false,
@@ -82,16 +83,15 @@ impl Public {
net: NetInfo {
assigned_port: None,
assigned_ssl_port: Some(443),
private_disabled: OrdSet::new(),
public_enabled: OrdSet::new(),
},
addresses: DerivedAddressInfo::default(),
},
)]
.into_iter()
.collect(),
),
public_domains: BTreeMap::new(),
private_domains: BTreeSet::new(),
hostname_info: BTreeMap::new(),
},
wifi: WifiInfo {
enabled: true,

View File

@@ -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<u16, bool>);
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<u16, Error> {
self.0.request_id().ok_or_else(|| {
Error::new(
pub fn alloc(&mut self, ssl: bool) -> Result<u16, Error> {
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<u16> {
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<Item = u16>) {
for port in ports {
self.0.return_id(port).unwrap_or_default();
self.0.remove(&port);
}
}
}

View File

@@ -1685,19 +1685,9 @@ where
#[test]
fn test_filter() {
use crate::net::host::binding::NetInfo;
let wg1 = "wg1".parse::<GatewayId>().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,

View File

@@ -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<PublicDomainConfig>,
private: bool,
},
#[serde(rename_all = "camelCase")]
pub struct HostAddress {
pub address: InternedString,
pub public: Option<PublicDomainConfig>,
pub private: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
@@ -151,29 +147,18 @@ pub fn address_api<C: Context, Kind: HostApiKind>()
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,
} => {
for entry in &res {
if let Some(PublicDomainConfig { gateway, acme }) = &entry.public {
table.add_row(row![
address,
entry.address,
&format!(
"{} ({gateway})",
if *private { "YES" } else { "ONLY" }
if entry.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"]);
}
} else {
table.add_row(row![entry.address, &format!("NO"), "N/A"]);
}
}

View File

@@ -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<Self>"]
pub struct DerivedAddressInfo {
/// User-controlled: private-gateway addresses the user has disabled
pub private_disabled: BTreeSet<HostnameInfo>,
/// User-controlled: public-gateway addresses the user has enabled
pub public_enabled: BTreeSet<HostnameInfo>,
/// COMPUTED: NetServiceData::update — all possible addresses for this binding
pub possible: BTreeSet<HostnameInfo>,
}
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<GatewayId> = 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<GatewayId>);
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<Self>"]
#[ts(export)]
pub struct Bindings(pub BTreeMap<u16, BindInfo>);
impl Map for Bindings {
type Key = u16;
type Value = BindInfo;
fn key_str(key: &Self::Key) -> Result<impl AsRef<str>, Error> {
Self::key_string(key)
}
fn key_string(key: &Self::Key) -> Result<InternedString, Error> {
Ok(InternedString::from_display(key))
}
}
impl std::ops::Deref for Bindings {
type Target = BTreeMap<u16, BindInfo>;
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<Self>"]
#[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::<GatewayId>")]
#[serde(default)]
pub private_disabled: OrdSet<GatewayId>,
#[ts(as = "BTreeSet::<GatewayId>")]
#[serde(default)]
pub public_enabled: OrdSet<GatewayId>,
pub assigned_port: Option<u16>,
pub assigned_ssl_port: Option<u16>,
}
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<Self, Error> {
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<Self, Error> {
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<C: Context, Kind: HostApiKind>()
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<C: Context, Kind: HostApiKind>()
.with_call_remote::<CliContext>(),
)
.subcommand(
"set-gateway-enabled",
from_fn_async(set_gateway_enabled::<Kind>)
"set-address-enabled",
from_fn_async(set_address_enabled::<Kind>)
.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::<CliContext>(),
)
}
@@ -227,7 +320,7 @@ pub async fn list_bindings<Kind: HostApiKind>(
ctx: RpcContext,
_: Empty,
inheritance: Kind::Inheritance,
) -> Result<BTreeMap<u16, BindInfo>, Error> {
) -> Result<Bindings, Error> {
Kind::host_for(&inheritance, &mut ctx.db.peek().await)?
.as_bindings()
.de()
@@ -236,50 +329,44 @@ pub async fn list_bindings<Kind: HostApiKind>(
#[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<bool>,
}
pub async fn set_gateway_enabled<Kind: HostApiKind>(
pub async fn set_address_enabled<Kind: HostApiKind>(
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(())

View File

@@ -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<Self>"]
#[ts(export)]
pub struct Host {
pub bindings: BTreeMap<u16, BindInfo>,
pub bindings: Bindings,
pub public_domains: BTreeMap<InternedString, PublicDomainConfig>,
pub private_domains: BTreeSet<InternedString>,
/// COMPUTED: NetService::update
pub hostname_info: BTreeMap<u16, Vec<HostnameInfo>>, // internal port -> Hostnames
}
impl AsRef<Host> for Host {
@@ -45,7 +42,7 @@ impl Host {
pub fn addresses<'a>(&'a self) -> impl Iterator<Item = HostAddress> + '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,

View File

@@ -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<u16, (SocketAddrV4, DynInterfaceFilter)> = BTreeMap::new();
let mut vhosts: BTreeMap<(Option<InternedString>, u16), ProxyTarget> = BTreeMap::new();
let mut private_dns: BTreeSet<InternedString> = BTreeSet::new();
let mut hostname_info: BTreeMap<u16, Vec<HostnameInfo>> = BTreeMap::new();
let binds = self.binds.entry(id.clone()).or_default();
let peek = ctrl.db.peek().await;
@@ -264,12 +268,30 @@ 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() {
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::<BTreeSet<_>>()
.into_iter()
.map(IdFilter)
.map(InterfaceFilter::into_dyn),
)
.collect(),
);
if let Some(ssl) = &bind.options.add_ssl {
let external = bind
.net
@@ -289,23 +311,22 @@ impl NetServiceData {
vhosts.insert(
(hostname, external),
ProxyTarget {
filter: bind.net.clone().into_dyn(),
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?
);
}
for address in host.addresses() {
match address {
HostAddress::Domain {
for HostAddress {
address,
public,
private,
} => {
} in host_addresses.iter().cloned()
{
if hostnames.insert(address.clone()) {
let address = Some(address.clone());
if ssl.preferred_external_port == 443 {
@@ -323,8 +344,7 @@ impl NetServiceData {
.into_dyn(),
acme: public.acme.clone(),
addr,
add_x_forwarded_headers: ssl
.add_x_forwarded_headers,
add_x_forwarded_headers: ssl.add_x_forwarded_headers,
connect_ssl: connect_ssl
.clone()
.map(|_| ctrl.tls_client_config.clone()),
@@ -352,8 +372,7 @@ impl NetServiceData {
.into_dyn(),
acme: public.acme.clone(),
addr,
add_x_forwarded_headers: ssl
.add_x_forwarded_headers,
add_x_forwarded_headers: ssl.add_x_forwarded_headers,
connect_ssl: connect_ssl
.clone()
.map(|_| ctrl.tls_client_config.clone()),
@@ -370,8 +389,7 @@ impl NetServiceData {
.into_dyn(),
acme: None,
addr,
add_x_forwarded_headers: ssl
.add_x_forwarded_headers,
add_x_forwarded_headers: ssl.add_x_forwarded_headers,
connect_ssl: connect_ssl
.clone()
.map(|_| ctrl.tls_client_config.clone()),
@@ -392,15 +410,13 @@ impl NetServiceData {
)
.into_dyn()
} else {
IdFilter(public.gateway.clone())
.into_dyn()
IdFilter(public.gateway.clone()).into_dyn()
},
)
.into_dyn(),
acme: public.acme.clone(),
addr,
add_x_forwarded_headers: ssl
.add_x_forwarded_headers,
add_x_forwarded_headers: ssl.add_x_forwarded_headers,
connect_ssl: connect_ssl
.clone()
.map(|_| ctrl.tls_client_config.clone()),
@@ -417,8 +433,7 @@ impl NetServiceData {
.into_dyn(),
acme: None,
addr,
add_x_forwarded_headers: ssl
.add_x_forwarded_headers,
add_x_forwarded_headers: ssl.add_x_forwarded_headers,
connect_ssl: connect_ssl
.clone()
.map(|_| ctrl.tls_client_config.clone()),
@@ -429,8 +444,6 @@ impl NetServiceData {
}
}
}
}
}
if bind
.options
.secure
@@ -451,8 +464,7 @@ impl NetServiceData {
),
);
}
let mut bind_hostname_info: Vec<HostnameInfo> =
hostname_info.remove(port).unwrap_or_default();
bind.addresses.possible.clear();
for (gateway_id, info) in net_ifaces
.iter()
.filter(|(_, info)| {
@@ -481,7 +493,7 @@ impl NetServiceData {
i.device_type != Some(NetworkInterfaceType::Wireguard)
})
{
bind_hostname_info.push(HostnameInfo {
bind.addresses.possible.insert(HostnameInfo {
gateway: gateway.clone(),
public: false,
hostname: IpHostname::Local {
@@ -494,19 +506,17 @@ impl NetServiceData {
},
});
}
for address in host.addresses() {
if let HostAddress::Domain {
for HostAddress {
address,
public,
private,
} = address
} 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);
let public = public.as_ref().map_or(false, |p| &p.gateway == gateway_id);
if public || private {
if bind
.options
@@ -514,7 +524,7 @@ impl NetServiceData {
.as_ref()
.map_or(false, |ssl| ssl.preferred_external_port == 443)
{
bind_hostname_info.push(HostnameInfo {
bind.addresses.possible.insert(HostnameInfo {
gateway: gateway.clone(),
public,
hostname: IpHostname::Domain {
@@ -524,7 +534,7 @@ impl NetServiceData {
},
});
} else {
bind_hostname_info.push(HostnameInfo {
bind.addresses.possible.insert(HostnameInfo {
gateway: gateway.clone(),
public,
hostname: IpHostname::Domain {
@@ -536,11 +546,10 @@ impl NetServiceData {
}
}
}
}
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 {
bind.addresses.possible.insert(HostnameInfo {
gateway: gateway.clone(),
public: true,
hostname: IpHostname::Ipv4 {
@@ -554,7 +563,7 @@ impl NetServiceData {
match ipnet {
IpNet::V4(net) => {
if !public {
bind_hostname_info.push(HostnameInfo {
bind.addresses.possible.insert(HostnameInfo {
gateway: gateway.clone(),
public,
hostname: IpHostname::Ipv4 {
@@ -566,7 +575,7 @@ impl NetServiceData {
}
}
IpNet::V6(net) => {
bind_hostname_info.push(HostnameInfo {
bind.addresses.possible.insert(HostnameInfo {
gateway: gateway.clone(),
public: public && !ipv6_is_local(net.addr()),
hostname: IpHostname::Ipv6 {
@@ -581,8 +590,6 @@ impl NetServiceData {
}
}
}
hostname_info.insert(*port, bind_hostname_info);
}
}
let all = binds
@@ -673,11 +680,28 @@ 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 res.revision.is_some() {
if let Some(cbs) = ctrl.callbacks.get_host_info(&(pkg_id.clone(), id)) {
cbs.call(vector![]).await?;
}
}
}
Ok(())
}

View File

@@ -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")]

View File

@@ -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);
}))
})?;
}

View File

@@ -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::<Vec<InternedString>>())
@@ -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::<Vec<InternedString>>())

View File

@@ -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");
}
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 }

View File

@@ -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<HostnameInfo>
/**
* User-controlled: public-gateway addresses the user has enabled
*/
publicEnabled: Array<HostnameInfo>
/**
* COMPUTED: NetServiceData::update — all possible addresses for this binding
*/
possible: Array<HostnameInfo>
}

View File

@@ -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<string>
/**
* COMPUTED: NetService::update
*/
hostnameInfo: { [key: number]: Array<HostnameInfo> }
}

View File

@@ -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<GatewayId>
publicEnabled: Array<GatewayId>
assignedPort: number | null
assignedSslPort: number | null
}

View File

@@ -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'

View File

@@ -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<F extends Filter>(
hostnames: HostnameInfo[],

View File

@@ -83,32 +83,8 @@ export class InterfaceGatewaysComponent {
readonly gateways = input.required<InterfaceGateway[] | undefined>()
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.
}
}

View File

@@ -252,10 +252,16 @@ export class InterfaceService {
serviceInterface: T.ServiceInterface,
host: T.Host,
): T.HostnameInfo[] {
let hostnameInfo =
host.hostnameInfo[serviceInterface.addressInfo.internalPort]
return (
hostnameInfo?.filter(
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' ||
!(
@@ -263,7 +269,6 @@ export class InterfaceService {
utils.IPV6_LINK_LOCAL.contains(h.hostname.value)) ||
h.gateway.id === 'lo'
),
) || []
)
}

View File

@@ -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),

View File

@@ -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),

View File

@@ -2126,22 +2126,12 @@ export namespace Mock {
net: {
assignedPort: 80,
assignedSslPort: 443,
publicEnabled: [],
},
addresses: {
privateDisabled: [],
},
options: {
addSsl: null,
preferredExternalPort: 443,
secure: { ssl: true },
},
},
},
publicDomains: {},
privateDomains: [],
hostnameInfo: {
80: [
publicEnabled: [],
possible: [
{
kind: 'ip',
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
@@ -2152,7 +2142,6 @@ export namespace Mock {
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
@@ -2163,7 +2152,6 @@ export namespace Mock {
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
@@ -2174,7 +2162,6 @@ export namespace Mock {
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
@@ -2185,7 +2172,6 @@ export namespace Mock {
},
},
{
kind: 'ip',
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
@@ -2197,7 +2183,6 @@ export namespace Mock {
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
@@ -2210,6 +2195,15 @@ export namespace Mock {
},
],
},
options: {
addSsl: null,
preferredExternalPort: 443,
secure: { ssl: true },
},
},
},
publicDomains: {},
privateDomains: [],
},
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: [],

View File

@@ -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

View File

@@ -336,9 +336,9 @@ export abstract class ApiService {
abstract removeAcme(params: RR.RemoveAcmeReq): Promise<RR.RemoveAcmeRes>
abstract serverBindingToggleGateway(
params: RR.ServerBindingToggleGatewayReq,
): Promise<RR.ServerBindingToggleGatewayRes>
abstract serverBindingSetAddressEnabled(
params: RR.ServerBindingSetAddressEnabledReq,
): Promise<RR.ServerBindingSetAddressEnabledRes>
abstract osUiAddPublicDomain(
params: RR.OsUiAddPublicDomainReq,
@@ -356,9 +356,9 @@ export abstract class ApiService {
params: RR.OsUiRemovePrivateDomainReq,
): Promise<RR.OsUiRemovePrivateDomainRes>
abstract pkgBindingToggleGateway(
params: RR.PkgBindingToggleGatewayReq,
): Promise<RR.PkgBindingToggleGatewayRes>
abstract pkgBindingSetAddressEnabled(
params: RR.PkgBindingSetAddressEnabledReq,
): Promise<RR.PkgBindingSetAddressEnabledRes>
abstract pkgAddPublicDomain(
params: RR.PkgAddPublicDomainReq,

View File

@@ -607,11 +607,11 @@ export class LiveApiService extends ApiService {
})
}
async serverBindingToggleGateway(
params: RR.ServerBindingToggleGatewayReq,
): Promise<RR.ServerBindingToggleGatewayRes> {
async serverBindingSetAddressEnabled(
params: RR.ServerBindingSetAddressEnabledReq,
): Promise<RR.ServerBindingSetAddressEnabledRes> {
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<RR.PkgBindingToggleGatewayRes> {
async pkgBindingSetAddressEnabled(
params: RR.PkgBindingSetAddressEnabledReq,
): Promise<RR.PkgBindingSetAddressEnabledRes> {
return this.rpcRequest({
method: 'package.host.binding.set-gateway-enabled',
method: 'package.host.binding.set-address-enabled',
params,
})
}

View File

@@ -1348,20 +1348,12 @@ export class MockApiService extends ApiService {
return null
}
async serverBindingToggleGateway(
params: RR.ServerBindingToggleGatewayReq,
): Promise<RR.ServerBindingToggleGatewayRes> {
async serverBindingSetAddressEnabled(
params: RR.ServerBindingSetAddressEnabledReq,
): Promise<RR.ServerBindingSetAddressEnabledRes> {
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<RR.PkgBindingToggleGatewayRes> {
async pkgBindingSetAddressEnabled(
params: RR.PkgBindingSetAddressEnabledReq,
): Promise<RR.PkgBindingSetAddressEnabledRes> {
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)

View File

@@ -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,22 +497,12 @@ export const mockPatchData: DataModel = {
net: {
assignedPort: 80,
assignedSslPort: 443,
publicEnabled: [],
},
addresses: {
privateDisabled: [],
},
options: {
addSsl: null,
preferredExternalPort: 443,
secure: { ssl: true },
},
},
},
publicDomains: {},
privateDomains: [],
hostnameInfo: {
80: [
publicEnabled: [],
possible: [
{
kind: 'ip',
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
@@ -529,7 +513,6 @@ export const mockPatchData: DataModel = {
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
@@ -540,7 +523,6 @@ export const mockPatchData: DataModel = {
},
},
{
kind: 'ip',
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
@@ -551,7 +533,6 @@ export const mockPatchData: DataModel = {
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
@@ -562,7 +543,6 @@ export const mockPatchData: DataModel = {
},
},
{
kind: 'ip',
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
@@ -574,7 +554,6 @@ export const mockPatchData: DataModel = {
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
@@ -587,6 +566,15 @@ export const mockPatchData: DataModel = {
},
],
},
options: {
addSsl: null,
preferredExternalPort: 443,
secure: { ssl: true },
},
},
},
publicDomains: {},
privateDomains: [],
},
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: [],