fix(daemon): tailscale-aware bind auth invariant + auto multi-bind

Bug-fix slice auth-bearer-tailscale-trust. PL-005 Phase B's
`assertBindAuthInvariant` was a generic public-internet auth pattern
applied to single-developer MVP context: any non-loopback bind plus
empty bearer caused the daemon to refuse to start. For tailscale binds
this was theater — the tailnet's WireGuard mesh + ACLs are the auth
boundary. The workaround in the field was to set
`OPENRIG_AUTH_BEARER_TOKEN=tailnet-trust-sentinel` and bind 0.0.0.0,
which exposed the LAN unnecessarily.

This change:

- Makes `assertBindAuthInvariant` async and extends it to accept three
  paths: (a) loopback bind, (b) tailscale-IP bind (CGNAT IPv4
  100.64.0.0/10 or ULA IPv6 fd7a:115c:a1e0::/48), (c) explicit
  non-empty bearer token. Hostnames are resolved via
  `node:dns/promises` first; if resolution yields a loopback or
  tailnet IP, the same short-circuit applies. The 3-part error
  message now cites all three accepted paths.
- Adds `isTailscaleBind` with strict CGNAT IPv4 + ULA IPv6 matching
  (boundary tests cover 100.63.x.x false / 100.64.x.x true /
  100.127.x.x true / 100.128.x.x false; fd7a:115c:a1e0:: true; other
  IPv6 false).
- Adds `resolveToIpOrNull` with a 5s race-timeout so an unresponsive
  resolver does not stall daemon startup (Risk 9.2 in slice IMPL-PRD).
- Adds `detectTailscaleInterface` + pure `findTailscaleIpInInterfaces`
  helper. Detection is IP-based via `os.networkInterfaces()`, not
  interface-name-based, so it survives platform differences in
  tailscale tun naming.
- Updates the daemon startup bind algorithm in
  `packages/daemon/src/index.ts`:
    * When `OPENRIG_HOST` is set: single bind to that host; the
      invariant enforces bearer requirement for genuinely public/LAN
      binds while loopback/tailscale binds short-circuit.
    * When `OPENRIG_HOST` is unset (default): bind 127.0.0.1 always;
      ALSO bind the active tailscale IP when present. Multi-bind via
      N `serve()` instances sharing the same Hono app; shutdown closes
      all in parallel.

The operator can now safely `unset OPENRIG_AUTH_BEARER_TOKEN && rig
daemon restart` after upgrading: a tailnet-present host auto-binds
both 127.0.0.1 and the tailscale interface, no LAN exposure.

No new SC-29 EXCEPTION:
- `daemon.host` is already allowlisted in
  packages/cli/src/config-store.ts and
  packages/daemon/src/domain/user-settings/settings-store.ts with
  env primary `OPENRIG_HOST` (legacy `RIGGED_HOST` alias is legitimate
  per the pre-rename window).
- No new event types, no DB migrations, no new routes.

Tests:
- 39 cases in `auth-bearer-token.test.ts` (was 16; +23 new) covering
  IMPL-PRD's 7 scenarios + boundary discriminators + helper
  unit tests + the multi-bind interface detector. DNS resolution is
  mocked for predictable test runs; an intentionally-unresolvable
  hostname verifies the "DNS fails → throws" path.
- Daemon full sweep: 3306/3306 green.
- Daemon tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 03:39:55 -07:00
1 parent facb105e6b
commit cafcfa5e41
3 files changed
+407 -48

No files matched your search

