Skip to content
Closed
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
5 changes: 4 additions & 1 deletion projects/start-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion projects/start-sdk/docs/src/interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion projects/start-sdk/docs/src/service-to-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions projects/start-sdk/lib/test/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions shared-libs/crates/start-core/src/db/model/public.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions shared-libs/crates/start-core/src/net/host/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PortForward>,
/// Internal ports the service retired, each with the port it named as its successor.
#[serde(default)]
pub retired_bindings: BTreeMap<u16, Option<u16>>,
}

fn default_port_forward_count() -> u16 {
Expand Down Expand Up @@ -666,6 +669,10 @@ impl Model<Host> {
{
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)?
Expand Down Expand Up @@ -707,6 +714,10 @@ impl Model<Host> {
{
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
Expand Down Expand Up @@ -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();
Expand Down
15 changes: 13 additions & 2 deletions shared-libs/crates/start-core/src/net/net_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool, Error> {
pub async fn retire_binding(
&self,
id: HostId,
internal_port: u16,
successor: Option<u16>,
) -> Result<bool, Error> {
let (ctrl, pkg_id) = {
let data = self.data.lock().await;
(data.net_controller()?, data.id.clone())
Expand Down Expand Up @@ -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)?;
Expand Down
10 changes: 8 additions & 2 deletions shared-libs/crates/start-core/src/service/effects/net/bind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,18 +128,24 @@ pub async fn retire_host(
pub struct RetireBindingParams {
pub id: HostId,
pub internal_port: u16,
#[ts(optional)]
pub successor: Option<u16>,
}

pub async fn retire_binding(
context: EffectContext,
RetireBindingParams { id, internal_port }: RetireBindingParams,
RetireBindingParams {
id,
internal_port,
successor,
}: RetireBindingParams,
) -> Result<bool, Error> {
let context = context.deref()?;
context
.seed
.persistent_container
.net_service
.retire_binding(id, internal_port)
.retire_binding(id, internal_port, successor)
.await
}

Expand Down
3 changes: 2 additions & 1 deletion shared-libs/ts-modules/start-core/lib/Effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*/
Expand Down
12 changes: 11 additions & 1 deletion shared-libs/ts-modules/start-core/lib/interfaces/Host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,19 +279,29 @@ 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.
*
* @example
* ```
* // 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<boolean> {
async retirePort(
internalPort: number,
options?: { successor?: number },
): Promise<boolean> {
return this.options.effects.retireBinding({
id: this.options.id,
internalPort,
successor: options?.successor,
})
}

Expand Down
Loading