Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions projects/start-os/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ file tracks notable changes since the move to the monorepo.

## [0.4.0.2]

### Added

- **A service can permanently retire a network host or a port it no longer
uses, and the port numbers it held become available again.** A service that
reorganizes its interfaces across an update — renaming a host, dropping a
port — could previously only switch the old one off, which keeps its port
number reserved for as long as the service is installed. Retiring removes it
outright and releases its port forwards, proxy entries, local DNS records and
any port mapping StartOS asked your router for. A domain you had assigned to
a retired host is removed with it, so check the service's release notes and
assign it to one of the service's current interfaces.

### Fixed

- **Image upgrades verify their checksum again.** `upgrade` compared the image's
Expand All @@ -18,6 +30,10 @@ file tracks notable changes since the move to the monorepo.
image would be installed without complaint. It now verifies whenever
`CHECKSUM` is set.

- **Removing a domain from a service leaves its network settings otherwise
untouched.** Naming a network host the service does not have — a stale id, or
a typo — added that host to the service as an empty entry, which then stayed
in its network settings with nothing to remove it.
- **A service whose startup routine throws now reports the failure in its own
logs, and StartOS names the failure for what it is.** The container runtime
handed the exception back to StartOS over its socket without also printing
Expand Down
22 changes: 14 additions & 8 deletions projects/start-os/container-runtime/src/Adapters/EffectCreator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,22 +162,28 @@ export function makeEffects(context: EffectContext): Effects {
},
},
bind(...[options]: Parameters<T.Effects['bind']>) {
return rpcRound('bind', {
...options,
stack: new Error().stack,
}) as ReturnType<T.Effects['bind']>
return rpcRound('bind', { ...options }) as ReturnType<T.Effects['bind']>
},
bindRange(...[options]: Parameters<T.Effects['bindRange']>) {
return rpcRound('bind-range', {
...options,
stack: new Error().stack,
}) as ReturnType<T.Effects['bindRange']>
return rpcRound('bind-range', { ...options }) as ReturnType<
T.Effects['bindRange']
>
},
clearBindings(...[options]: Parameters<T.Effects['clearBindings']>) {
return rpcRound('clear-bindings', { ...options }) as ReturnType<
T.Effects['clearBindings']
>
},
retireHost(...[options]: Parameters<T.Effects['retireHost']>) {
return rpcRound('retire-host', { ...options }) as ReturnType<
T.Effects['retireHost']
>
},
retireBinding(...[options]: Parameters<T.Effects['retireBinding']>) {
return rpcRound('retire-binding', { ...options }) as ReturnType<
T.Effects['retireBinding']
>
},
clearServiceInterfaces(
...[options]: Parameters<T.Effects['clearServiceInterfaces']>
) {
Expand Down
2 changes: 2 additions & 0 deletions projects/start-os/docs/src/interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ You can add domains to a gateway table by clicking "Add Domain" on the gateway a
- **[Public Domain](clearnet.md)** — A clearnet domain (e.g. `mysite.com`) accessible from the Internet. Requires DNS configuration and port forwarding.
- **[Private Domain](private-domains.md)** — A custom domain (e.g. `nextcloud.private`) that works on LAN and VPN. Requires your gateway to use StartOS for DNS. Only available on Ethernet and Wireless gateways — not WireGuard (StartTunnel) gateways.

A domain you add belongs to the interface you added it to. If an update reorganizes a service's interfaces and retires the one your domain was on, the domain is removed with it — check the service's release notes, which should say when an update does this, and add the domain to one of the service's current interfaces.

### Tor Onion Addresses

If the [Tor](tor.md) service is installed and running, a **Tor** table also appears among the interface's addresses. Tor functions like a gateway but is managed as a marketplace service rather than a system gateway.
Expand Down
37 changes: 32 additions & 5 deletions projects/start-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,43 @@
# Changelog

## 2.0.10 — StartOS 0.4.0
## 2.0.10 — StartOS 0.4.0.2

### Changed

- **Minimum StartOS version bumped to `0.4.0`.** The 2.0 line has declared
`0.4.0-beta.10` since 2.0.0; 0.4.0 is the release that shipped, and it is what
a package built with this SDK now writes as its manifest `osVersion` — so the
registry offers that package to servers on 0.4.0 or later
- **Minimum StartOS version bumped to `0.4.0.2`.** The 2.0 line had declared
`0.4.0-beta.10` since 2.0.0, against a 0.4.0 that has since shipped. `0.4.0.2`
is the release carrying the effects behind `MultiHost.retire()` /
`retirePort()` below, and it is what a package built with this SDK now writes
as its manifest `osVersion` — so the registry offers that package to servers
on 0.4.0.2 or later, and a server too old to run a retire migration is never
offered the package that would attempt one

- **`effects.getServicePortForward` resolves `null` instead of throwing when
the binding does not exist.** It is the one host effect with no `callback`,
so a caller cannot react to a change — and throwing was the worst available
answer for exactly that caller. It also could not tell "no such binding" from
"the host itself is gone", and a binding merely disabled by `clearBindings`
still reported its stale ports, which retiring now makes an observable
difference. Prefer `sdk.host.getBridgeAddress` to reach a dependency; this is
raw allocator metadata

### Added

- **`MultiHost.retire()` and `MultiHost.retirePort()` permanently remove a host
or a binding.** `setupInterfaces` ends each pass by
_disabling_ whatever it did not declare, which keeps the row, the external
port number and the user's per-address choices so a conditionally-declared
binding returns at the address they bookmarked. A binding dropped for good
therefore stayed behind: its external port stayed claimed for as long as the
service was installed, and a dependency resolving it through
`getBridgeAddress` still got a `10.0.3.1:<port>` that nothing listens on.
Retiring deletes the host or binding and its exported service interfaces —
and, for a whole host, the user's domains for it — returning the external
ports to the server's pool. Call it from the `up()` of the version that stops
binding; it resolves `false` where there was nothing to remove, so a re-run is
safe. See
[Retiring a Host or Binding](https://docs.start9.com/packaging/interfaces.html#retiring-a-host-or-binding)

- **`sdk.getRootCa(effects)` returns this server's root CA certificate.** A
service that dials an address the _user_ supplies — a monitor target, a
notification endpoint, a webhook — gets whatever address StartOS showed them,
Expand Down
58 changes: 58 additions & 0 deletions projects/start-sdk/docs/src/interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,64 @@ Two distinct endpoints are two `bindPortRange` calls — a range is a homogeneou
| `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. |

## Retiring a Host or Binding

`setupInterfaces()` ends every pass by **disabling** each binding it did not just declare — it does not delete it. Disabling is the right default: it keeps the row, the external port number, and the user's per-address choices, so a binding your package declares conditionally comes back at the same address they already bookmarked.

The cost is that a binding you stop declaring **for good** stays behind. It keeps its external port claimed for as long as your service is installed, it keeps recomputing its addresses, and a dependency resolving it through `getBridgeAddress` still gets a `10.0.3.1:<port>` that nothing listens on. Retire it explicitly:

```typescript
await sdk.MultiHost.of(effects, 'ui-multi').retire() // the whole host
await sdk.MultiHost.of(effects, 'api').retirePort(9090) // one port, or one range
```

`retire()` removes the host and everything under it: its bindings and port ranges, their exported service interfaces, the user's public and private domains for that host, and their per-address enable/disable and WAN opt-in choices. `retirePort()` removes whichever of the single port and the port range is bound at that `internalPort` — and both, if both are — leaving the host and its domains in place. Both return the external ports to the server's pool. Both are irreversible: after `retire()`, binding the id again starts a fresh host with none of the user's setup.

Note what that last part means: `retire()` discards configuration the **user** created, not just your package's. A domain they attached to the host goes with it, and nothing tells them. Name the host in your release notes whenever a release retires one, so they know to reattach the domain to a current interface.

### The migration pattern

Retire in the `up()` of the version that stops binding, in the same release as the `interfaces.ts` change:

```typescript
export const v2_0_0 = VersionInfo.of({
version: '2.0.0:0',
releaseNotes: {
en_US: 'Upstream 2.0. The web UI moved to a single host and the bundled metrics listener was removed.',
},
migrations: {
up: async ({ effects }) => {
await sdk.MultiHost.of(effects, 'ui-multi').retire()
await sdk.MultiHost.of(effects, 'api').retirePort(9090)
},
down: IMPOSSIBLE,
},
})
```

Both halves ship together. Retiring an id your `setupInterfaces` still binds simply recreates it on the next pass, minus the user's domains — so the retire has to land in the release that drops the binding, not before or after it. `down` is `IMPOSSIBLE` because a downgrade cannot give the user their domains back.

Retiring from **inside** the `sdk.setupInterfaces` callback throws. That pass ends with the disable sweep, so a retire in the middle of it would depend on statement order.

### Why this cannot be automatic

StartOS cannot infer it. A binding missing from one pass is indistinguishable from a binding the service will declare on the next one — under a different config, a backend the user has not selected yet, or a feature they toggled off. Deleting on absence would free the external port and drop their WAN opt-in every time they turned a feature off, and hand that port number to another package before they turned it back on. That is exactly what disabling exists to prevent.

The SDK cannot infer it either: it sees only the calls a pass actually made. Only the author knows a port is gone for good, and only knows it at a version boundary — which is what a migration is.

This is the same shape as [retiring a replay key](tasks.md#retiring-a-replay-key): state your package created, that outlives the release which stopped creating it, and that only your package can say is finished.

### Failure modes

- **Retiring an id you still bind.** Migrations run before `setupInterfaces`, so the port is normally reclaimed on the same pass and nothing looks wrong. The symptom is the user's setup silently reset — a custom domain and WAN toggle back to defaults after an update.
- **A port that moved rather than disappeared.** Retiring the old binding and adding the new one in the same release keeps the host's domains, but StartOS isolates a **public** domain from a binding added after it, so the user has to re-enable that domain on the new binding. Private domains carry over on their own. Say so in your release notes.
- **Treating `false` as failure.** Both calls resolve `false` when there was nothing to remove — the normal result on a re-run, and on a server that skipped the version. Not an error.
- **Retiring the last binding on a host.** That does not retire the host. Its domains stay, now addressing nothing. Use `retire()` when the host itself is going away.

### Cleaning up after the fact

A package that already shipped a version dropping a host or a port still has the row and the port claim sitting on every server that installed it. Retire is a no-op where the id was never present, so one maintenance release naming the stale ids in its `up()` covers the whole installed base at once. List the ids in your release notes: any domain the user attached to a host you retire is removed with it, and they will want to know where to reattach it.

## TLS Termination

StartOS terminates TLS at the platform edge and proxies plain HTTP to your container. This has two important consequences any time your service generates URLs or makes scheme decisions:
Expand Down
2 changes: 2 additions & 0 deletions projects/start-sdk/docs/src/recipe-version-migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The latest version always lives in `startos/versions/current.ts`. Adding a migra

**Reference:** [Versions](versions.md) · [File Models](file-models.md)

A version that stops binding a host or a port has one more job: retire it in the same `up()`, or its external port stays claimed and dependencies keep resolving a dead address. See [Retiring a Host or Binding](interfaces.md#retiring-a-host-or-binding).

## Examples

See `startos/versions/` in: [bitcoin-core](https://github.com/Start9Labs/bitcoin-core-startos), [cln](https://github.com/Start9Labs/cln-startos), [lnd](https://github.com/Start9Labs/lnd-startos), [monerod](https://github.com/Start9Labs/monerod-startos), [nextcloud](https://github.com/Start9Labs/nextcloud-startos), [simplex](https://github.com/Start9Labs/simplex-startos), [tor](https://github.com/Start9Labs/tor-startos), [synapse](https://github.com/Start9Labs/synapse-startos)
2 changes: 2 additions & 0 deletions projects/start-sdk/docs/src/service-to-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ Three things make this correct, and each matters:
> [!NOTE]
> Packages written before start-sdk 2.0.8 carry a local `bridgeAddress` helper in their `utils.ts` doing this by hand. Delete it and call `sdk.host.getBridgeAddress` instead.

When a dependency [retires](interfaces.md#retiring-a-host-or-binding) the host or binding you resolve, it disappears from the database and `getBridgeAddress` resolves `null` — the same path as the dependency not being installed, so rule 3 above already covers it. With `fallbackPort` you get the fallback instead, as always.

## The Tor exception: always-on flags

Some flags should be passed **unconditionally**, even when the dependency is absent — most commonly Bitcoin's `-onion=<tor SOCKS>`. A dead bridge address there is harmless (connection refused), and passing the flag always means Tor works the moment it's installed with no reconfiguration.
Expand Down
2 changes: 2 additions & 0 deletions projects/start-sdk/docs/src/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,5 @@ A user already stuck in either state can only be recovered from the CLI, naming
```bash
start-cli package action clear-task <creating-package> '<replay-id>' --force
```

Network state has the same shape and the same remedy — see [Retiring a Host or Binding](interfaces.md#retiring-a-host-or-binding).
4 changes: 3 additions & 1 deletion projects/start-sdk/lib/StartSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ import { createVolumes } from './util/Volume'
import { getDataVersion, setDataVersion } from './version'

/** The minimum StartOS version required by this SDK release */
export const OSVersion = testTypeVersion('0.4.0')
export const OSVersion = testTypeVersion('0.4.0.2')

// prettier-ignore
type AnyNeverCond<T extends any[], Then, Else> =
Expand Down Expand Up @@ -144,6 +144,8 @@ export class StartSdk<Manifest extends T.SDKManifest> {
| 'clearServiceInterfaces'
| 'bind'
| 'bindRange'
| 'retireHost'
| 'retireBinding'
| 'getHostInfo'
type MainUsedEffects = 'setMainStatus'
type CallbackEffects =
Expand Down
23 changes: 23 additions & 0 deletions projects/start-sdk/lib/test/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ describe('host', () => {
void _typecheck
})

describe('MultiHost.retire / retirePort', () => {
test('retire forwards the host id', async () => {
const retireHost = jest.fn(async () => true)
const host = sdk.MultiHost.of({ retireHost } as unknown as Effects, 'ui')
await expect(host.retire()).resolves.toBe(true)
expect(retireHost).toHaveBeenCalledWith({ id: 'ui' })
})

// The positional port becomes a named field — the shape most likely to rot.
test('retirePort forwards the host id and the port', async () => {
const retireBinding = jest.fn(async () => false)
const host = sdk.MultiHost.of(
{ retireBinding } as unknown as Effects,
'api',
)
await expect(host.retirePort(9090)).resolves.toBe(false)
expect(retireBinding).toHaveBeenCalledWith({
id: 'api',
internalPort: 9090,
})
})
})

describe('MultiHost.bindPortRange', () => {
const fakeEffects = (
bindRange: jest.Mock = jest.fn(async () => null),
Expand Down
13 changes: 8 additions & 5 deletions shared-libs/crates/start-core/src/net/host/address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -607,9 +607,10 @@ pub async fn remove_public_domain<Kind: HostApiKind>(
let fqdn = InternedString::intern(fqdn.to_ascii_lowercase());
ctx.db
.mutate(|db| {
Kind::host_for(&inheritance, db)?
.as_public_domains_mut()
.remove(&fqdn)?;
let Some(host) = Kind::host_for_existing(&inheritance, db)? else {
return Ok(());
};
host.as_public_domains_mut().remove(&fqdn)?;
let hostname = ServerHostname::load(db.as_public().as_server_info())?;
let gateways = db
.as_public()
Expand Down Expand Up @@ -698,8 +699,10 @@ pub async fn remove_private_domain<Kind: HostApiKind>(
let domain = InternedString::intern(domain.to_ascii_lowercase());
ctx.db
.mutate(|db| {
Kind::host_for(&inheritance, db)?
.as_private_domains_mut()
let Some(host) = Kind::host_for_existing(&inheritance, db)? else {
return Ok(());
};
host.as_private_domains_mut()
.mutate(|d| Ok(d.remove(&domain)))?;
let hostname = ServerHostname::load(db.as_public().as_server_info())?;
let gateways = db
Expand Down
Loading