diff --git a/projects/start-sdk/CHANGELOG.md b/projects/start-sdk/CHANGELOG.md index 0bded4cb7d..0c69b74e46 100644 --- a/projects/start-sdk/CHANGELOG.md +++ b/projects/start-sdk/CHANGELOG.md @@ -101,7 +101,10 @@ - **`MultiHost.retire()` and `MultiHost.retirePort()` permanently remove a host or a binding**, returning its external ports to the server's pool — where `setupInterfaces` otherwise only _disables_ what it did not declare. Call it - from the `up()` of the version that stops binding; re-running is safe. See + from the `up()` of the version that stops binding; re-running is safe. + `retirePort(old, { successor })` records the port the binding moved to in + the host's `retiredBindings`, which a URL plugin reads to move an address it + holds for the old port rather than park it. 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**, for a diff --git a/projects/start-sdk/docs/src/interfaces.md b/projects/start-sdk/docs/src/interfaces.md index c07a060c34..99fba7a30c 100644 --- a/projects/start-sdk/docs/src/interfaces.md +++ b/projects/start-sdk/docs/src/interfaces.md @@ -371,10 +371,13 @@ The cost is that a binding you stop declaring **for good** stays behind. It keep ```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 +await sdk.MultiHost.of(effects, 'peer').retirePort(8333, { successor: 58333 }) // moved to 58333 ``` `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. +A port that moved rather than disappeared takes its `successor`: `retirePort(8333, { successor: 58333 })` records on the host, in `retiredBindings`, that 8333 is now served at 58333. StartOS itself acts on nothing there, but a URL plugin holding an address for the old port does — Tor moves a `.onion` attached to 8333 onto 58333, where without the record it parks the address until the user moves it by hand. Omit `successor` when nothing replaces the port; a `.onion` attached to it then parks. The record is dropped if the port is ever bound again. + 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 @@ -412,7 +415,7 @@ This is the same shape as [retiring a replay key](tasks.md#retiring-a-replay-key ### 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. +- **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, and pass the new port as `successor` so a Tor address follows it. - **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. diff --git a/projects/start-sdk/docs/src/service-to-service.md b/projects/start-sdk/docs/src/service-to-service.md index a822d76995..36ec364415 100644 --- a/projects/start-sdk/docs/src/service-to-service.md +++ b/projects/start-sdk/docs/src/service-to-service.md @@ -63,7 +63,7 @@ Three things make this correct, and each matters: `getBridgeAddress` returns the same `Watchable` as `sdk.host.get`, so it carries every read strategy. Use `.const()` in `setupMain` and `setupOnInit`; use `.once()` only inside an action, where a live snapshot rather than a subscription is what you want. -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. +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. If the port moved rather than went away, the dependency's host names the new one in `retiredBindings`. ## The Tor exception: always-on flags diff --git a/projects/start-sdk/lib/test/host.test.ts b/projects/start-sdk/lib/test/host.test.ts index fc99d98076..d43b96f2f8 100644 --- a/projects/start-sdk/lib/test/host.test.ts +++ b/projects/start-sdk/lib/test/host.test.ts @@ -133,6 +133,22 @@ describe('host', () => { internalPort: 9090, }) }) + + test('retirePort forwards the successor it was given', async () => { + const retireBinding = jest.fn(async () => true) + const host = sdk.MultiHost.of( + { retireBinding } as unknown as Effects, + 'peer', + ) + await expect(host.retirePort(8333, { successor: 58333 })).resolves.toBe( + true, + ) + expect(retireBinding).toHaveBeenCalledWith({ + id: 'peer', + internalPort: 8333, + successor: 58333, + }) + }) }) describe('MultiHost.bindPortRange', () => { diff --git a/shared-libs/crates/start-core/src/db/model/public.rs b/shared-libs/crates/start-core/src/db/model/public.rs index 3126058870..87be920ade 100644 --- a/shared-libs/crates/start-core/src/db/model/public.rs +++ b/shared-libs/crates/start-core/src/db/model/public.rs @@ -98,6 +98,7 @@ impl Public { public_domains: BTreeMap::new(), private_domains: BTreeMap::new(), port_forwards: BTreeSet::new(), + retired_bindings: BTreeMap::new(), }, wifi: WifiInfo { enabled: false, diff --git a/shared-libs/crates/start-core/src/net/host/mod.rs b/shared-libs/crates/start-core/src/net/host/mod.rs index 75b5f8b981..60518b5924 100644 --- a/shared-libs/crates/start-core/src/net/host/mod.rs +++ b/shared-libs/crates/start-core/src/net/host/mod.rs @@ -44,6 +44,9 @@ pub struct Host { /// COMPUTED: port forwarding rules needed on gateways for public addresses to work. #[serde(default)] pub port_forwards: BTreeSet, + /// Internal ports the service retired, each with the port it named as its successor. + #[serde(default)] + pub retired_bindings: BTreeMap>, } fn default_port_forward_count() -> u16 { @@ -666,6 +669,10 @@ impl Model { { return Err(overlap_error(&claim, &existing)); } + self.as_retired_bindings_mut().mutate(|r| { + r.remove(&internal_port); + Ok(()) + })?; self.as_bindings_mut().mutate(|b| { let info = if let Some(info) = b.remove(&internal_port) { info.update(available_ports, options, privileged)? @@ -707,6 +714,10 @@ impl Model { { return Err(overlap_error(&claim, &existing)); } + self.as_retired_bindings_mut().mutate(|r| { + r.remove(&internal_start_port); + Ok(()) + })?; self.as_binding_ranges_mut().mutate(|ranges| { let existing = ranges.get(&internal_start_port); // Idempotent re-bind: a package may call bindPortRange on every @@ -1061,6 +1072,26 @@ mod tests { assert!(ports.try_alloc(40000, false, false).is_some()); } + #[test] + fn binding_a_retired_port_again_drops_its_tombstone() { + let mut ports = AvailablePorts::new(); + let mut host = host(); + host.as_retired_bindings_mut() + .mutate(|r| { + r.insert(8333, Some(58333)); + r.insert(28332, None); + Ok(()) + }) + .unwrap(); + + host.add_binding(&mut ports, 8333, plain(8333), false) + .unwrap(); + host.add_binding_range(&mut ports, 28332, 40000, 2, false) + .unwrap(); + + assert!(host.as_retired_bindings().de().unwrap().is_empty()); + } + #[test] fn a_dormant_binding_does_not_block_the_range_that_supersedes_it() { let mut ports = AvailablePorts::new(); diff --git a/shared-libs/crates/start-core/src/net/net_controller.rs b/shared-libs/crates/start-core/src/net/net_controller.rs index 4e2a0814f9..730e6d534e 100644 --- a/shared-libs/crates/start-core/src/net/net_controller.rs +++ b/shared-libs/crates/start-core/src/net/net_controller.rs @@ -1466,10 +1466,17 @@ impl NetService { /// Permanently remove one binding — or the port range at the same key, /// which `bindings` and `binding_ranges` share — and its exported service /// interfaces, returning its external ports to the pool. `false` if nothing - /// was bound at `internal_port`. + /// was bound at `internal_port`. What was retired is recorded on the host + /// with `successor`, the port the service moved it to, so a plugin holding + /// an address for the old port can follow it. /// /// The host survives, so the ordinary reconcile tears the datapath down. - pub async fn retire_binding(&self, id: HostId, internal_port: u16) -> Result { + pub async fn retire_binding( + &self, + id: HostId, + internal_port: u16, + successor: Option, + ) -> Result { let (ctrl, pkg_id) = { let data = self.data.lock().await; (data.net_controller()?, data.id.clone()) @@ -1506,6 +1513,10 @@ impl NetService { if !retired { return Ok(false); } + host.as_retired_bindings_mut().mutate(|r| { + r.insert(internal_port, successor); + Ok(()) + })?; // `port_forwards` is computed from the bindings, so it still // advertises the retired one until this re-derives it. host.update_addresses(&hostname, &gateways, &ports)?; diff --git a/shared-libs/crates/start-core/src/service/effects/net/bind.rs b/shared-libs/crates/start-core/src/service/effects/net/bind.rs index 0dcceaa994..43eedaf08f 100644 --- a/shared-libs/crates/start-core/src/service/effects/net/bind.rs +++ b/shared-libs/crates/start-core/src/service/effects/net/bind.rs @@ -128,18 +128,24 @@ pub async fn retire_host( pub struct RetireBindingParams { pub id: HostId, pub internal_port: u16, + #[ts(optional)] + pub successor: Option, } pub async fn retire_binding( context: EffectContext, - RetireBindingParams { id, internal_port }: RetireBindingParams, + RetireBindingParams { + id, + internal_port, + successor, + }: RetireBindingParams, ) -> Result { let context = context.deref()?; context .seed .persistent_container .net_service - .retire_binding(id, internal_port) + .retire_binding(id, internal_port, successor) .await } diff --git a/shared-libs/ts-modules/start-core/lib/Effects.ts b/shared-libs/ts-modules/start-core/lib/Effects.ts index c05c66bd08..c554c40c8d 100644 --- a/shared-libs/ts-modules/start-core/lib/Effects.ts +++ b/shared-libs/ts-modules/start-core/lib/Effects.ts @@ -205,7 +205,8 @@ export type Effects = { * Permanently removes whichever of the single-port binding and the port range * is bound at `internalPort` — and both, if both are — along with their * exported service interfaces. The external ports return to the server's - * pool. The host survives, keeping its domains. + * pool. The host survives, keeping its domains, and records the retired + * port with its `successor` in `retiredBindings`. * * Resolves `false` if nothing was bound at `internalPort`. */ diff --git a/shared-libs/ts-modules/start-core/lib/interfaces/Host.ts b/shared-libs/ts-modules/start-core/lib/interfaces/Host.ts index b409039f44..494e3c4f6f 100644 --- a/shared-libs/ts-modules/start-core/lib/interfaces/Host.ts +++ b/shared-libs/ts-modules/start-core/lib/interfaces/Host.ts @@ -279,6 +279,10 @@ export class MultiHost { * @param internalPort - the container-side port passed to * {@link MultiHost.bindPort}, or the `internalStartPort` passed to * {@link MultiHost.bindPortRange} + * @param options.successor - the port on this host the service now serves + * in its place. Recorded on the host, so a URL plugin such as Tor moves an + * address it holds for the old port instead of parking it. Omit it when + * nothing replaces the port. * @returns `true` if something was removed, `false` if nothing was bound * there — the normal result on a re-run, not an error. * @@ -286,12 +290,18 @@ export class MultiHost { * ``` * // upstream dropped the bundled metrics listener in 3.0 * await sdk.MultiHost.of(effects, 'api').retirePort(9090) + * // the P2P listener moved from 8333 to 58333 + * await sdk.MultiHost.of(effects, 'peer').retirePort(8333, { successor: 58333 }) * ``` */ - async retirePort(internalPort: number): Promise { + async retirePort( + internalPort: number, + options?: { successor?: number }, + ): Promise { return this.options.effects.retireBinding({ id: this.options.id, internalPort, + successor: options?.successor, }) }