diff --git a/apps/cli/config.toml.example b/apps/cli/config.toml.example index 623343dc..f0b4860c 100644 --- a/apps/cli/config.toml.example +++ b/apps/cli/config.toml.example @@ -7,6 +7,7 @@ local_port = 3000 local_host = "localhost" subdomain = "my-app" custom_domain = "app.example.com" +ip_allowlist = ["1.2.3.4/32"] [tunnel.api] protocol = "http" diff --git a/apps/cli/src/client.ts b/apps/cli/src/client.ts index 09b5bb00..325bd291 100644 --- a/apps/cli/src/client.ts +++ b/apps/cli/src/client.ts @@ -22,6 +22,7 @@ export class OutRayClient { private forceTakeover = false; private reconnectAttempts = 0; private lastPongReceived = Date.now(); + private ipAllowlist?: string[]; private noLog: boolean; private readonly PING_INTERVAL_MS = 25000; // 25 seconds private readonly PONG_TIMEOUT_MS = 10000; // 10 seconds to wait for pong @@ -32,6 +33,7 @@ export class OutRayClient { apiKey?: string, subdomain?: string, customDomain?: string, + ipAllowlist?: string[], noLog: boolean = false, ) { this.localPort = localPort; @@ -41,6 +43,7 @@ export class OutRayClient { this.customDomain = customDomain; this.requestedSubdomain = subdomain; this.noLog = noLog; + this.ipAllowlist = ipAllowlist; } public start(): void { @@ -99,6 +102,7 @@ export class OutRayClient { subdomain: this.subdomain, customDomain: this.customDomain, forceTakeover: this.forceTakeover, + ipAllowlist: this.ipAllowlist, }); this.ws?.send(handshake); } @@ -213,7 +217,7 @@ export class OutRayClient { if (!this.noLog) { console.log( chalk.dim("←") + - ` ${chalk.bold(message.method)} ${message.path} ${statusColor(statusCode)} ${chalk.dim(`${duration}ms`)}`, + ` ${chalk.bold(message.method)} ${message.path} ${statusColor(statusCode)} ${chalk.dim(`${duration}ms`)}`, ); } @@ -238,7 +242,7 @@ export class OutRayClient { if (!this.noLog) { console.log( chalk.dim("←") + - ` ${chalk.bold(message.method)} ${message.path} ${chalk.red("502")} ${chalk.dim(`${duration}ms`)} ${chalk.red(err.message)}`, + ` ${chalk.bold(message.method)} ${message.path} ${chalk.red("502")} ${chalk.dim(`${duration}ms`)} ${chalk.red(err.message)}`, ); } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index b14bb994..66e9556b 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -308,6 +308,7 @@ async function handleStartFromConfig( apiKey, tunnel.localHost, tunnel.remotePort, + tunnel.ipAllowlist ); } else if (tunnel.protocol === "udp") { client = new UDPTunnelClient( @@ -316,6 +317,7 @@ async function handleStartFromConfig( apiKey, tunnel.localHost, tunnel.remotePort, + tunnel.ipAllowlist ); } else { client = new OutRayClient( @@ -324,6 +326,7 @@ async function handleStartFromConfig( apiKey, tunnel.subdomain, tunnel.customDomain, + tunnel.ipAllowlist ); } @@ -385,6 +388,7 @@ function printHelp() { chalk.cyan(" --no-logs Disable tunnel request logs"), ); console.log(chalk.cyan(" --dev Use dev environment")); + console.log(chalk.cyan(" --ip Allow IP or CIDR (repeatable)")); console.log(chalk.cyan(" -v, --version Show version")); console.log(chalk.cyan(" -h, --help Show this help message")); } @@ -630,6 +634,22 @@ async function main() { // Handle --no-logs flag to disable tunnel request logs const noLogs = remainingArgs.includes("--no-logs"); + // Handle --ip flag for IP allowlisting (repeatable) + const ipAllowlist: string[] = []; + for (let i = 0; i < remainingArgs.length; i++) { + const arg = remainingArgs[i]; + + if (arg === "--ip" && remainingArgs[i + 1]) { + ipAllowlist.push(remainingArgs[i + 1]); + i++; + } else if (arg.startsWith("--ip=")) { + const value = arg.split("=")[1]; + if (value) { + ipAllowlist.push(value); + } + } + } + // Load and validate config let config = configManager.load(); @@ -705,7 +725,8 @@ async function main() { apiKey, "localhost", remotePort, - noLogs, + ipAllowlist, + noLogs ); } else if (tunnelProtocol === "udp") { client = new UDPTunnelClient( @@ -714,7 +735,8 @@ async function main() { apiKey, "localhost", remotePort, - noLogs, + ipAllowlist, + noLogs ); } else { client = new OutRayClient( @@ -723,7 +745,8 @@ async function main() { apiKey, subdomain, customDomain, - noLogs, + ipAllowlist, + noLogs ); } diff --git a/apps/cli/src/tcp-client.ts b/apps/cli/src/tcp-client.ts index 23e5e1ff..6f9a50bd 100644 --- a/apps/cli/src/tcp-client.ts +++ b/apps/cli/src/tcp-client.ts @@ -21,6 +21,7 @@ export class TCPTunnelClient { private shouldReconnect = true; private assignedPort: number | null = null; private connections = new Map(); + private ipAllowlist?: string[]; private noLog: boolean; constructor( @@ -29,6 +30,7 @@ export class TCPTunnelClient { apiKey?: string, localHost: string = "localhost", remotePort?: number, + ipAllowlist?: string[], noLog: boolean = false, ) { this.localPort = localPort; @@ -37,6 +39,7 @@ export class TCPTunnelClient { this.apiKey = apiKey; this.remotePort = remotePort; this.noLog = noLog; + this.ipAllowlist = ipAllowlist; } public start(): void { @@ -90,6 +93,7 @@ export class TCPTunnelClient { apiKey: this.apiKey, protocol: "tcp" as TunnelProtocol, remotePort: this.remotePort, + ipAllowlist: this.ipAllowlist, }); this.ws?.send(handshake); } diff --git a/apps/cli/src/toml-config.ts b/apps/cli/src/toml-config.ts index dfffa5bd..cd6edb31 100644 --- a/apps/cli/src/toml-config.ts +++ b/apps/cli/src/toml-config.ts @@ -13,6 +13,7 @@ export interface TunnelConfig { custom_domain?: string; remote_port?: number; org?: string; + ip_allowlist?: string[]; } export interface GlobalConfig { @@ -33,6 +34,7 @@ export interface ParsedTunnelConfig { customDomain?: string; remotePort?: number; org?: string; + ipAllowlist?: string[]; } const portSchema = Joi.number().integer().min(1).max(65535).required(); @@ -61,6 +63,7 @@ const tunnelConfigSchema = Joi.object({ custom_domain: Joi.string().hostname().optional(), remote_port: Joi.number().integer().min(1).max(65535).optional(), org: Joi.string().optional(), + ip_allowlist: Joi.array().items(Joi.string()).optional(), }).custom((value: TunnelConfig, helpers: Joi.CustomHelpers) => { const protocol = value.protocol; @@ -146,18 +149,18 @@ export class TomlConfigParser { } } - if (fieldName) { - const fieldDisplayName = fieldName.replace(/_/g, " "); - const fullPath = detail.path.join("."); - - if (message.includes(`"${fullPath}"`)) { - message = message.replace(`"${fullPath}"`, fieldDisplayName); - } else if (message.includes(fieldName)) { - message = message.replace(new RegExp(fieldName, "g"), fieldDisplayName); - } + if (fieldName) { + const fieldDisplayName = fieldName.replace(/_/g, " "); + const fullPath = detail.path.join("."); + + if (message.includes(`"${fullPath}"`)) { + message = message.replace(`"${fullPath}"`, fieldDisplayName); + } else if (message.includes(fieldName)) { + message = message.replace(new RegExp(fieldName, "g"), fieldDisplayName); } + } - message = message.replace(/^"/, "").replace(/"$/, ""); + message = message.replace(/^"/, "").replace(/"$/, ""); if (tunnelName && typeof tunnelName === "string") { const capitalizedMessage = message.charAt(0).toUpperCase() + message.slice(1); @@ -189,6 +192,7 @@ export class TomlConfigParser { customDomain: tunnel.custom_domain, remotePort: tunnel.remote_port, org: tunnel.org || globalConfig?.org, + ipAllowlist: tunnel.ip_allowlist, }); } diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index d9052f76..9e2368e9 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -8,6 +8,7 @@ export interface OpenTunnelMessage { forceTakeover?: boolean; protocol?: TunnelProtocol; remotePort?: number; // For TCP/UDP: the port to expose on the server + ipAllowlist?: string[]; // List of allowed IPs or CIDR ranges } export interface TunnelOpenedMessage { diff --git a/apps/cli/src/udp-client.ts b/apps/cli/src/udp-client.ts index c0f00580..54e41837 100644 --- a/apps/cli/src/udp-client.ts +++ b/apps/cli/src/udp-client.ts @@ -16,6 +16,7 @@ export class UDPTunnelClient { private shouldReconnect = true; private assignedPort: number | null = null; private socket: dgram.Socket | null = null; + private ipAllowlist?: string[]; private noLog: boolean; constructor( @@ -24,6 +25,7 @@ export class UDPTunnelClient { apiKey?: string, localHost: string = "localhost", remotePort?: number, + ipAllowlist?: string[], noLog: boolean = false, ) { this.localPort = localPort; @@ -32,6 +34,7 @@ export class UDPTunnelClient { this.apiKey = apiKey; this.remotePort = remotePort; this.noLog = noLog; + this.ipAllowlist = ipAllowlist; } public start(): void { @@ -87,6 +90,7 @@ export class UDPTunnelClient { apiKey: this.apiKey, protocol: "udp" as TunnelProtocol, remotePort: this.remotePort, + ipAllowlist: this.ipAllowlist, }); this.ws?.send(handshake); } diff --git a/apps/tunnel/package.json b/apps/tunnel/package.json index bfb49f17..f9570869 100644 --- a/apps/tunnel/package.json +++ b/apps/tunnel/package.json @@ -5,12 +5,14 @@ "scripts": { "dev": "tsx watch src/server.ts", "build": "tsup && cp src/offline.html src/bandwidth_exceeded.html dist/", - "start": "node dist/server.js" + "start": "node dist/server.js", + "test": "vitest run" }, "dependencies": { - "pg": "^8.13.1", "dotenv": "^17.2.3", "ioredis": "^5.4.1", + "ip-range-check": "^0.2.0", + "pg": "^8.13.1", "ws": "^8.18.0" }, "devDependencies": { @@ -19,6 +21,7 @@ "@types/ws": "^8.5.13", "tsup": "^8.5.1", "tsx": "^4.19.2", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^4.0.17" } -} +} \ No newline at end of file diff --git a/apps/tunnel/src/core/HTTPProxy.ts b/apps/tunnel/src/core/HTTPProxy.ts index ad236b73..f4bb7fa4 100644 --- a/apps/tunnel/src/core/HTTPProxy.ts +++ b/apps/tunnel/src/core/HTTPProxy.ts @@ -5,6 +5,7 @@ import { TunnelRouter } from "./TunnelRouter"; import { getBandwidthKey, generateId } from "../../../../shared/utils"; import { logger, requestCaptureLogger } from "../lib/tigerdata"; import { LogManager } from "./LogManager"; +import { IpGuard } from "../lib/IpGuard"; export class HTTPProxy { private router: TunnelRouter; @@ -52,6 +53,16 @@ export class HTTPProxy { }); const metadata = this.router.getTunnelMetadata(tunnelId); + + if (metadata?.ipAllowlist && metadata.ipAllowlist.length > 0) { + const clientIp = this.getClientIp(req); + if (!IpGuard.isAllowed(clientIp, metadata.ipAllowlist)) { + res.writeHead(403, { "Content-Type": "text/html" }); + res.end("

403 Forbidden

Access denied by IP Allowlist.

"); + return; + } + } + const redis = this.router.getRedis(); const bandwidthKey = metadata?.organizationId && redis @@ -149,7 +160,7 @@ export class HTTPProxy { if (metadata.fullCaptureEnabled) { const captureId = generateId("capture"); const maxBodySize = 1024 * 1024; // 1MB limit - + // Prepare request body (truncate if too large) let requestBody: string | null = null; let requestBodySize = bodyBuffer.length; @@ -202,27 +213,12 @@ export class HTTPProxy { } private getClientIp(req: IncomingMessage): string { - let ip = + const rawIp = (req.headers["x-forwarded-for"] as string) || req.socket.remoteAddress || "0.0.0.0"; - // Handle comma-separated list in x-forwarded-for - if (ip.includes(",")) { - ip = ip.split(",")[0].trim(); - } - - // Handle IPv4-mapped IPv6 addresses - if (ip.startsWith("::ffff:")) { - ip = ip.substring(7); - } - - // Handle localhost IPv6 - if (ip === "::1") { - ip = "127.0.0.1"; - } - - return ip; + return IpGuard.normalizeIp(rawIp); } private getOfflineHtml(tunnelId: string): string { diff --git a/apps/tunnel/src/core/TCPProxy.ts b/apps/tunnel/src/core/TCPProxy.ts index dc45af46..2974edcf 100644 --- a/apps/tunnel/src/core/TCPProxy.ts +++ b/apps/tunnel/src/core/TCPProxy.ts @@ -1,6 +1,7 @@ import net from "net"; import WebSocket from "ws"; import Redis from "ioredis"; +import { IpGuard } from "../lib/IpGuard"; import { Protocol, TCPConnectionMessage, @@ -31,6 +32,7 @@ interface TCPTunnel { bandwidthLimit?: number; port: number; connections: Map; + ipAllowlist?: string[]; } export class TCPProxy { @@ -54,6 +56,7 @@ export class TCPProxy { organizationId: string, requestedPort?: number, bandwidthLimit?: number, + ipAllowlist?: string[], ): Promise<{ success: boolean; port?: number; error?: string }> { // Clean up existing tunnel if any await this.closeTunnel(tunnelId); @@ -86,6 +89,7 @@ export class TCPProxy { bandwidthLimit, port, connections: new Map(), + ipAllowlist, }; this.tunnels.set(tunnelId, tunnel); @@ -110,8 +114,17 @@ export class TCPProxy { return; } + const rawIp = socket.remoteAddress || "0.0.0.0"; + const clientIp = IpGuard.normalizeIp(rawIp); + + if (tunnel.ipAllowlist && tunnel.ipAllowlist.length > 0) { + if (!IpGuard.isAllowed(clientIp, tunnel.ipAllowlist)) { + socket.destroy(); + return; + } + } + const connectionId = generateId("tcp"); - const clientIp = socket.remoteAddress || "unknown"; const clientPort = socket.remotePort || 0; const connection: TCPConnection = { diff --git a/apps/tunnel/src/core/TunnelRouter.ts b/apps/tunnel/src/core/TunnelRouter.ts index c2b25d74..5cd191b5 100644 --- a/apps/tunnel/src/core/TunnelRouter.ts +++ b/apps/tunnel/src/core/TunnelRouter.ts @@ -26,6 +26,7 @@ export interface TunnelMetadata { retentionDays?: number; plan?: string; fullCaptureEnabled?: boolean; + ipAllowlist?: string[]; } export class TunnelRouter { diff --git a/apps/tunnel/src/core/UDPProxy.ts b/apps/tunnel/src/core/UDPProxy.ts index 64d21481..7c0f6518 100644 --- a/apps/tunnel/src/core/UDPProxy.ts +++ b/apps/tunnel/src/core/UDPProxy.ts @@ -1,6 +1,7 @@ import dgram from "dgram"; import WebSocket from "ws"; import Redis from "ioredis"; +import { IpGuard } from "../lib/IpGuard"; import { Protocol, UDPDataMessage, UDPResponseMessage } from "./Protocol"; import { generateId, getBandwidthKey } from "../../../../shared/utils"; import { protocolLogger } from "../lib/tigerdata"; @@ -23,6 +24,7 @@ interface UDPTunnel { bandwidthLimit?: number; port: number; clients: Map; + ipAllowlist?: string[]; } export class UDPProxy { @@ -55,6 +57,7 @@ export class UDPProxy { organizationId: string, requestedPort?: number, bandwidthLimit?: number, + ipAllowlist?: string[], ): Promise<{ success: boolean; port?: number; error?: string }> { // Clean up existing tunnel if any await this.closeTunnel(tunnelId); @@ -93,6 +96,7 @@ export class UDPProxy { bandwidthLimit, port, clients: new Map(), + ipAllowlist, }; this.tunnels.set(tunnelId, tunnel); @@ -120,6 +124,12 @@ export class UDPProxy { return; } + if (tunnel.ipAllowlist && tunnel.ipAllowlist.length > 0) { + if (!IpGuard.isAllowed(rinfo.address, tunnel.ipAllowlist)) { + return; + } + } + if (await this.checkBandwidthExceeded(tunnel, msg.length)) { return; // Drop packet if bandwidth exceeded } diff --git a/apps/tunnel/src/core/WSHandler.ts b/apps/tunnel/src/core/WSHandler.ts index d6438a82..220463a5 100644 --- a/apps/tunnel/src/core/WSHandler.ts +++ b/apps/tunnel/src/core/WSHandler.ts @@ -285,6 +285,7 @@ export class WSHandler { organizationId || "", message.remotePort, bandwidthLimit, + message.ipAllowlist, ); if (!result.success) { @@ -362,6 +363,7 @@ export class WSHandler { organizationId || "", message.remotePort, bandwidthLimit, + message.ipAllowlist, ); if (!result.success) { @@ -507,6 +509,7 @@ export class WSHandler { bandwidthLimit, plan, fullCaptureEnabled, + ipAllowlist: message.ipAllowlist, }, ); @@ -574,6 +577,7 @@ export class WSHandler { bandwidthLimit, retentionDays, fullCaptureEnabled, + ipAllowlist: message.ipAllowlist, }, message.forceTakeover || false, ); @@ -611,6 +615,7 @@ export class WSHandler { bandwidthLimit, retentionDays, fullCaptureEnabled, + ipAllowlist: message.ipAllowlist, }, ); if (reservationAcquired) { @@ -633,6 +638,7 @@ export class WSHandler { bandwidthLimit, retentionDays, fullCaptureEnabled, + ipAllowlist: message.ipAllowlist, }, ); if (reservationAcquired) { @@ -712,6 +718,7 @@ export class WSHandler { retentionDays, plan, fullCaptureEnabled, + ipAllowlist: message.ipAllowlist, }, ); diff --git a/apps/tunnel/src/lib/IpGuard.ts b/apps/tunnel/src/lib/IpGuard.ts new file mode 100644 index 00000000..cb68801b --- /dev/null +++ b/apps/tunnel/src/lib/IpGuard.ts @@ -0,0 +1,36 @@ +import ipRangeCheck from "ip-range-check"; + +export class IpGuard { + /** + * Normalizes an IP address to a standard IPv4 or IPv6 format. + */ + static normalizeIp(ip?: string | null): string { + if (!ip) return "0.0.0.0"; + + let normalized = ip; + + if (normalized.includes(",")) { + normalized = normalized.split(",")[0].trim(); + } + if (normalized.startsWith("::ffff:")) { + normalized = normalized.substring(7); + } + if (normalized === "::1") { + normalized = "127.0.0.1"; + } + + return normalized; + } + + /** + * Checks if an IP address is allowed based on the provided allowlist. + */ + static isAllowed(ip: string, allowlist?: string[]): boolean { + if (!allowlist || allowlist.length === 0) { + return true; + } + + const normalizedIp = IpGuard.normalizeIp(ip); + return ipRangeCheck(normalizedIp, allowlist); + } +} diff --git a/apps/tunnel/tests/IpGuard.test.ts b/apps/tunnel/tests/IpGuard.test.ts new file mode 100644 index 00000000..d756d180 --- /dev/null +++ b/apps/tunnel/tests/IpGuard.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { IpGuard } from "../src/lib/IpGuard"; + +describe("IpGuard", () => { + describe("normalizeIp", () => { + it("should return IPv4 as is", () => { + expect(IpGuard.normalizeIp("1.2.3.4")).toBe("1.2.3.4"); + }); + + it("should normalize IPv4-mapped IPv6", () => { + expect(IpGuard.normalizeIp("::ffff:1.2.3.4")).toBe("1.2.3.4"); + }); + + it("should normalize localhost IPv6", () => { + expect(IpGuard.normalizeIp("::1")).toBe("127.0.0.1"); + }); + + it("should handle x-forwarded-for format", () => { + expect(IpGuard.normalizeIp("1.2.3.4, 5.6.7.8")).toBe("1.2.3.4"); + }); + + it("should handle empty or null", () => { + expect(IpGuard.normalizeIp("")).toBe("0.0.0.0"); + expect(IpGuard.normalizeIp(null)).toBe("0.0.0.0"); + }); + }); + + describe("isAllowed", () => { + it("should allow everything if allowlist is empty", () => { + expect(IpGuard.isAllowed("1.2.3.4", [])).toBe(true); + expect(IpGuard.isAllowed("1.2.3.4", undefined)).toBe(true); + }); + + it("should allow exact IP match", () => { + const allowlist = ["1.2.3.4", "5.6.7.8"]; + expect(IpGuard.isAllowed("1.2.3.4", allowlist)).toBe(true); + expect(IpGuard.isAllowed("5.6.7.8", allowlist)).toBe(true); + }); + + it("should deny non-matching IP", () => { + const allowlist = ["1.2.3.4"]; + expect(IpGuard.isAllowed("5.6.7.8", allowlist)).toBe(false); + }); + + it("should allow CIDR range match", () => { + const allowlist = ["10.0.0.0/8"]; + expect(IpGuard.isAllowed("10.0.0.1", allowlist)).toBe(true); + expect(IpGuard.isAllowed("10.255.255.255", allowlist)).toBe(true); + }); + + it("should deny outside CIDR range", () => { + const allowlist = ["10.0.0.0/8"]; + expect(IpGuard.isAllowed("11.0.0.1", allowlist)).toBe(false); + }); + + it("should normalize IP before checking", () => { + const allowlist = ["127.0.0.1"]; + expect(IpGuard.isAllowed("::1", allowlist)).toBe(true); + }); + + it("should handle mixed allowlist", () => { + const allowlist = ["1.2.3.4", "192.168.0.0/16"]; + expect(IpGuard.isAllowed("1.2.3.4", allowlist)).toBe(true); + expect(IpGuard.isAllowed("192.168.1.1", allowlist)).toBe(true); + expect(IpGuard.isAllowed("8.8.8.8", allowlist)).toBe(false); + }); + }); +}); diff --git a/apps/web/content/docs/(reference)/cli-reference.mdx b/apps/web/content/docs/(reference)/cli-reference.mdx index 901acd5a..d8a3204e 100644 --- a/apps/web/content/docs/(reference)/cli-reference.mdx +++ b/apps/web/content/docs/(reference)/cli-reference.mdx @@ -68,6 +68,7 @@ outray 3000 - `--subdomain `: Request a specific subdomain. - `--domain `: Use a custom domain. +- `--ip `: Restrict access to specific IP addresses or CIDR ranges (can be used multiple times). - `--org `: Run the tunnel under a specific organization. - `--key `: Use a specific API key instead of the logged-in user. - `--no-logs`: Disable tunnel request logs. @@ -152,6 +153,7 @@ remote_port = 30000 - `subdomain`: Custom subdomain for HTTP tunnels (optional, HTTP only) - `custom_domain`: Custom domain for HTTP tunnels (optional, HTTP only) - `remote_port`: Remote port for TCP/UDP tunnels (optional, TCP/UDP only) +- `ip_allowlist`: List of allowed IPs or CIDR ranges (optional, e.g., `["1.2.3.4", "10.0.0.0/8"]`) - `org`: Organization slug for this tunnel (optional, overrides global setting) ### Example Usage diff --git a/package-lock.json b/package-lock.json index 33380bf4..846f0722 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "dependencies": { "dotenv": "^17.2.3", "ioredis": "^5.4.1", + "ip-range-check": "^0.2.0", "pg": "^8.13.1", "ws": "^8.18.0" }, @@ -70,7 +71,8 @@ "@types/ws": "^8.5.13", "tsup": "^8.5.1", "tsx": "^4.19.2", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^4.0.17" } }, "apps/web": { @@ -5034,6 +5036,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -5303,6 +5316,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/draco3d": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", @@ -5823,6 +5843,117 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", + "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.17", + "@vitest/utils": "4.0.17", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", + "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.17", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", + "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", + "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", + "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.17", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", + "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", + "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.17", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webgpu/types": { "version": "0.1.68", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.68.tgz", @@ -5977,6 +6108,16 @@ "node": ">=12.0.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -6361,6 +6502,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7822,6 +7973,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -8299,6 +8457,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -9902,6 +10070,24 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ip-range-check": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/ip-range-check/-/ip-range-check-0.2.0.tgz", + "integrity": "sha512-oaM3l/3gHbLlt/tCWLvt0mj1qUaI+STuRFnUvARGCujK9vvU61+2JsDpmkMzR4VsJhuFXWWgeKKVnwwoFfzCqw==", + "license": "MIT", + "dependencies": { + "ipaddr.js": "^1.0.1" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -12285,6 +12471,17 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ofetch": { "version": "2.0.0-alpha.3", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-2.0.0-alpha.3.tgz", @@ -14088,6 +14285,13 @@ "simple-concat": "^1.0.0" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -14184,6 +14388,13 @@ "node": ">=20.16.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -14261,6 +14472,13 @@ "node": ">=8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -14540,6 +14758,13 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -14605,6 +14830,16 @@ "node": ">=4" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -16878,6 +17113,97 @@ } } }, + "node_modules/vitest": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", + "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.17", + "@vitest/mocker": "4.0.17", + "@vitest/pretty-format": "4.0.17", + "@vitest/runner": "4.0.17", + "@vitest/snapshot": "4.0.17", + "@vitest/spy": "4.0.17", + "@vitest/utils": "4.0.17", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.17", + "@vitest/browser-preview": "4.0.17", + "@vitest/browser-webdriverio": "4.0.17", + "@vitest/ui": "4.0.17", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", @@ -17003,6 +17329,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -17213,4 +17556,4 @@ } } } -} +} \ No newline at end of file diff --git a/shared/types.ts b/shared/types.ts index bbed9b87..9c83994a 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -14,6 +14,7 @@ export interface OpenTunnelMessage { forceTakeover?: boolean; protocol?: TunnelProtocol; remotePort?: number; // For TCP/UDP: the port to expose on the server + ipAllowlist?: string[]; // List of allowed IPs or CIDR ranges } export interface TunnelOpenedMessage {