diff --git a/.github/actions/setup-build-env/action.yml b/.github/actions/setup-build-env/action.yml index 0b6ed5dd88..ddce313962 100644 --- a/.github/actions/setup-build-env/action.yml +++ b/.github/actions/setup-build-env/action.yml @@ -65,10 +65,10 @@ runs: ASSET_NAME="start-cli_${ARCH}-${OS}" DOWNLOAD_URL=$(curl -fsS \ -H "Authorization: token ${{ github.token }}" \ - https://api.github.com/repos/Start9Labs/start-os/releases \ + https://api.github.com/repos/Start9Labs/start-technologies/releases \ | jq -r '[.[].assets[] | select(.name=="'"$ASSET_NAME"'")] | first | .browser_download_url') if [ -z "$DOWNLOAD_URL" ] || [ "$DOWNLOAD_URL" = "null" ]; then - echo "Error: Could not find asset '$ASSET_NAME' in Start9Labs/start-os releases" + echo "Error: Could not find asset '$ASSET_NAME' in Start9Labs/start-technologies releases" exit 1 fi curl -fsSL \ diff --git a/.github/actions/setup-publish-env/action.yml b/.github/actions/setup-publish-env/action.yml index e7118f1680..7f3876577f 100644 --- a/.github/actions/setup-publish-env/action.yml +++ b/.github/actions/setup-publish-env/action.yml @@ -21,10 +21,10 @@ runs: ASSET_NAME="start-cli_${ARCH}-${OS}" DOWNLOAD_URL=$(curl -fsS \ -H "Authorization: token ${{ github.token }}" \ - https://api.github.com/repos/Start9Labs/start-os/releases \ + https://api.github.com/repos/Start9Labs/start-technologies/releases \ | jq -r '[.[].assets[] | select(.name=="'"$ASSET_NAME"'")] | first | .browser_download_url') if [ -z "$DOWNLOAD_URL" ] || [ "$DOWNLOAD_URL" = "null" ]; then - echo "Error: Could not find asset '$ASSET_NAME' in Start9Labs/start-os releases" + echo "Error: Could not find asset '$ASSET_NAME' in Start9Labs/start-technologies releases" exit 1 fi curl -fsSL \ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5f13130398..63c88a6fb2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,10 +60,10 @@ jobs: steps: - name: Free up disk space if: ${{ inputs.FREE_DISK_SPACE }} - uses: Start9Labs/start-os/.github/actions/free-disk-space@master + uses: Start9Labs/start-technologies/.github/actions/free-disk-space@master - name: Setup build environment - uses: Start9Labs/start-os/.github/actions/setup-build-env@master + uses: Start9Labs/start-technologies/.github/actions/setup-build-env@master - name: Checkout services repository uses: actions/checkout@v6 @@ -89,7 +89,7 @@ jobs: shell: bash - name: Upload each s9pk as its own artifact - uses: Start9Labs/start-os/.github/actions/upload-each@master + uses: Start9Labs/start-technologies/.github/actions/upload-each@master with: pattern: "*.s9pk" retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 915fd10648..3997063c2f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,10 +85,10 @@ jobs: steps: - name: Free up disk space if: ${{ inputs.FREE_DISK_SPACE }} - uses: Start9Labs/start-os/.github/actions/free-disk-space@master + uses: Start9Labs/start-technologies/.github/actions/free-disk-space@master - name: Setup build environment - uses: Start9Labs/start-os/.github/actions/setup-build-env@master + uses: Start9Labs/start-technologies/.github/actions/setup-build-env@master - name: Checkout services repository uses: actions/checkout@v6 @@ -128,7 +128,7 @@ jobs: contents: write steps: - name: Setup publish environment - uses: Start9Labs/start-os/.github/actions/setup-publish-env@master + uses: Start9Labs/start-technologies/.github/actions/setup-publish-env@master - name: Checkout services repository uses: actions/checkout@v6 @@ -169,7 +169,7 @@ jobs: - name: Extract version id: version - uses: Start9Labs/start-os/.github/actions/extract-version@master + uses: Start9Labs/start-technologies/.github/actions/extract-version@master - name: Filter s9pk files for GitHub release id: filter_s9pk @@ -327,7 +327,7 @@ jobs: shell: bash - name: Upload each s9pk as its own artifact - uses: Start9Labs/start-os/.github/actions/upload-each@master + uses: Start9Labs/start-technologies/.github/actions/upload-each@master with: pattern: "*.s9pk" retention-days: 14 diff --git a/.github/workflows/startos-iso.yaml b/.github/workflows/startos-iso.yaml index 59a3ac83a6..ef7a6c13b2 100644 --- a/.github/workflows/startos-iso.yaml +++ b/.github/workflows/startos-iso.yaml @@ -366,7 +366,7 @@ jobs: ASSET_NAME="start-cli_${ARCH}-${OS}" DOWNLOAD_URL=$(curl -fsS \ -H "Authorization: token ${{ github.token }}" \ - https://api.github.com/repos/Start9Labs/start-os/releases \ + https://api.github.com/repos/Start9Labs/start-technologies/releases \ | jq -r '[.[].assets[] | select(.name=="'"$ASSET_NAME"'")] | first | .browser_download_url') curl -fsSL \ -H "Authorization: token ${{ github.token }}" \ diff --git a/.github/workflows/tagAndRelease.yml b/.github/workflows/tagAndRelease.yml index ce7ee601bb..ad769c5067 100644 --- a/.github/workflows/tagAndRelease.yml +++ b/.github/workflows/tagAndRelease.yml @@ -47,10 +47,10 @@ jobs: ASSET_NAME="start-cli_${ARCH}-${OS}" DOWNLOAD_URL=$(curl -fsS \ -H "Authorization: token ${{ github.token }}" \ - https://api.github.com/repos/Start9Labs/start-os/releases \ + https://api.github.com/repos/Start9Labs/start-technologies/releases \ | jq -r '[.[].assets[] | select(.name=="'"$ASSET_NAME"'")] | first | .browser_download_url') if [ -z "$DOWNLOAD_URL" ] || [ "$DOWNLOAD_URL" = "null" ]; then - echo "Error: Could not find asset '$ASSET_NAME' in Start9Labs/start-os releases" + echo "Error: Could not find asset '$ASSET_NAME' in Start9Labs/start-technologies releases" exit 1 fi curl -fsSL \ @@ -64,7 +64,7 @@ jobs: - name: Extract package ID and version id: extract - uses: Start9Labs/start-os/.github/actions/extract-version@master + uses: Start9Labs/start-technologies/.github/actions/extract-version@master - name: Check production registry id: check @@ -119,7 +119,7 @@ jobs: Release: needs: Tag if: needs.Tag.outputs.skip == 'false' - uses: Start9Labs/start-os/.github/workflows/release.yml@master + uses: Start9Labs/start-technologies/.github/workflows/release.yml@master with: TAG: ${{ needs.Tag.outputs.tag }} FREE_DISK_SPACE: ${{ inputs.FREE_DISK_SPACE }} diff --git a/AGENTS.md b/AGENTS.md index 33b3737c32..0ad6a07999 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,4 +66,4 @@ Already enforced or checked elsewhere (listed here for completeness; documented - [`projects/brochure-marketplace/AGENTS.md`](projects/brochure-marketplace/AGENTS.md) — public marketplace site - [`projects/start-docs/AGENTS.md`](projects/start-docs/AGENTS.md) — documentation website - [`shared-libs/AGENTS.md`](shared-libs/AGENTS.md) — shared libs container: [`crates/start-core`](shared-libs/crates/start-core/AGENTS.md) (Rust backend), [`web`](shared-libs/ts-modules/AGENTS.md) (Angular workspace + UI/setup-wizard/shared libs) -- `shared-libs/crates/patch-db/` — first-party crate (upstream: github.com/Start9Labs/patch-db) +- `shared-libs/crates/patch-db/` — first-party crate (maintained in-tree; the standalone `Start9Labs/patch-db` repo is being retired) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e82435b1a0..9771c377d6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -58,7 +58,7 @@ start-os/ # repo root (monorepo) - **`projects/start-sdk/`** — TypeScript SDK for packaging services (`@start9labs/start-sdk`), flattened with source in `lib/`. It imports the shared `@start9labs/start-core` lib (`shared-libs/ts-modules/start-core/` — core types, ABI, effects interface, also consumed directly by web) and bundles it into its published `dist/`, so container-runtime and external service developers install a single package. Its `Makefile`/`s9pk.mk` is the source of truth for the published tarball. -- **`shared-libs/crates/patch-db/`** — first-party crate providing diff-based state sync (CBOR encoded). Backend mutations produce diffs pushed to the frontend over WebSocket for reactive UI. See the [patch-db repo](https://github.com/Start9Labs/patch-db). +- **`shared-libs/crates/patch-db/`** — first-party crate providing diff-based state sync (CBOR encoded). Backend mutations produce diffs pushed to the frontend over WebSocket for reactive UI. See the [patch-db crate](https://github.com/Start9Labs/start-technologies/tree/master/shared-libs/crates/patch-db). ## Build pipeline diff --git a/Cargo.lock b/Cargo.lock index ce6b7f9681..df8c080b52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7422,7 +7422,7 @@ dependencies = [ [[package]] name = "start-tunnel" -version = "1.0.0" +version = "1.1.0" dependencies = [ "include_dir", "start-core", diff --git a/README.md b/README.md index 90b3a2f3b7..7250f9030c 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ All products share a single Rust backend library (`start-core`) and a single Ang | `shared-libs/ts-modules/` | — | Shared Angular libraries (the Angular workspace is rooted at the repo root) | | `shared-libs/crates/patch-db/` | — | Diff-based reactive state store (first-party crate) | -**Tech stack:** Rust backend (Tokio/Axum), Angular frontend (Taiga UI), Node.js container runtime with LXC, and a custom diff-based database ([Patch-DB](https://github.com/Start9Labs/patch-db)) for reactive state synchronization. Services run in isolated LXC containers, packaged as S9PKs — a signed, merkle-archived format supporting partial downloads and cryptographic verification. +**Tech stack:** Rust backend (Tokio/Axum), Angular frontend (Taiga UI), Node.js container runtime with LXC, and a custom diff-based database ([Patch-DB](https://github.com/Start9Labs/start-technologies/tree/master/shared-libs/crates/patch-db)) for reactive state synchronization. Services run in isolated LXC containers, packaged as S9PKs — a signed, merkle-archived format supporting partial downloads and cryptographic verification. See [ARCHITECTURE.md](ARCHITECTURE.md) for how the pieces fit together. diff --git a/projects/start-cli/Cargo.toml b/projects/start-cli/Cargo.toml index 7140185a0f..cedbfb6c51 100644 --- a/projects/start-cli/Cargo.toml +++ b/projects/start-cli/Cargo.toml @@ -3,7 +3,7 @@ name = "start-cli" 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 = "start-cli" diff --git a/projects/start-docs/bitcoin-guides/book.toml b/projects/start-docs/bitcoin-guides/book.toml index eb954b2bdc..311df0900e 100644 --- a/projects/start-docs/bitcoin-guides/book.toml +++ b/projects/start-docs/bitcoin-guides/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/docs/bitcoin-guides/{path}" +git-repository-url = "https://github.com/Start9Labs/start-technologies" +edit-url-template = "https://github.com/Start9Labs/start-technologies/edit/master/docs/bitcoin-guides/{path}" site-url = "/bitcoin-guides/" [output.html.fold] diff --git a/projects/start-os/Cargo.toml b/projects/start-os/Cargo.toml index ef42354e59..fd3f74fed9 100644 --- a/projects/start-os/Cargo.toml +++ b/projects/start-os/Cargo.toml @@ -3,7 +3,7 @@ name = "start-os" version = "0.4.0-beta.10" # VERSION_BUMP edition = "2024" license = "MIT" -repository = "https://github.com/Start9Labs/start-os" +repository = "https://github.com/Start9Labs/start-technologies" [[bin]] name = "startbox" diff --git a/projects/start-os/container-runtime/src/Adapters/EffectCreator.ts b/projects/start-os/container-runtime/src/Adapters/EffectCreator.ts index 29918263ca..14e5b91426 100644 --- a/projects/start-os/container-runtime/src/Adapters/EffectCreator.ts +++ b/projects/start-os/container-runtime/src/Adapters/EffectCreator.ts @@ -217,6 +217,13 @@ export function makeEffects(context: EffectContext): Effects { T.Effects["exportServiceInterface"] > }) as Effects["exportServiceInterface"], + exportRangeServiceInterface: (( + ...[options]: Parameters + ) => { + return rpcRound("export-range-service-interface", options) as ReturnType< + T.Effects["exportRangeServiceInterface"] + > + }) as Effects["exportRangeServiceInterface"], getContainerIp(...[options]: Parameters) { return rpcRound("get-container-ip", { ...options, diff --git a/projects/start-os/debian/postinst b/projects/start-os/debian/postinst index 45b8a7ecc7..a45b4cf1e1 100755 --- a/projects/start-os/debian/postinst +++ b/projects/start-os/debian/postinst @@ -102,7 +102,7 @@ VERSION_ID="${VERSION}" PRETTY_NAME="StartOS v${VERSION_ENV}" HOME_URL="https://start9.com/" SUPPORT_URL="https://docs.start9.com/0.3.5.x/support" -BUG_REPORT_URL="https://github.com/Start9Labs/start-os/issues" +BUG_REPORT_URL="https://github.com/Start9Labs/start-technologies/issues" VARIANT="${ENVIRONMENT}" VARIANT_ID="${ENVIRONMENT}" EOF diff --git a/projects/start-os/docs/book.toml b/projects/start-os/docs/book.toml index 20b898010a..2b05abc2ca 100644 --- a/projects/start-os/docs/book.toml +++ b/projects/start-os/docs/book.toml @@ -19,6 +19,6 @@ 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-os/docs/{path}" +git-repository-url = "https://github.com/Start9Labs/start-technologies" +edit-url-template = "https://github.com/Start9Labs/start-technologies/edit/master/projects/start-os/docs/{path}" site-url = "/start-os/" diff --git a/projects/start-os/scripts/manage-release.sh b/projects/start-os/scripts/manage-release.sh index 21e5037684..db6297f103 100755 --- a/projects/start-os/scripts/manage-release.sh +++ b/projects/start-os/scripts/manage-release.sh @@ -2,7 +2,7 @@ set -e -REPO="Start9Labs/start-os" +REPO="Start9Labs/start-technologies" REGISTRY="https://alpha-registry-x.start9.com" S3_BUCKET="s3://startos-images" S3_CDN="https://startos-images.nyc3.cdn.digitaloceanspaces.com" diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/actions.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/actions.component.ts index 027e6854fe..ea6c6d783f 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/actions.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/actions.component.ts @@ -169,6 +169,7 @@ import { DomainHealthService } from './domain-health.service' } @if ( address().hostnameInfo.metadata.kind === 'ipv4' && + address().access === 'public' && address().hostnameInfo.port !== null ) { + @if (!isRange) { + + } @@ -233,6 +256,12 @@ export type DomainValidationData = { text-align: end; } + // The port-forwarding 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; } @@ -317,6 +346,14 @@ export class DomainValidationComponent { readonly domain = parse(this.context.data.fqdn).domain || this.context.data.fqdn + // A port range forwards a span of ports and can't be tested a port at a time; + // only its DNS is verifiable here. + readonly isRange = this.context.data.count > 1 + readonly portDisplay = formatPortRange( + this.context.data.port, + this.context.data.count, + ) + readonly dnsLoading = signal(false) readonly portLoading = signal(false) readonly dnsPass = signal(undefined) @@ -326,8 +363,10 @@ export class DomainValidationComponent { const result = this.portResult() return ( this.dnsPass() === true && - !!result?.openInternally && - !!result?.openExternally + (this.isRange || + (!!result?.openInternally && + !!result?.openExternally && + !!result?.hairpinning)) ) }) diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/domain-health.service.ts b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/domain-health.service.ts index 644dd3cc43..d0b4bac4c2 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/domain-health.service.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/domain-health.service.ts @@ -20,11 +20,16 @@ export class DomainHealthService { fqdn: string, gatewayId: string, portOrRes: number | T.AddPublicDomainRes, + count = 1, ): Promise { try { const gateway = await this.getGatewayData(gatewayId) if (!gateway) return + // A port range can't be reachability-tested a port at a time, so we only + // verify DNS and never claim its port forward is (or isn't) open. + const isRange = count > 1 + let dnsPass: boolean let port: number let portResult: T.CheckPortRes | null @@ -36,27 +41,30 @@ export class DomainHealthService { .queryDns({ fqdn }) .then(ip => ip === gateway.ipInfo.wanIp) .catch(() => false), - this.api - .checkPort({ gateway: gatewayId, port: portOrRes }) - .catch((): null => null), + isRange + ? Promise.resolve(null) + : this.api + .checkPort({ gateway: gatewayId, port: portOrRes }) + .catch((): null => null), ]) dnsPass = dns portResult = portRes } else { dnsPass = portOrRes.dns === gateway.ipInfo.wanIp port = portOrRes.port.port - portResult = portOrRes.port + portResult = isRange ? null : portOrRes.port } const portOk = - !!portResult?.openInternally && - !!portResult?.openExternally && - !!portResult?.hairpinning + isRange || + (!!portResult?.openInternally && + !!portResult?.openExternally && + !!portResult?.hairpinning) if (!dnsPass || !portOk) { setTimeout( () => - this.openPublicDomainModal(fqdn, gateway, port, { + this.openPublicDomainModal(fqdn, gateway, port, count, { dnsPass, portResult, }), @@ -98,12 +106,13 @@ export class DomainHealthService { fqdn: string, gatewayId: string, port: number, + count = 1, ): Promise { try { const gateway = await this.getGatewayData(gatewayId) if (!gateway) return - this.openPublicDomainModal(fqdn, gateway, port) + this.openPublicDomainModal(fqdn, gateway, port, count) } catch (e: any) { this.errorService.handleError(e) } @@ -125,7 +134,7 @@ export class DomainHealthService { if (!portOk) { setTimeout( - () => this.openPortForwardModal(gateway, port, { portResult }), + () => this.openPortForwardModal(gateway, port, 1, { portResult }), 250, ) } @@ -134,12 +143,16 @@ export class DomainHealthService { } } - async showPortForwardSetup(gatewayId: string, port: number): Promise { + async showPortForwardSetup( + gatewayId: string, + port: number, + count = 1, + ): Promise { try { const gateway = await this.getGatewayData(gatewayId) if (!gateway) return - this.openPortForwardModal(gateway, port) + this.openPortForwardModal(gateway, port, count) } catch (e: any) { this.errorService.handleError(e) } @@ -169,6 +182,7 @@ export class DomainHealthService { fqdn: string, gateway: DnsGateway, port: number, + count: number, initialResults?: { dnsPass: boolean portResult: T.CheckPortRes | null @@ -178,7 +192,7 @@ export class DomainHealthService { .openComponent(DOMAIN_VALIDATION, { label: 'Address Requirements', size: 'm', - data: { fqdn, gateway, port, initialResults }, + data: { fqdn, gateway, port, count, initialResults }, }) .subscribe() } @@ -186,13 +200,14 @@ export class DomainHealthService { private openPortForwardModal( gateway: DnsGateway, port: number, + count: number, initialResults?: { portResult: T.CheckPortRes | null }, ) { this.dialog .openComponent(PORT_FORWARD_VALIDATION, { label: 'Address Requirements', size: 'm', - data: { gateway, port, initialResults }, + data: { gateway, port, count, initialResults }, }) .subscribe() } diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/gateway.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/gateway.component.ts index a05ea75489..3a56cf66a4 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/gateway.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/components/interfaces/addresses/gateway/gateway.component.ts @@ -1,5 +1,10 @@ -import { Component, inject, input } from '@angular/core' -import { DialogService, ErrorService, i18nPipe } from '@start9labs/shared' +import { Component, computed, inject, input } from '@angular/core' +import { + DialogService, + ErrorService, + i18nKey, + i18nPipe, +} from '@start9labs/shared' import { ISB, utils } from '@start9labs/start-core' import { TuiButton, TuiIcon } from '@taiga-ui/core' import { TuiNotificationMiddleService } from '@taiga-ui/kit' @@ -49,16 +54,7 @@ import { GatewayItemComponent } from './item.component' {{ 'Add Domain' | i18n }} - +
@for (address of gatewayGroup().addresses; track $index) { } @empty { - - - + } + - + @@ -48,7 +55,7 @@ import { DNS_ADD } from './add' - +
+ {{ '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();
- +
- - - - + @if (!isRange) { + + } + + + @if (!isRange) { + + }
- - {{ context.data.port }}{{ context.data.port }} - - + + {{ portDisplay }}{{ portDisplay }} + +
-
- -
+ @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 }}
- + @if (!isRange) { + + }
@@ -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) { - - } - -
- } @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) { - + + + + } } @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
Name TypeValueServer TTL
{{ record.name }} {{ record.type }}{{ record.value }}{{ serverDisplay(record) }} {{ record.ttl }}