mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
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:
1 parent
facb105e6b
commit
cafcfa5e41
3 files changed
+407
-48
No files matched your search
@@ -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.`,
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user