+59 -18
View File
@@ -1,37 +1,64 @@
import { serve } from "@hono/node-server";
import { serve, type ServerType } from "@hono/node-server";
import { readOpenRigEnv } from "./openrig-compat.js";
import { createDaemon } from "./startup.js";
import { assertBindAuthInvariant } from "./middleware/auth-bearer-token.js";
import {
assertBindAuthInvariant,
detectTailscaleInterface,
} from "./middleware/auth-bearer-token.js";
export async function startServer(port?: number) {
const p = port ?? parseInt(readOpenRigEnv("OPENRIG_PORT", "RIGGED_PORT") ?? "7433", 10);
const dbPath = readOpenRigEnv("OPENRIG_DB", "RIGGED_DB") ?? "openrig.sqlite";
const h = readOpenRigEnv("OPENRIG_HOST", "RIGGED_HOST") ?? "127.0.0.1";
// bug-fix slice auth-bearer-tailscale-trust: distinguish "explicit
// operator opt-in" from "default" by treating an undefined env as
// default. When explicit, the operator takes responsibility (and the
// bearer invariant applies to their chosen host). When default, the
// daemon always binds loopback and ALSO binds the active tailscale
// interface (if present) — both accepted paths in the invariant.
const explicitHost = readOpenRigEnv("OPENRIG_HOST", "RIGGED_HOST");
// PL-005 Phase B: bearer token for Mission Control write verbs.
// No legacy alias (this env var is new in Phase B).
const bearerToken = process.env.OPENRIG_AUTH_BEARER_TOKEN ?? null;
// PL-005 Phase B HARD-GATE (audit row 8): refuse non-loopback bind
// when no bearer token is configured. Prevents shipping a tailnet-
// bound daemon with no auth on Mission Control write verbs.
// Throws AuthBearerTokenStartupError before any DB or HTTP work.
assertBindAuthInvariant({ host: h, bearerToken });
let bindHosts: string[];
if (explicitHost) {
// Operator opt-in path — invariant enforces bearer requirement for
// genuinely public/LAN binds; loopback/tailscale binds short-circuit.
await assertBindAuthInvariant({ host: explicitHost, bearerToken });
bindHosts = [explicitHost];
} else {
// Default path — loopback always, plus tailscale auto-add when active.
const tailscaleIp = detectTailscaleInterface();
bindHosts = tailscaleIp ? ["127.0.0.1", tailscaleIp] : ["127.0.0.1"];
}
const { app, contextMonitor, deps } = await createDaemon({ dbPath, bearerToken });
const server = serve({ fetch: app.fetch, port: p, hostname: h }, (info) => {
console.log(`OpenRig daemon listening on http://localhost:${info.port}`);
// Start context monitor polling only after successful server bind
contextMonitor.start();
// PL-004 Phase C: start watchdog scheduler. Joins the supervision
// tree post-bind so the HTTP surface is ready before the first
// tick (matches contextMonitor pattern).
deps.watchdogScheduler?.start();
});
// Multi-bind via N serve() instances sharing the same Hono app.
// Hono's serve() targets a single host:port; for multi-bind we spawn
// one per host. The contextMonitor + watchdog scheduler only start
// once after the first successful bind callback fires.
let monitorsStarted = false;
const servers: ServerType[] = [];
for (const host of bindHosts) {
const srv = serve({ fetch: app.fetch, port: p, hostname: host }, (info) => {
console.log(`OpenRig daemon listening on http://${host}:${info.port}`);
if (!monitorsStarted) {
monitorsStarted = true;
contextMonitor.start();
// PL-004 Phase C: start watchdog scheduler. Joins the supervision
// tree post-bind so the HTTP surface is ready before the first
// tick (matches contextMonitor pattern).
deps.watchdogScheduler?.start();
}
});
servers.push(srv);
}
// PL-004 Phase C: graceful shutdown — stop scheduler before process
// exit so any in-flight policy evaluation completes (or is awaited).
// Multi-bind: close every serve() instance in parallel.
const shutdown = async (sig: string) => {
console.log(`OpenRig daemon received ${sig}; shutting down`);
try {
@@ -39,12 +66,26 @@ export async function startServer(port?: number) {
} catch (err) {
console.error("[watchdog] shutdown error", err);
}
await Promise.all(
servers.map(
(srv) =>
new Promise<void>((resolve) => {
try {
srv.close(() => resolve());
} catch {
resolve();
}
}),
),
);
process.exit(0);
};
process.once("SIGINT", () => void shutdown("SIGINT"));
process.once("SIGTERM", () => void shutdown("SIGTERM"));
return server;
// Backward-compatible single-server return (callers that just need a
// handle reference; multi-bind shutdown is wired via signal handlers).
return servers[0]!;
}
// Only start the server when this file is executed directly (not imported).
@@ -23,6 +23,8 @@
import { Buffer } from "node:buffer";
import { timingSafeEqual } from "node:crypto";
import { promises as dns } from "node:dns";
import { networkInterfaces, type NetworkInterfaceInfo } from "node:os";
import type { MiddlewareHandler } from "hono";
/**
@@ -137,6 +139,88 @@ export function isLoopbackBind(host: string | undefined | null): boolean {
return false;
}
/**
* Detect tailscale CGNAT IPv4 (100.64.0.0/10) and ULA IPv6 prefix
* (fd7a:115c:a1e0::/48). Returns true for tailnet-literal IP addresses
* only; hostname callers must first resolve via {@link resolveToIpOrNull}.
*
* Rationale: tailscale's WireGuard mesh + ACLs ARE the auth boundary, so
* a tailnet-bound daemon does not need an additional bearer token. The
* CGNAT IPv4 range and ULA IPv6 prefix are reserved by tailscale and
* documented; collisions on real-world networks are essentially zero.
*
* Boundary semantics for HG-3: CGNAT is 100.64.0.0/10 → first octet
* exactly 100, second octet 64..127 inclusive. 100.63.x.x and
* 100.128.x.x are NOT tailnet.
*/
export function isTailscaleBind(host: string | undefined | null): boolean {
if (!host) return false;
const trimmed = host.trim().toLowerCase();
const ipv4Match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(trimmed);
if (ipv4Match) {
const first = parseInt(ipv4Match[1]!, 10);
const second = parseInt(ipv4Match[2]!, 10);
if (first === 100 && second >= 64 && second <= 127) return true;
return false;
}
const cleaned = trimmed.replace(/^\[|\]$/g, "");
if (cleaned.startsWith("fd7a:115c:a1e0:")) return true;
return false;
}
/**
* Pure helper for {@link detectTailscaleInterface}: takes a NetworkInterfaces
* dictionary (the shape returned by os.networkInterfaces()) and returns the
* first non-internal address that matches the tailnet IP ranges, or null.
* Factored out so tests can supply synthetic interface fixtures without
* mocking node:os.
*/
export function findTailscaleIpInInterfaces(
interfaces: NodeJS.Dict<NetworkInterfaceInfo[]>,
): string | null {
for (const addrs of Object.values(interfaces)) {
if (!addrs) continue;
for (const addr of addrs) {
if (addr.internal) continue;
if (isTailscaleBind(addr.address)) return addr.address;
}
}
return null;
}
/**
* Probe the host's network interfaces and return the first tailscale-IP
* address found (CGNAT IPv4 or tailnet ULA IPv6), or null when no tailnet
* interface is currently active. Used at daemon startup to decide whether
* to multi-bind (loopback + tailnet) or loopback-only. Detection is
* IP-based, not interface-name-based, so it survives platform differences
* in how tailscale names its tun device.
*/
export function detectTailscaleInterface(): string | null {
return findTailscaleIpInInterfaces(networkInterfaces());
}
/**
* Resolve a hostname to its first IP via node:dns/promises lookup.
* Returns null on resolution failure (DNS error, timeout, no such host).
* Wrapped with a 5s race-timeout per Risk 9.2 in the slice IMPL-PRD so an
* unresponsive resolver does not stall daemon startup indefinitely.
*/
export async function resolveToIpOrNull(host: string): Promise<string | null> {
try {
const timeout = new Promise<null>((resolve) => {
setTimeout(() => resolve(null), 5000).unref();
});
const lookup = dns
.lookup(host, { all: false })
.then((r) => r.address)
.catch(() => null);
return await Promise.race([lookup, timeout]);
} catch {
return null;
}
}
export class AuthBearerTokenStartupError extends Error {
constructor(message: string) {
super(message);
@@ -146,20 +230,35 @@ export class AuthBearerTokenStartupError extends Error {
/**
* Startup-side check (HARD-GATE audit row 8). Throws an explicit
* AuthBearerTokenStartupError when the bind interface is non-loopback
* AND the bearer token is empty. Called from index.ts BEFORE
* `serve()` listens, so the daemon refuses to start in the unsafe
* configuration.
* AuthBearerTokenStartupError when the bind interface is genuinely
* public/LAN AND the bearer token is empty. Loopback or tailscale-IP
* binds short-circuit (the tailnet is the auth boundary). Hostname
* binds resolve via DNS first; if resolution yields a loopback or
* tailnet IP, the same short-circuit applies. Public/LAN binds without
* a bearer throw and the daemon refuses to start.
*/
export function assertBindAuthInvariant(opts: {
export async function assertBindAuthInvariant(opts: {
host: string;
bearerToken: string | null;
}): void {
}): Promise<void> {
if (isLoopbackBind(opts.host)) return;
if (isTailscaleBind(opts.host)) return;
let resolvedIp: string | null = null;
// Hostname (non-IP literal) — resolve and re-check.
if (!/^[\d.]+$/.test(opts.host) && !opts.host.includes(":")) {
resolvedIp = await resolveToIpOrNull(opts.host);
if (resolvedIp) {
if (isLoopbackBind(resolvedIp)) return;
if (isTailscaleBind(resolvedIp)) return;
}
}
if (opts.bearerToken && opts.bearerToken.length > 0) return;
throw new AuthBearerTokenStartupError(
`daemon refusing to start: bind host '${opts.host}' is non-loopback but auth.bearerToken (env OPENRIG_AUTH_BEARER_TOKEN) is empty. ` +
`Either bind to 127.0.0.1 / localhost, OR set OPENRIG_AUTH_BEARER_TOKEN to a non-empty value before start. ` +
`Mission Control write verbs require bearer auth on any non-loopback bind because tailnet/public exposure without auth would let any peer on the network mutate operator state.`,
`daemon refusing to start: bind host '${opts.host}'${resolvedIp ? ` (resolves to ${resolvedIp})` : ""} is not loopback or tailscale, ` +
`and auth.bearerToken (env OPENRIG_AUTH_BEARER_TOKEN) is empty. ` +
`Either: (a) bind to 127.0.0.1 / localhost, (b) bind to a tailscale interface (100.64.0.0/10 IPv4 or fd7a:115c:a1e0::/48 IPv6), ` +
`or (c) set OPENRIG_AUTH_BEARER_TOKEN to a non-empty value before starting the daemon.`,
);
}
+240 -21
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import {
AuthBearerTokenStartupError,
@@ -6,7 +6,11 @@ import {
authBearerTokenMiddleware,
constantTimeEqual,
isLoopbackBind,
isTailscaleBind,
resolveToIpOrNull,
findTailscaleIpInInterfaces,
} from "../src/middleware/auth-bearer-token.js";
import type { NetworkInterfaceInfo } from "node:os";
describe("auth-bearer-token middleware (PL-005 Phase B)", () => {
it("constantTimeEqual returns true for equal strings", () => {
@@ -42,35 +46,250 @@ describe("auth-bearer-token middleware (PL-005 Phase B)", () => {
expect(isLoopbackBind(null)).toBe(false);
});
// HARD-GATE audit row 8.
it("HARD-GATE: assertBindAuthInvariant throws when non-loopback bind has empty bearer", () => {
expect(() =>
// HARD-GATE audit row 8 (now async per auth-bearer-tailscale-trust slice).
// Note: 100.64.0.5 is now treated as tailscale (CGNAT) per the new model;
// the public-IP scenarios (0.0.0.0) still throw.
it("HARD-GATE: assertBindAuthInvariant throws when truly public bind has empty bearer", async () => {
await expect(
assertBindAuthInvariant({ host: "0.0.0.0", bearerToken: null }),
).toThrow(AuthBearerTokenStartupError);
expect(() =>
).rejects.toThrow(AuthBearerTokenStartupError);
await expect(
assertBindAuthInvariant({ host: "0.0.0.0", bearerToken: "" }),
).toThrow(AuthBearerTokenStartupError);
expect(() =>
assertBindAuthInvariant({ host: "100.64.0.5", bearerToken: null }),
).toThrow(AuthBearerTokenStartupError);
).rejects.toThrow(AuthBearerTokenStartupError);
});
it("HARD-GATE: assertBindAuthInvariant passes when loopback bind even with empty bearer", () => {
expect(() =>
it("HARD-GATE: assertBindAuthInvariant passes when loopback bind even with empty bearer", async () => {
await expect(
assertBindAuthInvariant({ host: "127.0.0.1", bearerToken: null }),
).not.toThrow();
expect(() =>
).resolves.toBeUndefined();
await expect(
assertBindAuthInvariant({ host: "localhost", bearerToken: "" }),
).not.toThrow();
).resolves.toBeUndefined();
});
it("HARD-GATE: assertBindAuthInvariant passes when non-loopback has non-empty bearer", () => {
expect(() =>
it("HARD-GATE: assertBindAuthInvariant passes when non-loopback has non-empty bearer", async () => {
await expect(
assertBindAuthInvariant({ host: "0.0.0.0", bearerToken: "secret" }),
).not.toThrow();
expect(() =>
assertBindAuthInvariant({ host: "100.64.0.5", bearerToken: "secret" }),
).not.toThrow();
).resolves.toBeUndefined();
await expect(
assertBindAuthInvariant({ host: "192.168.1.5", bearerToken: "secret" }),
).resolves.toBeUndefined();
});
// ==================================================================
// bug-fix slice: auth-bearer-tailscale-trust (2026-05-11)
// ==================================================================
describe("isTailscaleBind (CGNAT IPv4 + ULA IPv6)", () => {
it("matches CGNAT IPv4 100.64.0.0/10 — second octet 64..127 inclusive", () => {
expect(isTailscaleBind("100.64.0.0")).toBe(true);
expect(isTailscaleBind("100.64.0.5")).toBe(true);
expect(isTailscaleBind("100.95.124.51")).toBe(true);
expect(isTailscaleBind("100.127.255.255")).toBe(true);
});
it("rejects IPv4 outside CGNAT range (HG-3 boundary)", () => {
// Below: 100.63.x.x is NOT in 100.64.0.0/10
expect(isTailscaleBind("100.63.255.255")).toBe(false);
// Above: 100.128.x.x is NOT in 100.64.0.0/10
expect(isTailscaleBind("100.128.0.0")).toBe(false);
// First octet mismatch
expect(isTailscaleBind("101.64.0.0")).toBe(false);
expect(isTailscaleBind("99.64.0.0")).toBe(false);
});
it("rejects unrelated IPv4 (LAN, public, loopback)", () => {
expect(isTailscaleBind("127.0.0.1")).toBe(false);
expect(isTailscaleBind("192.168.1.5")).toBe(false);
expect(isTailscaleBind("10.0.0.1")).toBe(false);
expect(isTailscaleBind("203.0.113.45")).toBe(false);
expect(isTailscaleBind("0.0.0.0")).toBe(false);
});
it("matches tailscale ULA IPv6 prefix fd7a:115c:a1e0::/48", () => {
expect(isTailscaleBind("fd7a:115c:a1e0::1")).toBe(true);
expect(isTailscaleBind("fd7a:115c:a1e0:ab12:3456:7890:abcd:ef01")).toBe(true);
// Bracketed form (URL-style)
expect(isTailscaleBind("[fd7a:115c:a1e0::1]")).toBe(true);
// Case-insensitive
expect(isTailscaleBind("FD7A:115C:A1E0::1")).toBe(true);
});
it("rejects unrelated IPv6 (other ULA, public, loopback)", () => {
expect(isTailscaleBind("fd00::1")).toBe(false);
expect(isTailscaleBind("fd7b:115c:a1e0::1")).toBe(false); // off by one in first segment
expect(isTailscaleBind("::1")).toBe(false);
expect(isTailscaleBind("2001:db8::1")).toBe(false);
});
it("rejects empty / null / undefined / hostnames (resolver path handles those)", () => {
expect(isTailscaleBind("")).toBe(false);
expect(isTailscaleBind(undefined)).toBe(false);
expect(isTailscaleBind(null)).toBe(false);
expect(isTailscaleBind("foo.example.com")).toBe(false);
expect(isTailscaleBind("host.tail-scale-net.ts.net")).toBe(false);
});
});
describe("resolveToIpOrNull", () => {
it("resolves a real loopback hostname to an IP", async () => {
const ip = await resolveToIpOrNull("localhost");
// localhost resolves to 127.0.0.1 or ::1 depending on platform
expect(ip).toBeTruthy();
expect(typeof ip).toBe("string");
});
it("returns null for an intentionally-unresolvable hostname (HG-4)", async () => {
const ip = await resolveToIpOrNull("this-host-does-not-exist.invalid");
expect(ip).toBeNull();
});
});
describe("assertBindAuthInvariant — 7 IMPL-PRD scenarios", () => {
// Scenario 1: Loopback bind, no bearer → OK
it("(1) loopback bind 127.0.0.1 with no bearer → OK", async () => {
await expect(
assertBindAuthInvariant({ host: "127.0.0.1", bearerToken: null }),
).resolves.toBeUndefined();
});
// Scenario 2: Tailscale IPv4 bind, no bearer → OK
it("(2) tailscale IPv4 100.95.124.51 with no bearer → OK", async () => {
await expect(
assertBindAuthInvariant({ host: "100.95.124.51", bearerToken: null }),
).resolves.toBeUndefined();
});
// Scenario 3: Tailscale magicDNS hostname → OK (DNS resolves to tailscale IP)
it("(3) magicDNS hostname that resolves to tailscale IP → OK", async () => {
const dns = await import("node:dns");
const spy = vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "100.95.124.51", family: 4 } as unknown as never);
try {
await expect(
assertBindAuthInvariant({ host: "host.tail-scale-net.ts.net", bearerToken: null }),
).resolves.toBeUndefined();
} finally {
spy.mockRestore();
}
});
// Scenario 4: LAN bind, no bearer → THROW
it("(4) LAN bind 192.168.1.50 with no bearer → THROW", async () => {
await expect(
assertBindAuthInvariant({ host: "192.168.1.50", bearerToken: null }),
).rejects.toThrow(AuthBearerTokenStartupError);
});
// Scenario 5: 0.0.0.0 bind, no bearer → THROW
it("(5) wildcard 0.0.0.0 with no bearer → THROW", async () => {
await expect(
assertBindAuthInvariant({ host: "0.0.0.0", bearerToken: null }),
).rejects.toThrow(AuthBearerTokenStartupError);
});
// Scenario 6: Public IP, no bearer → THROW
it("(6) public IP 203.0.113.45 (TEST-NET-3) with no bearer → THROW", async () => {
await expect(
assertBindAuthInvariant({ host: "203.0.113.45", bearerToken: null }),
).rejects.toThrow(AuthBearerTokenStartupError);
});
// Scenario 7: DNS-fails hostname, no bearer → THROW
it("(7) DNS-unresolvable hostname with no bearer → THROW (HG-4)", async () => {
await expect(
assertBindAuthInvariant({ host: "this-host-does-not-exist.invalid", bearerToken: null }),
).rejects.toThrow(AuthBearerTokenStartupError);
});
// Existing-behavior preservation
it("LAN bind WITH bearer → OK (bearer covers explicit public/LAN opt-in)", async () => {
await expect(
assertBindAuthInvariant({ host: "192.168.1.50", bearerToken: "secret" }),
).resolves.toBeUndefined();
});
it("Public IP WITH bearer → OK", async () => {
await expect(
assertBindAuthInvariant({ host: "203.0.113.45", bearerToken: "secret" }),
).resolves.toBeUndefined();
});
it("error message cites all 3 accepted paths (HG-10)", async () => {
try {
await assertBindAuthInvariant({ host: "192.168.1.50", bearerToken: null });
expect.fail("should have thrown");
} catch (err) {
const msg = (err as Error).message;
expect(msg).toMatch(/loopback|127\.0\.0\.1|localhost/i);
expect(msg).toMatch(/tailscale|100\.64\.0\.0\/10|fd7a/i);
expect(msg).toMatch(/OPENRIG_AUTH_BEARER_TOKEN|bearer/i);
}
});
it("error message names hostname AND resolved IP when DNS resolves to public IP", async () => {
const dns = await import("node:dns");
const spy = vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "203.0.113.45", family: 4 } as unknown as never);
try {
await assertBindAuthInvariant({ host: "external.example.com", bearerToken: null });
expect.fail("should have thrown");
} catch (err) {
const msg = (err as Error).message;
expect(msg).toContain("external.example.com");
expect(msg).toContain("203.0.113.45");
} finally {
spy.mockRestore();
}
});
});
describe("findTailscaleIpInInterfaces (HG-5)", () => {
function iface(opts: Partial<NetworkInterfaceInfo> & { address: string }): NetworkInterfaceInfo {
return {
address: opts.address,
netmask: opts.netmask ?? "255.255.255.0",
family: opts.family ?? "IPv4",
mac: opts.mac ?? "00:00:00:00:00:00",
internal: opts.internal ?? false,
cidr: opts.cidr ?? null,
} as NetworkInterfaceInfo;
}
it("returns the tailscale IPv4 when a CGNAT-range interface is present", () => {
const result = findTailscaleIpInInterfaces({
lo0: [iface({ address: "127.0.0.1", internal: true })],
en0: [iface({ address: "192.168.1.5" })],
utun4: [iface({ address: "100.95.124.51" })],
});
expect(result).toBe("100.95.124.51");
});
it("returns the tailscale ULA IPv6 when only the IPv6 tailnet address is present", () => {
const result = findTailscaleIpInInterfaces({
utun4: [iface({ address: "fd7a:115c:a1e0::1", family: "IPv6" })],
});
expect(result).toBe("fd7a:115c:a1e0::1");
});
it("returns null when no interface is in the tailnet range (HG-7 condition)", () => {
const result = findTailscaleIpInInterfaces({
lo0: [iface({ address: "127.0.0.1", internal: true })],
en0: [iface({ address: "192.168.1.5" })],
});
expect(result).toBeNull();
});
it("skips internal interfaces (loopback) even if the address looked CGNAT-shaped", () => {
// Defense in depth: an internal flag should win over IP match.
const result = findTailscaleIpInInterfaces({
lo0: [iface({ address: "100.95.124.51", internal: true })],
});
expect(result).toBeNull();
});
it("ignores undefined interface entries gracefully", () => {
const result = findTailscaleIpInInterfaces({ ghost: undefined });
expect(result).toBeNull();
});
});
describe("middleware integration", () => {