From bb71224dadd775ec068c5ab402f366b5e05fb2e4 Mon Sep 17 00:00:00 2001 From: Rodrigo Uroz Date: Thu, 29 Jan 2026 14:11:56 -0300 Subject: [PATCH 1/3] feat(gateway): implement secure autoApproveNodes configuration Add gateway.nodes.autoApprove configuration to allow trusted nodes to connect without manual approval, with multiple security layers: - Gateway token required by default (requireToken: true) - IP CIDR allowlist for network-level restrictions - Role restriction (only specified roles can auto-approve) - Audit logging for all auto-approvals Security design: 1. Disabled by default (secure default) 2. Require explicit enablement and role configuration 3. If no ipAllowlist specified, only localhost is auto-approved 4. Audit log captures device ID, role, IP, client info Changes: - Add GatewayNodesAutoApproveConfig type (types.gateway.ts) - Add isIpv4InCidr, isValidCidr, isIpInAutoApproveAllowlist (net.ts) - Modify pairing logic to check autoApprove config (message-handler.ts) - Add comprehensive unit tests for CIDR validation Example configuration: { "gateway": { "nodes": { "autoApprove": { "enabled": true, "roles": ["node"], "ipAllowlist": ["10.0.0.0/8", "172.16.0.0/12"], "requireToken": true, "auditLog": true } } } } Co-Authored-By: Claude Opus 4.5 --- src/config/types.gateway.ts | 37 ++++++ src/gateway/net.test.ts | 116 +++++++++++++++++- src/gateway/net.ts | 89 ++++++++++++++ .../server/ws-connection/message-handler.ts | 50 +++++++- 4 files changed, 286 insertions(+), 6 deletions(-) diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index a0d562f7b..55799eef3 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -191,6 +191,38 @@ export type GatewayHttpConfig = { endpoints?: GatewayHttpEndpointsConfig; }; +export type GatewayNodesAutoApproveConfig = { + /** + * Enable auto-approval of node pairing requests. + * SECURITY: Only enable when you have additional security controls in place. + * Default: false (secure default - all pairings require manual approval). + */ + enabled?: boolean; + /** + * Roles that can be auto-approved (e.g., ["node"]). + * For security, "operator" should typically NOT be included. + * Default: [] (no roles auto-approved). + */ + roles?: string[]; + /** + * IP CIDR allowlist for auto-approval (e.g., ["10.0.0.0/8", "172.16.0.0/12"]). + * Only connections from these networks will be auto-approved. + * If undefined or empty, only localhost connections are auto-approved. + */ + ipAllowlist?: string[]; + /** + * Require valid gateway token for auto-approval. + * SECURITY: Should almost always be true. + * Default: true. + */ + requireToken?: boolean; + /** + * Log all auto-approved pairings for audit trail. + * Default: true. + */ + auditLog?: boolean; +}; + export type GatewayNodesConfig = { /** Browser routing policy for node-hosted browser proxies. */ browser?: { @@ -203,6 +235,11 @@ export type GatewayNodesConfig = { allowCommands?: string[]; /** Commands to deny even if they appear in the defaults or node claims. */ denyCommands?: string[]; + /** + * Auto-approve configuration for node pairing. + * Allows trusted nodes (e.g., in Kubernetes) to connect without manual approval. + */ + autoApprove?: GatewayNodesAutoApproveConfig; }; export type GatewayConfig = { diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 46c426d63..50db59021 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { resolveGatewayListenHosts } from "./net.js"; +import { + resolveGatewayListenHosts, + isIpv4InCidr, + isValidCidr, + isIpInAutoApproveAllowlist, +} from "./net.js"; describe("resolveGatewayListenHosts", () => { it("returns the input host when not loopback", async () => { @@ -26,3 +31,112 @@ describe("resolveGatewayListenHosts", () => { expect(hosts).toEqual(["127.0.0.1"]); }); }); + +describe("isIpv4InCidr", () => { + it("returns true for IP in /8 CIDR range", () => { + expect(isIpv4InCidr("10.0.1.5", "10.0.0.0/8")).toBe(true); + expect(isIpv4InCidr("10.255.255.255", "10.0.0.0/8")).toBe(true); + }); + + it("returns false for IP outside /8 CIDR range", () => { + expect(isIpv4InCidr("11.0.0.1", "10.0.0.0/8")).toBe(false); + expect(isIpv4InCidr("192.168.1.1", "10.0.0.0/8")).toBe(false); + }); + + it("returns true for IP in /16 CIDR range", () => { + expect(isIpv4InCidr("172.16.0.1", "172.16.0.0/12")).toBe(true); + expect(isIpv4InCidr("172.31.255.255", "172.16.0.0/12")).toBe(true); + }); + + it("returns false for IP outside /16 CIDR range", () => { + expect(isIpv4InCidr("172.32.0.1", "172.16.0.0/12")).toBe(false); + }); + + it("returns true for IP in /24 CIDR range", () => { + expect(isIpv4InCidr("192.168.1.1", "192.168.1.0/24")).toBe(true); + expect(isIpv4InCidr("192.168.1.254", "192.168.1.0/24")).toBe(true); + }); + + it("returns false for IP outside /24 CIDR range", () => { + expect(isIpv4InCidr("192.168.2.1", "192.168.1.0/24")).toBe(false); + }); + + it("returns true for /32 exact match", () => { + expect(isIpv4InCidr("192.168.1.1", "192.168.1.1/32")).toBe(true); + expect(isIpv4InCidr("192.168.1.2", "192.168.1.1/32")).toBe(false); + }); + + it("returns true for /0 (any IP)", () => { + expect(isIpv4InCidr("1.2.3.4", "0.0.0.0/0")).toBe(true); + expect(isIpv4InCidr("255.255.255.255", "0.0.0.0/0")).toBe(true); + }); + + it("returns false for invalid CIDR", () => { + expect(isIpv4InCidr("10.0.0.1", "invalid")).toBe(false); + expect(isIpv4InCidr("10.0.0.1", "10.0.0.0")).toBe(false); + }); +}); + +describe("isValidCidr", () => { + it("returns true for valid CIDR notation", () => { + expect(isValidCidr("10.0.0.0/8")).toBe(true); + expect(isValidCidr("172.16.0.0/12")).toBe(true); + expect(isValidCidr("192.168.1.0/24")).toBe(true); + expect(isValidCidr("192.168.1.1/32")).toBe(true); + expect(isValidCidr("0.0.0.0/0")).toBe(true); + }); + + it("returns false for invalid CIDR notation", () => { + expect(isValidCidr("10.0.0.0")).toBe(false); + expect(isValidCidr("10.0.0.0/")).toBe(false); + expect(isValidCidr("10.0.0.0/33")).toBe(false); + expect(isValidCidr("10.0.0.0/-1")).toBe(false); + expect(isValidCidr("10.0.0/8")).toBe(false); + expect(isValidCidr("256.0.0.0/8")).toBe(false); + expect(isValidCidr("invalid")).toBe(false); + expect(isValidCidr("")).toBe(false); + }); +}); + +describe("isIpInAutoApproveAllowlist", () => { + it("allows localhost when no allowlist is provided", () => { + expect(isIpInAutoApproveAllowlist("127.0.0.1", undefined)).toBe(true); + expect(isIpInAutoApproveAllowlist("127.0.0.1", [])).toBe(true); + expect(isIpInAutoApproveAllowlist("::1", undefined)).toBe(true); + }); + + it("denies non-localhost when no allowlist is provided", () => { + expect(isIpInAutoApproveAllowlist("10.0.0.1", undefined)).toBe(false); + expect(isIpInAutoApproveAllowlist("192.168.1.1", [])).toBe(false); + }); + + it("allows IP in CIDR allowlist", () => { + const allowlist = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]; + expect(isIpInAutoApproveAllowlist("10.0.1.5", allowlist)).toBe(true); + expect(isIpInAutoApproveAllowlist("172.20.0.1", allowlist)).toBe(true); + expect(isIpInAutoApproveAllowlist("192.168.100.50", allowlist)).toBe(true); + }); + + it("denies IP outside CIDR allowlist", () => { + const allowlist = ["10.0.0.0/8"]; + expect(isIpInAutoApproveAllowlist("192.168.1.1", allowlist)).toBe(false); + expect(isIpInAutoApproveAllowlist("8.8.8.8", allowlist)).toBe(false); + }); + + it("handles IPv4-mapped IPv6 addresses", () => { + const allowlist = ["10.0.0.0/8"]; + expect(isIpInAutoApproveAllowlist("::ffff:10.0.1.5", allowlist)).toBe(true); + expect(isIpInAutoApproveAllowlist("::ffff:192.168.1.1", allowlist)).toBe(false); + }); + + it("returns false for undefined/empty IP", () => { + expect(isIpInAutoApproveAllowlist(undefined, ["10.0.0.0/8"])).toBe(false); + expect(isIpInAutoApproveAllowlist("", ["10.0.0.0/8"])).toBe(false); + }); + + it("handles explicit localhost entries in allowlist", () => { + expect(isIpInAutoApproveAllowlist("127.0.0.1", ["127.0.0.1"])).toBe(true); + expect(isIpInAutoApproveAllowlist("::1", ["::1"])).toBe(true); + expect(isIpInAutoApproveAllowlist("127.0.0.1", ["localhost"])).toBe(true); + }); +}); diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 6702e0e8b..5d51fd013 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -179,3 +179,92 @@ function isValidIPv4(host: string): boolean { export function isLoopbackHost(host: string): boolean { return isLoopbackAddress(host); } + +/** + * Convert an IPv4 address string to a 32-bit unsigned integer. + */ +function ipv4ToNumber(ip: string): number { + const parts = ip.split("."); + if (parts.length !== 4) return 0; + return parts.reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0; +} + +/** + * Check if an IPv4 address is within a CIDR range. + * + * @param ip - The IPv4 address to check (e.g., "10.0.1.5") + * @param cidr - The CIDR range (e.g., "10.0.0.0/8") + * @returns True if the IP is within the CIDR range + */ +export function isIpv4InCidr(ip: string, cidr: string): boolean { + const [range, bitsStr] = cidr.split("/"); + if (!range || !bitsStr) return false; + const bits = parseInt(bitsStr, 10); + if (Number.isNaN(bits) || bits < 0 || bits > 32) return false; + + const ipNum = ipv4ToNumber(ip); + const rangeNum = ipv4ToNumber(range); + + // Create a mask with `bits` leading 1s + const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0; + + return (ipNum & mask) === (rangeNum & mask); +} + +/** + * Validate if a string is a valid CIDR notation. + * + * @param cidr - The CIDR string to validate (e.g., "10.0.0.0/8") + * @returns True if valid CIDR format + */ +export function isValidCidr(cidr: string): boolean { + const [range, bitsStr] = cidr.split("/"); + if (!range || !bitsStr) return false; + + // Validate the bits portion + const bits = parseInt(bitsStr, 10); + if (Number.isNaN(bits) || bits < 0 || bits > 32 || bitsStr !== String(bits)) { + return false; + } + + // Validate the IP portion + const parts = range.split("."); + if (parts.length !== 4) return false; + return parts.every((part) => { + const n = parseInt(part, 10); + return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n); + }); +} + +/** + * Check if an IP address should be auto-approved based on allowlist. + * + * Security-focused: If no allowlist is provided, only localhost is allowed. + * + * @param ip - The IP address to check + * @param allowlist - Array of CIDR ranges, or undefined/empty for localhost-only + * @returns True if the IP should be auto-approved + */ +export function isIpInAutoApproveAllowlist( + ip: string | undefined, + allowlist: string[] | undefined, +): boolean { + if (!ip) return false; + + // Normalize IPv4-mapped IPv6 addresses + const normalizedIp = ip.startsWith("::ffff:") ? ip.slice("::ffff:".length) : ip; + + // If no allowlist, only allow localhost + if (!allowlist || allowlist.length === 0) { + return isLoopbackAddress(normalizedIp); + } + + // Check if the IP matches any CIDR in the allowlist + return allowlist.some((cidr) => { + // Allow explicit localhost entries + if (cidr === "127.0.0.1" || cidr === "::1" || cidr === "localhost") { + return isLoopbackAddress(normalizedIp); + } + return isIpv4InCidr(normalizedIp, cidr); + }); +} diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index d1f6ae511..39bae7435 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -26,7 +26,12 @@ import type { ResolvedGatewayAuth } from "../../auth.js"; import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js"; import { loadConfig } from "../../../config/config.js"; import { buildDeviceAuthPayload } from "../../device-auth.js"; -import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js"; +import { + isLoopbackAddress, + isTrustedProxyAddress, + resolveGatewayClientIp, + isIpInAutoApproveAllowlist, +} from "../../net.js"; import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; import { type ConnectParams, @@ -623,6 +628,34 @@ export function attachGatewayWsMessageHandler(params: { const skipPairing = allowControlUiBypass && hasSharedAuth; if (device && devicePublicKey && !skipPairing) { + // Auto-approve logic with security checks + const autoApproveConfig = configSnapshot.gateway?.nodes?.autoApprove; + const shouldAutoApprove = (() => { + // Always auto-approve localhost (backward compatibility) + if (isLocalClient) return true; + + // Check if auto-approve is enabled + if (!autoApproveConfig?.enabled) return false; + + // Check role is in allowed list + const allowedRoles = autoApproveConfig.roles ?? []; + if (!allowedRoles.includes(role)) return false; + + // Check IP is in allowlist (if configured) + if (!isIpInAutoApproveAllowlist(reportedClientIp, autoApproveConfig.ipAllowlist)) { + return false; + } + + // Check token is valid (if required, which is the default) + if (autoApproveConfig.requireToken !== false && !authOk) return false; + + return true; + })(); + + // Audit log for auto-approvals (when not localhost) + const shouldAuditLog = + shouldAutoApprove && !isLocalClient && autoApproveConfig?.auditLog !== false; + const requirePairing = async (reason: string, _paired?: { deviceId: string }) => { const pairing = await requestDevicePairing({ deviceId: device.id, @@ -634,15 +667,22 @@ export function attachGatewayWsMessageHandler(params: { role, scopes, remoteIp: reportedClientIp, - silent: isLocalClient, + silent: shouldAutoApprove, }); const context = buildRequestContext(); if (pairing.request.silent === true) { const approved = await approveDevicePairing(pairing.request.requestId); if (approved) { - logGateway.info( - `device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`, - ); + // Audit log with detailed information for security review + if (shouldAuditLog) { + logGateway.info( + `auto-approve node pairing: device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"} ip=${reportedClientIp ?? "unknown"} client=${connectParams.client.id} displayName=${connectParams.client.displayName ?? "unknown"}`, + ); + } else { + logGateway.info( + `device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`, + ); + } context.broadcast( "device.pair.resolved", { From 190a74baeabb6563b269a860dee7069bc5235a40 Mon Sep 17 00:00:00 2001 From: Rodrigo Uroz Date: Thu, 29 Jan 2026 14:58:36 -0300 Subject: [PATCH 2/3] fix(gateway): add autoApprove to Zod config schema The TypeScript type was added but the Zod validation schema was missing, causing config validation to reject the autoApprove field. Co-Authored-By: Claude Opus 4.5 --- src/config/zod-schema.ts | 10 ++++++++++ src/gateway/net.test.ts | 6 ++++++ src/gateway/net.ts | 3 ++- src/gateway/server/ws-connection/message-handler.ts | 3 ++- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index ce4115517..e1f4beb9b 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -439,6 +439,16 @@ export const MoltbotSchema = z .optional(), allowCommands: z.array(z.string()).optional(), denyCommands: z.array(z.string()).optional(), + autoApprove: z + .object({ + enabled: z.boolean().optional(), + roles: z.array(z.string()).optional(), + ipAllowlist: z.array(z.string()).optional(), + requireToken: z.boolean().optional(), + auditLog: z.boolean().optional(), + }) + .strict() + .optional(), }) .strict() .optional(), diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 50db59021..9f0fd0dbe 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -75,6 +75,12 @@ describe("isIpv4InCidr", () => { expect(isIpv4InCidr("10.0.0.1", "invalid")).toBe(false); expect(isIpv4InCidr("10.0.0.1", "10.0.0.0")).toBe(false); }); + + it("returns false for invalid IPv4 input", () => { + expect(isIpv4InCidr("999.0.0.1", "10.0.0.0/8")).toBe(false); + expect(isIpv4InCidr("10.0.0.1", "999.0.0.0/8")).toBe(false); + expect(isIpv4InCidr("10.0.0.256", "10.0.0.0/8")).toBe(false); + }); }); describe("isValidCidr", () => { diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 5d51fd013..1f9de6400 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -197,10 +197,11 @@ function ipv4ToNumber(ip: string): number { * @returns True if the IP is within the CIDR range */ export function isIpv4InCidr(ip: string, cidr: string): boolean { + if (!isValidIPv4(ip)) return false; + if (!isValidCidr(cidr)) return false; const [range, bitsStr] = cidr.split("/"); if (!range || !bitsStr) return false; const bits = parseInt(bitsStr, 10); - if (Number.isNaN(bits) || bits < 0 || bits > 32) return false; const ipNum = ipv4ToNumber(ip); const rangeNum = ipv4ToNumber(range); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 39bae7435..5f6053f7f 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -626,6 +626,7 @@ export function attachGatewayWsMessageHandler(params: { return; } + const tokenAuthOk = authMethod === "token" || authMethod === "device-token"; const skipPairing = allowControlUiBypass && hasSharedAuth; if (device && devicePublicKey && !skipPairing) { // Auto-approve logic with security checks @@ -647,7 +648,7 @@ export function attachGatewayWsMessageHandler(params: { } // Check token is valid (if required, which is the default) - if (autoApproveConfig.requireToken !== false && !authOk) return false; + if (autoApproveConfig.requireToken !== false && !tokenAuthOk) return false; return true; })(); From 55c6b8efb7da82438d71775917f4a41dfd287b90 Mon Sep 17 00:00:00 2001 From: Rodrigo Uroz Date: Thu, 29 Jan 2026 18:32:19 +0000 Subject: [PATCH 3/3] docs(gateway): document autoApproveNodes configuration Add comprehensive documentation for the new gateway.nodes.autoApprove feature: - New section in configuration.md covering: - Full gateway.nodes config with browser, commands, and autoApprove - Field reference table with defaults and descriptions - Security considerations and defense-in-depth explanation - Practical examples for Kubernetes and local development - Updated pairing.md with: - Expanded auto-approval section covering both mechanisms - Configuration-based auto-approval example - Security layers explanation - Cross-reference to configuration docs Co-Authored-By: Claude Opus 4.5 --- docs/gateway/configuration.md | 121 ++++++++++++++++++++++++++++++++++ docs/gateway/pairing.md | 38 ++++++++++- 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 1d270974d..908707530 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2990,6 +2990,127 @@ CLAWDBOT_STATE_DIR=~/.clawdbot-a \ moltbot gateway --port 19001 ``` +### `gateway.nodes` (Node management) + +Configure node-related behavior including browser proxy routing, command allowlists, and auto-approval for node pairing. + +```json5 +{ + gateway: { + nodes: { + // Browser proxy routing (for remote browser control via nodes) + browser: { + mode: "auto", // "auto" | "manual" | "off" + node: "living-room-ipad" // pin a specific node (optional) + }, + // Command allowlist/denylist for node-invoked commands + allowCommands: ["system.run", "browser.*"], + denyCommands: ["system.run.sudo"], + // Auto-approval for node pairing (see below) + autoApprove: { + enabled: false, + roles: ["node"], + ipAllowlist: ["10.0.0.0/8"], + requireToken: true, + auditLog: true + } + } + } +} +``` + +#### `gateway.nodes.autoApprove` (Auto-approval for node pairing) + +Allows trusted nodes to connect without manual approval. Useful for automated deployments (e.g., Kubernetes pods, CI runners) where interactive approval is impractical. + +**Security:** This feature is disabled by default and implements defense-in-depth with multiple independent checks that must all pass. + +```json5 +{ + gateway: { + nodes: { + autoApprove: { + // Master switch - must be explicitly enabled + enabled: true, + // Roles that can be auto-approved (e.g., ["node"]) + // SECURITY: "operator" should typically NOT be included + roles: ["node"], + // IP CIDR allowlist - only these networks are auto-approved + // If empty/unset, only localhost (127.0.0.1, ::1) is allowed + ipAllowlist: [ + "10.0.0.0/8", // Private Class A + "172.16.0.0/12", // Private Class B + "192.168.0.0/16" // Private Class C + ], + // Require valid gateway token for auto-approval (default: true) + // SECURITY: Should almost always be true + requireToken: true, + // Log all auto-approved pairings for audit trail (default: true) + auditLog: true + } + } + } +} +``` + +Field reference: + +| Field | Default | Description | +|-------|---------|-------------| +| `enabled` | `false` | Master switch for auto-approval. Secure default is off. | +| `roles` | `[]` | Roles that can be auto-approved. Empty means no roles. | +| `ipAllowlist` | `[]` | CIDR ranges for allowed IPs. Empty means localhost only. | +| `requireToken` | `true` | Require valid gateway token. Should almost always be true. | +| `auditLog` | `true` | Log auto-approvals with device ID, role, IP, and client info. | + +**Security considerations:** + +- **Disabled by default**: Requires explicit opt-in via `enabled: true`. +- **Role restrictions**: Only roles in the `roles` array are auto-approved. Keep `operator` out unless you have a specific need. +- **IP allowlist**: If not configured, only localhost connections are auto-approved. Use CIDR notation for network ranges. +- **Token validation**: With `requireToken: true` (default), the connecting node must present a valid gateway token. +- **Audit logging**: When `auditLog: true` (default), all non-localhost auto-approvals are logged with full context for security review. + +**Example: Kubernetes cluster** + +```json5 +{ + gateway: { + nodes: { + autoApprove: { + enabled: true, + roles: ["node"], + ipAllowlist: ["10.244.0.0/16"], // Kubernetes pod CIDR + requireToken: true, + auditLog: true + } + } + } +} +``` + +**Example: Local development (localhost only)** + +```json5 +{ + gateway: { + nodes: { + autoApprove: { + enabled: true, + roles: ["node"], + // No ipAllowlist = localhost only (127.0.0.1, ::1) + requireToken: false // OK for local dev + } + } + } +} +``` + +Related docs: +- [Gateway pairing](/gateway/pairing) +- [Nodes CLI](/cli/nodes) +- [Gateway security](/gateway/security) + ### `hooks` (Gateway webhooks) Enable a simple HTTP webhook endpoint on the Gateway HTTP server. diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index 2954e0ae7..7a6487bb2 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -64,13 +64,47 @@ Notes: `node.pair.request`. - Requests may include `silent: true` as a hint for auto-approval flows. -## Auto-approval (macOS app) +## Auto-approval + +Auto-approval allows trusted nodes to connect without manual intervention. There are two mechanisms: + +### macOS app (SSH verification) The macOS app can optionally attempt a **silent approval** when: - the request is marked `silent`, and - the app can verify an SSH connection to the gateway host using the same user. -If silent approval fails, it falls back to the normal “Approve/Reject” prompt. +If silent approval fails, it falls back to the normal "Approve/Reject" prompt. + +### Configuration-based auto-approval + +For automated deployments (Kubernetes, CI runners, etc.), you can configure the gateway to auto-approve nodes based on role, IP address, and token validation. + +```json5 +{ + gateway: { + nodes: { + autoApprove: { + enabled: true, + roles: ["node"], // Only auto-approve "node" role + ipAllowlist: ["10.0.0.0/8"], // Only from private network + requireToken: true, // Must have valid gateway token + auditLog: true // Log all auto-approvals + } + } + } +} +``` + +**Security layers (all must pass):** +1. `enabled: true` - Feature must be explicitly enabled +2. Role must be in `roles` array +3. Client IP must match `ipAllowlist` (or be localhost if empty) +4. Valid gateway token required (when `requireToken: true`) + +Localhost connections (127.0.0.1, ::1) are always auto-approved for backward compatibility. + +See [gateway.nodes.autoApprove](/gateway/configuration#gatewaynodes-node-management) for the full configuration reference. ## Storage (local, private)