- 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
12 KiB
AI Agent TODOs
Pending tasks for AI agents. Remove items when completed.
Unreviewed CLAUDE.md Sections
- Architecture - Web (
/web) - @MattDHill
Features
-
Support preferred external ports besides 443 - @dr-bonez
Problem: Currently, port 443 is the only preferred external port that is actually honored. When a service requests
preferred_external_port: 8443(or any non-443 value) for SSL, the system ignores the preference and assigns a dynamic-range port (49152-65535). Thepreferred_external_portis only used as a label for Tor mappings and as a trigger for the port-443 special case inupdate().Goal: Honor
preferred_external_portfor both SSL and non-SSL binds when the requested port is available, with proper conflict resolution and fallback to dynamic-range allocation.Design
Key distinction: There are two separate concepts for SSL port usage:
- Port ownership (
assigned_ssl_port) — A port exclusively owned by a binding, allocated fromAvailablePorts. Used for server hostnames (.local, mDNS, etc.) and iptables forwards. - Domain SSL port — The port used for domain-based vhost entries. A binding does NOT need to own a port to have a domain vhost on it. The VHostController already supports multiple hostnames on the same port via SNI. Any binding can create a domain vhost entry on any SSL port that the VHostController has a listener for, regardless of who "owns" that port.
For example: the OS owns port 443 as its
assigned_ssl_port. A service withpreferred_external_port: 443won't get 443 as itsassigned_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 ✅ DONE
AvailablePorts::try_alloc(port) -> Option<u16>added toforward.rs.BindInfo::new()andBindInfo::update()attempt the preferred port first, falling back to dynamic-range allocation.2. Per-Address Enable/Disable ✅ DONE
Gateway-level
private_disabled/public_enabledonNetInforeplaced with per-addressDerivedAddressInfoonBindInfo.hostname_inforemoved fromHost— computed addresses now live inBindInfo.addresses.possible.DerivedAddressInfostruct (onBindInfo):pub struct DerivedAddressInfo { pub private_disabled: BTreeSet<HostnameInfo>, pub public_enabled: BTreeSet<HostnameInfo>, pub possible: BTreeSet<HostnameInfo>, // COMPUTED by update() }DerivedAddressInfo::enabled()returnspossiblefiltered by the two sets.HostnameInfoderivesOrdforBTreeSetusage.AddressFilter(implementingInterfaceFilter) derives enabled gateway set fromDerivedAddressInfofor vhost/forward filtering.RPC endpoint:
set-gateway-enabledreplaced withset-address-enabled(on bothserver.host.bindingandpackage.host.binding).How disabling works per address type (enforcement deferred to Section 3):
- 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.
3. Eliminate the Port 5443 Hack: Source-IP-Based WAN Blocking (
vhost.rs,net_controller.rs)Current problem: The
if ssl.preferred_external_port == 443branch (line 341 ofnet_controller.rs) creates a bespoke dual-vhost setup: port 5443 for private-only access and port 443 for public (or public+private). This exists because both public and private traffic arrive on the same port 443 listener, and the currentInterfaceFilter/PublicFiltermodel distinguishes public/private by which network interface the connection arrived on — which doesn't work when both traffic types share a listener.Solution: Determine public vs private based on source IP at the vhost level. Traffic arriving from the gateway IP should be treated as public (the gateway may MASQUERADE/NAT internet traffic, so anything from the gateway is potentially public). Traffic from LAN IPs is private.
This applies to all vhost targets, not just port 443:
- Add a
publicfield toProxyTarget(or an enum:Public,Private,Both) indicating what traffic this target accepts, derived from the binding's user-controlledpublicfield. - Modify
VHostTarget::filter()(vhost.rs:342): Instead of (or in addition to) checking the network interface viaGatewayInfo, check the source IP of the TCP connection against known gateway IPs. If the source IP matches a gateway or IP outside the subnet, the connection is public; otherwise it's private. Use this to gate against the target'spublicfield. - Eliminate the 5443 port entirely: A single vhost entry on port 443 (or any shared SSL port) can serve both public and private traffic, with per-target source-IP gating determining which backend handles which connections.
4. Port Forward Mapping in Patch-DB
When a binding is marked
public = true, StartOS must record the required port forwards in patch-db so the frontend can display them to the user. The user then configures these on their router manually.For each public binding, store:
- The external port the router should forward (the actual vhost port used for domains, or the
assigned_port/assigned_ssl_portfor non-domain access) - The protocol (TCP/UDP)
- The StartOS LAN IP as the forward target
- Which service/binding this forward is for (for display purposes)
This mapping should be in the public database model so the frontend can read and display it.
5. Simplify
update()Domain Vhost Logic (net_controller.rs)With source-IP gating in the vhost controller:
- Remove the
== 443special case and the 5443 secondary vhost. - For server hostnames (
.local, mDNS, embassy, startos, localhost): useassigned_ssl_port(the port the binding owns). - For domain-based vhost entries: attempt to use
preferred_external_portas the vhost port. This succeeds if the port is either unused or already has an SSL listener (SNI handles sharing). It fails only if the port is already in use by a non-SSL binding, or is a restricted port. On failure, fall back toassigned_ssl_port. - The binding's
publicfield determines theProxyTarget's public/private gating. - Hostname info must exactly match the actual vhost port used: for server hostnames, report
ssl_port: assigned_ssl_port. For domains, reportssl_port: preferred_external_portif it was successfully used for the domain vhost, otherwise reportssl_port: assigned_ssl_port.
6. Frontend: Interfaces Page Overhaul (View/Manage Split)
The current interfaces page is a single page showing gateways (with toggle), addresses, public domains, and private domains. It gets split into two pages: View and Manage.
SDK:
preferredExternalPortis already exposed. No additional SDK changes needed.View Page
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).No gateway-level toggles. The old
gateways.component.tstoggle UI is removed.Note: Exact UI element placement (where toggles, buttons, info badges go) is sensitive. Prompt the user for specific placement decisions during implementation.
Manage Page
Simple CRUD interface for configuring which addresses exist. Two sections:
- Public domains: Add/remove. Uses existing RPC endpoints:
{server,package}.host.address.domain.public.add{server,package}.host.address.domain.public.remove
- Private domains: Add/remove. Uses existing RPC endpoints:
{server,package}.host.address.domain.private.add{server,package}.host.address.domain.private.remove
Key Frontend Files to Modify
File Change web/projects/ui/src/app/routes/portal/components/interfaces/Overhaul: split into view/manage web/projects/ui/src/app/routes/portal/components/interfaces/gateways.component.tsRemove (replaced by per-address toggles on View page) web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.tsUpdate MappedServiceInterfaceto compute enabled addresses fromDerivedAddressInfoweb/projects/ui/src/app/routes/portal/components/interfaces/addresses/Refactor for View page with overflow menu (enable/disable) and test buttons web/projects/ui/src/app/routes/portal/routes/services/services.routes.tsAdd routes for view/manage sub-pages web/projects/ui/src/app/routes/portal/routes/system/system.routes.tsAdd routes for view/manage sub-pages 7. Reachability Test Endpoint
New RPC endpoint that tests whether an address is actually reachable, with diagnostic info on failure.
RPC endpoint (
binding.rsor new file):-
test-address— Test reachability of a specific address.interface BindingTestAddressParams { internalPort: number address: HostnameInfo }The backend simply performs the raw checks and returns the results. The frontend owns all interpretation — it already knows the address type, expected IP, expected port, etc. from the
HostnameInfodata, so it can compare against the backend results and construct fix messaging.interface TestAddressResult { dns: string[] | null // resolved IPs, null if not a domain address or lookup failed portOpen: boolean | null // TCP connect result, null if not applicable }This yields two RPC methods:
server.host.binding.test-addresspackage.host.binding.test-address
The frontend already has the full
HostnameInfocontext (expected IP, domain, port, gateway, public/private). It compares the backend's raw results against the expected state and constructs localized fix instructions. For example:dnsreturned but doesn't contain the expected WAN IP → "Update DNS A record for {domain} to {wanIp}"dnsisnullfor a domain address → "DNS lookup failed for {domain}"portOpenisfalse→ "Configure port forward on your router: external {port} TCP → {lanIp}:{port}"
Key Files
File Role core/src/net/forward.rsAvailablePorts— port pool allocation,try_alloc()for preferred portscore/src/net/host/binding.rsBindings(Map wrapper for patchdb),BindInfo/NetInfo/DerivedAddressInfo/AddressFilter— per-address enable/disable,set-address-enabledRPCcore/src/net/net_controller.rs:259NetServiceData::update()— computesDerivedAddressInfo.possible, vhost/forward/DNS reconciliation, 5443 hack removalcore/src/net/vhost.rsVHostController/ProxyTarget— source-IP gating for public/privatecore/src/net/gateway.rsInterfaceFiltertrait and filter types (AddressFilter,PublicFilter, etc.)core/src/net/service_interface.rsHostnameInfo— derivesOrdforBTreeSetusagecore/src/net/host/address.rsHostAddress(flattened struct), domain CRUD endpointssdk/base/lib/interfaces/Host.tsSDK MultiHost.bindPort()— no changes neededcore/src/db/model/public.rsPublic DB model — port forward mapping - Port ownership (
-
Auto-configure port forwards via UPnP/NAT-PMP/PCP - @dr-bonez
Blocked by: "Support preferred external ports besides 443" (must be implemented and tested end-to-end first).
Goal: When a binding is marked public, automatically configure port forwards on the user's router using UPnP, NAT-PMP, or PCP, instead of requiring manual router configuration. Fall back to displaying manual instructions (the port forward mapping from patch-db) when auto-configuration is unavailable or fails.