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

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

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,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: [],

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,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: [],