openclaw / src /pairing /setup-code.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
87dab07 verified
Raw
History Blame Contribute Delete
33.4 kB
// Tests setup code generation and environment-derived defaults.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SecretInput } from "../config/types.secrets.js";
import {
PAIRING_SETUP_BOOTSTRAP_PROFILE,
VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
} from "../shared/device-bootstrap-profile.js";
import { captureEnv } from "../test-utils/env.js";
vi.mock("../infra/device-bootstrap.js", () => ({
issueDevicePairSetupBootstrapToken: vi.fn(async () => ({
token: "bootstrap-123",
expiresAtMs: 123,
setupId: "setup-123",
})),
}));
const {
decodePairingSetupCode,
encodePairingSetupCode,
resolveConfiguredPairingPublicUrl,
resolvePairingSetupFromConfig,
} = await import("./setup-code.js");
const { issueDevicePairSetupBootstrapToken: issueDevicePairSetupBootstrapTokenMock } =
await import("../infra/device-bootstrap.js");
const TLS_FINGERPRINT = "ab".repeat(32);
const COLON_TLS_FINGERPRINT = (TLS_FINGERPRINT.match(/.{2}/gu)?.join(":") ?? "").toUpperCase();
describe("pairing setup code", () => {
it("reads the configured public pairing URL from its owning plugin entry", () => {
expect(
resolveConfiguredPairingPublicUrl({
plugins: {
entries: { "device-pair": { config: { publicUrl: " wss://public.example " } } },
},
}),
).toBe("wss://public.example");
expect(resolveConfiguredPairingPublicUrl({})).toBeUndefined();
});
it("round-trips setup codes while canonicalizing their TLS fingerprint", () => {
const payload = {
url: "wss://gateway.example:8443/openclaw-gw",
bootstrapToken: "Bootstrap-AbC123",
tlsFingerprint: `SHA256:${COLON_TLS_FINGERPRINT}`,
expiresAtMs: 20_000,
};
const setupCode = encodePairingSetupCode(payload);
expect(setupCode).toMatch(/[A-Z]/u);
const expected = { ...payload, tlsFingerprint: TLS_FINGERPRINT };
expect(decodePairingSetupCode(setupCode, { nowMs: 10_000 })).toEqual(expected);
expect(decodePairingSetupCode(`oc-pair://${setupCode}`, { nowMs: 10_000 })).toEqual(expected);
});
it.each(["abc123", "sha256:abc123", "g".repeat(64)])(
"rejects invalid TLS fingerprint %s in a setup code",
(tlsFingerprint) => {
const setupCode = encodePairingSetupCode({
url: "wss://gateway.example",
bootstrapToken: "bootstrap-123",
tlsFingerprint,
});
expect(() => decodePairingSetupCode(setupCode)).toThrow("Invalid pairing setup payload");
},
);
it("rejects garbage and expired shipped payload shapes", () => {
expect(() => decodePairingSetupCode("not-json")).toThrow("Invalid pairing setup");
const expired = encodePairingSetupCode({
url: "wss://gateway.example",
bootstrapToken: "bootstrap-123",
expiresAtMs: 10_000,
});
expect(() => decodePairingSetupCode(expired, { nowMs: 10_000 })).toThrow("expired");
});
it("accepts older payloads without a TLS fingerprint or expiry", () => {
const payload = { url: "wss://gateway.example", bootstrapToken: "bootstrap-123" };
expect(decodePairingSetupCode(encodePairingSetupCode(payload))).toEqual(payload);
});
type ResolvedSetup = Awaited<ReturnType<typeof resolvePairingSetupFromConfig>>;
type ResolveSetupConfig = Parameters<typeof resolvePairingSetupFromConfig>[0];
type ResolveSetupOptions = Parameters<typeof resolvePairingSetupFromConfig>[1];
type ResolveSetupEnv = NonNullable<ResolveSetupOptions>["env"];
const defaultEnvSecretProviderConfig = {
secrets: {
providers: {
default: { source: "env" },
},
},
} as const;
const limitedPlaintextAccess = {
bootstrapProfile: PAIRING_SETUP_BOOTSTRAP_PROFILE,
access: "limited" as const,
accessDowngraded: true,
};
const gatewayPasswordSecretRef: SecretInput = {
source: "env",
provider: "default",
id: "GW_PASSWORD",
};
const missingGatewayTokenSecretRef: SecretInput = {
source: "env",
provider: "default",
id: "MISSING_GW_TOKEN",
};
function createCustomGatewayConfig(
auth: NonNullable<ResolveSetupConfig["gateway"]>["auth"],
config: Omit<ResolveSetupConfig, "gateway"> = {},
): ResolveSetupConfig {
return {
...config,
gateway: {
bind: "custom",
customBindHost: "127.0.0.1",
auth,
},
};
}
function createTailnetDnsRunner() {
return vi.fn(async () => ({
code: 0,
stdout: '{"Self":{"DNSName":"mb-server.tailnet.ts.net."}}',
stderr: "",
}));
}
function createNoRouteRunner() {
return vi.fn(async () => ({
code: 1,
stdout: "",
stderr: "",
}));
}
function createDefaultRouteRunner(interfaceName: string) {
const stdout =
process.platform === "win32"
? JSON.stringify({ InterfaceAlias: interfaceName })
: process.platform === "linux"
? `default via 10.211.55.1 dev ${interfaceName} proto dhcp metric 100\n`
: ` route to: default\ninterface: ${interfaceName}\n`;
return vi.fn(async () => ({
code: 0,
stdout,
stderr: "",
}));
}
function createIpv4NetworkInterfaces(
address: string,
): ReturnType<NonNullable<NonNullable<ResolveSetupOptions>["networkInterfaces"]>> {
return {
en0: [
{
address,
family: "IPv4",
internal: false,
netmask: "255.255.255.0",
mac: "00:00:00:00:00:00",
cidr: `${address}/24`,
},
],
};
}
function expectResolvedSetupOk(
resolved: ResolvedSetup,
params: {
authLabel: string;
url?: string;
urls?: string[];
urlSource?: string;
bootstrapProfile?: { roles: string[]; scopes: string[]; purpose?: string };
access?: "full" | "limited" | "node";
accessDowngraded?: boolean;
},
) {
expect(resolved.ok).toBe(true);
if (!resolved.ok) {
throw new Error("expected setup resolution to succeed");
}
expect(resolved.authLabel).toBe(params.authLabel);
expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
expect(resolved.setupId).toBe("setup-123");
expect(resolved.expiresAtMs).toBe(123);
expect(issueDevicePairSetupBootstrapTokenMock).toHaveBeenCalledWith({
baseDir: undefined,
profile: params.bootstrapProfile ?? {
roles: ["node", "operator"],
scopes: [
"operator.admin",
"operator.approvals",
"operator.questions",
"operator.read",
"operator.talk.secrets",
"operator.write",
],
purpose: "mobile-full",
},
});
expect(resolved.payload).not.toHaveProperty("setupId");
expect(resolved.payload).toHaveProperty("expiresAtMs", 123);
if (params.url) {
expect(resolved.payload.url).toBe(params.url);
}
if (params.urls) {
expect(resolved.payload.urls).toEqual(params.urls);
}
if (params.urlSource) {
expect(resolved.urlSource).toBe(params.urlSource);
}
expect(resolved.access).toBe(params.access ?? "full");
expect(resolved.accessDowngraded).toBe(params.accessDowngraded ?? false);
}
function expectResolvedSetupError(resolved: ResolvedSetup, snippet: string) {
expect(resolved.ok).toBe(false);
if (resolved.ok) {
throw new Error("expected setup resolution to fail");
}
expect(resolved.error).toContain(snippet);
}
async function expectResolvedSetupSuccessCase(params: {
config: ResolveSetupConfig;
options?: ResolveSetupOptions;
expected: {
authLabel: string;
url: string;
urls?: string[];
urlSource: string;
bootstrapProfile?: { roles: string[]; scopes: string[]; purpose?: string };
access?: "full" | "limited" | "node";
accessDowngraded?: boolean;
};
runCommandWithTimeout?: ReturnType<typeof vi.fn>;
expectedRunCommandCalls?: number;
}) {
const resolved = await resolvePairingSetupFromConfig(params.config, params.options);
expectResolvedSetupOk(resolved, params.expected);
if (params.runCommandWithTimeout) {
expect(params.runCommandWithTimeout).toHaveBeenCalledTimes(
params.expectedRunCommandCalls ?? 0,
);
}
}
async function expectResolvedSetupFailureCase(params: {
config: ResolveSetupConfig;
options?: ResolveSetupOptions;
expectedError: string;
}) {
const resolved = await resolvePairingSetupFromConfig(params.config, params.options);
expectResolvedSetupError(resolved, params.expectedError);
}
async function expectResolveCustomGatewayRejects(params: {
auth: NonNullable<ResolveSetupConfig["gateway"]>["auth"];
env?: ResolveSetupEnv;
config?: Omit<ResolveSetupConfig, "gateway">;
expectedError: RegExp | string;
}) {
await expect(
resolveCustomGatewaySetup({
auth: params.auth,
env: params.env,
config: params.config,
}),
).rejects.toThrow(params.expectedError);
}
async function expectResolvedCustomGatewaySetupOk(params: {
auth: NonNullable<ResolveSetupConfig["gateway"]>["auth"];
env?: ResolveSetupEnv;
config?: Omit<ResolveSetupConfig, "gateway">;
expectedAuthLabel: string;
}) {
const resolved = await resolveCustomGatewaySetup({
auth: params.auth,
env: params.env,
config: params.config,
});
expectResolvedSetupOk(resolved, { authLabel: params.expectedAuthLabel });
}
let gatewayEnvSnapshot: ReturnType<typeof captureEnv> | undefined;
beforeEach(() => {
gatewayEnvSnapshot = captureEnv([
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD",
"OPENCLAW_GATEWAY_PORT",
]);
process.env.OPENCLAW_GATEWAY_TOKEN = "";
process.env.OPENCLAW_GATEWAY_PASSWORD = "";
process.env.OPENCLAW_GATEWAY_PORT = "";
});
beforeEach(() => {
vi.mocked(issueDevicePairSetupBootstrapTokenMock).mockClear();
});
afterEach(() => {
gatewayEnvSnapshot?.restore();
gatewayEnvSnapshot = undefined;
});
it.each([
{
name: "encodes payload as base64url JSON",
payload: {
url: "wss://gateway.example.com:443",
bootstrapToken: "abc",
},
expected:
"eyJ1cmwiOiJ3c3M6Ly9nYXRld2F5LmV4YW1wbGUuY29tOjQ0MyIsImJvb3RzdHJhcFRva2VuIjoiYWJjIn0",
},
] as const)("$name", ({ payload, expected }) => {
expect(encodePairingSetupCode(payload)).toBe(expected);
});
it("normalizes bare publicUrl host ports for setup code payloads", async () => {
await expectResolvedSetupSuccessCase({
config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }),
options: {
forceSecure: true,
publicUrl: "gateway.example.test:18789/setup",
},
expected: {
authLabel: "token",
url: "wss://gateway.example.test:18789",
urlSource: "plugins.entries.device-pair.config.publicUrl",
},
});
});
it("preserves context paths in fully qualified setup urls", async () => {
await expectResolvedSetupSuccessCase({
config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }),
options: {
publicUrl: "wss://gateway.example.test:18789/openclaw-gw",
},
expected: {
authLabel: "token",
url: "wss://gateway.example.test:18789/openclaw-gw",
urlSource: "plugins.entries.device-pair.config.publicUrl",
},
});
});
it("issues a node-only bootstrap profile for companion setup", async () => {
await expectResolvedSetupSuccessCase({
config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }),
options: {
forceSecure: true,
publicUrl: "gateway.example.test:18789/setup",
bootstrapProfile: { roles: ["node"], scopes: [] },
},
expected: {
authLabel: "token",
url: "wss://gateway.example.test:18789",
urlSource: "plugins.entries.device-pair.config.publicUrl",
bootstrapProfile: { roles: ["node"], scopes: [] },
access: "node",
},
});
});
it("issues a least-privilege voice-node bootstrap profile", async () => {
await expectResolvedSetupSuccessCase({
config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }),
options: {
forceSecure: true,
publicUrl: "gateway.example.test:18789/setup",
bootstrapProfile: VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
},
expected: {
authLabel: "token",
url: "wss://gateway.example.test:18789",
urlSource: "plugins.entries.device-pair.config.publicUrl",
bootstrapProfile: VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
access: "limited",
},
});
});
it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => {
await expectResolvedSetupFailureCase({
config: {
gateway: {
bind: "custom",
customBindHost: "127.0.0.1",
remote: { url: "http://localhost:notaport" },
auth: { mode: "token", token: "tok_123" },
},
},
options: {
preferRemoteUrl: true,
},
expectedError: "Configured gateway.remote.url is invalid.",
});
expect(issueDevicePairSetupBootstrapTokenMock).not.toHaveBeenCalled();
});
it.each([
"localhost:notaport",
"http://localhost:notaport",
"http:gateway.example.test",
"ws:gateway.example.test",
"http:/localhost:notaport",
"ftp:/gateway.example.test",
"mailto:foo@example.com",
"ws://user:pass@gateway.example.test:18789",
])("rejects invalid publicUrl %s before issuing setup code payloads", async (publicUrl) => {
await expectResolvedSetupFailureCase({
config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }),
options: {
forceSecure: true,
publicUrl,
},
expectedError: "Configured publicUrl is invalid.",
});
expect(issueDevicePairSetupBootstrapTokenMock).not.toHaveBeenCalled();
});
async function resolveCustomGatewaySetup(params: {
auth: NonNullable<ResolveSetupConfig["gateway"]>["auth"];
env?: ResolveSetupEnv;
config?: Omit<ResolveSetupConfig, "gateway">;
}) {
return await resolvePairingSetupFromConfig(
createCustomGatewayConfig(params.auth, params.config),
{
env: params.env ?? {},
},
);
}
it.each([
{
name: "resolves gateway.auth.password SecretRef for pairing payload",
auth: {
mode: "password",
password: gatewayPasswordSecretRef,
} as const,
env: {
GW_PASSWORD: "resolved-password", // pragma: allowlist secret
},
expectedAuthLabel: "password",
},
{
name: "does not resolve gateway.auth.password SecretRef in token mode",
auth: {
mode: "token",
token: "tok_123",
password: { source: "env", provider: "missing", id: "GW_PASSWORD" },
} as const,
env: {},
expectedAuthLabel: "token",
},
{
name: "resolves gateway.auth.token SecretRef for pairing payload",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "GW_TOKEN" },
} as const,
env: {
GW_TOKEN: "resolved-token",
},
expectedAuthLabel: "token",
},
] as const)("$name", async ({ auth, env, expectedAuthLabel }) => {
await expectResolvedCustomGatewaySetupOk({
auth,
env,
config: defaultEnvSecretProviderConfig,
expectedAuthLabel,
});
});
it.each([
{
name: "errors when gateway.auth.token SecretRef is unresolved in token mode",
config: createCustomGatewayConfig(
{
mode: "token",
token: missingGatewayTokenSecretRef,
},
defaultEnvSecretProviderConfig,
),
options: { env: {} },
expectedError: "MISSING_GW_TOKEN",
},
{
name: "does not let OPENCLAW_GATEWAY_PASSWORD mask a configured password SecretRef",
config: createCustomGatewayConfig(
{
mode: "password",
password: { source: "env", provider: "default", id: "MISSING_GW_PASSWORD" },
},
defaultEnvSecretProviderConfig,
),
options: {
env: { OPENCLAW_GATEWAY_PASSWORD: "password-from-env" },
},
expectedError: "MISSING_GW_PASSWORD",
},
] as const)("$name", async ({ config, options, expectedError }) => {
await expect(resolvePairingSetupFromConfig(config, options)).rejects.toThrow(expectedError);
});
it.each(["none"] as const)(
"names gateway.auth.mode %s when setup code generation lacks a shared secret",
async (mode) => {
await expectResolvedSetupFailureCase({
config: createCustomGatewayConfig({ mode }),
options: { env: {} },
expectedError: `Pairing setup requires gateway.auth.mode "token" or "password"; current mode is "${mode}".`,
});
expect(issueDevicePairSetupBootstrapTokenMock).not.toHaveBeenCalled();
},
);
it("keeps the unconfigured-auth error when gateway.auth.mode is unset", async () => {
await expectResolvedSetupFailureCase({
config: createCustomGatewayConfig({}),
options: { env: {} },
expectedError: "Gateway auth is not configured (no token or password).",
});
});
it("keeps the configured-password fallback for trusted-proxy mode", async () => {
await expectResolvedCustomGatewaySetupOk({
auth: { mode: "trusted-proxy", password: "secret" },
env: {},
expectedAuthLabel: "password",
});
});
async function resolveInferredModeWithPasswordEnv(token: SecretInput) {
return await resolvePairingSetupFromConfig(
{
gateway: {
bind: "custom",
customBindHost: "127.0.0.1",
auth: { token },
},
...defaultEnvSecretProviderConfig,
},
{
env: {
OPENCLAW_GATEWAY_PASSWORD: "password-from-env", // pragma: allowlist secret
},
},
);
}
async function expectInferredPasswordEnvSetupCase(token: SecretInput) {
const resolved = await resolveInferredModeWithPasswordEnv(token);
expectResolvedSetupOk(resolved, { authLabel: "password" });
}
it.each([
{
name: "uses password env in inferred mode without resolving token SecretRef",
token: {
source: "env",
provider: "default",
id: "MISSING_GW_TOKEN",
} satisfies SecretInput,
},
{
name: "does not treat env-template token as plaintext in inferred mode",
token: "${MISSING_GW_TOKEN}",
},
] as const)("$name", async ({ token }) => {
await expectInferredPasswordEnvSetupCase(token);
});
it.each([
{
name: "requires explicit auth mode when token and password are both configured",
auth: {
token: { source: "env", provider: "default", id: "GW_TOKEN" },
password: gatewayPasswordSecretRef,
} as const,
env: {
GW_TOKEN: "resolved-token",
GW_PASSWORD: "resolved-password", // pragma: allowlist secret
},
},
{
name: "errors when token and password SecretRefs are both configured with inferred mode",
auth: {
token: missingGatewayTokenSecretRef,
password: gatewayPasswordSecretRef,
} as const,
env: {
GW_PASSWORD: "resolved-password", // pragma: allowlist secret
},
},
] as const)("$name", async ({ auth, env }) => {
await expectResolveCustomGatewayRejects({
auth,
env,
config: defaultEnvSecretProviderConfig,
expectedError: /gateway\.auth\.mode is unset/i,
});
});
it.each([
{
name: "resolves custom bind + token auth",
config: {
gateway: {
bind: "custom",
customBindHost: "127.0.0.1",
port: 19001,
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
expected: {
authLabel: "token",
url: "ws://127.0.0.1:19001",
urlSource: "gateway.bind=custom",
},
},
{
name: "honors env token override",
config: {
gateway: {
bind: "custom",
customBindHost: "127.0.0.1",
auth: { mode: "token", token: "old" },
},
} satisfies ResolveSetupConfig,
options: {
env: {
OPENCLAW_GATEWAY_TOKEN: "new-token",
},
} satisfies ResolveSetupOptions,
expected: {
authLabel: "token",
url: "ws://127.0.0.1:18789",
urlSource: "gateway.bind=custom",
},
},
{
name: "allows android emulator cleartext setup urls",
config: {
gateway: {
bind: "custom",
customBindHost: "10.0.2.2",
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
expected: {
authLabel: "token",
url: "ws://10.0.2.2:18789",
urlSource: "gateway.bind=custom",
...limitedPlaintextAccess,
},
},
{
name: "allows mdns cleartext setup urls",
config: {
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
expected: {
authLabel: "token",
url: "ws://gateway.local:18789",
urlSource: "gateway.bind=custom",
...limitedPlaintextAccess,
},
},
{
name: "allows lan ip cleartext setup urls",
config: {
gateway: {
bind: "custom",
customBindHost: "192.168.1.20",
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
expected: {
authLabel: "token",
url: "ws://192.168.1.20:18789",
urlSource: "gateway.bind=custom",
...limitedPlaintextAccess,
},
},
] as const)("$name", async ({ config, options, expected }) => {
await expectResolvedSetupSuccessCase({
config,
options,
expected,
});
});
it.each([
{
name: "rejects custom bind public ws setup urls for mobile pairing",
config: {
gateway: {
bind: "custom",
customBindHost: "gateway.example",
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
expectedError: "Tailscale and public mobile pairing require a secure gateway URL",
},
{
name: "rejects tailnet bind remote ws setup urls for mobile pairing",
config: {
gateway: {
bind: "tailnet",
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
options: {
networkInterfaces: () => createIpv4NetworkInterfaces("100.64.0.9"),
} satisfies ResolveSetupOptions,
expectedError: "prefer gateway.tailscale.mode=serve",
},
] as const)("$name", async ({ config, options, expectedError }) => {
await expectResolvedSetupFailureCase({
config,
options,
expectedError,
});
});
it("allows LAN cleartext pairing without route probing for a single address", async () => {
const runCommandWithTimeout = createNoRouteRunner();
await expectResolvedSetupSuccessCase({
config: {
gateway: {
bind: "lan",
auth: { mode: "password", password: "secret" },
},
} satisfies ResolveSetupConfig,
options: {
networkInterfaces: () => createIpv4NetworkInterfaces("192.168.1.20"),
runCommandWithTimeout,
} satisfies ResolveSetupOptions,
expected: {
authLabel: "password",
url: "ws://192.168.1.20:18789",
urlSource: "gateway.bind=lan",
...limitedPlaintextAccess,
},
runCommandWithTimeout,
expectedRunCommandCalls: 0,
});
});
it("advertises the routed LAN interface instead of the first private interface", async () => {
const runCommandWithTimeout = createDefaultRouteRunner("en1");
await expectResolvedSetupSuccessCase({
config: {
gateway: {
bind: "lan",
auth: { mode: "password", password: "secret" },
},
} satisfies ResolveSetupConfig,
options: {
networkInterfaces: () =>
({
bridge100: [
{
address: "10.37.129.4",
family: "IPv4",
internal: false,
netmask: "255.255.255.0",
mac: "00:00:00:00:00:00",
cidr: "10.37.129.4/24",
},
],
en1: [
{
address: "10.211.55.3",
family: "IPv4",
internal: false,
netmask: "255.255.255.0",
mac: "00:00:00:00:00:00",
cidr: "10.211.55.3/24",
},
],
}) as ReturnType<NonNullable<NonNullable<ResolveSetupOptions>["networkInterfaces"]>>,
runCommandWithTimeout,
} satisfies ResolveSetupOptions,
expected: {
authLabel: "password",
url: "ws://10.211.55.3:18789",
urlSource: "gateway.bind=lan",
...limitedPlaintextAccess,
},
runCommandWithTimeout,
expectedRunCommandCalls: 1,
});
});
it("does not advertise a legacy Serve route targeting ordinary LAN ingress", async () => {
const defaultRoute = createDefaultRouteRunner("en0");
const runCommandWithTimeout = vi.fn(async (argv: string[]) => {
if (argv.includes("serve")) {
throw new Error("legacy Serve discovery must not run for a LAN bind");
}
return defaultRoute();
});
await expectResolvedSetupSuccessCase({
config: {
gateway: {
bind: "lan",
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
options: {
networkInterfaces: () => ({
...createIpv4NetworkInterfaces("192.168.139.3"),
bridge100: createIpv4NetworkInterfaces("10.37.129.4").en0,
}),
runCommandWithTimeout,
} satisfies ResolveSetupOptions,
expected: {
authLabel: "token",
url: "ws://192.168.139.3:18789",
urlSource: "gateway.bind=lan",
...limitedPlaintextAccess,
},
runCommandWithTimeout,
expectedRunCommandCalls: 1,
});
});
it("does not advertise a loopback Serve route for a custom bind", async () => {
const runCommandWithTimeout = vi.fn(async () => {
throw new Error("Tailscale Serve discovery must not run for a custom bind");
});
await expectResolvedSetupSuccessCase({
config: {
gateway: {
bind: "custom",
customBindHost: "192.168.139.3",
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
options: { runCommandWithTimeout } satisfies ResolveSetupOptions,
expected: {
authLabel: "token",
url: "ws://192.168.139.3:18789",
urlSource: "gateway.bind=custom",
...limitedPlaintextAccess,
},
runCommandWithTimeout,
expectedRunCommandCalls: 0,
});
});
it("allows tailnet bind setup urls when gateway TLS is enabled", async () => {
await expectResolvedSetupSuccessCase({
config: {
gateway: {
bind: "tailnet",
tls: {
enabled: true,
},
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
options: {
networkInterfaces: () => createIpv4NetworkInterfaces("100.64.0.9"),
} satisfies ResolveSetupOptions,
expected: {
authLabel: "token",
url: "wss://100.64.0.9:18789",
urlSource: "gateway.bind=tailnet",
},
});
});
it.each([
{
name: "errors when gateway is loopback only",
config: {
gateway: {
bind: "loopback",
auth: { mode: "token", token: "tok" },
},
} satisfies ResolveSetupConfig,
expectedError: "only bound to loopback",
},
{
name: "returns a bind-specific error when interface discovery throws",
config: {
gateway: {
bind: "lan",
auth: { mode: "token", token: "tok" },
},
} satisfies ResolveSetupConfig,
options: {
networkInterfaces: () => {
throw new Error("uv_interface_addresses failed");
},
} satisfies ResolveSetupOptions,
expectedError: "gateway.bind=lan set, but no private LAN IP was found.",
},
] as const)("$name", async ({ config, options, expectedError }) => {
await expectResolvedSetupFailureCase({
config,
options,
expectedError,
});
});
it.each([
{
name: "uses tailscale serve DNS when available",
createOptions: () => {
const runCommandWithTimeout = createTailnetDnsRunner();
return {
options: {
runCommandWithTimeout,
} satisfies ResolveSetupOptions,
runCommandWithTimeout,
expectedRunCommandCalls: 1,
};
},
config: {
gateway: {
tailscale: { mode: "serve" },
auth: { mode: "password", password: "secret" },
},
} satisfies ResolveSetupConfig,
expected: {
authLabel: "password",
url: "wss://mb-server.tailnet.ts.net",
urlSource: "gateway.tailscale.mode=serve",
},
},
{
name: "prefers gateway.remote.url over tailscale when requested",
createOptions: () => {
const runCommandWithTimeout = createTailnetDnsRunner();
return {
options: {
preferRemoteUrl: true,
runCommandWithTimeout,
} satisfies ResolveSetupOptions,
runCommandWithTimeout,
expectedRunCommandCalls: 0,
};
},
config: {
gateway: {
tailscale: { mode: "serve" },
remote: { url: "wss://remote.example.com:444" },
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
expected: {
authLabel: "token",
url: "wss://remote.example.com:444",
urlSource: "gateway.remote.url",
},
},
] as const)("$name", async ({ config, createOptions, expected }) => {
const { options, runCommandWithTimeout, expectedRunCommandCalls } = createOptions();
await expectResolvedSetupSuccessCase({
config,
options,
expected,
runCommandWithTimeout,
expectedRunCommandCalls,
});
});
it.each([false, true])(
"keeps local server pairing on its own endpoint and TLS pin (local=%s)",
async (useLocalGateway) => {
const config = createCustomGatewayConfig({ mode: "token", token: "local-token" });
config.gateway = {
...config.gateway,
mode: "remote",
port: 19443,
tls: { enabled: true },
remote: { url: "wss://primary.example", tlsFingerprint: "cd".repeat(32) },
};
const resolved = await resolvePairingSetupFromConfig(config, {
env: {},
useLocalGateway,
localTlsFingerprint: TLS_FINGERPRINT,
});
expect(resolved.ok).toBe(true);
if (!resolved.ok) {
throw new Error(resolved.error);
}
expect(resolved.payload.url).toBe(
useLocalGateway ? "wss://127.0.0.1:19443" : "wss://primary.example",
);
expect(resolved.payload.tlsFingerprint).toBe(
useLocalGateway ? TLS_FINGERPRINT : "cd".repeat(32),
);
},
);
it("pins the prepared leaf only for a direct TLS gateway URL", async () => {
const config = createCustomGatewayConfig({ mode: "token", token: "tok_123" });
config.gateway = { ...config.gateway, tls: { enabled: true } };
const direct = await resolvePairingSetupFromConfig(config, {
localTlsFingerprint: `sha256:${COLON_TLS_FINGERPRINT}`,
});
const proxied = await resolvePairingSetupFromConfig(config, {
publicUrl: "wss://proxy.example",
localTlsFingerprint: `sha256:${COLON_TLS_FINGERPRINT}`,
});
expect(direct.ok && direct.payload.tlsFingerprint).toBe(TLS_FINGERPRINT);
expect(proxied.ok && proxied.payload.tlsFingerprint).toBeUndefined();
});
it("rejects an invalid direct TLS fingerprint before issuing a setup token", async () => {
const config = createCustomGatewayConfig({ mode: "token", token: "tok_123" });
config.gateway = { ...config.gateway, tls: { enabled: true } };
const resolved = await resolvePairingSetupFromConfig(config, {
localTlsFingerprint: "sha256:abc123",
});
expectResolvedSetupError(resolved, "TLS fingerprint is invalid");
expect(issueDevicePairSetupBootstrapTokenMock).not.toHaveBeenCalled();
});
it("omits a configured remote TLS pin from a cleartext setup URL", async () => {
const config = createCustomGatewayConfig({ mode: "token", token: "tok_123" });
config.gateway = {
...config.gateway,
remote: {
url: "ws://127.0.0.1:18789",
tlsFingerprint: `sha256:${TLS_FINGERPRINT}`,
},
};
const resolved = await resolvePairingSetupFromConfig(config, { preferRemoteUrl: true });
expect(resolved.ok).toBe(true);
expect(resolved.ok && resolved.payload.tlsFingerprint).toBeUndefined();
});
});