diff --git a/web/db/migrations/20260719120000_iroh_direct_ports/migration.sql b/web/db/migrations/20260719120000_iroh_direct_ports/migration.sql new file mode 100644 index 000000000000..ce087bf96429 --- /dev/null +++ b/web/db/migrations/20260719120000_iroh_direct_ports/migration.sql @@ -0,0 +1,13 @@ +ALTER TABLE "iroh_endpoint_bindings" + ADD COLUMN "direct_port_v4" integer; +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + ADD COLUMN "direct_port_v6" integer; +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + ADD CONSTRAINT "iroh_endpoint_bindings_direct_port_v4_check" + CHECK ("direct_port_v4" IS NULL OR "direct_port_v4" BETWEEN 1 AND 65535); +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + ADD CONSTRAINT "iroh_endpoint_bindings_direct_port_v6_check" + CHECK ("direct_port_v6" IS NULL OR "direct_port_v6" BETWEEN 1 AND 65535); diff --git a/web/db/schema.ts b/web/db/schema.ts index 0110a7c18043..f67c44d85e13 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -723,6 +723,8 @@ export const irohEndpointBindings = pgTable( identityGeneration: integer("identity_generation").notNull(), pairingEnabled: boolean("pairing_enabled").notNull().default(false), capabilities: jsonb("capabilities").$type().notNull().default(sql`'[]'::jsonb`), + directPortV4: integer("direct_port_v4"), + directPortV6: integer("direct_port_v6"), pathHints: jsonb("path_hints").$type().notNull().default(sql`'[]'::jsonb`), pathHintsNextExpiry: timestamp("path_hints_next_expiry", { withTimezone: true }), deviceLimitOverrideUsed: boolean("device_limit_override_used").notNull().default(false), @@ -739,6 +741,8 @@ export const irohEndpointBindings = pgTable( check("iroh_endpoint_bindings_platform_check", sql`${table.platform} in ('mac', 'ios')`), check("iroh_endpoint_bindings_display_name_check", sql`${table.displayName} is null or ${table.displayName} !~ '[[:cntrl:]]'`), check("iroh_endpoint_bindings_capabilities_check", sql`jsonb_typeof(${table.capabilities}) = 'array' and jsonb_array_length(${table.capabilities}) <= 32`), + check("iroh_endpoint_bindings_direct_port_v4_check", sql`${table.directPortV4} is null or ${table.directPortV4} between 1 and 65535`), + check("iroh_endpoint_bindings_direct_port_v6_check", sql`${table.directPortV6} is null or ${table.directPortV6} between 1 and 65535`), check("iroh_endpoint_bindings_path_hints_check", sql`jsonb_typeof(${table.pathHints}) = 'array' and jsonb_array_length(${table.pathHints}) <= 16`), uniqueIndex("iroh_endpoint_bindings_active_endpoint_unique") .on(table.endpointId) diff --git a/web/services/iroh/README.md b/web/services/iroh/README.md index f8a74ccf430a..b0c55f66c583 100644 --- a/web/services/iroh/README.md +++ b/web/services/iroh/README.md @@ -10,6 +10,13 @@ persists and publishes only exact managed relay URLs. Endpoint or identity-generation replacement requires explicit revocation and reapproval; a signature from only the proposed new key cannot replace an active binding. +A signed registration may publish `directPorts` with independent IPv4 and IPv6 +UDP ports. The broker stores only those bounded ports, never a private address, +and returns them as `direct_ports` only inside the authenticated same-account +binding catalog. Clients combine a family-matching port with locally known +addresses after EndpointID authentication. Legacy registrations omit the field +and clear any previously published ports on their next signed refresh. + `relay_fleet` is the server-configured connection preset/allowlist. It is not a peer address. Each peer's published `relay_url` comes from its signed `watch_addr` payload and must match that allowlist. Discovery defensively diff --git a/web/services/iroh/model.ts b/web/services/iroh/model.ts index 5e8662d5bfdd..62da8b016eda 100644 --- a/web/services/iroh/model.ts +++ b/web/services/iroh/model.ts @@ -32,6 +32,11 @@ export type IrohPathHint = { }; }; +export type IrohDirectPorts = { + readonly ipv4?: number; + readonly ipv6?: number; +}; + export type IrohRegistrationPayload = { readonly route_contract_version: typeof IROH_ROUTE_CONTRACT_VERSION; readonly deviceId: string; @@ -43,6 +48,7 @@ export type IrohRegistrationPayload = { readonly identityGeneration: number; readonly pairingEnabled: boolean; readonly capabilities: readonly string[]; + readonly directPorts?: IrohDirectPorts; readonly pathHints: readonly IrohPathHint[]; }; @@ -157,6 +163,9 @@ export function parseRegistrationPayload(value: unknown, now: Date): IrohRegistr identityGeneration: positiveInteger(body.identityGeneration, "invalid_identity_generation"), pairingEnabled: boolean(body.pairingEnabled, "invalid_pairing_enabled"), capabilities, + ...(body.directPorts === undefined + ? {} + : { directPorts: parseIrohDirectPorts(body.directPorts) }), pathHints, }; rejectUnknownKeys(body, [ @@ -170,11 +179,29 @@ export function parseRegistrationPayload(value: unknown, now: Date): IrohRegistr "identityGeneration", "pairingEnabled", "capabilities", + "directPorts", "pathHints", ]); return payload; } +export function parseIrohDirectPorts(value: unknown): IrohDirectPorts { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new IrohInvalidInputError({ code: "invalid_direct_ports" }); + } + const ports = value as Record; + rejectUnknownKeys(ports, ["ipv4", "ipv6"]); + const ipv4 = ports.ipv4 === undefined ? undefined : udpPort(ports.ipv4); + const ipv6 = ports.ipv6 === undefined ? undefined : udpPort(ports.ipv6); + if (ipv4 === undefined && ipv6 === undefined) { + throw new IrohInvalidInputError({ code: "invalid_direct_ports" }); + } + return { + ...(ipv4 === undefined ? {} : { ipv4 }), + ...(ipv6 === undefined ? {} : { ipv6 }), + }; +} + export function parseBindingIdBody(value: unknown): { readonly bindingId: string } { const body = record(value); const result = { bindingId: uuid(body.bindingId, "invalid_binding_id") }; @@ -547,6 +574,13 @@ function positiveInteger(value: unknown, code: string): number { return value as number; } +function udpPort(value: unknown): number { + if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > 65_535) { + throw new IrohInvalidInputError({ code: "invalid_direct_ports" }); + } + return value as number; +} + function boundedString(value: unknown, min: number, max: number, code: string): string { if (typeof value !== "string" || value.length < min || value.length > max) { throw new IrohInvalidInputError({ code }); diff --git a/web/services/iroh/repository.ts b/web/services/iroh/repository.ts index 19f0bebaffaa..ebe415014707 100644 --- a/web/services/iroh/repository.ts +++ b/web/services/iroh/repository.ts @@ -307,6 +307,8 @@ function makeLiveRepository(): IrohRepositoryShape { displayName: input.payload.displayName ?? null, pairingEnabled: input.payload.pairingEnabled, capabilities: [...input.payload.capabilities], + directPortV4: input.payload.directPorts?.ipv4 ?? null, + directPortV6: input.payload.directPorts?.ipv6 ?? null, pathHints: accountPrivatePathHints, pathHintsNextExpiry: nextPathHintExpiry(accountPrivatePathHints), lastSeenAt: input.now, @@ -401,6 +403,8 @@ function makeLiveRepository(): IrohRepositoryShape { identityGeneration: input.payload.identityGeneration, pairingEnabled: input.payload.pairingEnabled, capabilities: [...input.payload.capabilities], + directPortV4: input.payload.directPorts?.ipv4 ?? null, + directPortV6: input.payload.directPorts?.ipv6 ?? null, pathHints: accountPrivatePathHints, pathHintsNextExpiry: nextPathHintExpiry(accountPrivatePathHints), deviceLimitOverrideUsed: usesDeviceOverride, @@ -933,6 +937,8 @@ async function revokeActiveBindings( .set({ revokedAt: input.now, revokedReason: input.reason, + directPortV4: null, + directPortV6: null, pathHints: [], pathHintsNextExpiry: null, updatedAt: input.now, @@ -1008,7 +1014,11 @@ async function drainIrohRetention(input: { select id from iroh_endpoint_bindings where revoked_at is not null - and path_hints_next_expiry is not null + and ( + path_hints_next_expiry is not null + or direct_port_v4 is not null + or direct_port_v6 is not null + ) order by revoked_at, id limit ${limit} for update skip locked @@ -1016,6 +1026,8 @@ async function drainIrohRetention(input: { update iroh_endpoint_bindings as binding set path_hints = '[]'::jsonb, path_hints_next_expiry = null, + direct_port_v4 = null, + direct_port_v6 = null, updated_at = ${nowIso}::timestamptz from candidates where binding.id = candidates.id diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index fea44a9655c3..bb3895200ad4 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -467,6 +467,14 @@ function publicBinding( identity_generation: binding.identityGeneration, pairing_enabled: binding.pairingEnabled, capabilities: binding.capabilities, + ...(binding.directPortV4 === null && binding.directPortV6 === null + ? {} + : { + direct_ports: { + ...(binding.directPortV4 === null ? {} : { ipv4: binding.directPortV4 }), + ...(binding.directPortV6 === null ? {} : { ipv6: binding.directPortV6 }), + }, + }), path_hints: accountPrivateIrohPathHints(binding.pathHints.flatMap((hint): IrohPathHint[] => { try { return [parseIrohPathHint(hint, now)]; diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index c8716f57f900..a10dc1ac8e6d 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -585,6 +585,97 @@ describe("Iroh trust broker database behavior", () => { expect(stored?.nextExpiry).toEqual(directExpiry); }); + dbTest("persists, updates, and clears family-specific direct ports", async () => { + const repo = requiredRepository(); + const userId = "user-direct-ports"; + const deviceId = randomUUID(); + const appInstanceId = randomUUID(); + const endpointId = "4f".repeat(32); + type DirectPorts = { readonly ipv4?: number; readonly ipv6?: number }; + + const register = async ( + directPorts: DirectPorts | undefined, + sequence: number, + ): Promise => { + const now = new Date(NOW.getTime() + sequence * 1_000); + const nonceHash = sequence.toString(16).padStart(64, "0"); + const challenge = await Effect.runPromise(repo.issueChallenge({ + userId, + deviceUuid: deviceId, + appInstanceId, + tag: "stable", + endpointId, + identityGeneration: 1, + payloadSha256: (sequence + 10).toString(16).padStart(64, "0"), + nonceHash, + now, + expiresAt: new Date(now.getTime() + 5 * 60 * 1_000), + })); + const payload: Parameters< + IrohRepositoryShape["consumeChallengeAndRegister"] + >[0]["payload"] & { readonly directPorts?: DirectPorts } = { + route_contract_version: 1, + deviceId, + appInstanceId, + tag: "stable", + platform: "mac", + endpointId, + identityGeneration: 1, + pairingEnabled: true, + capabilities: [], + ...(directPorts ? { directPorts } : {}), + pathHints: [], + }; + await Effect.runPromise(repo.consumeChallengeAndRegister({ + userId, + challengeId: challenge.id, + nonceHash, + payload, + now, + bindingQuota: { account: 32, device: 8, baselineDevice: 8, staleAfterMs: null }, + })); + }; + + await register({ ipv4: 49_152, ipv6: 49_153 }, 1); + let [stored] = await requiredSql()>` + select + direct_port_v4 as "directPortV4", + direct_port_v6 as "directPortV6" + from iroh_endpoint_bindings + where app_instance_id = ${appInstanceId} + `; + expect(stored).toEqual({ directPortV4: 49_152, directPortV6: 49_153 }); + + await register({ ipv6: 50_000 }, 2); + [stored] = await requiredSql()>` + select + direct_port_v4 as "directPortV4", + direct_port_v6 as "directPortV6" + from iroh_endpoint_bindings + where app_instance_id = ${appInstanceId} + `; + expect(stored).toEqual({ directPortV4: null, directPortV6: 50_000 }); + + await register(undefined, 3); + [stored] = await requiredSql()>` + select + direct_port_v4 as "directPortV4", + direct_port_v6 as "directPortV6" + from iroh_endpoint_bindings + where app_instance_id = ${appInstanceId} + `; + expect(stored).toEqual({ directPortV4: null, directPortV6: null }); + }); + dbTest("requires revocation before changing an active binding platform", async () => { const repo = requiredRepository(); const userId = "user-platform-change"; @@ -785,6 +876,73 @@ describe("Iroh trust broker database behavior", () => { `, "22003"); }); + dbTest("enforces the UDP port range for each direct-address family", async () => { + const bindingId = await insertBinding({ + userId: "user-direct-port-checks", + endpointId: "4e".repeat(32), + }); + await expectPostgresError(requiredSql()` + update iroh_endpoint_bindings set direct_port_v4 = 0 where id = ${bindingId} + `, "23514"); + await expectPostgresError(requiredSql()` + update iroh_endpoint_bindings set direct_port_v4 = 65536 where id = ${bindingId} + `, "23514"); + await expectPostgresError(requiredSql()` + update iroh_endpoint_bindings set direct_port_v6 = 0 where id = ${bindingId} + `, "23514"); + await expectPostgresError(requiredSql()` + update iroh_endpoint_bindings set direct_port_v6 = 65536 where id = ${bindingId} + `, "23514"); + + await requiredSql()` + update iroh_endpoint_bindings + set direct_port_v4 = 1, direct_port_v6 = 65535 + where id = ${bindingId} + `; + const [stored] = await requiredSql()>` + select + direct_port_v4 as "directPortV4", + direct_port_v6 as "directPortV6" + from iroh_endpoint_bindings + where id = ${bindingId} + `; + expect(stored).toEqual({ directPortV4: 1, directPortV6: 65_535 }); + }); + + dbTest("scrubs direct ports when a binding is revoked", async () => { + const repo = requiredRepository(); + const bindingId = await insertBinding({ + userId: "user-revoked-direct-ports", + endpointId: "4d".repeat(32), + }); + await requiredSql()` + update iroh_endpoint_bindings + set direct_port_v4 = 49_152, direct_port_v6 = 49_153 + where id = ${bindingId} + `; + + expect(await Effect.runPromise(repo.revokeBinding({ + userId: "user-revoked-direct-ports", + bindingId, + now: NOW, + }))).toBe(true); + + const [stored] = await requiredSql()>` + select + direct_port_v4 as "directPortV4", + direct_port_v6 as "directPortV6" + from iroh_endpoint_bindings + where id = ${bindingId} + `; + expect(stored).toEqual({ directPortV4: null, directPortV6: null }); + }); + dbTest("keeps LAN discovery account-scoped and coherent across binding revocation", async () => { const repo = requiredRepository(); const userId = "user-lan-revoke"; @@ -1186,11 +1344,23 @@ describe("Iroh trust broker database behavior", () => { userId: "user-retention", endpointId: "83".repeat(32), }); + const legacyRevokedId = await insertBinding({ + userId: "user-retention", + endpointId: "84".repeat(32), + }); await requiredSql()` update iroh_endpoint_bindings set revoked_at = ${new Date(NOW.getTime() - 31 * 24 * 60 * 60 * 1_000)} where id = ${oldRevokedId} `; + await requiredSql()` + update iroh_endpoint_bindings + set + revoked_at = ${NOW}, + direct_port_v4 = 49_152, + direct_port_v6 = 49_153 + where id = ${legacyRevokedId} + `; const [untouchedBefore] = await requiredSql()>` select updated_at as "updatedAt" from iroh_endpoint_bindings where id = ${untouchedId} `; @@ -1213,6 +1383,17 @@ describe("Iroh trust broker database behavior", () => { `; expect(rows.find((row) => row.id === activeId)?.pathHints).toHaveLength(1); expect(rows.find((row) => row.id === revokedId)?.pathHints).toEqual([]); + const [legacyRevoked] = await requiredSql()>` + select + direct_port_v4 as "directPortV4", + direct_port_v6 as "directPortV6" + from iroh_endpoint_bindings + where id = ${legacyRevokedId} + `; + expect(legacyRevoked).toEqual({ directPortV4: null, directPortV6: null }); const [grant] = await requiredSql()>` select revoked_at as "revokedAt" from iroh_pair_grant_issuances where acceptor_binding_id = ${revokedId} `; diff --git a/web/tests/iroh-model-crypto.test.ts b/web/tests/iroh-model-crypto.test.ts index e1e5a959e89e..a217de654e69 100644 --- a/web/tests/iroh-model-crypto.test.ts +++ b/web/tests/iroh-model-crypto.test.ts @@ -150,6 +150,42 @@ describe("Iroh route wire contract", () => { expect(parseRegistrationPayload(registrationPayload(hint), NOW).pathHints[0]).toEqual(hint); }); + test("accepts independent IPv4 and IPv6 UDP ports while preserving legacy omission", () => { + const safeDirectHint = directHint({ value: "8.8.8.8:4433" }); + const legacy = parseRegistrationPayload(registrationPayload(safeDirectHint), NOW); + expect("directPorts" in legacy).toBe(false); + + for (const directPorts of [ + { ipv4: 49_152 }, + { ipv6: 49_153 }, + { ipv4: 49_152, ipv6: 49_153 }, + ]) { + const parsed = parseRegistrationPayload({ + ...registrationPayload(safeDirectHint), + directPorts, + }, NOW) as unknown as { directPorts?: unknown }; + expect(parsed.directPorts).toEqual(directPorts); + } + }); + + for (const directPorts of [ + {}, + { ipv4: 0 }, + { ipv4: 65_536 }, + { ipv4: 4_433.5 }, + { ipv6: 0 }, + { ipv6: 65_536 }, + { ipv6: 4_433.5 }, + { ipv4: 4_433, extra: 4_434 }, + ]) { + test(`rejects invalid direct ports ${JSON.stringify(directPorts)}`, () => { + expect(() => parseRegistrationPayload({ + ...registrationPayload(directHint({ value: "8.8.8.8:4433" })), + directPorts, + }, NOW)).toThrow(); + }); + } + test("allows globally routed address space inside an explicit custom VPN profile", () => { const hint = directHint({ value: "8.8.4.4:4433", diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index ef298775a554..8a0f9051cf7e 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -37,6 +37,10 @@ import type { RelayPreference } from "../services/relay/model"; const NOW = new Date("2026-07-09T20:00:00.000Z"); const USER_A = "user-a"; const USER_B = "user-b"; +type TestDirectPorts = { + readonly ipv4?: number; + readonly ipv6?: number; +}; describe("Iroh trust broker registration", () => { test("registers a valid endpoint proof and mints relay credentials after commit", async () => { @@ -60,6 +64,64 @@ describe("Iroh trust broker registration", () => { expect(fixture.minter.calls).toBe(1); }); + test("persists and publishes signed family-specific direct ports to the same account", async () => { + const fixture = makeFixture({ + registrationDirectPorts: { ipv4: 49_152, ipv6: 49_153 }, + }); + const registered = await Effect.runPromise(fixture.broker.register( + USER_A, + await fixture.signedRegistration(), + NOW, + )) as { binding: { direct_ports?: TestDirectPorts } }; + + expect(registered.binding.direct_ports).toEqual({ ipv4: 49_152, ipv6: 49_153 }); + expect(fixture.repository.bindings[0]).toMatchObject({ + directPortV4: 49_152, + directPortV6: 49_153, + }); + + const sameAccount = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + bindings: Array<{ direct_ports?: TestDirectPorts }>; + }; + expect(sameAccount.bindings[0]?.direct_ports).toEqual({ ipv4: 49_152, ipv6: 49_153 }); + + const otherAccount = await Effect.runPromise(fixture.broker.discover(USER_B, NOW)) as { + bindings: Array<{ direct_ports?: TestDirectPorts }>; + }; + expect(otherAccount.bindings).toEqual([]); + }); + + test("updates or clears direct ports on a fresh signed registration", async () => { + const fixture = makeFixture({ registrationDirectPorts: { ipv4: 49_152 } }); + await Effect.runPromise(fixture.broker.register( + USER_A, + await fixture.signedRegistration(), + NOW, + )); + + const ipv6Only = await Effect.runPromise(fixture.broker.register( + USER_A, + await fixture.signedRegistration("mac", { ipv6: 49_153 }), + new Date(NOW.getTime() + 1_000), + )) as { binding: { direct_ports?: TestDirectPorts } }; + expect(ipv6Only.binding.direct_ports).toEqual({ ipv6: 49_153 }); + expect(fixture.repository.bindings[0]).toMatchObject({ + directPortV4: null, + directPortV6: 49_153, + }); + + const legacyRefresh = await Effect.runPromise(fixture.broker.register( + USER_A, + await fixture.signedRegistration("mac", null), + new Date(NOW.getTime() + 2_000), + )) as { binding: Record }; + expect("direct_ports" in legacyRefresh.binding).toBe(false); + expect(fixture.repository.bindings[0]).toMatchObject({ + directPortV4: null, + directPortV6: null, + }); + }); + test("preserves account-private routes while filtering unsafe registration hints", async () => { const publicDirectHint: IrohRegistrationPayload["pathHints"][number] = { kind: "direct_address", @@ -770,7 +832,11 @@ describe("developer binding override", () => { }); }); -type MutableBinding = IrohBindingRecord & { userId: string }; +type MutableBinding = IrohBindingRecord & { + userId: string; + directPortV4: number | null; + directPortV6: number | null; +}; class MemoryRepository implements IrohRepositoryShape { readonly challenges: IrohChallengeRecord[] = []; @@ -828,6 +894,11 @@ class MemoryRepository implements IrohRepositoryShape { existing.platform !== input.payload.platform ) return Effect.fail(new IrohConflictError({ code: "binding_replacement_requires_revocation" })); challenge.consumedAt = input.now; + const directPorts = (input.payload as IrohRegistrationPayload & { + directPorts?: TestDirectPorts; + }).directPorts; + existing.directPortV4 = directPorts?.ipv4 ?? null; + existing.directPortV6 = directPorts?.ipv6 ?? null; existing.pathHints = [...input.payload.pathHints]; existing.lastSeenAt = input.now; existing.updatedAt = input.now; @@ -882,6 +953,12 @@ class MemoryRepository implements IrohRepositoryShape { identityGeneration: input.payload.identityGeneration, pairingEnabled: input.payload.pairingEnabled, capabilities: [...input.payload.capabilities], + directPortV4: (input.payload as IrohRegistrationPayload & { + directPorts?: TestDirectPorts; + }).directPorts?.ipv4 ?? null, + directPortV6: (input.payload as IrohRegistrationPayload & { + directPorts?: TestDirectPorts; + }).directPorts?.ipv6 ?? null, pathHints: [...input.payload.pathHints], deviceLimitOverrideUsed: activeDevice.length >= input.bindingQuota.baselineDevice, registeredAt: input.now, @@ -1062,6 +1139,7 @@ function makeFixture(options: { identityGeneration?: number; relayPreference?: RelayPreference; registrationPathHints?: IrohRegistrationPayload["pathHints"]; + registrationDirectPorts?: TestDirectPorts; developmentBindingLimits?: { account: number; device: number; @@ -1128,8 +1206,11 @@ function makeFixture(options: { setRelayPreference(next: RelayPreference) { relayPreference = next; }, - async signedRegistration(platform: "mac" | "ios" = "mac") { - const payload: IrohRegistrationPayload = { + async signedRegistration( + platform: "mac" | "ios" = "mac", + directPorts: TestDirectPorts | null | undefined = options.registrationDirectPorts, + ) { + const payload: IrohRegistrationPayload & { directPorts?: TestDirectPorts } = { route_contract_version: 1, deviceId, appInstanceId, @@ -1140,6 +1221,7 @@ function makeFixture(options: { identityGeneration, pairingEnabled: true, capabilities: ["terminal", "artifacts"], + ...(directPorts ? { directPorts } : {}), pathHints: options.registrationPathHints ?? [{ kind: "direct_address", value: "8.8.8.8:4433", @@ -1197,6 +1279,8 @@ function binding(overrides: Partial = {}): MutableBinding { identityGeneration: 1, pairingEnabled: true, capabilities: [], + directPortV4: null, + directPortV6: null, pathHints: [], pathHintsNextExpiry: null, deviceLimitOverrideUsed: false,