-
+
{{ 'No addresses' | i18n }}
@@ -117,6 +113,24 @@ export class GatewayComponent {
readonly value = input()
readonly isRunning = input.required()
+ // The Certificate Authority column only makes sense when some address is
+ // actually SSL-terminated. Non-SSL bindings and port ranges have none.
+ readonly showCert = computed(
+ () =>
+ this.value()?.gatewayGroups.some(g =>
+ g.addresses.some(a => a.certificate !== '-'),
+ ) ?? false,
+ )
+
+ readonly header = computed>(() =>
+ this.showCert()
+ ? [null, 'Access', 'Type', 'Certificate Authority', 'URL', null]
+ : [null, 'Access', 'Type', 'URL', null],
+ )
+
+ // Forwarded-port span, uniform across a range's addresses (1 for single-port).
+ readonly count = computed(() => this.gatewayGroup().addresses[0]?.count ?? 1)
+
openDomainTypePicker() {
this.dialog
.openComponent(DOMAIN_TYPE_PICKER, {
@@ -287,7 +301,12 @@ export class GatewayComponent {
res = await this.api.osUiAddPublicDomain(params)
}
- await this.domainHealth.checkPublicDomain(fqdn, gatewayId, res)
+ await this.domainHealth.checkPublicDomain(
+ fqdn,
+ gatewayId,
+ res,
+ this.count(),
+ )
return true
} catch (e: any) {
diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/item.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/item.component.ts
index 4b1088e4ce..5797b9bf29 100644
--- a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/item.component.ts
+++ b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/item.component.ts
@@ -16,6 +16,7 @@ import { DomainHealthService } from './domain-health.service'
selector: 'tr[address]',
host: {
'[class._disabled]': '!address().enabled',
+ '[class._no-cert]': '!showCert()',
},
template: `
@if (address(); as address) {
@@ -49,21 +50,28 @@ import { DomainHealthService } from './domain-health.service'
{{ address.type }}
-
-
- @if (address.certificate === 'Root CA') {
-
- } @else if (address.certificate.startsWith("Let's Encrypt")) {
-
- } @else if (
- address.certificate !== '-' && address.certificate !== 'Self signed'
- ) {
-
- }
- {{ address.certificate }}
-
-
-
+ @if (showCert()) {
+
+
+ @if (address.certificate === 'Root CA') {
+
+ } @else if (address.certificate.startsWith("Let's Encrypt")) {
+
+ } @else if (
+ address.certificate !== '-' &&
+ address.certificate !== 'Self signed'
+ ) {
+
+ }
+ {{ address.certificate }}
+
+
+ }
+
@if (address.masked && currentlyMasked()) {
••••••••••••••••••••••••••••••••••••••••••••••••••••
@@ -192,7 +200,7 @@ import { DomainHealthService } from './domain-health.service'
padding-inline-end: 0.5rem;
}
- td:nth-child(4) {
+ .cert-cell {
grid-area: 2 / 1 / 2 / 3;
.cert-icon {
@@ -200,10 +208,15 @@ import { DomainHealthService } from './domain-health.service'
}
}
- td:nth-child(5) {
+ .url-cell {
grid-area: 3 / 1 / 3 / 3;
}
+ // With no cert row, the URL takes its place directly under the header.
+ &._no-cert .url-cell {
+ grid-area: 2 / 1 / 2 / 3;
+ }
+
td:last-child {
grid-area: 1 / 3 / 4 / 5;
align-self: center;
@@ -235,6 +248,14 @@ export class GatewayItemComponent {
readonly toggling = signal(false)
readonly currentlyMasked = signal(true)
+ // The Certificate Authority column only shows when some address is SSL; kept
+ // in sync with GatewayComponent's header (both derive from the same data).
+ readonly showCert = computed(
+ () =>
+ this.value()?.gatewayGroups.some(g =>
+ g.addresses.some(a => a.certificate !== '-'),
+ ) ?? false,
+ )
readonly urlHtml = computed(() => {
const { url, hostnameInfo } = this.address()
const idx = url.indexOf(hostnameInfo.hostname)
@@ -273,13 +294,20 @@ export class GatewayItemComponent {
try {
if (this.packageId()) {
- await this.api.pkgBindingSetAddressEnabled({
+ const params = {
internalPort: iface.addressInfo.internalPort,
address: addressJson,
enabled,
package: this.packageId(),
host: iface.addressInfo.hostId,
- })
+ }
+ // A range spans >1 port and lives in a separate subtree, so it has its
+ // own endpoint; a single-port binding is exactly 1.
+ if (addr.count > 1) {
+ await this.api.pkgBindingSetRangeAddressEnabled(params)
+ } else {
+ await this.api.pkgBindingSetAddressEnabled(params)
+ }
} else {
await this.api.serverBindingSetAddressEnabled({
internalPort: 80,
@@ -295,6 +323,7 @@ export class GatewayItemComponent {
addr.hostnameInfo.hostname,
this.gatewayId(),
addr.hostnameInfo.port,
+ addr.count,
)
} else if (kind === 'private-domain') {
await this.domainHealth.checkPrivateDomain(
@@ -304,7 +333,10 @@ export class GatewayItemComponent {
} else if (
kind === 'ipv4' &&
addr.access === 'public' &&
- addr.hostnameInfo.port !== null
+ addr.hostnameInfo.port !== null &&
+ // A port range spans many ports; a single-port reachability check
+ // would be misleading, so don't auto-test it on enable.
+ addr.count === 1
) {
await this.domainHealth.checkPortForward(
this.gatewayId(),
diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/port-forward.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/port-forward.component.ts
index 9da26f3f63..9f493710f2 100644
--- a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/port-forward.component.ts
+++ b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/port-forward.component.ts
@@ -8,11 +8,13 @@ import { PortCheckIconComponent } from 'src/app/routes/portal/components/port-ch
import { PortCheckWarningsComponent } from 'src/app/routes/portal/components/port-check-warnings.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
+import { formatPortRange } from 'src/app/utils/format-port-range'
import { DnsGateway } from './dns.component'
export type PortForwardValidationData = {
gateway: DnsGateway
port: number
+ count: number
initialResults?: { portResult: T.CheckPortRes | null }
}
@@ -31,44 +33,63 @@ export type PortForwardValidationData = {
@let portRes = portResult();
-
+
-
-
-
- {{ context.data.port }}
- {{ context.data.port }}
-
-
- {{ 'Test' | i18n }}
-
-
+ @if (!isRange) {
+
+
+
+ }
+ {{ portDisplay }}
+ {{ portDisplay }}
+ @if (!isRange) {
+
+
+ {{ 'Test' | i18n }}
+
+
+ }
-
+ @if (!isRange) {
+
+ }
- {{ 'External Port' | i18n }}
- {{ context.data.port }}
+
+ {{ (isRange ? 'External Range' : 'External Port') | i18n }}
+
+ {{ portDisplay }}
- {{ 'Internal Port' | i18n }}
- {{ context.data.port }}
+
+ {{ (isRange ? 'Internal Range' : 'Internal Port') | i18n }}
+
+ {{ portDisplay }}
-
- {{ 'Test' | i18n }}
-
+ @if (!isRange) {
+
+ {{ 'Test' | i18n }}
+
+ }
@@ -120,6 +141,12 @@ export type PortForwardValidationData = {
text-align: end;
}
+ // A range table has no status/Test columns, so its last cell is the
+ // internal-range value — keep it left-aligned with its header.
+ .range-table td:last-child {
+ text-align: start;
+ }
+
footer {
margin-top: 1.5rem;
}
@@ -189,12 +216,23 @@ export class PortForwardValidationComponent {
readonly context =
injectContext>()
+ // A port range forwards a span of ports and can't be tested a port at a time.
+ readonly isRange = this.context.data.count > 1
+ readonly portDisplay = formatPortRange(
+ this.context.data.port,
+ this.context.data.count,
+ )
+
readonly loading = signal(false)
readonly portResult = signal(undefined)
readonly portOk = computed(() => {
const result = this.portResult()
- return !!result?.openInternally && !!result?.openExternally
+ return (
+ !!result?.openInternally &&
+ !!result?.openExternally &&
+ !!result?.hairpinning
+ )
})
readonly isManualMode = !this.context.data.initialResults
diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/interface.service.ts b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/interface.service.ts
index 2977c12a5e..3debefe7f1 100644
--- a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/interface.service.ts
+++ b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/interface.service.ts
@@ -99,11 +99,52 @@ export class InterfaceService {
const binding = host.bindings[serviceInterface.addressInfo.internalPort]
if (!binding) return []
- const addr = binding.addresses
- const masked = serviceInterface.masked
- const ui = serviceInterface.type === 'ui'
- const { addSsl, secure } = binding.options
+ return this.buildGatewayGroups(
+ binding.addresses,
+ serviceInterface.addressInfo,
+ serviceInterface.masked,
+ serviceInterface.type === 'ui',
+ binding.options.addSsl,
+ binding.options.secure,
+ host,
+ gateways,
+ )
+ }
+ // Port ranges reuse the same per-gateway address grouping, but their
+ // addresses live on the range binding and are always non-SSL (no cert/CA).
+ // `numberOfPorts` drives the port-span shown in URLs and forwarding rules.
+ getRangeGatewayGroups(
+ addressInfo: T.AddressInfo,
+ addresses: T.DerivedAddressInfo,
+ host: T.Host,
+ gateways: GatewayPlus[],
+ numberOfPorts: number,
+ ): GatewayAddressGroup[] {
+ return this.buildGatewayGroups(
+ addresses,
+ addressInfo,
+ false,
+ false,
+ null,
+ null,
+ host,
+ gateways,
+ numberOfPorts,
+ )
+ }
+
+ private buildGatewayGroups(
+ addr: T.DerivedAddressInfo,
+ addressInfo: T.AddressInfo,
+ masked: boolean,
+ ui: boolean,
+ addSsl: T.AddSslOptions | null,
+ secure: T.Security | null,
+ host: T.Host,
+ gateways: GatewayPlus[],
+ count = 1,
+ ): GatewayAddressGroup[] {
const groupMap = new Map()
const gatewayMap = new Map()
@@ -126,7 +167,13 @@ export class InterfaceService {
enabled: isEnabled(addr, h),
type: getAddressType(h),
access: h.public ? 'public' : 'private',
- url: utils.addressHostToUrl(serviceInterface.addressInfo, h),
+ // A port range exposes a span of ports, not one, so its URL is the
+ // bare host (no port) — the span is shown once in the interface
+ // header, never faked onto a per-row URL that no one would copy.
+ url:
+ count > 1
+ ? utils.addressHostToUrl(addressInfo, { ...h, port: null })
+ : utils.addressHostToUrl(addressInfo, h),
hostnameInfo: h,
masked,
ui,
@@ -134,6 +181,7 @@ export class InterfaceService {
h.metadata.kind === 'private-domain' ||
h.metadata.kind === 'public-domain',
certificate: getCertificate(h, host, addSsl, secure),
+ count,
})
}
}
@@ -324,6 +372,9 @@ export type GatewayAddress = {
ui: boolean
deletable: boolean
certificate: string
+ // Number of forwarded ports: 1 for a single-port binding, the range span for
+ // a port range. Drives the port-span shown in forwarding rules.
+ count: number
}
export type GatewayAddressGroup = {
diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/port-ranges/port-range-forward.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/port-ranges/port-range-forward.component.ts
deleted file mode 100644
index e87d5d6119..0000000000
--- a/projects/start-os/web/ui/src/app/routes/portal/components/port-ranges/port-range-forward.component.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Component, inject } from '@angular/core'
-import { i18nPipe } from '@start9labs/shared'
-import { T } from '@start9labs/start-core'
-import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
-import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
-import { ActionSuccessMemberComponent } from 'src/app/routes/portal/routes/services/modals/action-success/action-success-member.component'
-
-export type PortRangeForwardData = {
- gatewayName: string
- range: string
-}
-
-@Component({
- selector: 'port-range-forward',
- template: `
-
- {{ 'In your gateway' | i18n }} "{{ context.data.gatewayName }}",
- {{
- 'forward the following port range to this server, for both TCP and UDP, if you have not already'
- | i18n
- }}:
-
-
-
- `,
- styles: `
- footer {
- margin-top: 1.5rem;
- }
- `,
- imports: [TuiButton, i18nPipe, ActionSuccessMemberComponent],
-})
-export class PortRangeForwardComponent {
- private readonly i18n = inject(i18nPipe)
-
- readonly context =
- injectContext>()
-
- protected readonly member: T.ActionResultMember & { type: 'single' } = {
- type: 'single',
- name: this.i18n.transform('Range'),
- description: null,
- value: this.context.data.range,
- copyable: true,
- qr: false,
- masked: false,
- }
-}
-
-export const PORT_RANGE_FORWARD = new PolymorpheusComponent(
- PortRangeForwardComponent,
-)
diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/port-ranges/port-range.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/port-ranges/port-range.component.ts
deleted file mode 100644
index b0adb0f269..0000000000
--- a/projects/start-os/web/ui/src/app/routes/portal/components/port-ranges/port-range.component.ts
+++ /dev/null
@@ -1,199 +0,0 @@
-import { Component, computed, inject, input, signal } from '@angular/core'
-import { DialogService, ErrorService, i18nPipe } from '@start9labs/shared'
-import { T } from '@start9labs/start-core'
-import { TuiCell, TuiIcon, TuiTitle } from '@taiga-ui/core'
-import { TuiNotificationMiddleService, TuiSegmented } from '@taiga-ui/kit'
-import { PORT_RANGE_FORWARD } from 'src/app/routes/portal/components/port-ranges/port-range-forward.component'
-import { ApiService } from 'src/app/services/api/embassy-api.service'
-import { GatewayService } from 'src/app/services/gateway.service'
-
-export type MappedPortRange = {
- hostId: string
- internalStartPort: number
- externalStartPort: number
- numberOfPorts: number
- gatewayAccess: { [id: string]: T.RangeGatewayAccess }
-}
-
-type AccessOption = {
- access: T.RangeGatewayAccess
- icon: string
- label: string
-}
-
-@Component({
- selector: 'service-port-range',
- template: `
-
- {{ 'Range' | i18n }}:
- {{ format() }}
-
- @for (gw of gateways(); track gw.id) {
-
- @switch (gw.ipInfo.deviceType) {
- @case ('ethernet') {
-
- }
- @case ('wireless') {
-
- }
- @case ('wireguard') {
-
- }
- }
-
- {{ gw.name }}
-
-
- @for (opt of options; track opt.access) {
-
-
- {{ opt.label }}
-
- }
-
-
- } @empty {
-
- {{ 'No gateways' | i18n }}
-
- }
- `,
- styles: `
- :host {
- max-width: 48rem;
- }
-
- [data-access='disabled'] tui-icon {
- color: var(--tui-status-negative);
- }
-
- [data-access='lan'] tui-icon {
- color: var(--tui-status-warning);
- }
-
- [data-access='lan-wan'] tui-icon {
- color: var(--tui-status-positive);
- }
-
- :host-context(tui-root._mobile) .label {
- display: none;
- }
- `,
- host: { class: 'g-card' },
- imports: [TuiCell, TuiIcon, TuiTitle, TuiSegmented, i18nPipe],
-})
-export class PortRangeComponent {
- private readonly api = inject(ApiService)
- private readonly errorService = inject(ErrorService)
- private readonly loader = inject(TuiNotificationMiddleService)
- private readonly gatewayService = inject(GatewayService)
- private readonly dialog = inject(DialogService)
- private readonly i18n = inject(i18nPipe)
-
- readonly packageId = input.required()
- readonly value = input.required()
-
- readonly busy = signal>({})
-
- // Disabled → off (red), LAN → LAN-only (amber), LAN+WAN → public (green).
- protected readonly options: readonly AccessOption[] = [
- {
- access: 'disabled',
- icon: '@tui.x',
- label: this.i18n.transform('Disabled'),
- },
- {
- access: 'lan',
- icon: '@tui.house',
- label: this.i18n.transform('LAN'),
- },
- {
- access: 'lan-wan',
- icon: '@tui.globe',
- label: this.i18n.transform('LAN+WAN'),
- },
- ]
-
- // Shown in the card header and the forwarding dialog (copyable), e.g.
- // "49152-49251" — a plain hyphen so it pastes cleanly into router configs.
- readonly format = computed(() => {
- const { externalStartPort: start, numberOfPorts: count } = this.value()
- return count > 1 ? `${start}-${start + count - 1}` : `${start}`
- })
-
- // Inbound gateways only — outbound-only gateways can't receive forwards.
- readonly gateways = computed(() =>
- (this.gatewayService.gateways() ?? []).filter(
- gw => gw.type !== 'outbound-only',
- ),
- )
-
- indexFor(gatewayId: string): number {
- switch (this.accessFor(gatewayId)) {
- case 'disabled':
- return 0
- case 'lan':
- return 1
- default:
- return 2
- }
- }
-
- async set(gatewayId: string, access: T.RangeGatewayAccess) {
- if (this.accessFor(gatewayId) === access) return
-
- this.busy.update(b => ({ ...b, [gatewayId]: true }))
- const loader = this.loader.open('Saving').subscribe()
-
- try {
- await this.api.pkgBindingSetRangeAccess({
- package: this.packageId(),
- host: this.value().hostId,
- internalStartPort: this.value().internalStartPort,
- gateway: gatewayId,
- access,
- })
-
- // LAN+WAN exposes the range to the WAN, which requires a router
- // port-forward. A contiguous UDP range can't be reliably reachability-
- // tested, so instead of probing we always show the operator exactly what
- // to forward on this gateway (TCP + UDP) for them to confirm.
- if (access === 'lan-wan') {
- this.dialog
- .openComponent(PORT_RANGE_FORWARD, {
- label: 'Port Forwarding',
- size: 's',
- data: {
- gatewayName:
- this.gateways().find(gw => gw.id === gatewayId)?.name ??
- gatewayId,
- range: this.format(),
- },
- })
- .subscribe()
- }
- } catch (e: any) {
- this.errorService.handleError(e)
- } finally {
- loader.unsubscribe()
- this.busy.update(b => ({ ...b, [gatewayId]: false }))
- }
- }
-
- private accessFor(gatewayId: string): T.RangeGatewayAccess {
- return this.value().gatewayAccess[gatewayId] ?? 'lan'
- }
-}
diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/components/controls.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/components/controls.component.ts
index 5a326e4335..63a7ee9d10 100644
--- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/components/controls.component.ts
+++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/components/controls.component.ts
@@ -149,12 +149,15 @@ export class ServiceControlsComponent {
)
readonly interfaces = computed(() =>
- Object.values(this.pkg().serviceInterfaces).filter(
- i =>
- i.type === 'ui' &&
- (i.addressInfo.scheme === 'http' ||
- i.addressInfo.sslScheme === 'https'),
- ),
+ Object.values(this.pkg().hosts)
+ .flatMap(host => Object.values(host.bindings))
+ .flatMap(binding => Object.values(binding.interfaces))
+ .filter(
+ i =>
+ i.type === 'ui' &&
+ (i.addressInfo.scheme === 'http' ||
+ i.addressInfo.sslScheme === 'https'),
+ ),
)
readonly hasAnyHref = computed(() =>
diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/routes/interfaces.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/routes/interfaces.component.ts
index 09e11aa934..a63ac10d3a 100644
--- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/routes/interfaces.component.ts
+++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/routes/interfaces.component.ts
@@ -17,13 +17,17 @@ import { GatewayService } from 'src/app/services/gateway.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getInstalledBaseStatus } from 'src/app/services/pkg-status-rendering.service'
+// A port-range interface renders like a single-port interface (same per-gateway
+// address card) plus its external port span shown in the header.
+type MappedRangeInterface = MappedServiceInterface & { portRange: string }
+
@Component({
template: `
@if (pkg()) {
- @if (interfaces().length) {
+ @if (interfaces().length || ranges().length) {
@for (iface of interfaces(); track iface.id) {
-
+
{{ iface.name }}
@@ -44,6 +48,29 @@ import { getInstalledBaseStatus } from 'src/app/services/pkg-status-rendering.se
/>
}
+ @for (range of ranges(); track range.id) {
+
+
+
+ {{ range.name }}
+ API
+
+ {{ range.portRange }}
+
+
+ @if (range.description) {
+ {{ range.description }}
+ }
+
+
+
+
+
+ }
} @else {
@@ -59,6 +86,10 @@ import { getInstalledBaseStatus } from 'src/app/services/pkg-status-rendering.se
padding-block: 0.75rem;
white-space: normal;
}
+
+ [tuiBadge] {
+ margin-inline-start: 0.25rem;
+ }
`,
host: { class: 'g-subpage' },
providers: [GatewayService],
@@ -84,36 +115,101 @@ export default class ServiceInterfacesRoute {
pkg ? getInstalledBaseStatus(pkg.statusInfo) === 'running' : false,
)
+ readonly single = computed(
+ () => this.interfaces().length + this.ranges().length === 1,
+ )
+
+ // Single-port service interfaces, reached host -> binding -> interface.
readonly interfaces = computed(() => {
const pkg = this.pkg()
if (!pkg) return []
- const { serviceInterfaces, hosts } = pkg
+ const { hosts } = pkg
const gateways = this.gatewayService.gateways() || []
const allPackageData = this.allPackageData()
- return Object.values(serviceInterfaces)
- .sort(tuiDefaultSort)
- .map(iFace => {
- const hostId = iFace.addressInfo.hostId || ''
- const host = hosts[hostId]
- const binding = host?.bindings[iFace.addressInfo.internalPort]
- const sharedHostNames = Object.values(serviceInterfaces)
- .filter(si => si.addressInfo.hostId === hostId && si.id !== iFace.id)
- .map(si => si.name)
+ const all = Object.values(hosts).flatMap(host =>
+ Object.values(host.bindings).flatMap(binding =>
+ Object.values(binding.interfaces),
+ ),
+ )
+
+ return all.sort(tuiDefaultSort).map(iFace => {
+ const hostId = iFace.addressInfo.hostId || ''
+ const host = hosts[hostId]
+ const binding = host?.bindings[iFace.addressInfo.internalPort]
+ const sharedHostNames = all
+ .filter(si => si.addressInfo.hostId === hostId && si.id !== iFace.id)
+ .map(si => si.name)
+
+ return {
+ ...iFace,
+ gatewayGroups: host
+ ? this.interfaceService.getGatewayGroups(iFace, host, gateways)
+ : [],
+ pluginGroups: host
+ ? this.interfaceService.getPluginGroups(iFace, host, allPackageData)
+ : [],
+ addSsl: !!binding?.options.addSsl,
+ sharedHostNames,
+ }
+ })
+ })
+
+ // Port-range interfaces, reached host -> bindingRange -> interface. They reuse
+ // the single-port per-gateway address card (non-SSL), driven by the range's
+ // own `addresses`; the external port span is shown in the header.
+ readonly ranges = computed(() => {
+ const pkg = this.pkg()
+ if (!pkg) return []
+
+ const gateways = this.gatewayService.gateways() || []
+
+ return Object.entries(pkg.hosts)
+ .flatMap(([hostId, host]) =>
+ Object.entries(host.bindingRanges)
+ .filter(([, range]) => range.interface)
+ .map(([key, range]) => {
+ const iface = range.interface!
+ const internalStartPort = Number(key)
+ const end = range.externalStartPort + range.numberOfPorts - 1
+ // Representative addressInfo: `internalPort` is the range's internal
+ // start port (its key in bindingRanges) so the per-address toggle
+ // and add-domain effects target the range; `scheme` prefixes URLs.
+ const addressInfo: T.AddressInfo = {
+ username: null,
+ hostId,
+ internalPort: internalStartPort,
+ scheme: iface.scheme,
+ sslScheme: null,
+ suffix: '',
+ }
- return {
- ...iFace,
- gatewayGroups: host
- ? this.interfaceService.getGatewayGroups(iFace, host, gateways)
- : [],
- pluginGroups: host
- ? this.interfaceService.getPluginGroups(iFace, host, allPackageData)
- : [],
- addSsl: !!binding?.options.addSsl,
- sharedHostNames,
- }
- })
+ return {
+ id: iface.id,
+ name: iface.name,
+ description: iface.description,
+ masked: false,
+ type: 'api' as const,
+ addressInfo,
+ gatewayGroups: this.interfaceService.getRangeGatewayGroups(
+ addressInfo,
+ range.addresses,
+ host,
+ gateways,
+ range.numberOfPorts,
+ ),
+ pluginGroups: [],
+ addSsl: false,
+ sharedHostNames: [],
+ portRange:
+ range.numberOfPorts > 1
+ ? `${range.externalStartPort}-${end}`
+ : `${range.externalStartPort}`,
+ }
+ }),
+ )
+ .sort((a, b) => a.addressInfo.internalPort - b.addressInfo.internalPort)
})
getAppearance(type: T.ServiceInterfaceType = 'ui'): string {
diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/routes/outlet.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/routes/outlet.component.ts
index e49afe1fe0..02df826ac0 100644
--- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/routes/outlet.component.ts
+++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/routes/outlet.component.ts
@@ -191,27 +191,12 @@ export class ServiceOutletComponent {
private readonly router = inject(Router)
private readonly params = inject(ActivatedRoute).paramMap
- // Port Ranges only appears when the service has a contiguous port-range
- // binding on one of its hosts.
protected readonly nav = computed(() => {
- const pkg = this.service()
- const hasRanges = Object.values(pkg?.hosts ?? {}).some(
- host => Object.keys(host.bindingRanges ?? {}).length > 0,
- )
-
const items: NavItem[] = [
{ title: 'dashboard', icon: '@tui.layout-dashboard', link: './' },
{ title: 'interfaces', icon: '@tui.monitor', link: 'interfaces' },
]
- if (hasRanges) {
- items.push({
- title: 'port ranges',
- icon: '@tui.chevrons-left-right-ellipsis',
- link: 'port-ranges',
- })
- }
-
items.push(
{ title: 'actions & config', icon: '@tui.cog', link: 'actions' },
{
diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/routes/port-ranges.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/routes/port-ranges.component.ts
deleted file mode 100644
index 4cf5676b97..0000000000
--- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/routes/port-ranges.component.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { Component, computed, inject } from '@angular/core'
-import { toSignal } from '@angular/core/rxjs-interop'
-import { getPkgId, i18nPipe } from '@start9labs/shared'
-import { PatchDB } from 'patch-db-client'
-import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
-import {
- MappedPortRange,
- PortRangeComponent,
-} from 'src/app/routes/portal/components/port-ranges/port-range.component'
-import { GatewayService } from 'src/app/services/gateway.service'
-import { DataModel } from 'src/app/services/patch-db/data-model'
-
-@Component({
- template: `
- @if (pkg()) {
- @for (range of ranges(); track range.internalStartPort) {
-
- } @empty {
-
- {{ 'No port ranges' | i18n }}
-
- }
- }
- `,
- styles: `
- :host {
- display: flex;
- flex-direction: column;
- gap: 1rem;
- }
- `,
- host: { class: 'g-subpage' },
- providers: [GatewayService],
- imports: [PortRangeComponent, PlaceholderComponent, i18nPipe],
-})
-export default class ServicePortRangesRoute {
- private readonly patch = inject>(PatchDB)
-
- readonly pkgId = getPkgId()
- readonly pkg = toSignal(this.patch.watch$('packageData', this.pkgId))
-
- readonly ranges = computed(() => {
- const pkg = this.pkg()
- if (!pkg) return []
-
- const out: MappedPortRange[] = []
- for (const [hostId, host] of Object.entries(pkg.hosts)) {
- for (const [key, range] of Object.entries(host.bindingRanges)) {
- out.push({
- hostId,
- internalStartPort: Number(key),
- externalStartPort: range.externalStartPort,
- numberOfPorts: range.numberOfPorts,
- gatewayAccess: range.gatewayAccess,
- })
- }
- }
-
- return out.sort((a, b) => a.internalStartPort - b.internalStartPort)
- })
-}
diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/services.routes.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/services.routes.ts
index 08820f04e1..009d47db2f 100644
--- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/services.routes.ts
+++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/services.routes.ts
@@ -18,10 +18,6 @@ export const ROUTES: Routes = [
path: 'interfaces',
loadComponent: () => import('./routes/interfaces.component'),
},
- {
- path: 'port-ranges',
- loadComponent: () => import('./routes/port-ranges.component'),
- },
{
path: 'actions',
loadComponent: () => import('./routes/actions.component'),
diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/gateways/port-forwards.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/gateways/port-forwards.component.ts
index d5700afcbf..1ec8d2e5cf 100644
--- a/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/gateways/port-forwards.component.ts
+++ b/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/gateways/port-forwards.component.ts
@@ -13,6 +13,7 @@ import { PortCheckWarningsComponent } from 'src/app/routes/portal/components/por
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
+import { formatPortRange } from 'src/app/utils/format-port-range'
export type PortForwardsModalData = {
gatewayId: string
@@ -35,10 +36,6 @@ function parseSocketAddr(s: string): { ip: string; port: number } {
}
}
-function formatPortRange(start: number, count: number): string {
- return count > 1 ? `${start}-${start + count - 1}` : `${start}`
-}
-
@Component({
selector: 'port-forwards-modal',
template: `
@@ -181,13 +178,18 @@ export class PortForwardsModalComponent {
pkgId
for (const [hostId, host] of Object.entries(pkg.hosts)) {
- // Find interface names pointing to this host
+ // Interface names exported from this host's bindings + ranges
const ifaceNames: string[] = []
- for (const iface of Object.values(pkg.serviceInterfaces)) {
- if (iface.addressInfo.hostId === hostId) {
+ for (const binding of Object.values(host.bindings)) {
+ for (const iface of Object.values(binding.interfaces)) {
ifaceNames.push(`${title} - ${iface.name}`)
}
}
+ for (const range of Object.values(host.bindingRanges)) {
+ if (range.interface) {
+ ifaceNames.push(`${title} - ${range.interface.name}`)
+ }
+ }
const label =
ifaceNames.length > 0 ? ifaceNames : [`${title} - ${hostId}`]
diff --git a/projects/start-os/web/ui/src/app/services/api/api.fixures.ts b/projects/start-os/web/ui/src/app/services/api/api.fixures.ts
index 0e3b20f775..e30699ac7e 100644
--- a/projects/start-os/web/ui/src/app/services/api/api.fixures.ts
+++ b/projects/start-os/web/ui/src/app/services/api/api.fixures.ts
@@ -2237,56 +2237,6 @@ For the full changelog, see https://github.com/bitcoin/bitcoin/blob/v27.0.0/doc/
group: null,
},
},
- serviceInterfaces: {
- ui: {
- id: 'ui',
- masked: false,
- name: 'Web UI',
- description:
- 'A launchable web app for you to interact with your Bitcoin node',
- type: 'ui',
- addressInfo: {
- username: null,
- hostId: 'abcdefg',
- internalPort: 80,
- scheme: 'http',
- sslScheme: 'https',
- suffix: '',
- },
- },
- rpc: {
- id: 'rpc',
- masked: false,
- name: 'RPC',
- description:
- 'Used by dependent services and client wallets for connecting to your node',
- type: 'api',
- addressInfo: {
- username: null,
- hostId: 'bcdefgh',
- internalPort: 8332,
- scheme: 'http',
- sslScheme: 'https',
- suffix: '',
- },
- },
- p2p: {
- id: 'p2p',
- masked: false,
- name: 'P2P',
- description:
- 'Used for connecting to other nodes on the Bitcoin network',
- type: 'p2p',
- addressInfo: {
- username: null,
- hostId: 'cdefghi',
- internalPort: 8333,
- scheme: 'bitcoin',
- sslScheme: null,
- suffix: '',
- },
- },
- },
currentDependencies: {},
hosts: {
abcdefg: {
@@ -2346,6 +2296,24 @@ For the full changelog, see https://github.com/bitcoin/bitcoin/blob/v27.0.0/doc/
preferredExternalPort: 443,
secure: { ssl: true },
},
+ interfaces: {
+ ui: {
+ id: 'ui',
+ masked: false,
+ name: 'Web UI',
+ description:
+ 'A launchable web app for you to interact with your Bitcoin node',
+ type: 'ui',
+ addressInfo: {
+ username: null,
+ hostId: 'abcdefg',
+ internalPort: 80,
+ scheme: 'http',
+ sslScheme: 'https',
+ suffix: '',
+ },
+ },
+ },
},
},
bindingRanges: {},
@@ -2371,6 +2339,24 @@ For the full changelog, see https://github.com/bitcoin/bitcoin/blob/v27.0.0/doc/
preferredExternalPort: 8332,
secure: { ssl: false },
},
+ interfaces: {
+ rpc: {
+ id: 'rpc',
+ masked: false,
+ name: 'RPC',
+ description:
+ 'Used by dependent services and client wallets for connecting to your node',
+ type: 'api',
+ addressInfo: {
+ username: null,
+ hostId: 'bcdefgh',
+ internalPort: 8332,
+ scheme: 'http',
+ sslScheme: 'https',
+ suffix: '',
+ },
+ },
+ },
},
},
bindingRanges: {},
@@ -2396,6 +2382,24 @@ For the full changelog, see https://github.com/bitcoin/bitcoin/blob/v27.0.0/doc/
preferredExternalPort: 8333,
secure: { ssl: false },
},
+ interfaces: {
+ p2p: {
+ id: 'p2p',
+ masked: false,
+ name: 'P2P',
+ description:
+ 'Used for connecting to other nodes on the Bitcoin network',
+ type: 'p2p',
+ addressInfo: {
+ username: null,
+ hostId: 'cdefghi',
+ internalPort: 8333,
+ scheme: 'bitcoin',
+ sslScheme: null,
+ suffix: '',
+ },
+ },
+ },
},
},
bindingRanges: {},
@@ -2447,23 +2451,6 @@ For the full changelog, see https://github.com/bitcoin/bitcoin/blob/v27.0.0/doc/
error: null,
},
actions: {},
- serviceInterfaces: {
- ui: {
- id: 'ui',
- masked: false,
- name: 'Web UI',
- description: 'A launchable web app for Bitcoin Proxy',
- type: 'ui',
- addressInfo: {
- username: null,
- hostId: 'hijklmnop',
- internalPort: 80,
- scheme: 'http',
- sslScheme: 'https',
- suffix: '',
- },
- },
- },
currentDependencies: {
bitcoind: {
title: BitcoinDep.title,
@@ -2516,56 +2503,6 @@ For the full changelog, see https://github.com/bitcoin/bitcoin/blob/v27.0.0/doc/
group: 'Connecting',
},
},
- serviceInterfaces: {
- grpc: {
- id: 'grpc',
- masked: false,
- name: 'GRPC',
- description:
- 'Used by dependent services and client wallets for connecting to your node',
- type: 'api',
- addressInfo: {
- username: null,
- hostId: 'qrstuv',
- internalPort: 10009,
- scheme: null,
- sslScheme: 'grpc',
- suffix: '',
- },
- },
- lndconnect: {
- id: 'lndconnect',
- masked: true,
- name: 'LND Connect',
- description:
- 'Used by client wallets adhering to LND Connect protocol to connect to your node',
- type: 'api',
- addressInfo: {
- username: null,
- hostId: 'qrstuv',
- internalPort: 10009,
- scheme: null,
- sslScheme: 'lndconnect',
- suffix: 'cert=askjdfbjadnaskjnd&macaroon=ksjbdfnhjasbndjksand',
- },
- },
- p2p: {
- id: 'p2p',
- masked: false,
- name: 'P2P',
- description:
- 'Used for connecting to other nodes on the Bitcoin network',
- type: 'p2p',
- addressInfo: {
- username: null,
- hostId: 'rstuvw',
- internalPort: 9735,
- scheme: 'lightning',
- sslScheme: null,
- suffix: '',
- },
- },
- },
currentDependencies: {
bitcoind: {
title: BitcoinDep.title,
diff --git a/projects/start-os/web/ui/src/app/services/api/api.types.ts b/projects/start-os/web/ui/src/app/services/api/api.types.ts
index bb2de4df54..935e7678f9 100644
--- a/projects/start-os/web/ui/src/app/services/api/api.types.ts
+++ b/projects/start-os/web/ui/src/app/services/api/api.types.ts
@@ -41,15 +41,6 @@ export type PkgBindingSetAddressEnabledReq = Omit<
host: T.HostId // string
}
-export type PkgBindingSetRangeAccessReq = {
- // package.host.binding.set-range-gateway-access
- internalStartPort: number
- gateway: T.GatewayId // string
- access: T.RangeGatewayAccess // 'disabled' | 'lan' | 'lan-wan'
- package: T.PackageId // string
- host: T.HostId // string
-}
-
// package domains
export type PkgAddPublicDomainReq = T.AddPublicDomainParams & {
diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts
index 49f0638a44..ef567707a6 100644
--- a/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts
+++ b/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts
@@ -18,7 +18,6 @@ import {
PkgAddPrivateDomainReq,
PkgAddPublicDomainReq,
PkgBindingSetAddressEnabledReq,
- PkgBindingSetRangeAccessReq,
PkgRemovePrivateDomainReq,
PkgRemovePublicDomainReq,
ServerBindingSetAddressEnabledReq,
@@ -357,8 +356,8 @@ export abstract class ApiService {
params: PkgBindingSetAddressEnabledReq,
): Promise
- abstract pkgBindingSetRangeAccess(
- params: PkgBindingSetRangeAccessReq,
+ abstract pkgBindingSetRangeAddressEnabled(
+ params: PkgBindingSetAddressEnabledReq,
): Promise
abstract pkgAddPublicDomain(
diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts
index 112505b3c3..35eaa0b6d8 100644
--- a/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts
+++ b/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts
@@ -31,7 +31,6 @@ import {
PkgAddPrivateDomainReq,
PkgAddPublicDomainReq,
PkgBindingSetAddressEnabledReq,
- PkgBindingSetRangeAccessReq,
PkgRemovePrivateDomainReq,
PkgRemovePublicDomainReq,
ServerBindingSetAddressEnabledReq,
@@ -674,11 +673,11 @@ export class LiveApiService extends ApiService {
})
}
- async pkgBindingSetRangeAccess(
- params: PkgBindingSetRangeAccessReq,
+ async pkgBindingSetRangeAddressEnabled(
+ params: PkgBindingSetAddressEnabledReq,
): Promise {
return this.rpcRequest({
- method: 'package.host.binding.set-range-gateway-access',
+ method: 'package.host.binding.set-range-address-enabled',
params,
})
}
diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts
index 745f3bb9fd..6aa4c72eef 100644
--- a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts
+++ b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts
@@ -43,7 +43,6 @@ import {
PkgAddPrivateDomainReq,
PkgAddPublicDomainReq,
PkgBindingSetAddressEnabledReq,
- PkgBindingSetRangeAccessReq,
PkgRemovePrivateDomainReq,
PkgRemovePublicDomainReq,
ServerBindingSetAddressEnabledReq,
@@ -1652,18 +1651,13 @@ export class MockApiService extends ApiService {
return null
}
- async pkgBindingSetRangeAccess(
- params: PkgBindingSetRangeAccessReq,
+ async pkgBindingSetRangeAddressEnabled(
+ params: PkgBindingSetAddressEnabledReq,
): Promise {
await pauseFor(2000)
- const hostPath = `/packageData/${params.package}/hosts/${params.host}`
- this.mockSetRangeGatewayAccess(
- hostPath,
- params.internalStartPort,
- params.gateway,
- params.access,
- )
+ const basePath = `/packageData/${params.package}/hosts/${params.host}/bindingRanges/${params.internalPort}/addresses`
+ this.mockSetAddressEnabled(basePath, params.address, params.enabled)
return null
}
@@ -2161,71 +2155,6 @@ export class MockApiService extends ApiService {
}
}
- private mockSetRangeGatewayAccess(
- hostPath: string,
- internalStartPort: number,
- gateway: T.GatewayId,
- access: T.RangeGatewayAccess,
- ): void {
- const rangePath = `${hostPath}/bindingRanges/${internalStartPort}`
- const range = this.mockData(rangePath) as T.RangeBindInfo
-
- const gatewayAccess: { [id: string]: T.RangeGatewayAccess } = {
- ...range.gatewayAccess,
- }
- // 'lan' is the default, so clear the entry; otherwise record the choice.
- if (access === 'lan') {
- delete gatewayAccess[gateway]
- } else {
- gatewayAccess[gateway] = access
- }
- range.gatewayAccess = gatewayAccess
-
- // Recompute this range's derived port forwards the way `update_addresses`
- // does on the backend: only Public gateways with a WAN IP need a
- // router-facing forward (Private is LAN-only, Disabled forwards nothing,
- // and outbound-only gateways never receive inbound forwards).
- const gateways = this.mockData('/serverInfo/network/gateways') as Record<
- string,
- T.NetworkInterfaceInfo
- >
- const portForwards = this.mockData(
- `${hostPath}/portForwards`,
- ) as T.PortForward[]
- const portOf = (sa: string) => Number(sa.slice(sa.lastIndexOf(':') + 1))
- // Drop this range's existing entries (keyed by its external start port)...
- const next = portForwards.filter(
- pf => portOf(pf.src) !== range.externalStartPort,
- )
- // ...then re-add for each LAN+WAN inbound gateway that has a WAN IP.
- for (const [gwId, gw] of Object.entries(gateways)) {
- if (gw.type === 'outbound-only') continue
- if ((gatewayAccess[gwId] ?? 'lan') !== 'lan-wan') continue
- const wanIp = gw.ipInfo?.wanIp
- if (!wanIp) continue
- for (const subnet of gw.ipInfo!.subnets) {
- const ip = subnet.split('/')[0]
- if (!ip || ip.includes(':')) continue // IPv4 only, matching update_addresses
- next.push({
- src: `${wanIp}:${range.externalStartPort}`,
- dst: `${ip}:${internalStartPort}`,
- gateway: gwId,
- count: range.numberOfPorts,
- })
- }
- }
- portForwards.splice(0, portForwards.length, ...next)
-
- this.mockRevision([
- {
- op: PatchOp.REPLACE,
- path: `${rangePath}/gatewayAccess`,
- value: gatewayAccess,
- },
- { op: PatchOp.REPLACE, path: `${hostPath}/portForwards`, value: next },
- ])
- }
-
private mockData(path: string): any {
const parts = path.split('/').filter(Boolean)
let obj: any = mockPatchData
diff --git a/projects/start-os/web/ui/src/app/services/api/mock-patch.ts b/projects/start-os/web/ui/src/app/services/api/mock-patch.ts
index 36cacc10b5..1efea4fba9 100644
--- a/projects/start-os/web/ui/src/app/services/api/mock-patch.ts
+++ b/projects/start-os/web/ui/src/app/services/api/mock-patch.ts
@@ -130,6 +130,7 @@ export const mockPatchData: DataModel = {
},
secure: null,
},
+ interfaces: {},
},
},
bindingRanges: {},
@@ -295,56 +296,6 @@ export const mockPatchData: DataModel = {
group: 'Connecting',
},
},
- serviceInterfaces: {
- grpc: {
- id: 'grpc',
- masked: false,
- name: 'GRPC',
- description:
- 'Used by dependent services and client wallets for connecting to your node',
- type: 'api',
- addressInfo: {
- username: null,
- hostId: 'qrstuv',
- internalPort: 10009,
- scheme: null,
- sslScheme: 'grpc',
- suffix: '',
- },
- },
- lndconnect: {
- id: 'lndconnect',
- masked: true,
- name: 'LND Connect',
- description:
- 'Used by client wallets adhering to LND Connect protocol to connect to your node',
- type: 'api',
- addressInfo: {
- username: null,
- hostId: 'qrstuv',
- internalPort: 10009,
- scheme: null,
- sslScheme: 'lndconnect',
- suffix: 'cert=askjdfbjadnaskjnd&macaroon=ksjbdfnhjasbndjksand',
- },
- },
- p2p: {
- id: 'p2p',
- masked: false,
- name: 'P2P',
- description:
- 'Used for connecting to other nodes on the Bitcoin network',
- type: 'p2p',
- addressInfo: {
- username: null,
- hostId: 'rstuvw',
- internalPort: 8333,
- scheme: 'bitcoin',
- sslScheme: null,
- suffix: '',
- },
- },
- },
currentDependencies: {
bitcoind: {
title: Mock.BitcoinDep.title,
@@ -496,71 +447,6 @@ export const mockPatchData: DataModel = {
group: null,
},
},
- serviceInterfaces: {
- ui: {
- id: 'ui',
- masked: false,
- name: 'Web UI',
- description:
- 'A launchable web app for you to interact with your Bitcoin node',
- type: 'ui',
- addressInfo: {
- username: null,
- hostId: 'abcdefg',
- internalPort: 80,
- scheme: 'http',
- sslScheme: 'https',
- suffix: '',
- },
- },
- 'admin-ui': {
- id: 'admin-ui',
- masked: false,
- name: 'Admin UI',
- description: 'An admin panel for managing your Bitcoin node',
- type: 'ui',
- addressInfo: {
- username: null,
- hostId: 'abcdefg',
- internalPort: 80,
- scheme: 'http',
- sslScheme: 'https',
- suffix: '/admin',
- },
- },
- rpc: {
- id: 'rpc',
- masked: true,
- name: 'RPC',
- description:
- 'Used by dependent services and client wallets for connecting to your node',
- type: 'api',
- addressInfo: {
- username: 'rpcuser',
- hostId: 'bcdefgh',
- internalPort: 8332,
- scheme: 'http',
- sslScheme: 'https',
- suffix: '',
- },
- },
- p2p: {
- id: 'p2p',
- masked: false,
- name: 'P2P',
- description:
- 'Used for connecting to other nodes on the Bitcoin network',
- type: 'p2p',
- addressInfo: {
- username: null,
- hostId: 'cdefghi',
- internalPort: 8333,
- scheme: 'bitcoin',
- sslScheme: null,
- suffix: '',
- },
- },
- },
currentDependencies: {},
hosts: {
abcdefg: {
@@ -677,6 +563,39 @@ export const mockPatchData: DataModel = {
},
secure: null,
},
+ interfaces: {
+ ui: {
+ id: 'ui',
+ masked: false,
+ name: 'Web UI',
+ description:
+ 'A launchable web app for you to interact with your Bitcoin node',
+ type: 'ui',
+ addressInfo: {
+ username: null,
+ hostId: 'abcdefg',
+ internalPort: 80,
+ scheme: 'http',
+ sslScheme: 'https',
+ suffix: '',
+ },
+ },
+ 'admin-ui': {
+ id: 'admin-ui',
+ masked: false,
+ name: 'Admin UI',
+ description: 'An admin panel for managing your Bitcoin node',
+ type: 'ui',
+ addressInfo: {
+ username: null,
+ hostId: 'abcdefg',
+ internalPort: 80,
+ scheme: 'http',
+ sslScheme: 'https',
+ suffix: '/admin',
+ },
+ },
+ },
},
},
bindingRanges: {
@@ -684,11 +603,64 @@ export const mockPatchData: DataModel = {
enabled: true,
externalStartPort: 49152,
numberOfPorts: 100,
- // Absent gateways default to 'lan' (LAN only). Seed eth0 as
- // 'lan-wan' and wlan0 as 'disabled' so all three states are
- // visible on load (wireguard1 stays at the 'lan' default;
- // wireguard2/Mullvad is outbound-only and excluded from the UI).
- gatewayAccess: { eth0: 'lan-wan', wlan0: 'disabled' },
+ // Same per-address model as a single-port binding, IPv4-only and
+ // non-SSL, every entry on the external start port (49152). The
+ // public WAN IP is seeded into `enabled` (WAN is opt-in); LAN /
+ // mDNS / domains are enabled by default.
+ addresses: {
+ enabled: ['203.0.113.45:49152'],
+ disabled: [],
+ available: [
+ {
+ ssl: false,
+ public: false,
+ hostname: 'adjective-noun.local',
+ port: 49152,
+ metadata: { kind: 'mdns', gateways: ['eth0', 'wlan0'] },
+ },
+ {
+ ssl: false,
+ public: false,
+ hostname: '10.0.0.1',
+ port: 49152,
+ metadata: { kind: 'ipv4', gateway: 'eth0' },
+ },
+ {
+ ssl: false,
+ public: true,
+ hostname: '203.0.113.45',
+ port: 49152,
+ metadata: { kind: 'ipv4', gateway: 'eth0' },
+ },
+ {
+ ssl: false,
+ public: true,
+ hostname: 'bitcoin.example.com',
+ port: 49152,
+ metadata: { kind: 'public-domain', gateway: 'eth0' },
+ },
+ {
+ ssl: false,
+ public: false,
+ hostname: '192.168.10.11',
+ port: 49152,
+ metadata: { kind: 'ipv4', gateway: 'wlan0' },
+ },
+ {
+ ssl: false,
+ public: false,
+ hostname: 'my-bitcoin.home',
+ port: 49152,
+ metadata: { kind: 'private-domain', gateways: ['wlan0'] },
+ },
+ ],
+ },
+ interface: {
+ id: 'zmq',
+ name: 'ZMQ',
+ description: 'Bitcoin ZMQ notification endpoints',
+ scheme: 'tcp',
+ },
},
},
publicDomains: {
@@ -771,6 +743,24 @@ export const mockPatchData: DataModel = {
preferredExternalPort: 48332,
secure: { ssl: false },
},
+ interfaces: {
+ rpc: {
+ id: 'rpc',
+ masked: true,
+ name: 'RPC',
+ description:
+ 'Used by dependent services and client wallets for connecting to your node',
+ type: 'api',
+ addressInfo: {
+ username: 'rpcuser',
+ hostId: 'bcdefgh',
+ internalPort: 8332,
+ scheme: 'http',
+ sslScheme: 'https',
+ suffix: '',
+ },
+ },
+ },
},
},
bindingRanges: {},
@@ -796,6 +786,24 @@ export const mockPatchData: DataModel = {
preferredExternalPort: 48333,
secure: { ssl: false },
},
+ interfaces: {
+ p2p: {
+ id: 'p2p',
+ masked: false,
+ name: 'P2P',
+ description:
+ 'Used for connecting to other nodes on the Bitcoin network',
+ type: 'p2p',
+ addressInfo: {
+ username: null,
+ hostId: 'cdefghi',
+ internalPort: 8333,
+ scheme: 'bitcoin',
+ sslScheme: null,
+ suffix: '',
+ },
+ },
+ },
},
},
bindingRanges: {},
@@ -878,7 +886,6 @@ export const mockPatchData: DataModel = {
group: null,
},
},
- serviceInterfaces: {},
currentDependencies: {},
hosts: {},
storeExposedDependents: [],
diff --git a/projects/start-os/web/ui/src/app/utils/format-port-range.ts b/projects/start-os/web/ui/src/app/utils/format-port-range.ts
new file mode 100644
index 0000000000..bc367c597a
--- /dev/null
+++ b/projects/start-os/web/ui/src/app/utils/format-port-range.ts
@@ -0,0 +1,4 @@
+// Renders a forwarded port span: a single port when count is 1, else `start-end`.
+export function formatPortRange(start: number, count: number): string {
+ return count > 1 ? `${start}-${start + count - 1}` : `${start}`
+}
diff --git a/projects/start-registry/Cargo.toml b/projects/start-registry/Cargo.toml
index 1d29e1b1cb..c73e3871b2 100644
--- a/projects/start-registry/Cargo.toml
+++ b/projects/start-registry/Cargo.toml
@@ -3,7 +3,7 @@ name = "start-registry"
version = "1.0.0" # VERSION_BUMP
edition = "2024"
license = "MIT"
-repository = "https://github.com/Start9Labs/start-os"
+repository = "https://github.com/Start9Labs/start-technologies"
[[bin]]
name = "registrybox"
diff --git a/projects/start-sdk/CHANGELOG.md b/projects/start-sdk/CHANGELOG.md
index 218c2813de..e6bf4c7741 100644
--- a/projects/start-sdk/CHANGELOG.md
+++ b/projects/start-sdk/CHANGELOG.md
@@ -9,7 +9,7 @@
- **Backup/restore progress mirrors the same structure.** `Backups.createBackup` and `restoreBackup` build an auto-syncing `FullProgressTracker` instead of calling the effect directly. The pre/post backup and restore hooks (`setPreBackup`, `setPostBackup`, `setPreRestore`, `setPostRestore`) now receive a `FullProgressTracker` as a second argument so custom work (DB dumps, etc.) can report sub-progress; restore progress flows through the init tracker since restore runs during init. Existing hooks that ignore the new argument keep working
- `userspaceFilesystems` and `virtualNetworking` manifest flags split the former `nestedRuntime` flag into its two independent device grants. `userspaceFilesystems` mounts `/dev/fuse` for fuse-overlayfs storage (the rootless driver behind a nested OCI runtime). `virtualNetworking` mounts `/dev/net/tun` so a service can bring up kernel tun interfaces for VPN / WireGuard / tun-class workloads. The service LXC already retains `CAP_NET_ADMIN` within its user namespace via the standard `userns.conf` include, so no extra capability machinery is required — only the device node was missing
- **`SharedOptions.idmap` on volume / asset / dependency / backup mounts is now functional** (the field was previously declared but inert). Each entry is `{ fromId, toId, range? }` — map `range` consecutive ids (default `1`) from filesystem id `fromId` (u) to mountpoint id `toId` (k) — so a mount can present files under the uid/gid the service expects regardless of how they're stored on the host volume. The container's own LXC id-mapping is applied automatically and must **not** be included here. End-to-end support requires the StartOS 0.4.0-beta.10 host (7.0.7-backports kernel): the inner bind now runs through a new in-LXC `start-container mount` (`open_tree(OPEN_TREE_CLONE)` + `mount_setattr(MOUNT_ATTR_IDMAP)` + `move_mount`) rather than `mount --bind -oX-mount.idmap=…`, and `SubContainer.bind()` uses the same path. The standalone `IdMap` binding is removed (folded into `MountTarget.idmap`) and the host-side `IdMap::stack` workaround (which produced overlapping uid_map ranges on 6.x) is gone. The public `effects.mount(...)` signature is unchanged. Fixes [#3248](https://github.com/Start9Labs/start-technologies/pull/3248)
-- `MultiHost.bindPortRange({ internalStartPort, externalStartPort, numberOfPorts })` reserves a contiguous TCP+UDP port range in a single call, intended for real-time / WebRTC servers (coturn, RTP, SIP) that need a public range. The whole range is allocated atomically; any partial collision with already-bound external ports is a hard error (no shifted-range fallback). A range is **2–500 ports**; for a single port use `bindPort`. `externalStartPort` may differ from `internalStartPort`: the forward maps the external range onto the internal range by offset (port-preserving when the two bases are equal). Returns `Promise`; range bindings have no `Origin` / `.export()` because they aren't addressable as HTTP-style service interfaces. The OS persists the range as a single `RangeBindInfo` record under `Host.binding_ranges` (not N entries in `Host.bindings`), and installs one nft rule per chain covering the whole range (`PortForward` gains a `count` field, defaulting to 1 for back-compat). Backed by a new effect `effects.bindRange(...)` (requires StartOS with the matching backend handler — landed in this PR). Fixes [#3269](https://github.com/Start9Labs/start-technologies/issues/3269)
+- `MultiHost.bindPortRange({ internalStartPort, externalStartPort, numberOfPorts })` reserves a contiguous TCP+UDP port range in a single call, intended for real-time / WebRTC servers (coturn, RTP, SIP) that need a public range. The whole range is allocated atomically; any partial collision with already-bound external ports is a hard error (no shifted-range fallback). A range is **2–500 ports**; for a single port use `bindPort`. `externalStartPort` may differ from `internalStartPort`: the forward maps the external range onto the internal range by offset (port-preserving when the two bases are equal). Returns a `RangeOrigin`, on which `.export(sdk.createRangeInterface(effects, { id, name, description, scheme? }))` registers the range's single, restricted `api` service interface — no SSL and no `masked`/`username`/`path`/`query`/`schemeOverride`. A range exposes **exactly one** interface (distinct endpoints are separate `bindPortRange` calls); the optional `scheme` is a transport prefix (e.g. `tcp` for bitcoin ZMQ endpoints) that most ranges (coturn RTP, FTP data) omit. The OS persists the range as a single `RangeBindInfo` record under `Host.binding_ranges` (not N entries in `Host.bindings`), carrying the exported interface and installing one nft rule per chain covering the whole range (`PortForward` gains a `count` field, defaulting to 1 for back-compat). Backed by the effects `effects.bindRange(...)` and `effects.exportRangeServiceInterface(...)` (requires StartOS with the matching backend handlers — landed in this PR). Fixes [#3269](https://github.com/Start9Labs/start-technologies/issues/3269)
- `sdk.Daemons.dynamic(fn)` builds a reactive `main` entrypoint whose daemon set is a function of on-disk state. The supplied builder returns a regular `sdk.Daemons.of({ effects }).addDaemon(...)` chain (now record-then-materialize — see below), and the SDK diffs its entries against the running set on every `effects.constRetry` trigger (typically fired by a `FileHelper.read().const(effects)` watcher). Per id: absent → present **start**, present → absent **stop**, same `configHash` **leave alone**, different `configHash` **restart**. Dependents of any restarted or stopped daemon are also restarted to keep `requires` wiring consistent. Re-runs coalesce while one is in flight. Designed for multi-tenant packages like the registry-portal s9pk that add, rename, and delete sub-instance daemons without restarting the service
- `configHash` covers the subcontainer descriptor (`imageId`, `sharedRun`, `name`, structural `mounts.build()`), exec (`command`, `env`, `cwd`, `user`, `runAsInit`, `sigtermTimeout`), `requires` (sorted), and the structural parts of `ready` (`display`, `gracePeriod`). Closures (`ready.fn`, `ready.trigger`, function-form `exec.fn`) and pre-built `Daemon` instances are intentionally excluded — surface a value through one of the hashed fields if you want the reconciler to react to it changing
- `sdk.SubContainer.eager(...)` creates a SubContainer with its filesystem materialized immediately. Use when you need `createFs` failures to surface at the construction site instead of at first method call, or when you need sync access to `rootfs` / `guid` / `subpath()` before running any methods
@@ -32,6 +32,8 @@
- **Breaking — `input-not-matches` task input split into `accept` (a list) and `set`.** `TaskInput` was `{ kind: 'partial', value }`, where the single `value` both decided whether the task was satisfied (the current action input had to be a superset of it) and prefilled the action form when the user ran the task. It is now `{ kind: 'partial', accept: DeepPartial [], set: DeepPartial }`: the task is satisfied when the current input matches **any** entry in `accept`, and when none match the task is shown and prefills `set`. This lets a package accept several already-good configurations while still pushing a single recommended value when none of them hold — e.g. accept either of two valid network modes, but set the preferred one otherwise. The cross-package critical-conflict guard now activates only when the input conflicts with **every** `accept` entry. Migration: replace `value: X` with `accept: [X], set: X` for identical behavior to 1.x. Already-published s9pks built against the pre-2.0 SDK keep working without a rebuild — the StartOS host still accepts the legacy `{ kind: 'partial', value }` payload over the effects socket and normalizes it to `accept: [value], set: value`
- **Breaking (internal) — zod loose-object handling unified onto a single `z` export; the global `require.cache` patch was removed.** The SDK makes `z.object()` preserve unknown keys (so `FileHelper` models that declare only a subset of a config file round-trip the rest). This was previously done by *two* mechanisms: a shadow `z` (the one exported from `@start9labs/start-sdk`) plus a walk over Node's `require.cache` that mutated the raw `zod` module so the SDK's own internals — which `import { z } from 'zod'` — also got loose objects. The cache walk was fragile (it duck-typed the module cache and silently no-op'd outside CommonJS). It is removed: the SDK's internal modules now import `z` from the shadow directly, and `FileHelper`'s structured factories (`json` / `yaml` / `toml` / `ini` / `env` / `xml`) now deep-loosen their shape explicitly via `z.deepLoose(shape)`, making unknown-key preservation a property of the file-model boundary rather than a global default. **Packaging code is unaffected:** `import { z } from '@start9labs/start-sdk'` still yields loose-by-default objects and every package's file models keep preserving undeclared on-disk keys. The only behavioral change is for code that imported `z` directly from `zod` and relied on the SDK to have patched it — it must import `z` from `@start9labs/start-sdk` instead. No StartOS packaging code does this (only vendored upstream application sources import `zod` directly, and they use their own copy)
+- **Breaking — service interfaces moved onto their binding; `sdk.serviceInterface.*` accessors replaced by `sdk.host.*`.** A service interface is no longer a flat entry on `PackageDataEntry.serviceInterfaces` (that field is removed) — it now lives on the binding that exported it: `Host.bindings[internalPort].interfaces` (a `ServiceInterfaceId`-keyed map; a port may back several) for single-port `Origin.export`, and `Host.bindingRanges[internalStartPort].interface` for a range's `RangeOrigin.export`. Correspondingly the SDK drops `sdk.serviceInterface.{getOwn, get, getAllOwn, getAll}` and adds `sdk.host.getOwn(effects, hostId)` / `sdk.host.get(effects, { hostId, packageId? })`, which return the reactive `Host` (same `const`/`once`/`watch`/`onChange`/`waitFor` read strategies). Both accessors take an optional `map` (and `eq`, default deep-equal) selector — matching the old `sdk.serviceInterface.*` — so `const()` can re-run on a change to a single child attr of the host rather than wholesale on the entire host. Reach an interface by walking the host — `Object.values(host.bindings).flatMap(b => Object.values(b.interfaces))` (or `host.bindingRanges[start].interface`) — then format an address with `utils.filledAddress(host, iface.addressInfo)`. The win: a binding's host is now reachable even when it exports no interface.
+
### Fixed
- Every materialized `SubContainer` is now torn down when the effects context that created it leaves (`onLeaveContext`), instead of lingering until GC eventually runs its `Drop` finalizer. This closes the gap where a subcontainer created in `main` (or any context) but never attached to a daemon — e.g. an ad-hoc setup/bootstrap container — could outlive its context. One cleanup hook is armed per effects object and each subcontainer removes itself on `destroy()`, so repeated short-lived containers (`withTemp`, per-poll health checks) don't accumulate registrations; teardown still routes through the hold-aware `destroy()`, so a container held by a running daemon defers until the daemon's own shutdown releases it
@@ -44,6 +46,7 @@
- `SubContainerOwned`, `SubContainerRc`, `SubContainer.rc()`, `SubContainer.isOwned()` — folded into the unified `SubContainerEager` / `SubContainerLazy` with hold/release lifecycle
- `Daemon.subcontainerRc()`, `Daemon.markManaged()`, `Daemon.sharesSubcontainerWith()` — superseded by `daemon.subcontainer` (public readonly) and the hold-count model
- `destroySubcontainer` option on `Daemon.term` / `HealthDaemon.term` — `Daemons` calls `subcontainer.destroy()` for each unique subc on shutdown, and the hold-count decides actual timing
+- **Breaking — `sdk.serviceInterface.{getOwn, get, getAllOwn, getAll}` and `PackageDataEntry.serviceInterfaces`** — interfaces moved onto their binding; use `sdk.host.{getOwn, get}` and walk the host's bindings (see _Changed_)
## 1.5.3 — StartOS 0.4.0-beta.9 (2026-05-20)
diff --git a/projects/start-sdk/docs/book.toml b/projects/start-sdk/docs/book.toml
index 1192d7ccdd..503df18b45 100644
--- a/projects/start-sdk/docs/book.toml
+++ b/projects/start-sdk/docs/book.toml
@@ -19,8 +19,8 @@ default-theme = "ayu"
preferred-dark-theme = "ayu"
additional-css = ["./theme/youtube.css", "./theme/tabs.css", "./accent.css"]
additional-js = ["./theme/youtube.js", "./theme/tabs.js", "./theme/home-link.js"]
-git-repository-url = "https://github.com/Start9Labs/start-os"
-edit-url-template = "https://github.com/Start9Labs/start-os/edit/master/projects/start-sdk/docs/{path}"
+git-repository-url = "https://github.com/Start9Labs/start-technologies"
+edit-url-template = "https://github.com/Start9Labs/start-technologies/edit/master/projects/start-sdk/docs/{path}"
site-url = "/packaging/"
[output.html.fold]
diff --git a/projects/start-sdk/docs/src/dependencies.md b/projects/start-sdk/docs/src/dependencies.md
index 6fe8bf094c..b8b4699843 100644
--- a/projects/start-sdk/docs/src/dependencies.md
+++ b/projects/start-sdk/docs/src/dependencies.md
@@ -105,20 +105,23 @@ sdk.action.createTask(
## Reading Dependency Interfaces
-Use `sdk.serviceInterface.get()` in `main.ts` to read a dependency's interface at runtime:
+Get the dependency's **host** with `sdk.host.get()` (the `hostId` and interface `id` are part of the dependency's documented contract), then read the interface off its bindings:
```typescript
-const url = await sdk.serviceInterface
- .get(
- effects,
- { id: 'interface-id', packageId: 'dependency-id' },
- (i) => {
- const urls = i?.addressInfo?.format()
- if (!urls || urls.length === 0) return null
- return urls[0]
- },
- )
- .const() // re-runs setupMain if the interface changes
+import { utils } from '@start9labs/start-sdk'
+
+const host = await sdk.host
+ .get(effects, { hostId: 'host-id', packageId: 'dependency-id' })
+ .const() // re-runs setupMain if the dependency's host changes
+
+const iface = Object.values(host?.bindings ?? {})
+ .flatMap((b) => Object.values(b.interfaces))
+ .find((i) => i.id === 'interface-id')
+
+const url =
+ host && iface
+ ? (utils.filledAddress(host, iface.addressInfo).format()[0] ?? null)
+ : null
```
Alternatively, services are reachable directly by hostname at `http://.startos:`:
diff --git a/projects/start-sdk/docs/src/interfaces.md b/projects/start-sdk/docs/src/interfaces.md
index 41268c3671..c20ac1371d 100644
--- a/projects/start-sdk/docs/src/interfaces.md
+++ b/projects/start-sdk/docs/src/interfaces.md
@@ -172,7 +172,7 @@ The key steps are:
```typescript
sdk.createInterface(effects, {
name: i18n('Display Name'), // Shown in UI (wrap with i18n)
- id: 'unique-id', // Used in sdk.serviceInterface.getOwn()
+ id: 'unique-id', // How you find this interface under its host
description: i18n('Description'),// Shown in UI (wrap with i18n)
type: 'ui', // 'ui', 'api', or 'p2p'
masked: false, // Hide URLs with sensitive credentials?
@@ -186,7 +186,7 @@ sdk.createInterface(effects, {
| Option | Type | Description |
|--------|------|-------------|
| `name` | `string` | Display name shown to the user. Wrap with `i18n()`. |
-| `id` | `string` | Unique identifier. Used to retrieve this interface in [main.ts](./main.md) via `sdk.serviceInterface.getOwn()`. |
+| `id` | `string` | Unique identifier. How you find this interface at runtime, by walking the host from `sdk.host.getOwn()` (see [main.ts](./main.md)). |
| `description` | `string` | Description shown to the user. Wrap with `i18n()`. |
| `type` | `'ui'`, `'api'`, or `'p2p'` | `'ui'` for browser interfaces, `'api'` for programmatic endpoints, `'p2p'` for peer-to-peer connections. |
| `masked` | `boolean` | If `true`, the interface URL is shown as a copyable secret. Use for URLs containing credentials or tokens. |
@@ -196,7 +196,60 @@ sdk.createInterface(effects, {
| `query` | `object` | URL query parameters as key-value pairs (e.g., `{ macaroon: 'abc123' }`). |
> [!TIP]
-> The `id` you assign to an interface is what you use in `main.ts` to retrieve hostnames for that interface. For example, if you set `id: 'ui'`, you would call `sdk.serviceInterface.getOwn(effects, 'ui')` to get its address information. See [Main](./main.md#getting-hostnames) for details.
+> The `id` you assign to an interface is what you use in `main.ts` to retrieve hostnames for it. Interfaces are reached through their **host**: `sdk.host.getOwn(effects, hostId)` returns the host, and the interface lives at `host.bindings[internalPort].interfaces[id]`. See [Main](./main.md#getting-hostnames) for details.
+
+## Port Ranges
+
+Some services need a **contiguous block of ports** rather than a single one — coturn / RTP media relays, bitcoin's ZMQ notification endpoints, passive-FTP data ports. Use `bindPortRange` instead of one `bindPort` per port:
+
+```typescript
+export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
+ const turn = sdk.MultiHost.of(effects, 'turn')
+ const range = await turn.bindPortRange({
+ internalStartPort: 49152,
+ externalStartPort: 49152, // may differ; the forward maps by offset
+ numberOfPorts: 100, // 2–500 contiguous ports
+ })
+
+ return [
+ await range.export(
+ sdk.createRangeInterface(effects, {
+ id: 'turn-relay',
+ name: i18n('TURN Relay'),
+ description: i18n('WebRTC media relay ports'),
+ }),
+ ),
+ ]
+})
+```
+
+A range binds **TCP + UDP** together and exposes **exactly one** `api` service interface spanning the whole range. The interface is deliberately restricted compared to `createInterface`: it is always `type: 'api'` and has **no** `masked`, `username`, `path`, `query`, or `schemeOverride`. The one extra option is an optional `scheme` — a transport prefix for protocols addressed as `scheme://host:port`, e.g. `tcp` for bitcoin ZMQ:
+
+```typescript
+const zmq = sdk.MultiHost.of(effects, 'zmq')
+const zmqRange = await zmq.bindPortRange({
+ internalStartPort: 28332,
+ externalStartPort: 28332,
+ numberOfPorts: 2,
+})
+await zmqRange.export(
+ sdk.createRangeInterface(effects, {
+ id: 'zmq',
+ name: i18n('ZMQ'),
+ description: i18n('Bitcoin ZMQ notification endpoints'),
+ scheme: 'tcp', // omit for raw UDP/TCP ranges (coturn, RTP, FTP data)
+ }),
+)
+```
+
+Two distinct endpoints are two `bindPortRange` calls — a range is a homogeneous pool of ports, so it maps to one named interface. Range interfaces show up in the service's **Interfaces** page with a per-gateway LAN / LAN+WAN access control; choosing LAN+WAN prompts the operator with the exact port range to forward on their router.
+
+| `createRangeInterface` option | Type | Description |
+|--------|------|-------------|
+| `id` | `string` | Unique identifier for the range interface. |
+| `name` | `string` | Display name shown to the user. Wrap with `i18n()`. |
+| `description` | `string` | Description shown to the user. Wrap with `i18n()`. |
+| `scheme` | `string` \| `null` | Optional transport prefix (e.g. `'tcp'`). Omit for raw UDP/TCP ranges. |
## TLS Termination
diff --git a/projects/start-sdk/docs/src/main.md b/projects/start-sdk/docs/src/main.md
index 0a62361211..bc30578de3 100644
--- a/projects/start-sdk/docs/src/main.md
+++ b/projects/start-sdk/docs/src/main.md
@@ -142,23 +142,34 @@ const secretKey = await storeJson.read((s) => s.secretKey).const(effects);
## Getting Hostnames
-Use a mapper function to extract only the data you need. The service only restarts if the mapped result changes, not if other interface properties change:
+Interfaces are reached through their **host**. `sdk.host.getOwn(effects, hostId)` returns the host (`hostId` is the id you passed to `sdk.MultiHost.of`); the interface you exported lives under one of the host's bindings, and `utils.filledAddress(host, addressInfo)` turns its address into resolvable hostnames/URLs:
```typescript
-// With mapper - only restarts if hostnames change
-const allowedHosts =
- (await sdk.serviceInterface
- .getOwn(effects, "ui", (i) =>
- i?.addressInfo?.format("hostname-info").map((h) => h.hostname.value),
- )
- .const()) || [];
-
-// Without mapper - restarts on any interface change (not recommended)
-const uiInterface = await sdk.serviceInterface.getOwn(effects, "ui").const();
+import { utils } from "@start9labs/start-sdk";
+
+const host = await sdk.host.getOwn(effects, "ui").const();
+const ui = Object.values(host?.bindings ?? {})
+ .flatMap((b) => Object.values(b.interfaces))
+ .find((i) => i.id === "ui");
+
const allowedHosts =
- uiInterface?.addressInfo
- ?.format("hostname-info")
- .map((h) => h.hostname.value) ?? [];
+ host && ui
+ ? utils
+ .filledAddress(host, ui.addressInfo)
+ .format("hostname-info")
+ .map((h) => h.hostname.value)
+ : [];
+```
+
+`.const()` sets up a reactive watcher — `setupMain` re-runs whenever the host's bindings, addresses, or exported interfaces change.
+
+To react to only a slice of the host, pass a `map` selector (and optional `eq`, default deep-equal) to `getOwn`/`get`. `.const()` then re-runs only when the mapped value changes rather than on any change to the whole host:
+
+```typescript
+// re-run only when THIS interface's address info changes
+const ui = await sdk.host
+ .getOwn(effects, "ui", (host) => host?.bindings[80]?.interfaces["ui"])
+ .const();
```
## Oneshots (Runtime)
diff --git a/projects/start-sdk/docs/src/recipe-dependency.md b/projects/start-sdk/docs/src/recipe-dependency.md
index 694702309b..38a8dc0fed 100644
--- a/projects/start-sdk/docs/src/recipe-dependency.md
+++ b/projects/start-sdk/docs/src/recipe-dependency.md
@@ -8,7 +8,7 @@ In `setupDependencies()`, return an object mapping dependency package IDs to the
These declarations drive the **warning UI** StartOS shows the user when a dependency isn't installed, isn't running, or has a listed health check failing. They do **not** gate your service's startup — your service starts whenever the user starts it, regardless of dependency state. If your service genuinely cannot operate before a dependency reaches a particular state, handle that at runtime in `setupMain` (poll, retry, or surface your own error); don't expect the dependency declaration to block startup for you.
-Read the dependency's connection info in `setupMain` either via `sdk.serviceInterface.get()` or directly as `http://.startos:`.
+Read the dependency's connection info in `setupMain` either via `sdk.host.get()` (walk the host's bindings to the interface) or directly as `http://.startos:`.
**Reference:** [Dependencies](dependencies.md)
diff --git a/projects/start-sdk/lib/StartSdk.ts b/projects/start-sdk/lib/StartSdk.ts
index 4f9ca699d9..85e23c6381 100644
--- a/projects/start-sdk/lib/StartSdk.ts
+++ b/projects/start-sdk/lib/StartSdk.ts
@@ -34,15 +34,15 @@ import {
setupUninit,
} from '@start9labs/start-core/inits'
import { MultiHost, Scheme } from '@start9labs/start-core/interfaces/Host'
+import { RangeInterfaceBuilder } from '@start9labs/start-core/interfaces/RangeInterfaceBuilder'
import { ServiceInterfaceBuilder } from '@start9labs/start-core/interfaces/ServiceInterfaceBuilder'
import { setupExportedUrls } from '@start9labs/start-core/interfaces/setupExportedUrls'
import { setupServiceInterfaces } from '@start9labs/start-core/interfaces/setupInterfaces'
import * as T from '@start9labs/start-core/types'
import { Effects, ServiceInterfaceType } from '@start9labs/start-core/types'
import { GetContainerIp } from '@start9labs/start-core/util/GetContainerIp'
+import { getHost, getOwnHost } from '@start9labs/start-core/util/GetHostInfo'
import { GetStatus } from '@start9labs/start-core/util/GetStatus'
-import { getOwnServiceInterface } from '@start9labs/start-core/util/getServiceInterface'
-import { getOwnServiceInterfaces } from '@start9labs/start-core/util/getServiceInterfaces'
import * as patterns from '@start9labs/start-core/util/patterns'
import { Backups } from './backup/Backups'
import { SetupBackupsParams, setupBackups } from './backup/setupBackups'
@@ -57,8 +57,6 @@ import {
GetOutboundGateway,
GetSslCertificate,
GetSystemSmtp,
- getServiceInterface,
- getServiceInterfaces,
getServiceManifest,
nullIfEmpty,
splitCommand,
@@ -137,6 +135,7 @@ export class StartSdk {
| 'getServiceInterface'
| 'listServiceInterfaces'
| 'exportServiceInterface'
+ | 'exportRangeServiceInterface'
| 'clearServiceInterfaces'
| 'bind'
| 'bindRange'
@@ -290,15 +289,35 @@ export class StartSdk {
effects: Effects,
packageIds?: DependencyId[],
) => Promise>,
- serviceInterface: {
- /** Retrieve a single service interface belonging to this package by its ID */
- getOwn: getOwnServiceInterface,
- /** Retrieve a single service interface from any package */
- get: getServiceInterface,
- /** Retrieve all service interfaces belonging to this package */
- getAllOwn: getOwnServiceInterfaces,
- /** Retrieve all service interfaces, optionally filtering by package */
- getAll: getServiceInterfaces,
+ host: {
+ /**
+ * Retrieve one of this package's own hosts by id, with reactive read
+ * strategies (`const`/`once`/`watch`/`onChange`/`waitFor`). Reach an
+ * exported service interface by walking the host:
+ * `host.bindings[internalPort].interfaces[id]` (single-port) or
+ * `host.bindingRanges[internalStartPort].interface` (port range).
+ *
+ * Pass an optional `map` (and `eq`) to react to only a slice of the
+ * host — with `const()`, the calling context re-runs when the mapped
+ * value changes rather than on any change to the whole host.
+ *
+ * @param effects - The effects context
+ * @param hostId - The host id passed to `sdk.MultiHost.of`
+ * @param map - optional selector narrowing reactivity to a child attr
+ * @param eq - optional equality for the mapped value (default deep-equal)
+ */
+ getOwn: getOwnHost,
+ /**
+ * Retrieve a host from any package by id (defaults to this package when
+ * `packageId` is omitted), with the same reactive read strategies and
+ * the same optional `map`/`eq` selective reactivity as `getOwn`.
+ *
+ * @param effects - The effects context
+ * @param opts - `{ hostId, packageId? }`
+ * @param map - optional selector narrowing reactivity to a child attr
+ * @param eq - optional equality for the mapped value (default deep-equal)
+ */
+ get: getHost,
},
/**
* Get the container IP address with reactive subscription support.
@@ -517,6 +536,44 @@ export class StartSdk {
masked: boolean
},
) => new ServiceInterfaceBuilder({ ...options, effects }),
+ /**
+ * A function for creating the single, restricted service interface a port
+ * range exports. Pass the result to `RangeOrigin.export` (the handle
+ * returned by `MultiHost.bindPortRange`). A range interface is always
+ * `api`-typed and has no masked/username/path/query/SSL — its address is
+ * the host plus the range's external port span.
+ *
+ * @example
+ * ```
+ const range = await sdk.MultiHost.of(effects, 'zmq').bindPortRange({
+ internalStartPort: 28332,
+ externalStartPort: 28332,
+ numberOfPorts: 2,
+ })
+ await range.export(
+ sdk.createRangeInterface(effects, {
+ id: 'zmq',
+ name: 'ZMQ',
+ description: 'Bitcoin ZMQ notification endpoints',
+ scheme: 'tcp',
+ }),
+ )
+ * ```
+ */
+ createRangeInterface: (
+ effects: Effects,
+ options: {
+ /** A unique ID for this range service interface. */
+ id: string
+ /** The human readable name of this range service interface. */
+ name: string
+ /** The human readable description. */
+ description: string
+ /** (optional) transport scheme prefix, e.g. `'tcp'` for bitcoin ZMQ
+ * endpoints. Omit for raw UDP/TCP ranges (coturn, RTP, FTP). */
+ scheme?: string | null
+ },
+ ) => new RangeInterfaceBuilder({ ...options, effects }),
/**
* Get the system SMTP configuration with reactive subscription support.
* @param effects - The effects context
diff --git a/projects/start-sdk/lib/test/host.test.ts b/projects/start-sdk/lib/test/host.test.ts
index 8c05815ea6..3df3cbddf4 100644
--- a/projects/start-sdk/lib/test/host.test.ts
+++ b/projects/start-sdk/lib/test/host.test.ts
@@ -108,5 +108,57 @@ describe('host', () => {
}),
).rejects.toThrow(/greater than 1024/)
})
+
+ test('export() registers the restricted range interface', async () => {
+ const bindRange = jest.fn(async () => null)
+ const exportRangeServiceInterface = jest.fn(async () => null)
+ const effects = {
+ bindRange,
+ exportRangeServiceInterface,
+ } as unknown as Effects
+ const range = await sdk.MultiHost.of(effects, 'zmq').bindPortRange({
+ internalStartPort: 28332,
+ externalStartPort: 28332,
+ numberOfPorts: 2,
+ })
+ await range.export(
+ sdk.createRangeInterface(effects, {
+ id: 'zmq',
+ name: 'ZMQ',
+ description: 'Bitcoin ZMQ endpoints',
+ scheme: 'tcp',
+ }),
+ )
+ expect(exportRangeServiceInterface).toHaveBeenCalledWith({
+ hostId: 'zmq',
+ internalStartPort: 28332,
+ id: 'zmq',
+ name: 'ZMQ',
+ description: 'Bitcoin ZMQ endpoints',
+ scheme: 'tcp',
+ })
+ })
+
+ test('export() defaults an omitted scheme to null', async () => {
+ const effects = {
+ bindRange: jest.fn(async () => null),
+ exportRangeServiceInterface: jest.fn(async () => null),
+ } as unknown as Effects
+ const range = await sdk.MultiHost.of(effects, 'turn').bindPortRange({
+ internalStartPort: 49152,
+ externalStartPort: 49152,
+ numberOfPorts: 100,
+ })
+ await range.export(
+ sdk.createRangeInterface(effects, {
+ id: 'turn-relay',
+ name: 'TURN Relay',
+ description: 'WebRTC media relay ports',
+ }),
+ )
+ expect(effects.exportRangeServiceInterface).toHaveBeenCalledWith(
+ expect.objectContaining({ scheme: null }),
+ )
+ })
})
})
diff --git a/projects/start-sdk/package.json b/projects/start-sdk/package.json
index 465add841f..cdbd85a40d 100644
--- a/projects/start-sdk/package.json
+++ b/projects/start-sdk/package.json
@@ -20,14 +20,14 @@
},
"repository": {
"type": "git",
- "url": "git+https://github.com/Start9Labs/start-os.git"
+ "url": "git+https://github.com/Start9Labs/start-technologies.git"
},
"author": "Start9 Labs",
"license": "MIT",
"bugs": {
- "url": "https://github.com/Start9Labs/start-os/issues"
+ "url": "https://github.com/Start9Labs/start-technologies/issues"
},
- "homepage": "https://github.com/Start9Labs/start-os#readme",
+ "homepage": "https://github.com/Start9Labs/start-technologies#readme",
"dependencies": {
"@iarna/toml": "^3.0.0",
"@noble/curves": "^1.9.7",
diff --git a/projects/start-tunnel/CHANGELOG.md b/projects/start-tunnel/CHANGELOG.md
index 38c7b5b675..f15c2b6c0a 100644
--- a/projects/start-tunnel/CHANGELOG.md
+++ b/projects/start-tunnel/CHANGELOG.md
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.1.0]
+
+### Added
+
+- **Port-range forwarding.** A manual port forward can now span a contiguous range of ports. Set "Number of Ports" in the Add Port Forward dialog — or `--count` on `start-tunnel port-forward add` — to forward that many consecutive ports counting up from both the external and internal port. Ranges are plain port forwards and cannot be combined with SNI demux. (Automatic PCP PORT_SET range forwarding requested by connected devices was already supported; this exposes it to manually-added forwards.)
+
## [1.0.0]
- **Independent versioning.** `start-tunnel` now carries its own version (starting at `1.0.0`) in its `Cargo.toml`, decoupled from the StartOS release line; its `.deb` is versioned from the manifest.
diff --git a/projects/start-tunnel/Cargo.toml b/projects/start-tunnel/Cargo.toml
index 797647f922..594c1cbff8 100644
--- a/projects/start-tunnel/Cargo.toml
+++ b/projects/start-tunnel/Cargo.toml
@@ -1,9 +1,9 @@
[package]
name = "start-tunnel"
-version = "1.0.0" # VERSION_BUMP
+version = "1.1.0" # VERSION_BUMP
edition = "2024"
license = "MIT"
-repository = "https://github.com/Start9Labs/start-os"
+repository = "https://github.com/Start9Labs/start-technologies"
[[bin]]
name = "tunnelbox"
diff --git a/projects/start-tunnel/docs/book.toml b/projects/start-tunnel/docs/book.toml
index 39cf1e88aa..5ce1d299e2 100644
--- a/projects/start-tunnel/docs/book.toml
+++ b/projects/start-tunnel/docs/book.toml
@@ -19,8 +19,8 @@ default-theme = "ayu"
preferred-dark-theme = "ayu"
additional-css = ["./theme/youtube.css", "./theme/tabs.css", "./accent.css"]
additional-js = ["./theme/youtube.js", "./theme/tabs.js", "./theme/home-link.js"]
-git-repository-url = "https://github.com/Start9Labs/start-os"
-edit-url-template = "https://github.com/Start9Labs/start-os/edit/master/projects/start-tunnel/docs/{path}"
+git-repository-url = "https://github.com/Start9Labs/start-technologies"
+edit-url-template = "https://github.com/Start9Labs/start-technologies/edit/master/projects/start-tunnel/docs/{path}"
site-url = "/start-tunnel/"
[output.html.fold]
diff --git a/projects/start-tunnel/docs/src/cli-reference.md b/projects/start-tunnel/docs/src/cli-reference.md
index 137dc86463..38bfb412c0 100644
--- a/projects/start-tunnel/docs/src/cli-reference.md
+++ b/projects/start-tunnel/docs/src/cli-reference.md
@@ -101,11 +101,13 @@ Display the WireGuard configuration file for a device. Optionally override the W
Expose a device's port on the server's public IP.
-### `start-tunnel port-forward add `
+### `start-tunnel port-forward add `
-Add a port forwarding rule mapping a public source to a private target.
+Add a port forwarding rule mapping a public external port to a private target. The external IP is fixed server-side to the target device's WAN.
- `--label ` — Human-readable label
+- `--sni ` — Hostname to SNI-demux on a shared external port (TLS services only); repeatable. Omit for a plain port forward.
+- `--count ` — Number of contiguous ports to forward as a range (a PCP PORT_SET range), counting up from both the external port and the target port. Defaults to 1. Not valid together with `--sni`.
### `start-tunnel port-forward remove `
diff --git a/projects/start-tunnel/docs/src/devices.md b/projects/start-tunnel/docs/src/devices.md
index 1fbef5df70..f51dfa22d3 100644
--- a/projects/start-tunnel/docs/src/devices.md
+++ b/projects/start-tunnel/docs/src/devices.md
@@ -33,9 +33,9 @@ A Server has two independently-toggleable capabilities, shown as switches in the
Only enable these for servers you trust. Clients have neither capability.
-## Promoting and demoting
+## Changing a device's role
-Use a device's actions menu to **Promote to Server** or **Demote to Client**. Promoting turns both Server capabilities on; demoting turns them off and moves the device to the Clients table.
+Use a device's actions menu to **Change to Server** or **Change to Client**. Changing to Server turns both Server capabilities on; changing to Client turns them off and moves the device to the Clients table.
## Removing a Device
diff --git a/projects/start-tunnel/docs/src/dns-records.md b/projects/start-tunnel/docs/src/dns-records.md
index 1b4e4c01bb..59bce3197d 100644
--- a/projects/start-tunnel/docs/src/dns-records.md
+++ b/projects/start-tunnel/docs/src/dns-records.md
@@ -12,7 +12,7 @@ DNS injection is **off by default** for every device. Only enable it for devices
> [!WARNING]
> A device allowed to inject DNS records can create, overwrite, or delete any record StartTunnel serves. Enable this only for trusted devices, such as your own StartOS server.
-1. In StartTunnel, navigate to `Devices`. DNS injection is a **Server** capability — if the device is a Client, promote it to a Server first (see [Devices](/start-tunnel/devices.html)).
+1. In StartTunnel, navigate to `Devices`. DNS injection is a **Server** capability — if the device is a Client, change it to a Server first (see [Devices](/start-tunnel/devices.html)).
1. In the Servers table, toggle **DNS injection** on for the device.
diff --git a/projects/start-tunnel/docs/src/port-forwarding.md b/projects/start-tunnel/docs/src/port-forwarding.md
index a351c2ab2b..932d490f12 100644
--- a/projects/start-tunnel/docs/src/port-forwarding.md
+++ b/projects/start-tunnel/docs/src/port-forwarding.md
@@ -18,7 +18,9 @@ The `Port Forwards` page shows two tables: **Manual** forwards you added by hand
1. Select the external IP address you want to use (there is usually only one).
-1. Enter the external port and the internal (device) port as. In almost all cases, they will be the same.
+1. Enter the external port and the internal (device) port. In almost all cases, they will be the same.
+
+1. To forward a **range** of ports, set "Number of Ports" to the size of the range. It counts up from both the external and internal ports you entered — for example external `49152`, internal `49152`, count `100` forwards `49152–49251` on each side. Leave it at `1` for a single port. Ranges are plain port forwards and cannot be combined with an SNI hostname.
1. If you are forwarding port `443 -> 443`, you will see a checkbox to also forward port `80 -> 443`. This is highly recommended, as it will automatically redirect HTTP to HTTPS.
diff --git a/projects/start-tunnel/man/start-tunnel-device-add.1 b/projects/start-tunnel/man/start-tunnel-device-add.1
index eff42352e6..2e83838df9 100644
--- a/projects/start-tunnel/man/start-tunnel-device-add.1
+++ b/projects/start-tunnel/man/start-tunnel-device-add.1
@@ -4,11 +4,24 @@
.SH NAME
start\-tunnel\-device\-add \- Add a device to a subnet
.SH SYNOPSIS
-\fBstart\-tunnel device add\fR [\fB\-h\fR|\fB\-\-help\fR] <\fISUBNET\fR> <\fINAME\fR> [\fIIP\fR]
+\fBstart\-tunnel device add\fR [\fB\-\-kind\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fISUBNET\fR> <\fINAME\fR> [\fIIP\fR]
.SH DESCRIPTION
Add a device to a subnet
.SH OPTIONS
.TP
+\fB\-\-kind\fR \fI\fR [default: client]
+Client (no autoconfig) or Server (gateway\-autoconfig on by default)
+.br
+
+.br
+\fIPossible values:\fR
+.RS 14
+.IP \(bu 2
+client
+.IP \(bu 2
+server
+.RE
+.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
diff --git a/projects/start-tunnel/man/start-tunnel-device-set-auto-port-forward.1 b/projects/start-tunnel/man/start-tunnel-device-set-auto-port-forward.1
new file mode 100644
index 0000000000..61b2a2a0c1
--- /dev/null
+++ b/projects/start-tunnel/man/start-tunnel-device-set-auto-port-forward.1
@@ -0,0 +1,22 @@
+.ie \n(.g .ds Aq \(aq
+.el .ds Aq '
+.TH start-tunnel-device-set-auto-port-forward 1 "set-auto-port-forward "
+.SH NAME
+start\-tunnel\-device\-set\-auto\-port\-forward \- Allow or deny a device to auto\-create port forwards
+.SH SYNOPSIS
+\fBstart\-tunnel device set\-auto\-port\-forward\fR [\fB\-\-enabled\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fISUBNET\fR> <\fIIP\fR>
+.SH DESCRIPTION
+Allow or deny a device to auto\-create port forwards
+.SH OPTIONS
+.TP
+\fB\-\-enabled\fR
+
+.TP
+\fB\-h\fR, \fB\-\-help\fR
+Print help
+.TP
+<\fISUBNET\fR>
+
+.TP
+<\fIIP\fR>
+
diff --git a/projects/start-tunnel/man/start-tunnel-device-set-kind.1 b/projects/start-tunnel/man/start-tunnel-device-set-kind.1
new file mode 100644
index 0000000000..5faef4641a
--- /dev/null
+++ b/projects/start-tunnel/man/start-tunnel-device-set-kind.1
@@ -0,0 +1,30 @@
+.ie \n(.g .ds Aq \(aq
+.el .ds Aq '
+.TH start-tunnel-device-set-kind 1 "set-kind "
+.SH NAME
+start\-tunnel\-device\-set\-kind \- Promote a device to a server or demote it to a client
+.SH SYNOPSIS
+\fBstart\-tunnel device set\-kind\fR <\fB\-\-kind\fR> [\fB\-h\fR|\fB\-\-help\fR] <\fISUBNET\fR> <\fIIP\fR>
+.SH DESCRIPTION
+Promote a device to a server or demote it to a client
+.SH OPTIONS
+.TP
+\fB\-\-kind\fR \fI\fR
+
+.br
+\fIPossible values:\fR
+.RS 14
+.IP \(bu 2
+client
+.IP \(bu 2
+server
+.RE
+.TP
+\fB\-h\fR, \fB\-\-help\fR
+Print help
+.TP
+<\fISUBNET\fR>
+
+.TP
+<\fIIP\fR>
+
diff --git a/projects/start-tunnel/man/start-tunnel-device-set-wan.1 b/projects/start-tunnel/man/start-tunnel-device-set-wan.1
new file mode 100644
index 0000000000..0834e91d10
--- /dev/null
+++ b/projects/start-tunnel/man/start-tunnel-device-set-wan.1
@@ -0,0 +1,22 @@
+.ie \n(.g .ds Aq \(aq
+.el .ds Aq '
+.TH start-tunnel-device-set-wan 1 "set-wan "
+.SH NAME
+start\-tunnel\-device\-set\-wan \- Override the WAN IP for a single device
+.SH SYNOPSIS
+\fBstart\-tunnel device set\-wan\fR [\fB\-\-wan\-ip\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fISUBNET\fR> <\fIIP\fR>
+.SH DESCRIPTION
+Override the WAN IP for a single device
+.SH OPTIONS
+.TP
+\fB\-\-wan\-ip\fR \fI\fR
+
+.TP
+\fB\-h\fR, \fB\-\-help\fR
+Print help
+.TP
+<\fISUBNET\fR>
+
+.TP
+<\fIIP\fR>
+
diff --git a/projects/start-tunnel/man/start-tunnel-device.1 b/projects/start-tunnel/man/start-tunnel-device.1
index 048ce2abc1..cf9dddd281 100644
--- a/projects/start-tunnel/man/start-tunnel-device.1
+++ b/projects/start-tunnel/man/start-tunnel-device.1
@@ -22,8 +22,17 @@ List devices in a subnet
start\-tunnel\-device\-remove(1)
Remove device from subnet
.TP
+start\-tunnel\-device\-set\-auto\-port\-forward(1)
+Allow or deny a device to auto\-create port forwards
+.TP
start\-tunnel\-device\-set\-dns\-injection(1)
Allow or deny a device to inject DNS records
.TP
+start\-tunnel\-device\-set\-kind(1)
+Promote a device to a server or demote it to a client
+.TP
+start\-tunnel\-device\-set\-wan(1)
+Override the WAN IP for a single device
+.TP
start\-tunnel\-device\-show\-config(1)
Show WireGuard configuration for device
diff --git a/projects/start-tunnel/man/start-tunnel-port-forward-add.1 b/projects/start-tunnel/man/start-tunnel-port-forward-add.1
index e3f3e1210b..004259553c 100644
--- a/projects/start-tunnel/man/start-tunnel-port-forward-add.1
+++ b/projects/start-tunnel/man/start-tunnel-port-forward-add.1
@@ -4,19 +4,25 @@
.SH NAME
start\-tunnel\-port\-forward\-add \- Add a new port forward
.SH SYNOPSIS
-\fBstart\-tunnel port\-forward add\fR [\fB\-\-label\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fISOURCE\fR> <\fITARGET\fR>
+\fBstart\-tunnel port\-forward add\fR [\fB\-\-label\fR] [\fB\-\-sni\fR] [\fB\-\-count\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIEXTERNAL_PORT\fR> <\fITARGET\fR>
.SH DESCRIPTION
Add a new port forward
.SH OPTIONS
.TP
\fB\-\-label\fR \fI\fR
+.TP
+\fB\-\-sni\fR \fI\fR
+Hostnames to SNI\-demux on the shared external port. Empty = normal DNAT
+.TP
+\fB\-\-count\fR \fI\fR
+Number of contiguous ports to forward (a PCP PORT_SET range), counting up from both `external_port` and the target port. Defaults to 1. Not valid together with SNI demux
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
-<\fISOURCE\fR>
-
+<\fIEXTERNAL_PORT\fR>
+External (WAN) port to forward. The external IP is fixed to the target\*(Aqs WAN so return traffic stays symmetric
.TP
<\fITARGET\fR>
diff --git a/projects/start-tunnel/man/start-tunnel-port-forward-remove.1 b/projects/start-tunnel/man/start-tunnel-port-forward-remove.1
index ee6c411dfe..ea02186547 100644
--- a/projects/start-tunnel/man/start-tunnel-port-forward-remove.1
+++ b/projects/start-tunnel/man/start-tunnel-port-forward-remove.1
@@ -4,11 +4,14 @@
.SH NAME
start\-tunnel\-port\-forward\-remove \- Remove port forward
.SH SYNOPSIS
-\fBstart\-tunnel port\-forward remove\fR [\fB\-h\fR|\fB\-\-help\fR] <\fISOURCE\fR>
+\fBstart\-tunnel port\-forward remove\fR [\fB\-\-hostname\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fISOURCE\fR>
.SH DESCRIPTION
Remove port forward
.SH OPTIONS
.TP
+\fB\-\-hostname\fR \fI\fR
+Remove a single SNI route on `source`; omit to remove the whole forward
+.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
diff --git a/projects/start-tunnel/man/start-tunnel-port-forward-set-enabled.1 b/projects/start-tunnel/man/start-tunnel-port-forward-set-enabled.1
index 7f2559bfb2..9654fa0f36 100644
--- a/projects/start-tunnel/man/start-tunnel-port-forward-set-enabled.1
+++ b/projects/start-tunnel/man/start-tunnel-port-forward-set-enabled.1
@@ -4,13 +4,16 @@
.SH NAME
start\-tunnel\-port\-forward\-set\-enabled \- Enable or disable a port forward
.SH SYNOPSIS
-\fBstart\-tunnel port\-forward set\-enabled\fR [\fB\-\-enabled\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fISOURCE\fR>
+\fBstart\-tunnel port\-forward set\-enabled\fR [\fB\-\-enabled\fR] [\fB\-\-hostname\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fISOURCE\fR>
.SH DESCRIPTION
Enable or disable a port forward
.SH OPTIONS
.TP
\fB\-\-enabled\fR
+.TP
+\fB\-\-hostname\fR \fI\fR
+Toggle a single SNI route on `source`; omit for a DNAT forward
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
diff --git a/projects/start-tunnel/man/start-tunnel-port-forward-update-label.1 b/projects/start-tunnel/man/start-tunnel-port-forward-update-label.1
index c8feffa4a8..d191a31681 100644
--- a/projects/start-tunnel/man/start-tunnel-port-forward-update-label.1
+++ b/projects/start-tunnel/man/start-tunnel-port-forward-update-label.1
@@ -4,11 +4,14 @@
.SH NAME
start\-tunnel\-port\-forward\-update\-label \- Update the label of a port forward
.SH SYNOPSIS
-\fBstart\-tunnel port\-forward update\-label\fR [\fB\-h\fR|\fB\-\-help\fR] <\fISOURCE\fR> [\fILABEL\fR]
+\fBstart\-tunnel port\-forward update\-label\fR [\fB\-\-hostname\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fISOURCE\fR> [\fILABEL\fR]
.SH DESCRIPTION
Update the label of a port forward
.SH OPTIONS
.TP
+\fB\-\-hostname\fR \fI\fR
+Label a single SNI route on `source`; omit to label the DNAT forward
+.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
diff --git a/projects/start-tunnel/man/start-tunnel-subnet-set-wan.1 b/projects/start-tunnel/man/start-tunnel-subnet-set-wan.1
new file mode 100644
index 0000000000..0cec411bbb
--- /dev/null
+++ b/projects/start-tunnel/man/start-tunnel-subnet-set-wan.1
@@ -0,0 +1,19 @@
+.ie \n(.g .ds Aq \(aq
+.el .ds Aq '
+.TH start-tunnel-subnet-set-wan 1 "set-wan "
+.SH NAME
+start\-tunnel\-subnet\-set\-wan \- Assign the WAN IP a subnet\*(Aqs traffic uses
+.SH SYNOPSIS
+\fBstart\-tunnel subnet set\-wan\fR [\fB\-\-wan\-ip\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fISUBNET\fR>
+.SH DESCRIPTION
+Assign the WAN IP a subnet\*(Aqs traffic uses
+.SH OPTIONS
+.TP
+\fB\-\-wan\-ip\fR \fI\fR
+
+.TP
+\fB\-h\fR, \fB\-\-help\fR
+Print help
+.TP
+<\fISUBNET\fR>
+
diff --git a/projects/start-tunnel/man/start-tunnel-subnet.1 b/projects/start-tunnel/man/start-tunnel-subnet.1
index ba3ab9a711..6a453eb995 100644
--- a/projects/start-tunnel/man/start-tunnel-subnet.1
+++ b/projects/start-tunnel/man/start-tunnel-subnet.1
@@ -24,3 +24,6 @@ Remove a subnet
.TP
start\-tunnel\-subnet\-set\-dns(1)
Set subnet DNS
+.TP
+start\-tunnel\-subnet\-set\-wan(1)
+Assign the WAN IP a subnet\*(Aqs traffic uses
diff --git a/projects/start-tunnel/web/src/app/routes/home/components/outlet.ts b/projects/start-tunnel/web/src/app/routes/home/components/outlet.ts
index b0a7d4fe1a..90300281ca 100644
--- a/projects/start-tunnel/web/src/app/routes/home/components/outlet.ts
+++ b/projects/start-tunnel/web/src/app/routes/home/components/outlet.ts
@@ -53,9 +53,6 @@ import { UpdateService } from 'src/app/services/update.service'
-
- {{ title() }}
-
@@ -73,10 +70,6 @@ import { UpdateService } from 'src/app/services/update.service'
border-start-start-radius: 1rem;
padding: 0;
}
-
- nav {
- display: none;
- }
}
:host {
@@ -109,6 +102,7 @@ import { UpdateService } from 'src/app/services/update.service'
tui-scrollbar {
min-inline-size: 100%;
+ padding-block-start: 1.5rem;
padding-inline-end: 1.5rem;
border-radius: var(--tui-radius-s);
@@ -125,11 +119,6 @@ import { UpdateService } from 'src/app/services/update.service'
[tuiAsideItem]::after {
color: var(--tui-status-positive);
}
-
- nav {
- border-image: none;
- clip-path: inset(0);
- }
}
`,
imports: [RouterOutlet, TuiNavigation, RouterLink, TuiIcon, TuiScrollbar],
diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/devices/index.ts b/projects/start-tunnel/web/src/app/routes/home/routes/devices/index.ts
index a7840923ae..ebbcafdbc7 100644
--- a/projects/start-tunnel/web/src/app/routes/home/routes/devices/index.ts
+++ b/projects/start-tunnel/web/src/app/routes/home/routes/devices/index.ts
@@ -8,6 +8,7 @@ import {
TuiButton,
TuiDataList,
TuiDropdown,
+ TuiIcon,
TuiLoader,
TuiTitle,
} from '@taiga-ui/core'
@@ -36,6 +37,7 @@ import { MappedDevice } from './utils'
template: `
+
Servers
@@ -127,10 +129,10 @@ import { MappedDevice } from './utils'
- Demote to Client
+ Change to Client
+
Clients
@@ -211,10 +214,10 @@ import { MappedDevice } from './utils'
- Promote to Server
+ Change to Server
- Value
+ Server
@if (mobile) {
} @else {
}
@if (!mobile) {
diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/dns/index.ts b/projects/start-tunnel/web/src/app/routes/home/routes/dns/index.ts
index 7efaaf12f4..f192ec12d1 100644
--- a/projects/start-tunnel/web/src/app/routes/home/routes/dns/index.ts
+++ b/projects/start-tunnel/web/src/app/routes/home/routes/dns/index.ts
@@ -8,7 +8,13 @@ import {
import { toSignal } from '@angular/core/rxjs-interop'
import { ErrorService } from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
-import { TuiButton, TuiDataList, TuiDropdown, TuiTitle } from '@taiga-ui/core'
+import {
+ TuiButton,
+ TuiDataList,
+ TuiDropdown,
+ TuiIcon,
+ TuiTitle,
+} from '@taiga-ui/core'
import {
TUI_CONFIRM,
TuiNotificationMiddleService,
@@ -28,6 +34,7 @@ import { DNS_ADD } from './add'
template: `
+
Manual
Add
@@ -38,7 +45,7 @@ import { DNS_ADD } from './add'
Name
Type
- Value
+ Server
TTL
@@ -48,7 +55,7 @@ import { DNS_ADD } from './add'
{{ record.name }}
{{ record.type }}
- {{ record.value }}
+ {{ serverDisplay(record) }}
{{ record.ttl }}
-
+
Name
Type
- Value
+ Server
TTL
- Source
-
@@ -109,38 +115,12 @@ import { DNS_ADD } from './add'
{{ record.name }}
{{ record.type }}
- {{ record.value }}
+ {{ serverDisplay(record) }}
{{ record.ttl }}
- {{ sourceName(record.source) }}
-
-
- Actions
-
-
- Delete
-
-
-
-
} @empty {
-
+
No automatic DNS records. Devices you trust can add their own
via RFC 2136.
@@ -168,6 +148,7 @@ import { DNS_ADD } from './add'
PlaceholderComponent,
TuiSkeleton,
TuiHeader,
+ TuiIcon,
TuiTitle,
],
})
@@ -199,17 +180,37 @@ export default class Dns {
{ initialValue: [] },
)
- // Records carry the injecting device's IP; show its friendly name when known.
- protected sourceName(source: string | null): string {
- if (!source) return '—'
- return this.devices().find(d => d.ip === source)?.name ?? source
+ // DNS records point a name at a server, so the picker lists servers only.
+ protected readonly servers: Signal = toSignal(
+ this.patch.watch$('wg', 'subnets').pipe(
+ map(subnets =>
+ Object.values(subnets).flatMap(({ clients }) =>
+ Object.entries(clients)
+ .filter(([, c]) => c.kind === 'server')
+ .map(([ip, { name }]) => ({ ip, name })),
+ ),
+ ),
+ ),
+ { initialValue: [] },
+ )
+
+ // The Server column shows the targeted server's friendly name and its IP: the
+ // injecting server for an automatic record, the selected server (the record
+ // value) for a manual one. Falls back to the raw value when unmatched.
+ protected serverDisplay(record: {
+ source: string | null
+ value: string
+ }): string {
+ const ip = record.source ?? record.value
+ const name = this.devices().find(d => d.ip === ip)?.name
+ return name ? `${name} (${ip})` : record.value
}
protected onAdd(): void {
this.dialogs
.open(DNS_ADD, {
label: 'Add DNS record',
- data: { devices: this.devices },
+ data: { devices: this.servers },
})
.subscribe()
}
diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/add.ts b/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/add.ts
index 33fe3b9ed8..cdb03acc37 100644
--- a/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/add.ts
+++ b/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/add.ts
@@ -1,7 +1,9 @@
import { Component, inject } from '@angular/core'
import {
+ AbstractControl,
NonNullableFormBuilder,
ReactiveFormsModule,
+ ValidationErrors,
Validators,
} from '@angular/forms'
import { WA_IS_MOBILE } from '@ng-web-apis/platform'
@@ -33,6 +35,20 @@ import { ApiService } from 'src/app/services/api/api.service'
import { MappedDevice, PortForwardsData } from './utils'
+// A range counts up from both the external and internal port, so neither side
+// may run past the u16 port space. Mirrors the server-side guard in add_forward.
+function portRangeOverflow(group: AbstractControl): ValidationErrors | null {
+ const ext = group.get('externalport')?.value
+ const int = group.get('internalport')?.value
+ const count = group.get('count')?.value ?? 1
+ if (count <= 1) return null
+ const last = count - 1
+ return (ext != null && ext + last > 65535) ||
+ (int != null && int + last > 65535)
+ ? { portRangeOverflow: true }
+ : null
+}
+
@Component({
template: `
{{ forward.label || '—' }}
{{ forward.externalip }}
- {{ forward.externalport }}
+ {{ span(forward.externalport, forward.count) }}
{{ forward.sni || '—' }}
{{ forward.device.name }}
- {{ forward.internalport }}
+ {{ span(forward.internalport, forward.count) }}
TCP/UDP
-
-
+
+
External IP
External Port
Hostname
- Device
+ Server
Internal Port
Protocol
-
@for (forward of automatic(); track $index) {
{{ forward.externalip }}
- {{ forward.externalport }}
+ {{ span(forward.externalport, forward.count) }}
{{ forward.sni || '—' }}
{{ forward.device.name }}
- {{ forward.internalport }}
+ {{ span(forward.internalport, forward.count) }}
TCP/UDP
-
-
- Actions
-
-
- Delete
-
-
-
-
} @empty {
-
+
No port forwards
@@ -206,6 +185,7 @@ import { mapForwards, MappedDevice, MappedForward } from './utils'
PlaceholderComponent,
TuiSkeleton,
TuiHeader,
+ TuiIcon,
TuiTitle,
],
})
@@ -229,16 +209,17 @@ export default class PortForwards {
{ initialValue: [] },
)
+ // Only servers can receive forwards, so the picker lists servers only.
private readonly devices: Signal = toSignal(
- this.patch
- .watch$('wg', 'subnets')
- .pipe(
- map(subnets =>
- Object.values(subnets).flatMap(({ clients }) =>
- Object.entries(clients).map(([ip, { name }]) => ({ ip, name })),
- ),
+ this.patch.watch$('wg', 'subnets').pipe(
+ map(subnets =>
+ Object.values(subnets).flatMap(({ clients }) =>
+ Object.entries(clients)
+ .filter(([, c]) => c.kind === 'server')
+ .map(([ip, { name }]) => ({ ip, name })),
),
),
+ ),
{ initialValue: [] },
)
@@ -259,6 +240,12 @@ export default class PortForwards {
return `${forward.externalip}:${forward.externalport}:${forward.hostname ?? ''}`
}
+ // Renders a forwarded port span: a single port when count is 1, else `start-end`.
+ protected span(startPort: string, count: number): string {
+ const start = Number(startPort)
+ return count > 1 ? `${start}-${start + count - 1}` : startPort
+ }
+
protected async onToggle(forward: MappedForward) {
const key = this.key(forward)
this.toggling.set(key)
diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/utils.ts b/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/utils.ts
index 3096537c96..8fa03e290a 100644
--- a/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/utils.ts
+++ b/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/utils.ts
@@ -8,9 +8,12 @@ export interface MappedDevice {
export interface MappedForward {
readonly externalip: string
+ // Start port of the forward; `externalport`/`internalport` stay the raw start
+ // (they rebuild the operation source key), while `count` gives the span.
readonly externalport: string
readonly device: MappedDevice
readonly internalport: string
+ readonly count: number
readonly label: T.Tunnel.SniRoute['label']
readonly enabled: T.Tunnel.SniRoute['enabled']
readonly auto: T.Tunnel.SniRoute['auto']
@@ -37,6 +40,7 @@ export function mapForwards(
route.enabled,
route.auto,
hostname,
+ 1,
),
)
: [
@@ -47,6 +51,7 @@ export function mapForwards(
forward.enabled,
forward.auto,
null,
+ forward.count,
),
],
)
@@ -58,6 +63,7 @@ export function mapForwards(
enabled: boolean,
auto: boolean,
hostname: string | null,
+ count: number,
): MappedForward {
const [externalip, externalport] = source.split(':')
const [targetip, internalport] = target.split(':')
@@ -73,6 +79,7 @@ export function mapForwards(
name: targetip!,
},
internalport: internalport!,
+ count,
label,
enabled,
auto,
diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/subnets/index.ts b/projects/start-tunnel/web/src/app/routes/home/routes/subnets/index.ts
index ff59932044..b67bd84adb 100644
--- a/projects/start-tunnel/web/src/app/routes/home/routes/subnets/index.ts
+++ b/projects/start-tunnel/web/src/app/routes/home/routes/subnets/index.ts
@@ -2,7 +2,13 @@ import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { T, utils } from '@start9labs/start-core'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
-import { TuiButton, TuiDataList, TuiDropdown, TuiTitle } from '@taiga-ui/core'
+import {
+ TuiButton,
+ TuiDataList,
+ TuiDropdown,
+ TuiIcon,
+ TuiTitle,
+} from '@taiga-ui/core'
import {
TUI_CONFIRM,
TuiNotificationMiddleService,
@@ -26,6 +32,7 @@ import { SUBNETS_ADD } from './add'
template: `
+
Subnets
Add
@@ -109,6 +116,7 @@ import { SUBNETS_ADD } from './add'
PlaceholderComponent,
TuiSkeleton,
TuiHeader,
+ TuiIcon,
TuiTitle,
],
})
diff --git a/projects/start-tunnel/web/src/app/services/api/mock-api.service.ts b/projects/start-tunnel/web/src/app/services/api/mock-api.service.ts
index 41777c7527..b75e4a5252 100644
--- a/projects/start-tunnel/web/src/app/services/api/mock-api.service.ts
+++ b/projects/start-tunnel/web/src/app/services/api/mock-api.service.ts
@@ -353,7 +353,7 @@ export class MockApiService extends ApiService {
target: params.target,
label: params.label || null,
enabled: true,
- count: 1,
+ count: params.count ?? 1,
auto: false,
}
forwards[source] = value
diff --git a/projects/start-tunnel/web/src/styles.scss b/projects/start-tunnel/web/src/styles.scss
index f01572ef20..80e4623de1 100644
--- a/projects/start-tunnel/web/src/styles.scss
+++ b/projects/start-tunnel/web/src/styles.scss
@@ -106,33 +106,21 @@ tui-notification-middle {
.g-table {
width: 100%;
- border-collapse: collapse;
- border-radius: var(--tui-radius-s);
background: var(--tui-background-elevation-1);
-
- thead {
- tr {
- position: sticky;
- top: 0;
- background: var(--tui-background-elevation-2);
- backdrop-filter: blur(5rem);
- z-index: 1;
- border-radius: var(--tui-radius-s) var(--tui-radius-s) 0 0;
- }
-
- th {
- &:first-child {
- border-top-left-radius: inherit;
- }
-
- &:last-child {
- border-top-right-radius: inherit;
- }
- }
+ border: 1px solid var(--tui-background-neutral-1);
+ border-radius: var(--tui-radius-s);
+ // Separate borders (not collapse) so border-radius works; overflow clips the
+ // cell backgrounds to the rounded corners. No clip-path/box-shadow needed.
+ border-collapse: separate;
+ border-spacing: 0;
+ overflow: hidden;
+
+ thead tr {
+ background: var(--tui-background-elevation-2);
}
tr:nth-child(even) {
- backdrop-filter: brightness(0.9);
+ background: rgba(0, 0, 0, 0.1);
}
th,
@@ -140,13 +128,30 @@ tui-notification-middle {
height: var(--tui-height-m);
padding: 0 1rem;
text-align: start;
- background: transparent;
- border: none;
+ border-bottom: 1px solid var(--tui-background-neutral-1);
&:last-child {
text-align: end;
}
}
+
+ tbody tr:last-child td {
+ border-bottom: 0;
+ }
+
+ // Keep the loader hugging its switch so the overlay spinner sits on the
+ // toggle, not centered across the whole cell.
+ tui-loader {
+ inline-size: fit-content;
+ }
+}
+
+// Tables without an actions column shouldn't right-align their last (data) cell.
+.g-table.no-actions {
+ th:last-child,
+ td:last-child {
+ text-align: start;
+ }
}
qr-code {
diff --git a/shared-libs/crates/exver/Cargo.toml b/shared-libs/crates/exver/Cargo.toml
index 5998908b2e..72c2290494 100644
--- a/shared-libs/crates/exver/Cargo.toml
+++ b/shared-libs/crates/exver/Cargo.toml
@@ -10,7 +10,7 @@ keywords = ["version", "semver", "embassy", "wasm"]
license = "MIT"
name = "exver"
readme = "README.md"
-repository = "https://github.com/Start9Labs/exver-rs"
+repository = "https://github.com/Start9Labs/start-technologies"
version = "0.2.1"
diff --git a/shared-libs/crates/patch-db/core/Cargo.toml b/shared-libs/crates/patch-db/core/Cargo.toml
index 43a04b1a41..cb3cc72039 100644
--- a/shared-libs/crates/patch-db/core/Cargo.toml
+++ b/shared-libs/crates/patch-db/core/Cargo.toml
@@ -7,7 +7,7 @@ keywords = ["json", "json-patch", "json-pointer"]
license = "MIT"
name = "patch-db"
readme = "README.md"
-repository = "https://github.com/Start9Labs/patch-db"
+repository = "https://github.com/Start9Labs/start-technologies"
version = "0.1.0"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
diff --git a/shared-libs/crates/patch-db/macro-internals/Cargo.toml b/shared-libs/crates/patch-db/macro-internals/Cargo.toml
index d4cd349178..0c0d85b02b 100644
--- a/shared-libs/crates/patch-db/macro-internals/Cargo.toml
+++ b/shared-libs/crates/patch-db/macro-internals/Cargo.toml
@@ -5,7 +5,7 @@ authors = ["Aiden McClelland "]
edition = "2018"
description = "internals for macros for defining typed patch dbs"
license = "MIT"
-repository = "https://github.com/Start9Labs/patch-db"
+repository = "https://github.com/Start9Labs/start-technologies"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
diff --git a/shared-libs/crates/patch-db/macro/Cargo.toml b/shared-libs/crates/patch-db/macro/Cargo.toml
index 3c7f4e5bd8..8c0c19c869 100644
--- a/shared-libs/crates/patch-db/macro/Cargo.toml
+++ b/shared-libs/crates/patch-db/macro/Cargo.toml
@@ -5,7 +5,7 @@ authors = ["Aiden McClelland "]
edition = "2018"
description = "macros for defining typed patch dbs"
license = "MIT"
-repository = "https://github.com/Start9Labs/patch-db"
+repository = "https://github.com/Start9Labs/start-technologies"
[lib]
proc-macro = true
diff --git a/shared-libs/crates/pi-beep/Cargo.toml b/shared-libs/crates/pi-beep/Cargo.toml
index 53ef348d54..30bbab2d59 100644
--- a/shared-libs/crates/pi-beep/Cargo.toml
+++ b/shared-libs/crates/pi-beep/Cargo.toml
@@ -5,7 +5,7 @@ edition = "2021"
description = "A reimplementation of `beep` but using the pwm chip of the raspberry pi"
license = "MIT"
readme = "README.txt"
-repository = "https://github.com/Start9Labs/pi-beep"
+repository = "https://github.com/Start9Labs/start-technologies"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
diff --git a/shared-libs/crates/rpc-toolkit/Cargo.toml b/shared-libs/crates/rpc-toolkit/Cargo.toml
index 1fde22869a..1ba1407a68 100644
--- a/shared-libs/crates/rpc-toolkit/Cargo.toml
+++ b/shared-libs/crates/rpc-toolkit/Cargo.toml
@@ -7,7 +7,7 @@ description = "A toolkit for creating JSON-RPC 2.0 servers with automatic cli bi
license = "MIT"
documentation = "https://docs.rs/rpc-toolkit"
keywords = ["json", "rpc", "cli"]
-repository = "https://github.com/Start9Labs/rpc-toolkit"
+repository = "https://github.com/Start9Labs/start-technologies"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
diff --git a/shared-libs/crates/start-core/ARCHITECTURE.md b/shared-libs/crates/start-core/ARCHITECTURE.md
index 9755d52728..a5d526a782 100644
--- a/shared-libs/crates/start-core/ARCHITECTURE.md
+++ b/shared-libs/crates/start-core/ARCHITECTURE.md
@@ -40,7 +40,7 @@ how `startbox` dispatches to `startd`, `start-cli`, etc. The per-entrypoint logi
## RPC Pattern
-The API is JSON-RPC (not REST). All endpoints are RPC methods organized in a hierarchical command structure using [rpc-toolkit](https://github.com/Start9Labs/rpc-toolkit). Handlers are registered in a tree of `ParentHandler` nodes, with four handler types: `from_fn_async` (standard), `from_fn_async_local` (non-Send), `from_fn` (sync), and `from_fn_blocking` (blocking). Metadata like `.with_about()` drives middleware and documentation.
+The API is JSON-RPC (not REST). All endpoints are RPC methods organized in a hierarchical command structure using [rpc-toolkit](https://github.com/Start9Labs/start-technologies/tree/master/shared-libs/crates/rpc-toolkit). Handlers are registered in a tree of `ParentHandler` nodes, with four handler types: `from_fn_async` (standard), `from_fn_async_local` (non-Send), `from_fn` (sync), and `from_fn_blocking` (blocking). Metadata like `.with_about()` drives middleware and documentation.
See [rpc-toolkit.md](rpc-toolkit.md) for full handler patterns and configuration.
diff --git a/shared-libs/crates/start-core/Cargo.toml b/shared-libs/crates/start-core/Cargo.toml
index 8bf8d694e0..1c8d45b633 100644
--- a/shared-libs/crates/start-core/Cargo.toml
+++ b/shared-libs/crates/start-core/Cargo.toml
@@ -14,7 +14,7 @@ keywords = [
license = "MIT"
name = "start-core"
readme = "README.md"
-repository = "https://github.com/Start9Labs/start-os"
+repository = "https://github.com/Start9Labs/start-technologies"
version = "0.4.0-beta.10" # VERSION_BUMP
[lib]
diff --git a/shared-libs/crates/start-core/exver.md b/shared-libs/crates/start-core/exver.md
index 20caade281..4d60d138b9 100644
--- a/shared-libs/crates/start-core/exver.md
+++ b/shared-libs/crates/start-core/exver.md
@@ -3,7 +3,7 @@
Extended semver supporting **downstream versioning** (wrapper updates independent of upstream) and **flavors** (package fork variants).
Two implementations exist:
-- **Rust crate** (`exver`) — used in `core/`. Source: https://github.com/Start9Labs/exver-rs
+- **Rust crate** (`exver`) — used in `core/`. Source: https://github.com/Start9Labs/start-technologies/tree/master/shared-libs/crates/exver
- **TypeScript** (`shared-libs/ts-modules/start-core/lib/exver/index.ts`, package `@start9labs/start-core`) — used in `sdk/` and `web/`
Both parse the same string format and agree on `satisfies` semantics.
diff --git a/shared-libs/crates/start-core/locales/i18n.yaml b/shared-libs/crates/start-core/locales/i18n.yaml
index e934ebfaa6..a802938036 100644
--- a/shared-libs/crates/start-core/locales/i18n.yaml
+++ b/shared-libs/crates/start-core/locales/i18n.yaml
@@ -3481,12 +3481,6 @@ help.arg.pty-size:
fr_FR: "Taille du terminal PTY (:[::])"
pl_PL: "Rozmiar terminala PTY (:[::])"
-help.arg.range-access:
- en_US: "How the range is exposed on the gateway: disabled, private, or public"
- de_DE: "Wie der Bereich auf dem Gateway verfügbar ist: deaktiviert, privat oder öffentlich"
- es_ES: "Cómo se expone el rango en la puerta de enlace: deshabilitado, privado o público"
- fr_FR: "Comment la plage est exposée sur la passerelle : désactivée, privée ou publique"
- pl_PL: "Sposób udostępniania zakresu na bramie: wyłączony, prywatny lub publiczny"
help.arg.registry-hostname:
en_US: "Registry server hostname"
@@ -3992,12 +3986,6 @@ help.arg.internal-port:
fr_FR: "Numéro de port interne"
pl_PL: "Numer portu wewnętrznego"
-help.arg.internal-start-port:
- en_US: "Internal start port of the range"
- de_DE: "Interner Startport des Bereichs"
- es_ES: "Puerto inicial interno del rango"
- fr_FR: "Port de début interne de la plage"
- pl_PL: "Wewnętrzny port początkowy zakresu"
help.arg.is-public:
en_US: "Whether the interface is publicly addressable"
@@ -5974,6 +5962,13 @@ about.set-address-enabled-for-binding:
fr_FR: "Définir une adresse de passerelle activée pour une liaison"
pl_PL: "Ustaw adres bramy jako włączony dla powiązania"
+about.set-range-address-enabled-for-binding:
+ en_US: "Set a gateway address enabled for a port-range binding"
+ de_DE: "Gateway-Adresse für eine Portbereichs-Bindung aktivieren"
+ es_ES: "Establecer una dirección de gateway habilitada para un vínculo de rango de puertos"
+ fr_FR: "Définir une adresse de passerelle activée pour une liaison de plage de ports"
+ pl_PL: "Ustaw adres bramy jako włączony dla powiązania zakresu portów"
+
about.set-country:
en_US: "Set the country"
de_DE: "Das Land festlegen"
@@ -6044,12 +6039,6 @@ about.set-outbound-gateway-package:
fr_FR: "Définir la passerelle sortante pour un package"
pl_PL: "Ustaw bramę wychodzącą dla pakietu"
-about.set-range-gateway-access-for-binding:
- en_US: "Set how a port range is exposed on a specific gateway (disabled, private, or public)"
- de_DE: "Festlegen, wie ein Portbereich auf einem bestimmten Gateway verfügbar ist (deaktiviert, privat oder öffentlich)"
- es_ES: "Establecer cómo se expone un rango de puertos en una puerta de enlace específica (deshabilitado, privado o público)"
- fr_FR: "Définir comment une plage de ports est exposée sur une passerelle spécifique (désactivée, privée ou publique)"
- pl_PL: "Określ sposób udostępniania zakresu portów dla określonej bramy (wyłączony, prywatny lub publiczny)"
about.set-registry-icon:
en_US: "Set the registry icon"
diff --git a/shared-libs/crates/start-core/rpc-toolkit.md b/shared-libs/crates/start-core/rpc-toolkit.md
index a1499dc294..7a27abb5e7 100644
--- a/shared-libs/crates/start-core/rpc-toolkit.md
+++ b/shared-libs/crates/start-core/rpc-toolkit.md
@@ -1,6 +1,6 @@
# rpc-toolkit
-StartOS uses [rpc-toolkit](https://github.com/Start9Labs/rpc-toolkit) for its JSON-RPC API. This document covers the patterns used in this codebase.
+StartOS uses [rpc-toolkit](https://github.com/Start9Labs/start-technologies/tree/master/shared-libs/crates/rpc-toolkit) for its JSON-RPC API. This document covers the patterns used in this codebase.
## Overview
diff --git a/shared-libs/crates/start-core/src/db/model/package.rs b/shared-libs/crates/start-core/src/db/model/package.rs
index 6b91467bb1..80571ebafd 100644
--- a/shared-libs/crates/start-core/src/db/model/package.rs
+++ b/shared-libs/crates/start-core/src/db/model/package.rs
@@ -11,14 +11,13 @@ use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::net::host::Hosts;
-use crate::net::service_interface::ServiceInterface;
use crate::prelude::*;
use crate::progress::FullProgress;
use crate::s9pk::manifest::{LocaleString, Manifest};
use crate::status::StatusInfo;
use crate::util::DataUrl;
use crate::util::serde::{Pem, is_partial_of};
-use crate::{ActionId, GatewayId, HealthCheckId, HostId, PackageId, ReplayId, ServiceInterfaceId};
+use crate::{ActionId, GatewayId, HealthCheckId, HostId, PackageId, ReplayId};
#[derive(Debug, Default, Deserialize, Serialize, TS)]
#[ts(export)]
@@ -398,7 +397,6 @@ pub struct PackageDataEntry {
pub current_dependencies: CurrentDependencies,
pub actions: BTreeMap,
pub tasks: BTreeMap,
- pub service_interfaces: BTreeMap,
pub hosts: Hosts,
#[ts(type = "string[]")]
pub store_exposed_dependents: Vec,
diff --git a/shared-libs/crates/start-core/src/db/model/public.rs b/shared-libs/crates/start-core/src/db/model/public.rs
index 1ed18d1a80..73e7fdecda 100644
--- a/shared-libs/crates/start-core/src/db/model/public.rs
+++ b/shared-libs/crates/start-core/src/db/model/public.rs
@@ -88,6 +88,7 @@ impl Public {
assigned_ssl_port: Some(443),
},
addresses: DerivedAddressInfo::default(),
+ interfaces: BTreeMap::new(),
},
)]
.into_iter()
diff --git a/shared-libs/crates/start-core/src/net/host/binding.rs b/shared-libs/crates/start-core/src/net/host/binding.rs
index 3741b0b14b..d981f38807 100644
--- a/shared-libs/crates/start-core/src/net/host/binding.rs
+++ b/shared-libs/crates/start-core/src/net/host/binding.rs
@@ -8,14 +8,15 @@ use rpc_toolkit::{Context, Empty, HandlerArgs, HandlerExt, ParentHandler, from_f
use serde::{Deserialize, Serialize};
use ts_rs::TS;
-use crate::GatewayId;
use crate::HostId;
+use crate::ServiceInterfaceId;
use crate::context::{CliContext, RpcContext};
use crate::db::prelude::Map;
-use crate::hostname::ServerHostname;
use crate::net::forward::AvailablePorts;
use crate::net::host::HostApiKind;
-use crate::net::service_interface::{HostnameInfo, HostnameMetadata};
+use crate::net::service_interface::{
+ HostnameInfo, HostnameMetadata, RangeServiceInterface, ServiceInterface,
+};
use crate::net::vhost::AlpnInfo;
use crate::prelude::*;
use crate::util::FromStrParser;
@@ -68,7 +69,10 @@ impl DerivedAddressInfo {
self.available
.iter()
.filter(|h| {
- if h.public && h.metadata.is_ip() {
+ if h.is_internal() {
+ // lo / lxcbr0 are always reachable and never operator-disablable.
+ true
+ } else if h.public && h.metadata.is_ip() {
// Public IPs: disabled by default, explicitly enabled via SocketAddr
h.to_socket_addr().map_or(
true, // should never happen, but would rather see them if it does
@@ -113,38 +117,6 @@ impl std::ops::DerefMut for Bindings {
}
}
-/// Per-gateway exposure of a port range, chosen by the operator.
-///
-/// Mirrors how single-port bindings treat WAN vs LAN addresses in
-/// [`crate::net::forward::ForwardRequirements`]: `LanWan` puts the gateway in
-/// `public_gateways` (no source filter → reachable from LAN and WAN), `Lan`
-/// puts the gateway's subnet(s) in `private_ips` (source filtered to the LAN →
-/// reachable from LAN only), and `Disabled` forwards on neither.
-#[derive(
- Clone,
- Copy,
- Debug,
- Default,
- PartialEq,
- Eq,
- PartialOrd,
- Ord,
- Deserialize,
- Serialize,
- TS,
- clap::ValueEnum,
-)]
-#[ts(export)]
-#[serde(rename_all = "kebab-case")]
-pub enum RangeGatewayAccess {
- Disabled,
- /// Default: a freshly-bound range is reachable on the LAN only. Exposing it
- /// to the WAN is an explicit, per-gateway operator opt-in (`LanWan`).
- #[default]
- Lan,
- LanWan,
-}
-
/// Contiguous port-range binding (e.g. WebRTC/STUN/TURN RTP ranges).
///
/// Keyed by `internal_start_port` in [`BindingRanges`]. The range covers
@@ -159,13 +131,18 @@ pub struct RangeBindInfo {
pub enabled: bool,
pub external_start_port: u16,
pub number_of_ports: u16,
- /// Per-gateway exposure chosen by the operator. Absent gateways default to
- /// [`RangeGatewayAccess::Lan`] (LAN-only), so a freshly-bound range is
- /// not exposed to the WAN until the operator opts in. Persisted
- /// independently of `enabled` (the service-lifecycle flag) so operator
- /// choices survive restarts / rebinds.
+ /// Reachable addresses for this range (LAN IPv4 / WAN IPv4 / mDNS /
+ /// domains) with per-address enabled/disabled overrides — the same model as
+ /// a single-port binding, but IPv4-only and non-SSL. COMPUTED by
+ /// `update_addresses`; every entry uses `external_start_port` as its
+ /// representative port. Public IPs are disabled by default (WAN is opt-in);
+ /// LAN/mDNS/domains are enabled by default.
+ #[serde(default)]
+ pub addresses: DerivedAddressInfo,
+ /// The single restricted `api` interface exported from this range, if any
+ /// (`RangeOrigin.export`). Preserved across idempotent re-binds.
#[serde(default)]
- pub gateway_access: BTreeMap,
+ pub interface: Option,
}
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
@@ -202,13 +179,16 @@ impl RangeBindInfo {
self.enabled = false;
}
- /// Effective exposure for `gateway` — gateways the operator hasn't touched
- /// default to [`RangeGatewayAccess::Lan`] (LAN-only).
- pub fn access_for(&self, gateway: &GatewayId) -> RangeGatewayAccess {
- self.gateway_access
- .get(gateway)
- .copied()
- .unwrap_or_default()
+ /// Addresses actually served by this range. Analogous to
+ /// [`BindInfo::enabled_addresses`]: a range with no exported interface is
+ /// internal-only (lo / lxcbr0), its per-address overrides dormant.
+ pub fn enabled_addresses(&self) -> BTreeSet<&HostnameInfo> {
+ let enabled = self.addresses.enabled();
+ if self.interface.is_none() {
+ enabled.into_iter().filter(|a| a.is_internal()).collect()
+ } else {
+ enabled
+ }
}
}
@@ -221,6 +201,11 @@ pub struct BindInfo {
pub options: BindOptions,
pub net: NetInfo,
pub addresses: DerivedAddressInfo,
+ /// Service interfaces exported from this binding (`Origin.export`). A single
+ /// binding (host + internal port) may back several interfaces (e.g. a `ui`
+ /// and an `api` on the same port), so this is keyed by interface id.
+ #[serde(default)]
+ pub interfaces: BTreeMap,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS, PartialEq, Eq, PartialOrd, Ord)]
@@ -231,6 +216,19 @@ pub struct NetInfo {
pub assigned_ssl_port: Option,
}
impl BindInfo {
+ /// Addresses actually served by this binding. A binding with no exported
+ /// service interface listens internally only (lo / lxcbr0) — the operator's
+ /// per-address `enabled`/`disabled` overrides stay stored but dormant until
+ /// an interface is exported, at which point they take effect again.
+ pub fn enabled_addresses(&self) -> BTreeSet<&HostnameInfo> {
+ let enabled = self.addresses.enabled();
+ if self.interfaces.is_empty() {
+ enabled.into_iter().filter(|a| a.is_internal()).collect()
+ } else {
+ enabled
+ }
+ }
+
pub fn new(available_ports: &mut AvailablePorts, options: BindOptions) -> Result {
let mut assigned_port = None;
let mut assigned_ssl_port = None;
@@ -256,6 +254,7 @@ impl BindInfo {
assigned_ssl_port,
},
addresses: DerivedAddressInfo::default(),
+ interfaces: BTreeMap::new(),
})
}
pub fn update(
@@ -266,6 +265,7 @@ impl BindInfo {
let Self {
net: mut lan,
addresses,
+ interfaces,
..
} = self;
if options
@@ -306,6 +306,7 @@ impl BindInfo {
options,
net: lan,
addresses,
+ interfaces,
})
}
pub fn disable(&mut self) {
@@ -459,12 +460,12 @@ pub fn binding()
.with_call_remote::(),
)
.subcommand(
- "set-range-gateway-access",
- from_fn_async(set_range_gateway_access::)
+ "set-range-address-enabled",
+ from_fn_async(set_range_address_enabled::)
.with_metadata("sync_db", Value::Bool(true))
.with_inherited(Kind::inheritance)
.no_display()
- .with_about("about.set-range-gateway-access-for-binding")
+ .with_about("about.set-range-address-enabled-for-binding")
.with_call_remote::(),
)
}
@@ -492,6 +493,102 @@ pub struct BindingSetAddressEnabledParams {
enabled: Option,
}
+/// Toggle one address on/off for a binding's `DerivedAddressInfo`. Public IPs
+/// live in the `enabled` set (keyed by `SocketAddr`); domains and private IPs
+/// live in the `disabled` set (keyed by `(hostname, port)`). Non-SSL Ipv4 ↔
+/// PublicDomain on the same gateway+port are cascaded so they toggle together.
+/// Shared by single-port bindings and port ranges (whose addresses all use
+/// `external_start_port` as their port, so the same keying applies).
+fn set_address_enabled_on(
+ addresses: &mut DerivedAddressInfo,
+ address: &HostnameInfo,
+ enabled: bool,
+) -> Result<(), Error> {
+ if address.public && address.metadata.is_ip() {
+ // Public IPs: toggle via SocketAddr in `enabled` set
+ let sa = address.to_socket_addr().ok_or_else(|| {
+ Error::new(
+ eyre!("cannot convert address to socket addr"),
+ ErrorKind::InvalidRequest,
+ )
+ })?;
+ if enabled {
+ addresses.enabled.insert(sa);
+ } else {
+ addresses.enabled.remove(&sa);
+ }
+ // Non-SSL Ipv4: cascade to PublicDomains on same gateway
+ if !address.ssl {
+ if let HostnameMetadata::Ipv4 { gateway } = &address.metadata {
+ let port = sa.port();
+ for a in &addresses.available {
+ if a.ssl {
+ continue;
+ }
+ if let HostnameMetadata::PublicDomain { gateway: gw } = &a.metadata {
+ if gw == gateway && a.port.unwrap_or(80) == port {
+ let k = (a.hostname.clone(), a.port.unwrap_or(80));
+ if enabled {
+ addresses.disabled.remove(&k);
+ } else {
+ addresses.disabled.insert(k);
+ }
+ }
+ }
+ }
+ }
+ }
+ } else {
+ // Domains and private IPs: toggle via (host, port) in `disabled` set
+ let port = address.port.unwrap_or(if address.ssl { 443 } else { 80 });
+ let key = (address.hostname.clone(), port);
+ if enabled {
+ addresses.disabled.remove(&key);
+ } else {
+ addresses.disabled.insert(key);
+ }
+ // Non-SSL PublicDomain: cascade to Ipv4 + other PublicDomains on same gateway
+ if !address.ssl {
+ if let HostnameMetadata::PublicDomain { gateway } = &address.metadata {
+ for a in &addresses.available {
+ if a.ssl {
+ continue;
+ }
+ match &a.metadata {
+ HostnameMetadata::Ipv4 { gateway: gw } if a.public && gw == gateway => {
+ if let Some(sa) = a.to_socket_addr() {
+ if sa.port() == port {
+ if enabled {
+ addresses.enabled.insert(sa);
+ } else {
+ addresses.enabled.remove(&sa);
+ }
+ }
+ }
+ }
+ HostnameMetadata::PublicDomain { gateway: gw } if gw == gateway => {
+ let dp = a.port.unwrap_or(80);
+ if dp == port {
+ let k = (a.hostname.clone(), dp);
+ if enabled {
+ addresses.disabled.remove(&k);
+ } else {
+ addresses.disabled.insert(k);
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Toggle one address of a single-port binding (keyed by its internal port).
+/// Port ranges use [`set_range_address_enabled`] — they live in a separate DB
+/// subtree, so the API distinguishes the two rather than probing both.
pub async fn set_address_enabled(
ctx: RpcContext,
BindingSetAddressEnabledParams {
@@ -504,98 +601,19 @@ pub async fn set_address_enabled(
let enabled = enabled.unwrap_or(true);
let address: HostnameInfo =
serde_json::from_str(&address).with_kind(ErrorKind::Deserialization)?;
+ if !enabled && address.is_internal() {
+ return Err(Error::new(
+ eyre!("loopback / bridge (internal) addresses cannot be disabled"),
+ ErrorKind::InvalidRequest,
+ ));
+ }
ctx.db
.mutate(|db| {
Kind::host_for(&inheritance, db)?
.as_bindings_mut()
.mutate(|b| {
let bind = b.get_mut(&internal_port).or_not_found(internal_port)?;
- if address.public && address.metadata.is_ip() {
- // Public IPs: toggle via SocketAddr in `enabled` set
- let sa = address.to_socket_addr().ok_or_else(|| {
- Error::new(
- eyre!("cannot convert address to socket addr"),
- ErrorKind::InvalidRequest,
- )
- })?;
- if enabled {
- bind.addresses.enabled.insert(sa);
- } else {
- bind.addresses.enabled.remove(&sa);
- }
- // Non-SSL Ipv4: cascade to PublicDomains on same gateway
- if !address.ssl {
- if let HostnameMetadata::Ipv4 { gateway } = &address.metadata {
- let port = sa.port();
- for a in &bind.addresses.available {
- if a.ssl {
- continue;
- }
- if let HostnameMetadata::PublicDomain { gateway: gw } =
- &a.metadata
- {
- if gw == gateway && a.port.unwrap_or(80) == port {
- let k = (a.hostname.clone(), a.port.unwrap_or(80));
- if enabled {
- bind.addresses.disabled.remove(&k);
- } else {
- bind.addresses.disabled.insert(k);
- }
- }
- }
- }
- }
- }
- } else {
- // Domains and private IPs: toggle via (host, port) in `disabled` set
- let port = address.port.unwrap_or(if address.ssl { 443 } else { 80 });
- let key = (address.hostname.clone(), port);
- if enabled {
- bind.addresses.disabled.remove(&key);
- } else {
- bind.addresses.disabled.insert(key);
- }
- // Non-SSL PublicDomain: cascade to Ipv4 + other PublicDomains on same gateway
- if !address.ssl {
- if let HostnameMetadata::PublicDomain { gateway } = &address.metadata {
- for a in &bind.addresses.available {
- if a.ssl {
- continue;
- }
- match &a.metadata {
- HostnameMetadata::Ipv4 { gateway: gw }
- if a.public && gw == gateway =>
- {
- if let Some(sa) = a.to_socket_addr() {
- if sa.port() == port {
- if enabled {
- bind.addresses.enabled.insert(sa);
- } else {
- bind.addresses.enabled.remove(&sa);
- }
- }
- }
- }
- HostnameMetadata::PublicDomain { gateway: gw }
- if gw == gateway =>
- {
- let dp = a.port.unwrap_or(80);
- if dp == port {
- let k = (a.hostname.clone(), dp);
- if enabled {
- bind.addresses.disabled.remove(&k);
- } else {
- bind.addresses.disabled.insert(k);
- }
- }
- }
- _ => {}
- }
- }
- }
- }
- }
- Ok(())
+ set_address_enabled_on(&mut bind.addresses, &address, enabled)
})
})
.await
@@ -603,61 +621,38 @@ pub async fn set_address_enabled(
Ok(())
}
-#[derive(Deserialize, Serialize, Parser, TS)]
-#[group(skip)]
-#[serde(rename_all = "camelCase")]
-#[ts(export)]
-pub struct BindingSetRangeGatewayAccessParams {
- #[arg(help = "help.arg.internal-start-port")]
- internal_start_port: u16,
- #[arg(long, help = "help.arg.gateway-id")]
- gateway: GatewayId,
- #[arg(long, help = "help.arg.range-access")]
- access: RangeGatewayAccess,
-}
-
-/// Set how a port-range binding is exposed on a single gateway
-/// (disabled / lan / lan-wan). The range's `enabled` flag is
-/// service-controlled; this only records the operator's per-gateway choice.
-/// `Lan` is the default, so setting a gateway to `Lan` clears its entry.
-pub async fn set_range_gateway_access(
+/// Toggle one address of a port-range binding (keyed by its internal start
+/// port). The range counterpart of [`set_address_enabled`]; both share the
+/// address-toggle logic in [`set_address_enabled_on`].
+pub async fn set_range_address_enabled(
ctx: RpcContext,
- BindingSetRangeGatewayAccessParams {
- internal_start_port,
- gateway,
- access,
- }: BindingSetRangeGatewayAccessParams,
+ BindingSetAddressEnabledParams {
+ internal_port,
+ address,
+ enabled,
+ }: BindingSetAddressEnabledParams,
inheritance: Kind::Inheritance,
) -> Result<(), Error> {
+ let enabled = enabled.unwrap_or(true);
+ let address: HostnameInfo =
+ serde_json::from_str(&address).with_kind(ErrorKind::Deserialization)?;
+ if !enabled && address.is_internal() {
+ return Err(Error::new(
+ eyre!("loopback / bridge (internal) addresses cannot be disabled"),
+ ErrorKind::InvalidRequest,
+ ));
+ }
ctx.db
.mutate(|db| {
Kind::host_for(&inheritance, db)?
.as_binding_ranges_mut()
.mutate(|ranges| {
- let range = ranges
- .get_mut(&internal_start_port)
- .or_not_found(internal_start_port)?;
- if access == RangeGatewayAccess::default() {
- range.gateway_access.remove(&gateway);
- } else {
- range.gateway_access.insert(gateway, access);
- }
- Ok(())
- })?;
- // Recompute derived port_forwards so the gateways port-forwards UI
- // reflects the change immediately; the Hosts DB watcher then
- // reconciles the iptables rules.
- let hostname = ServerHostname::load(db.as_public().as_server_info())?;
- let gateways = db
- .as_public()
- .as_server_info()
- .as_network()
- .as_gateways()
- .de()?;
- let ports = db.as_private().as_available_ports().de()?;
- Kind::host_for(&inheritance, db)?.update_addresses(&hostname, &gateways, &ports)
+ let range = ranges.get_mut(&internal_port).or_not_found(internal_port)?;
+ set_address_enabled_on(&mut range.addresses, &address, enabled)
+ })
})
.await
.result?;
Ok(())
}
+
diff --git a/shared-libs/crates/start-core/src/net/host/mod.rs b/shared-libs/crates/start-core/src/net/host/mod.rs
index 2a46ae2416..caa69eacde 100644
--- a/shared-libs/crates/start-core/src/net/host/mod.rs
+++ b/shared-libs/crates/start-core/src/net/host/mod.rs
@@ -18,7 +18,7 @@ use crate::hostname::ServerHostname;
use crate::net::forward::AvailablePorts;
use crate::net::host::address::{HostAddress, PublicDomainConfig, address_api};
use crate::net::host::binding::{
- BindInfo, BindOptions, BindingRanges, Bindings, RangeBindInfo, RangeGatewayAccess, binding,
+ BindInfo, BindOptions, BindingRanges, Bindings, RangeBindInfo, binding,
};
use crate::net::service_interface::{HostnameInfo, HostnameMetadata};
use crate::prelude::*;
@@ -341,11 +341,112 @@ impl Model {
bind.as_addresses_mut().as_available_mut().ser(&available)?;
}
- // compute port forwards from available public addresses
+ // Port-range bindings get the same reachable-address set as single-port
+ // bindings, but IPv4-only and non-SSL: LAN IPv4 / WAN IPv4 / server mDNS
+ // / public+private domains. Every entry uses the range's
+ // `external_start_port` as its port so the single-port
+ // enabled/disabled + forward machinery applies unchanged.
+ let mdns_host = mdns.local_domain_name();
+ let mdns_gateways: BTreeSet = gateways
+ .iter()
+ .filter(|(_, g)| {
+ matches!(
+ g.ip_info.as_ref().and_then(|i| i.device_type),
+ Some(NetworkInterfaceType::Ethernet | NetworkInterfaceType::Wireless)
+ )
+ })
+ .map(|(id, _)| id.clone())
+ .collect();
+ for (_, range) in this.binding_ranges.as_entries_mut()? {
+ let port = range.as_external_start_port().de()?;
+
+ // Preserve any plugin-provided addresses across recomputation.
+ let mut available = range.as_addresses().as_available().de()?;
+ available.retain(|h| matches!(h.metadata, HostnameMetadata::Plugin { .. }));
+
+ for (gid, g) in gateways {
+ // Never expose a range on an outbound-only gateway (e.g. a VPN
+ // egress) — they don't receive inbound forwards.
+ if matches!(g.gateway_type, Some(GatewayType::OutboundOnly)) {
+ continue;
+ }
+ let Some(ip_info) = &g.ip_info else {
+ continue;
+ };
+ for subnet in &ip_info.subnets {
+ // IPv4 only — ranges forward purely as IPv4 nft DNAT.
+ if !subnet.addr().is_ipv4() {
+ continue;
+ }
+ available.insert(HostnameInfo {
+ ssl: false,
+ public: false,
+ hostname: InternedString::from_display(&subnet.addr()),
+ port: Some(port),
+ metadata: HostnameMetadata::Ipv4 {
+ gateway: gid.clone(),
+ },
+ });
+ }
+ if let Some(wan_ip) = &ip_info.wan_ip {
+ available.insert(HostnameInfo {
+ ssl: false,
+ public: true,
+ hostname: InternedString::from_display(&wan_ip),
+ port: Some(port),
+ metadata: HostnameMetadata::Ipv4 {
+ gateway: gid.clone(),
+ },
+ });
+ }
+ }
+
+ if !mdns_gateways.is_empty() {
+ available.insert(HostnameInfo {
+ ssl: false,
+ public: false,
+ hostname: mdns_host.clone(),
+ port: Some(port),
+ metadata: HostnameMetadata::Mdns {
+ gateways: mdns_gateways.clone(),
+ },
+ });
+ }
+
+ for (domain, info) in this.public_domains.de()? {
+ available.insert(HostnameInfo {
+ ssl: false,
+ public: true,
+ hostname: domain,
+ port: Some(port),
+ metadata: HostnameMetadata::PublicDomain {
+ gateway: info.gateway.clone(),
+ },
+ });
+ }
+
+ for (domain, domain_gateways) in this.private_domains.de()? {
+ available.insert(HostnameInfo {
+ ssl: false,
+ public: false,
+ hostname: domain,
+ port: Some(port),
+ metadata: HostnameMetadata::PrivateDomain {
+ gateways: domain_gateways,
+ },
+ });
+ }
+
+ range.as_addresses_mut().as_available_mut().ser(&available)?;
+ }
+
+ // compute port forwards from enabled public addresses. A non-exported
+ // binding/range serves internally only (`enabled_addresses`), and no
+ // internal address is public, so it contributes nothing here.
let bindings: Bindings = this.bindings.de()?;
let mut port_forwards = BTreeSet::new();
for bind in bindings.values() {
- for addr in bind.addresses.enabled() {
+ for addr in bind.enabled_addresses() {
if !addr.public {
continue;
}
@@ -380,24 +481,28 @@ impl Model {
}
}
- // Port-range bindings: `port_forwards` records only the rules an
- // operator must add on their router (WAN exposure), mirroring the
- // single-port loop above which emits public addresses only. So a range
- // contributes a PortForward only for gateways set to `Public` that have
- // a WAN IP; `Private` (LAN-only) and `Disabled` need no router config,
- // and outbound-only gateways never receive inbound forwards.
+ // Port-range bindings: emit a router PortForward (the WAN exposure the
+ // operator must add) for each enabled PUBLIC address — same as the
+ // single-port loop above, but with `count = number_of_ports`. A fresh
+ // range's public (WAN) addresses are disabled by default, so it
+ // contributes nothing until the operator enables the WAN address.
let binding_ranges: BindingRanges = this.binding_ranges.de()?;
for (&internal_start, range) in binding_ranges.iter() {
if !range.enabled {
continue;
}
- for (gw_id, gw_info) in gateways {
- if matches!(gw_info.gateway_type, Some(GatewayType::OutboundOnly)) {
+ for addr in range.enabled_addresses() {
+ if !addr.public {
continue;
}
- if range.access_for(gw_id) != RangeGatewayAccess::LanWan {
+ let gw_id = match &addr.metadata {
+ HostnameMetadata::Ipv4 { gateway }
+ | HostnameMetadata::PublicDomain { gateway } => gateway,
+ _ => continue,
+ };
+ let Some(gw_info) = gateways.get(gw_id) else {
continue;
- }
+ };
let Some(ip_info) = &gw_info.ip_info else {
continue;
};
@@ -529,12 +634,15 @@ impl Model {
if e.external_start_port == external_start_port
&& e.number_of_ports == number_of_ports
);
- // Preserve the operator's per-gateway access choices across rebinds
- // — bindPortRange is service-driven, so it must not clobber a UI
- // choice made between restarts.
- let gateway_access = existing
- .map(|e| e.gateway_access.clone())
- .unwrap_or_default();
+ // Preserve the operator's per-address enable/disable choices across
+ // rebinds — bindPortRange is service-driven, so it must not clobber
+ // a UI choice made between restarts. The `available` set is
+ // recomputed by update_addresses.
+ let addresses = existing.map(|e| e.addresses.clone()).unwrap_or_default();
+ // Preserve the exported interface across rebinds; the trailing
+ // `RangeOrigin.export` re-affirms it and `clearServiceInterfaces`
+ // prunes it if the service no longer exports it.
+ let interface = existing.and_then(|e| e.interface.clone());
if !unchanged {
// New, resized, or moved range: free any prior allocation, then
// claim the new one. `number_of_ports >= 2`, so subtract before
@@ -553,7 +661,8 @@ impl Model {
enabled: true,
external_start_port,
number_of_ports,
- gateway_access,
+ addresses,
+ interface,
},
);
Ok(())
diff --git a/shared-libs/crates/start-core/src/net/net_controller.rs b/shared-libs/crates/start-core/src/net/net_controller.rs
index c697a8a76d..9e26cdb1c2 100644
--- a/shared-libs/crates/start-core/src/net/net_controller.rs
+++ b/shared-libs/crates/start-core/src/net/net_controller.rs
@@ -23,11 +23,13 @@ use crate::net::forward::{
};
use crate::net::gateway::NetworkInterfaceController;
use crate::net::host::binding::{
- AddSslOptions, BindId, BindOptions, RangeGatewayAccess, UpstreamCertValidation,
+ AddSslOptions, BindId, BindOptions, UpstreamCertValidation,
};
use crate::net::host::{Host, Hosts, host_for};
use crate::net::port_map::{PortMapController, candidate_gateways};
-use crate::net::service_interface::HostnameMetadata;
+use crate::net::service_interface::{
+ AddressInfo, HostnameMetadata, ServiceInterface, ServiceInterfaceType,
+};
use crate::net::socks::SocksController;
use crate::net::vhost::{AlpnInfo, DynVHostTarget, ProxyTarget, VHostController};
use crate::prelude::*;
@@ -35,7 +37,7 @@ use crate::service::effects::callbacks::ServiceCallbacks;
use crate::util::Invoke;
use crate::util::serde::MaybeUtf8String;
use crate::util::sync::{SyncMutex, Watch};
-use crate::{GatewayId, HOST_IP, HostId, OptionExt, PackageId};
+use crate::{GatewayId, HOST_IP, HostId, Id, OptionExt, PackageId, ServiceInterfaceId};
pub struct NetController {
pub(crate) db: TypedPatchDb,
@@ -231,6 +233,45 @@ impl NetController {
)
.await?;
+ // Sync the OS's own UI as a service interface (idempotent — the OS's
+ // equivalent of a service's setupInterfaces) so the server binding
+ // always has an exported interface. Bindings/ranges without one are
+ // treated as internal-only below.
+ self.db
+ .mutate(|db| {
+ let iface_id = ServiceInterfaceId::from(
+ Id::try_from("startos-ui".to_owned()).expect("valid id"),
+ );
+ let iface = ServiceInterface {
+ id: iface_id.clone(),
+ name: "StartOS UI".to_owned(),
+ description:
+ "The web user interface for your StartOS server, accessible from any browser."
+ .to_owned(),
+ masked: false,
+ address_info: AddressInfo {
+ username: None,
+ host_id: HostId::default(),
+ internal_port: 80,
+ scheme: Some(InternedString::intern("http")),
+ ssl_scheme: Some(InternedString::intern("https")),
+ suffix: String::new(),
+ },
+ interface_type: ServiceInterfaceType::Ui,
+ };
+ db.as_public_mut()
+ .as_server_info_mut()
+ .as_network_mut()
+ .as_host_mut()
+ .as_bindings_mut()
+ .as_idx_mut(&80)
+ .or_not_found(80)?
+ .as_interfaces_mut()
+ .ser(&[(iface_id, iface)].into_iter().collect::>())
+ })
+ .await
+ .result?;
+
Ok(service)
}
}
@@ -284,8 +325,9 @@ impl NetServiceData {
if bind.net.assigned_port.is_none() && bind.net.assigned_ssl_port.is_none() {
continue;
}
-
- let enabled_addresses = bind.addresses.enabled();
+ // A binding with no exported interface is internal-only: it forwards
+ // to lo / lxcbr0 but never to a gateway (see `enabled_addresses`).
+ let enabled_addresses = bind.enabled_addresses();
let addr: SocketAddr = (self.ip, *port).into();
// Key private DNS by its live gateways so the resolver only answers
@@ -497,56 +539,73 @@ impl NetServiceData {
}
}
- // Port-range bindings: forward each enabled range to its container per
- // the operator's per-gateway choice. `Public` → `public_gateways` (no
- // source filter, reachable from LAN + WAN); `Private` → the gateway's
- // subnets in `private_ips` (source-filtered to the LAN); `Disabled` →
- // neither. Outbound-only gateways never receive inbound forwards.
+ // Port-range bindings: forward each enabled range the same way as a
+ // single-port non-SSL binding — `private_ips` from enabled private
+ // (LAN) addresses, `public_gateways` from enabled public (WAN)
+ // addresses — but with `count = number_of_ports`. Outbound-only
+ // gateways never get range addresses synthesized, so they never appear
+ // here. Private domains register their resolver entries like single-port.
//
// `secure: true` is intentional: ranges carry no Security option (no
- // SSL/vhost), and the operator's explicit Public/Private choice IS the
- // access decision — without it the forward.rs security gate
+ // SSL/vhost), so without it the forward.rs security gate
// (`!reqs.secure && !info.secure()`) would drop every range on a normal
// (non-secure) WAN gateway, since no gateway is ever marked secure.
- if !host.binding_ranges.is_empty() {
- for (&internal_start, range) in host.binding_ranges.iter() {
- if !range.enabled {
- continue;
- }
- let mut public_gateways = BTreeSet::new();
- let mut private_ips = BTreeSet::new();
- for (gw_id, info) in net_ifaces.iter() {
- if matches!(info.gateway_type, Some(GatewayType::OutboundOnly)) {
- continue;
- }
- match range.access_for(gw_id) {
- RangeGatewayAccess::Disabled => {}
- RangeGatewayAccess::LanWan => {
- public_gateways.insert(gw_id.clone());
- }
- RangeGatewayAccess::Lan => {
- if let Some(ip_info) = &info.ip_info {
- private_ips.extend(ip_info.subnets.iter().map(|s| s.addr()));
- }
- }
+ for (&internal_start, range) in host.binding_ranges.iter() {
+ if !range.enabled {
+ continue;
+ }
+ // A range with no exported interface is internal-only: it forwards
+ // to lo / lxcbr0 but never to a gateway (see `enabled_addresses`).
+ let enabled_addresses = range.enabled_addresses();
+
+ for addr_info in &enabled_addresses {
+ if let HostnameMetadata::PrivateDomain { gateways } = &addr_info.metadata {
+ let live: BTreeSet = gateways
+ .iter()
+ .filter(|gw| {
+ net_ifaces
+ .get(*gw)
+ .map_or(false, |info| info.ip_info.is_some())
+ })
+ .cloned()
+ .collect();
+ if !live.is_empty() {
+ private_dns
+ .entry(addr_info.hostname.clone())
+ .or_default()
+ .extend(live);
}
}
- if public_gateways.is_empty() && private_ips.is_empty() {
- continue;
- }
- forwards.insert(
- range.external_start_port,
- (
- SocketAddrV4::new(self.ip, internal_start),
- range.number_of_ports,
- ForwardRequirements {
- public_gateways,
- private_ips,
- secure: true,
- },
- ),
- );
}
+
+ let public_gateways: BTreeSet = enabled_addresses
+ .iter()
+ .filter(|a| a.public)
+ .flat_map(|a| a.metadata.gateways())
+ .cloned()
+ .collect();
+ let private_ips: BTreeSet = enabled_addresses
+ .iter()
+ .filter(|a| !a.public)
+ .flat_map(|a| a.metadata.gateways())
+ .filter_map(|gw| net_ifaces.get(gw).and_then(|i| i.ip_info.as_ref()))
+ .flat_map(|ip| ip.subnets.iter().map(|s| s.addr()))
+ .collect();
+ if public_gateways.is_empty() && private_ips.is_empty() {
+ continue;
+ }
+ forwards.insert(
+ range.external_start_port,
+ (
+ SocketAddrV4::new(self.ip, internal_start),
+ range.number_of_ports,
+ ForwardRequirements {
+ public_gateways,
+ private_ips,
+ secure: true,
+ },
+ ),
+ );
}
// Best-effort HTTP→HTTPS redirect: when something is publicly exposed on
diff --git a/shared-libs/crates/start-core/src/net/service_interface.rs b/shared-libs/crates/start-core/src/net/service_interface.rs
index 6fc2aa52e8..5f1b4e2640 100644
--- a/shared-libs/crates/start-core/src/net/service_interface.rs
+++ b/shared-libs/crates/start-core/src/net/service_interface.rs
@@ -60,6 +60,20 @@ impl HostnameInfo {
pub fn to_san_hostname(&self) -> InternedString {
self.hostname.clone()
}
+
+ /// True for the always-on internal interfaces — loopback (`lo`) and the
+ /// `lxcbr0` bridge (`HOST_IP`). These are how the host and other containers
+ /// reach the service; they are never operator-disablable, and a binding with
+ /// no exported interface is restricted to them.
+ pub fn is_internal(&self) -> bool {
+ match self.hostname.parse::() {
+ Ok(std::net::IpAddr::V4(v4)) => {
+ v4.is_loopback() || v4 == std::net::Ipv4Addr::from(crate::HOST_IP)
+ }
+ Ok(std::net::IpAddr::V6(v6)) => v6.is_loopback(),
+ Err(_) => false,
+ }
+ }
}
impl HostnameMetadata {
@@ -181,3 +195,22 @@ pub struct AddressInfo {
pub ssl_scheme: Option,
pub suffix: String,
}
+
+/// The single restricted service interface a port-range binding may export.
+///
+/// Unlike [`ServiceInterface`], a range interface is always `api`-typed and
+/// carries no `masked` / `username` / `path` / `query` and no per-address
+/// [`AddressInfo`] — its address is the host plus the range's external port
+/// span, taken from the [`RangeBindInfo`](crate::net::host::binding::RangeBindInfo)
+/// it lives under. `scheme` is an optional transport prefix (e.g. `tcp` for
+/// bitcoin ZMQ endpoints); most ranges (coturn RTP, FTP data) omit it.
+#[derive(Clone, Debug, Deserialize, Serialize, TS)]
+#[ts(export)]
+#[serde(rename_all = "camelCase")]
+pub struct RangeServiceInterface {
+ pub id: ServiceInterfaceId,
+ pub name: String,
+ pub description: String,
+ #[ts(type = "string | null")]
+ pub scheme: Option,
+}
diff --git a/shared-libs/crates/start-core/src/os_install/gpt.rs b/shared-libs/crates/start-core/src/os_install/gpt.rs
index 39e237e58d..0d1c12b723 100644
--- a/shared-libs/crates/start-core/src/os_install/gpt.rs
+++ b/shared-libs/crates/start-core/src/os_install/gpt.rs
@@ -342,7 +342,7 @@ mod tests {
f.set_len(capacity).unwrap();
}
- /// Regression test for https://github.com/Start9Labs/start-os/pull/3193
+ /// Regression test for https://github.com/Start9Labs/start-technologies/pull/3193
/// preserve path on 0.3.5.1 disks: the .min() that was originally used to
/// pick the free region before the protected data partition picked the
/// 2014-LBA sliver between LBA 34 and the new 2048-aligned EFI start, so
diff --git a/shared-libs/crates/start-core/src/service/effects/callbacks.rs b/shared-libs/crates/start-core/src/service/effects/callbacks.rs
index 0661cb303d..9f472f4110 100644
--- a/shared-libs/crates/start-core/src/service/effects/callbacks.rs
+++ b/shared-libs/crates/start-core/src/service/effects/callbacks.rs
@@ -15,7 +15,6 @@ use ts_rs::TS;
use crate::db::model::package::PackageState;
use crate::db::model::public::NetworkInterfaceInfo;
use crate::net::host::Host;
-use crate::net::service_interface::ServiceInterface;
use crate::net::ssl::FullchainCertData;
use crate::prelude::*;
use crate::service::effects::context::EffectContext;
@@ -153,11 +152,11 @@ impl ServiceCallbacks {
self.get_service_manifest.gc();
}
- pub(super) fn add_get_service_interface(
+ pub(super) fn add_get_service_interface(
&self,
package_id: PackageId,
service_interface_id: ServiceInterfaceId,
- watch: TypedDbWatch,
+ watch: TypedDbWatch,
handler: CallbackHandler,
) {
self.get_service_interface
diff --git a/shared-libs/crates/start-core/src/service/effects/mod.rs b/shared-libs/crates/start-core/src/service/effects/mod.rs
index 68ad6232c2..5900454b11 100644
--- a/shared-libs/crates/start-core/src/service/effects/mod.rs
+++ b/shared-libs/crates/start-core/src/service/effects/mod.rs
@@ -186,6 +186,10 @@ pub fn handler() -> ParentHandler {
"export-service-interface",
from_fn_async(net::interface::export_service_interface).no_cli(),
)
+ .subcommand(
+ "export-range-service-interface",
+ from_fn_async(net::interface::export_range_service_interface).no_cli(),
+ )
.subcommand(
"get-service-interface",
from_fn_async(net::interface::get_service_interface).no_cli(),
diff --git a/shared-libs/crates/start-core/src/service/effects/net/interface.rs b/shared-libs/crates/start-core/src/service/effects/net/interface.rs
index 51c1223c63..7500b452c6 100644
--- a/shared-libs/crates/start-core/src/service/effects/net/interface.rs
+++ b/shared-libs/crates/start-core/src/service/effects/net/interface.rs
@@ -1,11 +1,21 @@
use std::collections::BTreeMap;
-use crate::net::service_interface::{AddressInfo, ServiceInterface, ServiceInterfaceType};
+use imbl_value::InternedString;
+
+use crate::net::host::Hosts;
+use crate::net::service_interface::{
+ AddressInfo, RangeServiceInterface, ServiceInterface, ServiceInterfaceType,
+};
use crate::service::effects::callbacks::CallbackHandler;
use crate::service::effects::prelude::*;
use crate::service::rpc::CallbackId;
-use crate::{PackageId, ServiceInterfaceId};
+use crate::{HostId, PackageId, ServiceInterfaceId};
+// Every service interface lives under the binding it was exported from
+// (`hosts/{hostId}/bindings/{internalPort}/interfaces/{id}` for single-port
+// `Origin.export`, `hosts/{hostId}/bindingRanges/{internalStartPort}/interface`
+// for `RangeOrigin.export`). The flat `PackageDataEntry.serviceInterfaces` map
+// is gone — these effects read/write the host tree directly.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
@@ -31,6 +41,8 @@ pub async fn export_service_interface(
let context = context.deref()?;
let package_id = context.seed.id.clone();
+ let host_id = address_info.host_id.clone();
+ let internal_port = address_info.internal_port;
let service_interface = ServiceInterface {
id: id.clone(),
name,
@@ -45,13 +57,18 @@ pub async fn export_service_interface(
.ctx
.db
.mutate(|db| {
- let ifaces = db
- .as_public_mut()
+ db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&package_id)
.or_not_found(&package_id)?
- .as_service_interfaces_mut();
- ifaces.insert(&id, &service_interface)?;
+ .as_hosts_mut()
+ .as_idx_mut(&host_id)
+ .or_not_found(&host_id)?
+ .as_bindings_mut()
+ .as_idx_mut(&internal_port)
+ .or_not_found(internal_port)?
+ .as_interfaces_mut()
+ .insert(&id, &service_interface)?;
Ok(())
})
.await
@@ -60,6 +77,84 @@ pub async fn export_service_interface(
Ok(())
}
+#[derive(Debug, Clone, Serialize, Deserialize, TS)]
+#[ts(export)]
+#[serde(rename_all = "camelCase")]
+pub struct ExportRangeServiceInterfaceParams {
+ host_id: HostId,
+ internal_start_port: u16,
+ id: ServiceInterfaceId,
+ name: String,
+ description: String,
+ #[ts(type = "string | null")]
+ scheme: Option,
+}
+pub async fn export_range_service_interface(
+ context: EffectContext,
+ ExportRangeServiceInterfaceParams {
+ host_id,
+ internal_start_port,
+ id,
+ name,
+ description,
+ scheme,
+ }: ExportRangeServiceInterfaceParams,
+) -> Result<(), Error> {
+ let context = context.deref()?;
+ let package_id = context.seed.id.clone();
+
+ let interface = RangeServiceInterface {
+ id,
+ name,
+ description,
+ scheme,
+ };
+
+ context
+ .seed
+ .ctx
+ .db
+ .mutate(|db| {
+ db.as_public_mut()
+ .as_package_data_mut()
+ .as_idx_mut(&package_id)
+ .or_not_found(&package_id)?
+ .as_hosts_mut()
+ .as_idx_mut(&host_id)
+ .or_not_found(&host_id)?
+ .as_binding_ranges_mut()
+ .as_idx_mut(&internal_start_port)
+ .or_not_found(internal_start_port)?
+ .as_interface_mut()
+ .ser(&Some(interface))
+ })
+ .await
+ .result?;
+
+ Ok(())
+}
+
+/// Single-port service interface lookup, scanning every binding of every host
+/// for `service_interface_id`. Range interfaces are intentionally excluded —
+/// they have no addressable `AddressInfo` and are read off the host model.
+fn find_service_interface(hosts: &Hosts, id: &ServiceInterfaceId) -> Option {
+ hosts
+ .0
+ .values()
+ .flat_map(|host| host.bindings.values())
+ .find_map(|bind| bind.interfaces.get(id).cloned())
+}
+
+fn list_all_service_interfaces(hosts: &Hosts) -> BTreeMap {
+ hosts
+ .0
+ .values()
+ .flat_map(|host| host.bindings.values())
+ .flat_map(|bind| bind.interfaces.iter())
+ .map(|(id, iface)| (id.clone(), iface.clone()))
+ .collect()
+}
+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
@@ -81,21 +176,16 @@ pub async fn get_service_interface(
let context = context.deref()?;
let package_id = package_id.unwrap_or_else(|| context.seed.id.clone());
- let ptr = format!(
- "/public/packageData/{}/serviceInterfaces/{}",
- package_id, service_interface_id
- )
- .parse()
- .expect("valid json pointer");
- let mut watch = context
- .seed
- .ctx
- .db
- .watch(ptr)
- .await
- .typed::();
+ let ptr = format!("/public/packageData/{}/hosts", package_id)
+ .parse()
+ .expect("valid json pointer");
+ let mut watch = context.seed.ctx.db.watch(ptr).await.typed::();
- let res = watch.peek_and_mark_seen()?.de().ok();
+ let res = watch
+ .peek_and_mark_seen()?
+ .de()
+ .ok()
+ .and_then(|hosts: Hosts| find_service_interface(&hosts, &service_interface_id));
if let Some(callback) = callback {
let callback = callback.register(&context.seed.persistent_container);
@@ -129,23 +219,28 @@ pub async fn list_service_interfaces(
let context = context.deref()?;
let package_id = package_id.unwrap_or_else(|| context.seed.id.clone());
- let ptr = format!("/public/packageData/{}/serviceInterfaces", package_id)
+ let ptr = format!("/public/packageData/{}/hosts", package_id)
.parse()
.expect("valid json pointer");
- let mut watch = context.seed.ctx.db.watch(ptr).await;
+ let mut watch = context.seed.ctx.db.watch(ptr).await.typed::();
- let res: Option<_> = from_value(watch.peek_and_mark_seen()?)?;
+ let res = watch
+ .peek_and_mark_seen()?
+ .de()
+ .ok()
+ .map(|hosts: Hosts| list_all_service_interfaces(&hosts))
+ .unwrap_or_default();
if let Some(callback) = callback {
let callback = callback.register(&context.seed.persistent_container);
context.seed.ctx.callbacks.add_list_service_interfaces(
package_id.clone(),
- watch.typed::>>(),
+ watch,
CallbackHandler::new(&context, callback),
);
}
- Ok(res.unwrap_or_default())
+ Ok(res)
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]
@@ -168,12 +263,28 @@ pub async fn clear_service_interfaces(
.ctx
.db
.mutate(|db| {
- db.as_public_mut()
+ for (_, host) in db
+ .as_public_mut()
.as_package_data_mut()
.as_idx_mut(&package_id)
.or_not_found(&package_id)?
- .as_service_interfaces_mut()
- .mutate(|s| Ok(s.retain(|id, _| except.contains(id))))
+ .as_hosts_mut()
+ .as_entries_mut()?
+ {
+ for (_, bind) in host.as_bindings_mut().as_entries_mut()? {
+ bind.as_interfaces_mut()
+ .mutate(|ifaces| Ok(ifaces.retain(|id, _| except.contains(id))))?;
+ }
+ for (_, range) in host.as_binding_ranges_mut().as_entries_mut()? {
+ range.as_interface_mut().mutate(|iface| {
+ if iface.as_ref().map_or(false, |i| !except.contains(&i.id)) {
+ *iface = None;
+ }
+ Ok(())
+ })?;
+ }
+ }
+ Ok(())
})
.await
.result?;
diff --git a/shared-libs/crates/start-core/src/service/service_map.rs b/shared-libs/crates/start-core/src/service/service_map.rs
index 4ee7ba2e20..d6ba6e17bf 100644
--- a/shared-libs/crates/start-core/src/service/service_map.rs
+++ b/shared-libs/crates/start-core/src/service/service_map.rs
@@ -250,7 +250,6 @@ impl ServiceMap {
current_dependencies: Default::default(),
actions: Default::default(),
tasks: Default::default(),
- service_interfaces: Default::default(),
hosts: Default::default(),
store_exposed_dependents: Default::default(),
outbound_gateway: None,
diff --git a/shared-libs/crates/start-core/src/tunnel/api.rs b/shared-libs/crates/start-core/src/tunnel/api.rs
index a607745f38..a29277f133 100644
--- a/shared-libs/crates/start-core/src/tunnel/api.rs
+++ b/shared-libs/crates/start-core/src/tunnel/api.rs
@@ -975,6 +975,13 @@ pub struct AddPortForwardParams {
#[arg(long = "sni")]
#[serde(default)]
sni: Vec,
+ /// Number of contiguous ports to forward (a PCP PORT_SET range), counting up
+ /// from both `external_port` and the target port. Defaults to 1. Not valid
+ /// together with SNI demux.
+ #[arg(long)]
+ #[serde(default)]
+ #[ts(optional)]
+ count: Option,
}
pub async fn add_forward(
@@ -984,8 +991,17 @@ pub async fn add_forward(
target,
label,
sni,
+ count,
}: AddPortForwardParams,
) -> Result<(), Error> {
+ let count = count.unwrap_or(1);
+ if count == 0 {
+ return Err(Error::new(
+ eyre!("count must be at least 1"),
+ ErrorKind::InvalidRequest,
+ ));
+ }
+
let external_ip = ctx.external_ipv4(*target.ip()).await.ok_or_else(|| {
Error::new(
eyre!("no WAN IP available for device {}", target.ip()),
@@ -994,6 +1010,12 @@ pub async fn add_forward(
})?;
let source = SocketAddrV4::new(external_ip, external_port);
if !sni.is_empty() {
+ if count > 1 {
+ return Err(Error::new(
+ eyre!("SNI demux does not support port ranges"),
+ ErrorKind::InvalidRequest,
+ ));
+ }
ctx.add_sni_forward(source, target, &sni, None)
.await
.map_err(|code| {
@@ -1005,10 +1027,42 @@ pub async fn add_forward(
return Ok(());
}
+ // The span must fit the u16 port range on both the external and target side.
+ if external_port.checked_add(count - 1).is_none()
+ || target.port().checked_add(count - 1).is_none()
+ {
+ return Err(Error::new(
+ eyre!(
+ "port range of {count} starting at {external_port} (-> {}) exceeds 65535",
+ target.port(),
+ ),
+ ErrorKind::InvalidRequest,
+ ));
+ }
+
+ // Reject a range whose external ports overlap a different existing forward on
+ // this WAN IP (an exact same-port clash is caught by the insert below).
+ if let Some(conflict) = ctx
+ .db
+ .peek()
+ .await
+ .as_port_forwards()
+ .de()?
+ .overlapping(source, count)
+ {
+ return Err(Error::new(
+ eyre!(
+ "external ports {external_port}-{} overlap an existing forward at {conflict}",
+ external_port + count - 1,
+ ),
+ ErrorKind::InvalidRequest,
+ ));
+ }
+
let prefix = crate::tunnel::forward::igd::prefix_for(&ctx, target.ip()).await;
let rc = ctx
.forward
- .add_forward(source, target, prefix, None)
+ .add_forward_range(source, target, count, prefix, None)
.await?;
ctx.active_forwards.mutate(|m| {
m.insert(source, rc);
@@ -1018,7 +1072,7 @@ pub async fn add_forward(
target,
label,
enabled: true,
- count: 1,
+ count,
auto: false,
};
diff --git a/shared-libs/crates/start-core/src/tunnel/db.rs b/shared-libs/crates/start-core/src/tunnel/db.rs
index c28d9e2562..8c58177ecb 100644
--- a/shared-libs/crates/start-core/src/tunnel/db.rs
+++ b/shared-libs/crates/start-core/src/tunnel/db.rs
@@ -204,6 +204,52 @@ fn sni_and_dnat_persistence_round_trip() {
}
}
+#[test]
+fn port_forward_overlap_detection() {
+ let dnat = |target: &str, count: u16| PortForward::Dnat {
+ target: target.parse().unwrap(),
+ label: None,
+ enabled: true,
+ count,
+ auto: false,
+ };
+ let src = |s: &str| s.parse::().unwrap();
+
+ let mut map = BTreeMap::new();
+ // An existing 10-port range 8000..=8009 on WAN IP 1.2.3.4.
+ map.insert(src("1.2.3.4:8000"), dnat("10.0.0.2:8000", 10));
+ // An SNI forward occupying the single port 443.
+ map.insert(
+ src("1.2.3.4:443"),
+ PortForward::Sni {
+ routes: BTreeMap::new(),
+ },
+ );
+ let forwards = PortForwards(map);
+
+ // A single port inside the existing range overlaps it.
+ assert_eq!(
+ forwards.overlapping(src("1.2.3.4:8005"), 1),
+ Some(src("1.2.3.4:8000")),
+ );
+ // A new range straddling the end of the existing range overlaps.
+ assert_eq!(
+ forwards.overlapping(src("1.2.3.4:8009"), 5),
+ Some(src("1.2.3.4:8000")),
+ );
+ // A range that swallows the single SNI port overlaps it.
+ assert_eq!(
+ forwards.overlapping(src("1.2.3.4:440"), 8),
+ Some(src("1.2.3.4:443")),
+ );
+ // An adjacent, disjoint range (8010..=8019) does not overlap.
+ assert_eq!(forwards.overlapping(src("1.2.3.4:8010"), 10), None);
+ // The same span on a different WAN IP does not overlap.
+ assert_eq!(forwards.overlapping(src("5.6.7.8:8005"), 1), None);
+ // The exact same source key is excluded (collision / re-assert, not overlap).
+ assert_eq!(forwards.overlapping(src("1.2.3.4:8000"), 10), None);
+}
+
#[test]
fn export_bindings_tunnel_db() {
use crate::tunnel::api::*;
@@ -296,6 +342,17 @@ pub struct DnsRecordEntry {
#[derive(Clone, Debug, Default, Deserialize, Serialize, TS)]
pub struct DnsRecords(pub Vec);
+impl PortForward {
+ /// Number of contiguous external ports this forward occupies: a DNAT spans
+ /// its `count`; an SNI-demuxed forward holds the single shared port.
+ pub fn port_span(&self) -> u16 {
+ match self {
+ PortForward::Dnat { count, .. } => (*count).max(1),
+ PortForward::Sni { .. } => 1,
+ }
+ }
+}
+
#[derive(Clone, Debug, Default, Deserialize, Serialize, TS)]
pub struct PortForwards(pub BTreeMap);
impl Map for PortForwards {
@@ -308,6 +365,25 @@ impl Map for PortForwards {
Ok(InternedString::from_display(key))
}
}
+impl PortForwards {
+ /// The source of an existing forward on the same external IP whose port span
+ /// overlaps `[source.port(), source.port() + count - 1]`, if any. An exact
+ /// `source` match is excluded — callers treat that as an idempotent
+ /// re-assert (auto) or a same-port collision (manual); this catches the case
+ /// ranges introduce, where two *different* start ports cover shared ports.
+ pub fn overlapping(&self, source: SocketAddrV4, count: u16) -> Option {
+ let new_lo = source.port();
+ let new_hi = new_lo.saturating_add(count.saturating_sub(1));
+ self.0.iter().find_map(|(src, pf)| {
+ if src.ip() != source.ip() || *src == source {
+ return None;
+ }
+ let lo = src.port();
+ let hi = lo.saturating_add(pf.port_span().saturating_sub(1));
+ (new_lo <= hi && lo <= new_hi).then_some(*src)
+ })
+ }
+}
pub fn db_api() -> ParentHandler {
ParentHandler::new()
diff --git a/shared-libs/crates/start-core/src/tunnel/forward/igd.rs b/shared-libs/crates/start-core/src/tunnel/forward/igd.rs
index 6355228c15..7364f4ba28 100644
--- a/shared-libs/crates/start-core/src/tunnel/forward/igd.rs
+++ b/shared-libs/crates/start-core/src/tunnel/forward/igd.rs
@@ -258,6 +258,21 @@ pub(super) async fn apply_peer_forward_range(
None => {}
}
+ // A new range must not overlap a different existing forward's ports on this
+ // external IP (the exact-source cases are handled by the match above).
+ if ctx
+ .db
+ .peek()
+ .await
+ .as_port_forwards()
+ .de()
+ .map_err(|_| 501u16)?
+ .overlapping(source, count)
+ .is_some()
+ {
+ return Err(718); // ConflictInMappingEntry
+ }
+
let prefix = prefix_for(ctx, target.ip()).await;
let rc = ctx
.forward
diff --git a/shared-libs/crates/yasi/Cargo.toml b/shared-libs/crates/yasi/Cargo.toml
index c6e9f8c2cb..aeeb02a94e 100644
--- a/shared-libs/crates/yasi/Cargo.toml
+++ b/shared-libs/crates/yasi/Cargo.toml
@@ -6,7 +6,7 @@ edition = "2024"
description = "Yet Another String Interner"
license = "MIT"
documentation = "https://docs.rs/yasi"
-repository = "https://github.com/Start9Labs/yasi.git"
+repository = "https://github.com/Start9Labs/start-technologies"
keywords = ["arc", "intern", "string", "display"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts
index 3f69d58e34..99dbe70f07 100644
--- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts
+++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts
@@ -788,4 +788,8 @@ export default {
879: 'Konfiguration aktualisieren',
880: 'Oder aktivieren Sie DNS Injection für dieses Gerät auf dem Gateway.',
881: 'Oder aktivieren Sie die automatische Portweiterleitung (UPnP / NAT-PMP / PCP) auf dem Gateway.',
+ 882: 'Portbereich',
+ 883: 'Externer Portbereich',
+ 884: 'Interner Portbereich',
+ 885: 'Oder aktivieren Sie die automatische Portweiterleitung (PCP) auf dem Gateway. UPnP und NAT-PMP unterstützen keine Portbereiche.',
} satisfies i18n
diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts
index 21d0b8b836..c90fbf3b8d 100644
--- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts
+++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts
@@ -789,4 +789,8 @@ export const ENGLISH: Record = {
'You will need to repeat this on every device you use to connect to your server.': 878,
'Or enable DNS Injection for this device on the gateway.': 880,
'Or enable automatic port forwarding (UPnP / NAT-PMP / PCP) on the gateway.': 881,
+ 'Port Range': 882,
+ 'External Range': 883,
+ 'Internal Range': 884,
+ 'Or enable automatic port forwarding (PCP) on the gateway. UPnP and NAT-PMP do not support port ranges.': 885,
}
diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts
index fa00e07547..e13afbbf9c 100644
--- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts
+++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts
@@ -788,4 +788,8 @@ export default {
879: 'Actualizar configuración',
880: 'O habilite DNS Injection para este dispositivo en la puerta de enlace.',
881: 'O habilite el reenvío de puertos automático (UPnP / NAT-PMP / PCP) en la puerta de enlace.',
+ 882: 'Rango de puertos',
+ 883: 'Rango externo',
+ 884: 'Rango interno',
+ 885: 'O habilite el reenvío de puertos automático (PCP) en la puerta de enlace. UPnP y NAT-PMP no admiten rangos de puertos.',
} satisfies i18n
diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts
index aabc7ced4a..ee12443637 100644
--- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts
+++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts
@@ -788,4 +788,8 @@ export default {
879: 'Mettre à jour la configuration',
880: 'Ou activez DNS Injection pour cet appareil sur la passerelle.',
881: 'Ou activez la redirection de port automatique (UPnP / NAT-PMP / PCP) sur la passerelle.',
+ 882: 'Plage de ports',
+ 883: 'Plage externe',
+ 884: 'Plage interne',
+ 885: 'Ou activez la redirection de port automatique (PCP) sur la passerelle. UPnP et NAT-PMP ne prennent pas en charge les plages de ports.',
} satisfies i18n
diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts
index 74a65f0da1..0eb6516d07 100644
--- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts
+++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts
@@ -788,4 +788,8 @@ export default {
879: 'Zaktualizuj konfigurację',
880: 'Lub włącz DNS Injection dla tego urządzenia w bramie.',
881: 'Lub włącz automatyczne przekierowanie portów (UPnP / NAT-PMP / PCP) w bramie.',
+ 882: 'Zakres portów',
+ 883: 'Zakres zewnętrzny',
+ 884: 'Zakres wewnętrzny',
+ 885: 'Lub włącz automatyczne przekierowanie portów (PCP) w bramie. UPnP i NAT-PMP nie obsługują zakresów portów.',
} satisfies i18n
diff --git a/shared-libs/ts-modules/start-core/lib/Effects.ts b/shared-libs/ts-modules/start-core/lib/Effects.ts
index aa4d4632b9..b7ca5b92fb 100644
--- a/shared-libs/ts-modules/start-core/lib/Effects.ts
+++ b/shared-libs/ts-modules/start-core/lib/Effects.ts
@@ -13,6 +13,7 @@ import {
NetInfo,
Host,
ExportServiceInterfaceParams,
+ ExportRangeServiceInterfaceParams,
ServiceInterface,
CreateTaskParams,
MountParams,
@@ -156,12 +157,12 @@ export type Effects = {
bind(options: BindParams): Promise
/**
* Binds a contiguous range of UDP+TCP ports to the specified host. Used
- * for real-time / WebRTC servers (coturn, RTP, SIP) that need a public
- * port range. Both `internalStartPort` and `externalStartPort` must be
- * equal — the underlying iptables forward preserves the destination port
- * across the range. The whole range is allocated atomically; any
- * partial collision with already-bound external ports is a hard error.
- * Capped at 500 ports per call.
+ * for real-time / WebRTC servers (coturn, RTP, SIP) and other pooled-port
+ * protocols (bitcoin ZMQ, FTP data) that need a public port range.
+ * `externalStartPort` may differ from `internalStartPort` — the forward
+ * maps the external range onto the internal range by offset. The whole
+ * range is allocated atomically; any partial collision with already-bound
+ * external ports is a hard error. Capped at 500 ports per call.
*/
bindRange(options: BindRangeParams): Promise
/** Get the port address for a service */
@@ -193,6 +194,13 @@ export type Effects = {
// interface
/** Creates an interface bound to a specific host and port to show to the user */
exportServiceInterface(options: ExportServiceInterfaceParams): Promise
+ /**
+ * Exports the single restricted `api` interface for a port-range binding,
+ * stored on the range it belongs to (`RangeOrigin.export`).
+ */
+ exportRangeServiceInterface(
+ options: ExportRangeServiceInterfaceParams,
+ ): Promise
/** Returns an exported service interface */
getServiceInterface(options: {
packageId?: PackageId
diff --git a/shared-libs/ts-modules/start-core/lib/interfaces/Host.ts b/shared-libs/ts-modules/start-core/lib/interfaces/Host.ts
index 3e0ddb4bd7..b1cea19873 100644
--- a/shared-libs/ts-modules/start-core/lib/interfaces/Host.ts
+++ b/shared-libs/ts-modules/start-core/lib/interfaces/Host.ts
@@ -1,6 +1,7 @@
import { z } from '../zExport'
import { Effects } from '../Effects'
import { Origin } from './Origin'
+import { RangeOrigin } from './RangeOrigin'
import { AddSslOptions } from '../osBindings'
import { Security } from '../osBindings'
import { BindOptions } from '../osBindings'
@@ -150,9 +151,10 @@ export class MultiHost {
/**
* Bind a contiguous range of UDP+TCP ports to this host.
*
- * Intended for real-time / WebRTC servers (coturn, RTP, SIP) that need a
- * public port range. The whole range is allocated atomically; any
- * partial collision with already-bound external ports is a hard error.
+ * Intended for real-time / WebRTC servers (coturn, RTP, SIP) and other
+ * pooled-port protocols (bitcoin ZMQ, FTP data) that need a public port
+ * range. The whole range is allocated atomically; any partial collision
+ * with already-bound external ports is a hard error.
*
* `externalStartPort` may differ from `internalStartPort` — the forward
* maps the external range onto the internal range by offset.
@@ -163,19 +165,27 @@ export class MultiHost {
* - All `numberOfPorts` external ports starting at `externalStartPort`
* must be free and non-restricted.
*
- * Returns `void`: range bindings are not addressable as HTTP-style
- * service interfaces, so there is no {@link Origin} / `.export()` step.
+ * Returns a {@link RangeOrigin}, on which you call `.export(builder)` with a
+ * `sdk.createRangeInterface(...)` builder to register the range's single,
+ * restricted `api` service interface (no SSL, no path/query/auth).
*
* @example
* ```
- * await sdk.MultiHost.of(effects, 'turn-relay').bindPortRange({
+ * const turn = await sdk.MultiHost.of(effects, 'turn-relay').bindPortRange({
* internalStartPort: 49152,
* externalStartPort: 49152,
* numberOfPorts: 100,
* })
+ * await turn.export(
+ * sdk.createRangeInterface(effects, {
+ * id: 'turn-relay',
+ * name: 'TURN Relay',
+ * description: 'WebRTC media relay ports',
+ * }),
+ * )
* ```
*/
- async bindPortRange(options: BindPortRangeOptions): Promise {
+ async bindPortRange(options: BindPortRangeOptions): Promise {
const { internalStartPort, externalStartPort, numberOfPorts } = options
if (!Number.isInteger(numberOfPorts) || numberOfPorts < 2) {
throw new Error(
@@ -211,6 +221,13 @@ export class MultiHost {
externalStartPort,
numberOfPorts,
})
+
+ return new RangeOrigin(
+ this,
+ internalStartPort,
+ externalStartPort,
+ numberOfPorts,
+ )
}
private async bindPortForUnknown(
diff --git a/shared-libs/ts-modules/start-core/lib/interfaces/RangeInterfaceBuilder.ts b/shared-libs/ts-modules/start-core/lib/interfaces/RangeInterfaceBuilder.ts
new file mode 100644
index 0000000000..b1953bf49f
--- /dev/null
+++ b/shared-libs/ts-modules/start-core/lib/interfaces/RangeInterfaceBuilder.ts
@@ -0,0 +1,23 @@
+import { Effects } from '../Effects'
+
+/**
+ * A restricted interface builder for port-range bindings, produced by
+ * `sdk.createRangeInterface` and handed to {@link RangeOrigin.export}.
+ *
+ * Unlike {@link ServiceInterfaceBuilder}, a range interface is always
+ * `api`-typed and carries no `masked` / `username` / `path` / `query`. Its
+ * address is the host plus the range's external port span — there is no single
+ * clickable URL. `scheme` is an optional transport prefix (e.g. `tcp` for
+ * bitcoin ZMQ endpoints); omit it for raw UDP/TCP ranges (coturn, RTP, FTP).
+ */
+export class RangeInterfaceBuilder {
+ constructor(
+ readonly options: {
+ effects: Effects
+ id: string
+ name: string
+ description: string
+ scheme?: string | null
+ },
+ ) {}
+}
diff --git a/shared-libs/ts-modules/start-core/lib/interfaces/RangeOrigin.ts b/shared-libs/ts-modules/start-core/lib/interfaces/RangeOrigin.ts
new file mode 100644
index 0000000000..1cc1f09004
--- /dev/null
+++ b/shared-libs/ts-modules/start-core/lib/interfaces/RangeOrigin.ts
@@ -0,0 +1,35 @@
+import { MultiHost } from './Host'
+import { RangeInterfaceBuilder } from './RangeInterfaceBuilder'
+
+/**
+ * Handle returned by {@link MultiHost.bindPortRange}. Mirrors {@link Origin}
+ * but exports a single, restricted `api` service interface spanning the whole
+ * range — a range is a homogeneous pool of ports (RTP media, ZMQ sockets, FTP
+ * data), so it exposes exactly one interface. Distinct endpoints should be
+ * separate {@link MultiHost.bindPortRange} calls.
+ */
+export class RangeOrigin {
+ constructor(
+ readonly host: MultiHost,
+ readonly internalStartPort: number,
+ readonly externalStartPort: number,
+ readonly numberOfPorts: number,
+ ) {}
+
+ /**
+ * Register the range's single `api` service interface with StartOS.
+ *
+ * @param serviceInterface - a builder from `sdk.createRangeInterface`
+ */
+ async export(serviceInterface: RangeInterfaceBuilder): Promise {
+ const { effects, id, name, description, scheme } = serviceInterface.options
+ await effects.exportRangeServiceInterface({
+ hostId: this.host.options.id,
+ internalStartPort: this.internalStartPort,
+ id,
+ name,
+ description,
+ scheme: scheme ?? null,
+ })
+ }
+}
diff --git a/shared-libs/ts-modules/start-core/lib/interfaces/setupInterfaces.ts b/shared-libs/ts-modules/start-core/lib/interfaces/setupInterfaces.ts
index edbb1f6e2f..c793efde51 100644
--- a/shared-libs/ts-modules/start-core/lib/interfaces/setupInterfaces.ts
+++ b/shared-libs/ts-modules/start-core/lib/interfaces/setupInterfaces.ts
@@ -45,6 +45,12 @@ export const setupServiceInterfaces: SetupServiceInterfaces = <
interfaces.push(params.id)
return effects.exportServiceInterface(params)
},
+ exportRangeServiceInterface: (
+ params: T.ExportRangeServiceInterfaceParams,
+ ) => {
+ interfaces.push(params.id)
+ return effects.exportRangeServiceInterface(params)
+ },
},
})
await effects.clearBindings({ except: bindings })
diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/BindInfo.ts b/shared-libs/ts-modules/start-core/lib/osBindings/BindInfo.ts
index d83c6948f6..7b2b79ce52 100644
--- a/shared-libs/ts-modules/start-core/lib/osBindings/BindInfo.ts
+++ b/shared-libs/ts-modules/start-core/lib/osBindings/BindInfo.ts
@@ -2,10 +2,18 @@
import type { BindOptions } from './BindOptions'
import type { DerivedAddressInfo } from './DerivedAddressInfo'
import type { NetInfo } from './NetInfo'
+import type { ServiceInterface } from './ServiceInterface'
+import type { ServiceInterfaceId } from './ServiceInterfaceId'
export type BindInfo = {
enabled: boolean
options: BindOptions
net: NetInfo
addresses: DerivedAddressInfo
+ /**
+ * Service interfaces exported from this binding (`Origin.export`). A single
+ * binding (host + internal port) may back several interfaces (e.g. a `ui`
+ * and an `api` on the same port), so this is keyed by interface id.
+ */
+ interfaces: { [key: ServiceInterfaceId]: ServiceInterface }
}
diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/BindingSetRangeGatewayAccessParams.ts b/shared-libs/ts-modules/start-core/lib/osBindings/BindingSetRangeGatewayAccessParams.ts
deleted file mode 100644
index 117ef14490..0000000000
--- a/shared-libs/ts-modules/start-core/lib/osBindings/BindingSetRangeGatewayAccessParams.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-// 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'
-import type { RangeGatewayAccess } from './RangeGatewayAccess'
-
-export type BindingSetRangeGatewayAccessParams = {
- internalStartPort: number
- gateway: GatewayId
- access: RangeGatewayAccess
-}
diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/ExportRangeServiceInterfaceParams.ts b/shared-libs/ts-modules/start-core/lib/osBindings/ExportRangeServiceInterfaceParams.ts
new file mode 100644
index 0000000000..4727d4424c
--- /dev/null
+++ b/shared-libs/ts-modules/start-core/lib/osBindings/ExportRangeServiceInterfaceParams.ts
@@ -0,0 +1,12 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { HostId } from './HostId'
+import type { ServiceInterfaceId } from './ServiceInterfaceId'
+
+export type ExportRangeServiceInterfaceParams = {
+ hostId: HostId
+ internalStartPort: number
+ id: ServiceInterfaceId
+ name: string
+ description: string
+ scheme: string | null
+}
diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/PackageDataEntry.ts b/shared-libs/ts-modules/start-core/lib/osBindings/PackageDataEntry.ts
index ac219040e0..92ab6cd4ec 100644
--- a/shared-libs/ts-modules/start-core/lib/osBindings/PackageDataEntry.ts
+++ b/shared-libs/ts-modules/start-core/lib/osBindings/PackageDataEntry.ts
@@ -7,8 +7,6 @@ import type { Hosts } from './Hosts'
import type { PackagePlugin } from './PackagePlugin'
import type { PackageState } from './PackageState'
import type { ReplayId } from './ReplayId'
-import type { ServiceInterface } from './ServiceInterface'
-import type { ServiceInterfaceId } from './ServiceInterfaceId'
import type { StatusInfo } from './StatusInfo'
import type { TaskEntry } from './TaskEntry'
@@ -23,7 +21,6 @@ export type PackageDataEntry = {
currentDependencies: CurrentDependencies
actions: { [key: ActionId]: ActionMetadata }
tasks: { [key: ReplayId]: TaskEntry }
- serviceInterfaces: { [key: ServiceInterfaceId]: ServiceInterface }
hosts: Hosts
storeExposedDependents: string[]
outboundGateway: string | null
diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/RangeBindInfo.ts b/shared-libs/ts-modules/start-core/lib/osBindings/RangeBindInfo.ts
index da04b8c2a9..3b0ce140a7 100644
--- a/shared-libs/ts-modules/start-core/lib/osBindings/RangeBindInfo.ts
+++ b/shared-libs/ts-modules/start-core/lib/osBindings/RangeBindInfo.ts
@@ -1,6 +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'
-import type { RangeGatewayAccess } from './RangeGatewayAccess'
+import type { DerivedAddressInfo } from './DerivedAddressInfo'
+import type { RangeServiceInterface } from './RangeServiceInterface'
/**
* Contiguous port-range binding (e.g. WebRTC/STUN/TURN RTP ranges).
@@ -15,11 +15,17 @@ export type RangeBindInfo = {
externalStartPort: number
numberOfPorts: number
/**
- * Per-gateway exposure chosen by the operator. Absent gateways default to
- * [`RangeGatewayAccess::Lan`] (LAN-only), so a freshly-bound range is
- * not exposed to the WAN until the operator opts in. Persisted
- * independently of `enabled` (the service-lifecycle flag) so operator
- * choices survive restarts / rebinds.
+ * Reachable addresses for this range (LAN IPv4 / WAN IPv4 / mDNS /
+ * domains) with per-address enabled/disabled overrides — the same model as
+ * a single-port binding, but IPv4-only and non-SSL. COMPUTED by
+ * `update_addresses`; every entry uses `external_start_port` as its
+ * representative port. Public IPs are disabled by default (WAN is opt-in);
+ * LAN/mDNS/domains are enabled by default.
*/
- gatewayAccess: { [key: GatewayId]: RangeGatewayAccess }
+ addresses: DerivedAddressInfo
+ /**
+ * The single restricted `api` interface exported from this range, if any
+ * (`RangeOrigin.export`). Preserved across idempotent re-binds.
+ */
+ interface: RangeServiceInterface | null
}
diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/RangeGatewayAccess.ts b/shared-libs/ts-modules/start-core/lib/osBindings/RangeGatewayAccess.ts
deleted file mode 100644
index e0a5459bd8..0000000000
--- a/shared-libs/ts-modules/start-core/lib/osBindings/RangeGatewayAccess.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
-
-/**
- * Per-gateway exposure of a port range, chosen by the operator.
- *
- * Mirrors how single-port bindings treat WAN vs LAN addresses in
- * [`crate::net::forward::ForwardRequirements`]: `LanWan` puts the gateway in
- * `public_gateways` (no source filter → reachable from LAN and WAN), `Lan`
- * puts the gateway's subnet(s) in `private_ips` (source filtered to the LAN →
- * reachable from LAN only), and `Disabled` forwards on neither.
- */
-export type RangeGatewayAccess = 'disabled' | 'lan' | 'lan-wan'
diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/RangeServiceInterface.ts b/shared-libs/ts-modules/start-core/lib/osBindings/RangeServiceInterface.ts
new file mode 100644
index 0000000000..c0a9b5dc80
--- /dev/null
+++ b/shared-libs/ts-modules/start-core/lib/osBindings/RangeServiceInterface.ts
@@ -0,0 +1,19 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { ServiceInterfaceId } from './ServiceInterfaceId'
+
+/**
+ * The single restricted service interface a port-range binding may export.
+ *
+ * Unlike [`ServiceInterface`], a range interface is always `api`-typed and
+ * carries no `masked` / `username` / `path` / `query` and no per-address
+ * [`AddressInfo`] — its address is the host plus the range's external port
+ * span, taken from the [`RangeBindInfo`](crate::net::host::binding::RangeBindInfo)
+ * it lives under. `scheme` is an optional transport prefix (e.g. `tcp` for
+ * bitcoin ZMQ endpoints); most ranges (coturn RTP, FTP data) omit it.
+ */
+export type RangeServiceInterface = {
+ id: ServiceInterfaceId
+ name: string
+ description: string
+ scheme: string | null
+}
diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/index.ts b/shared-libs/ts-modules/start-core/lib/osBindings/index.ts
index 2b58df46ec..84ce03bf3f 100644
--- a/shared-libs/ts-modules/start-core/lib/osBindings/index.ts
+++ b/shared-libs/ts-modules/start-core/lib/osBindings/index.ts
@@ -49,7 +49,6 @@ export { BindParams } from './BindParams'
export { BindRangeParams } from './BindRangeParams'
export { BindingRanges } from './BindingRanges'
export { BindingSetAddressEnabledParams } from './BindingSetAddressEnabledParams'
-export { BindingSetRangeGatewayAccessParams } from './BindingSetRangeGatewayAccessParams'
export { Bindings } from './Bindings'
export { Blake3Commitment } from './Blake3Commitment'
export { BlockDev } from './BlockDev'
@@ -104,6 +103,7 @@ export { EncryptedWire } from './EncryptedWire'
export { ErrorData } from './ErrorData'
export { EventId } from './EventId'
export { ExportActionParams } from './ExportActionParams'
+export { ExportRangeServiceInterfaceParams } from './ExportRangeServiceInterfaceParams'
export { ExportServiceInterfaceParams } from './ExportServiceInterfaceParams'
export { FileType } from './FileType'
export { ForgetGatewayParams } from './ForgetGatewayParams'
@@ -230,7 +230,7 @@ export { Public } from './Public'
export { PublicDomainConfig } from './PublicDomainConfig'
export { QueryDnsParams } from './QueryDnsParams'
export { RangeBindInfo } from './RangeBindInfo'
-export { RangeGatewayAccess } from './RangeGatewayAccess'
+export { RangeServiceInterface } from './RangeServiceInterface'
export { RebuildParams } from './RebuildParams'
export { RecoverySource } from './RecoverySource'
export { RegistryAsset } from './RegistryAsset'
diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/tunnel/AddPortForwardParams.ts b/shared-libs/ts-modules/start-core/lib/osBindings/tunnel/AddPortForwardParams.ts
index 75cec0f0f0..90013f4123 100644
--- a/shared-libs/ts-modules/start-core/lib/osBindings/tunnel/AddPortForwardParams.ts
+++ b/shared-libs/ts-modules/start-core/lib/osBindings/tunnel/AddPortForwardParams.ts
@@ -12,4 +12,10 @@ export type AddPortForwardParams = {
* Hostnames to SNI-demux on the shared external port. Empty = normal DNAT.
*/
sni: Array
+ /**
+ * Number of contiguous ports to forward (a PCP PORT_SET range), counting up
+ * from both `external_port` and the target port. Defaults to 1. Not valid
+ * together with SNI demux.
+ */
+ count?: number
}
diff --git a/shared-libs/ts-modules/start-core/lib/test/startosTypeValidation.test.ts b/shared-libs/ts-modules/start-core/lib/test/startosTypeValidation.test.ts
index b90fa7208f..f9b8da8008 100644
--- a/shared-libs/ts-modules/start-core/lib/test/startosTypeValidation.test.ts
+++ b/shared-libs/ts-modules/start-core/lib/test/startosTypeValidation.test.ts
@@ -30,6 +30,7 @@ import { GetSystemSmtpParams } from '.././osBindings'
import { GetOutboundGatewayParams } from '.././osBindings'
import { GetServicePortForwardParams } from '.././osBindings'
import { ExportServiceInterfaceParams } from '.././osBindings'
+import { ExportRangeServiceInterfaceParams } from '.././osBindings'
import { ListServiceInterfacesParams } from '.././osBindings'
import { ExportActionParams } from '.././osBindings'
import { MountParams } from '.././osBindings'
@@ -97,6 +98,7 @@ describe('startosTypeValidation ', () => {
getServicePortForward: {} as GetServicePortForwardParams,
clearServiceInterfaces: {} as ClearServiceInterfacesParams,
exportServiceInterface: {} as ExportServiceInterfaceParams,
+ exportRangeServiceInterface: {} as ExportRangeServiceInterfaceParams,
listServiceInterfaces: {} as WithCallback,
mount: {} as MountParams,
checkDependencies: {} as CheckDependenciesParam,
diff --git a/shared-libs/ts-modules/start-core/lib/util/GetHostInfo.ts b/shared-libs/ts-modules/start-core/lib/util/GetHostInfo.ts
index bd822781ca..5fa4ad0381 100644
--- a/shared-libs/ts-modules/start-core/lib/util/GetHostInfo.ts
+++ b/shared-libs/ts-modules/start-core/lib/util/GetHostInfo.ts
@@ -1,18 +1,85 @@
import { Effects } from '../Effects'
import { Host, HostId, PackageId } from '../osBindings'
+import { deepEqual } from './deepEqual'
import { Watchable } from './Watchable'
-export class GetHostInfo extends Watchable {
+export class GetHostInfo extends Watchable<
+ Host | null,
+ Mapped
+> {
protected readonly label = 'GetHostInfo'
constructor(
effects: Effects,
readonly opts: { hostId: HostId; packageId?: PackageId },
+ options?: {
+ map?: (value: Host | null) => Mapped
+ eq?: (a: Mapped, b: Mapped) => boolean
+ },
) {
- super(effects)
+ super(effects, options)
}
protected fetch(callback?: () => void) {
return this.effects.getHostInfo({ ...this.opts, callback })
}
}
+
+/**
+ * Reactive reader for one of this package's own hosts.
+ *
+ * Pass `map` to react to only a slice of the host: `const()` re-runs the
+ * calling context when the mapped value changes (compared with `eq`, default
+ * deep-equal) rather than on any change to the whole host. Reach an exported
+ * interface by walking the host — e.g.
+ * `map: h => h?.bindings[80]?.interfaces['ui']`.
+ */
+export function getOwnHost(effects: Effects, hostId: HostId): GetHostInfo
+export function getOwnHost(
+ effects: Effects,
+ hostId: HostId,
+ map: (host: Host | null) => Mapped,
+ eq?: (a: Mapped, b: Mapped) => boolean,
+): GetHostInfo
+export function getOwnHost(
+ effects: Effects,
+ hostId: HostId,
+ map?: (host: Host | null) => Mapped,
+ eq?: (a: Mapped, b: Mapped) => boolean,
+): GetHostInfo {
+ return new GetHostInfo(
+ effects,
+ { hostId },
+ {
+ map: map ?? (a => a as Mapped),
+ eq: eq ?? ((a, b) => deepEqual(a, b)),
+ },
+ )
+}
+
+/**
+ * Reactive reader for a host on any package (defaults to this package when
+ * `packageId` is omitted). Pass `map`/`eq` to narrow `const()` reactivity to a
+ * slice of the host — see {@link getOwnHost}.
+ */
+export function getHost(
+ effects: Effects,
+ opts: { hostId: HostId; packageId?: PackageId },
+): GetHostInfo
+export function getHost(
+ effects: Effects,
+ opts: { hostId: HostId; packageId?: PackageId },
+ map: (host: Host | null) => Mapped,
+ eq?: (a: Mapped, b: Mapped) => boolean,
+): GetHostInfo
+export function getHost(
+ effects: Effects,
+ opts: { hostId: HostId; packageId?: PackageId },
+ map?: (host: Host | null) => Mapped,
+ eq?: (a: Mapped, b: Mapped) => boolean,
+): GetHostInfo {
+ return new GetHostInfo(effects, opts, {
+ map: map ?? (a => a as Mapped),
+ eq: eq ?? ((a, b) => deepEqual(a, b)),
+ })
+}
diff --git a/shared-libs/ts-modules/start-core/package.json b/shared-libs/ts-modules/start-core/package.json
index f491419046..b467b49aa6 100644
--- a/shared-libs/ts-modules/start-core/package.json
+++ b/shared-libs/ts-modules/start-core/package.json
@@ -12,14 +12,14 @@
},
"repository": {
"type": "git",
- "url": "git+https://github.com/Start9Labs/start-os.git"
+ "url": "git+https://github.com/Start9Labs/start-technologies.git"
},
"author": "Start9 Labs",
"license": "MIT",
"bugs": {
- "url": "https://github.com/Start9Labs/start-os/issues"
+ "url": "https://github.com/Start9Labs/start-technologies/issues"
},
- "homepage": "https://github.com/Start9Labs/start-os#readme",
+ "homepage": "https://github.com/Start9Labs/start-technologies#readme",
"dependencies": {
"@iarna/toml": "^3.0.0",
"@noble/curves": "^1.9.7",