Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cli/config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 6 additions & 2 deletions apps/cli/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +33,7 @@ export class OutRayClient {
apiKey?: string,
subdomain?: string,
customDomain?: string,
ipAllowlist?: string[],
noLog: boolean = false,
) {
this.localPort = localPort;
Expand All @@ -41,6 +43,7 @@ export class OutRayClient {
this.customDomain = customDomain;
this.requestedSubdomain = subdomain;
this.noLog = noLog;
this.ipAllowlist = ipAllowlist;
}

public start(): void {
Expand Down Expand Up @@ -99,6 +102,7 @@ export class OutRayClient {
subdomain: this.subdomain,
customDomain: this.customDomain,
forceTakeover: this.forceTakeover,
ipAllowlist: this.ipAllowlist,
});
this.ws?.send(handshake);
}
Expand Down Expand Up @@ -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`)}`,
);
}

Expand All @@ -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)}`,
);
}

Expand Down
29 changes: 26 additions & 3 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ async function handleStartFromConfig(
apiKey,
tunnel.localHost,
tunnel.remotePort,
tunnel.ipAllowlist
);
} else if (tunnel.protocol === "udp") {
client = new UDPTunnelClient(
Expand All @@ -316,6 +317,7 @@ async function handleStartFromConfig(
apiKey,
tunnel.localHost,
tunnel.remotePort,
tunnel.ipAllowlist
);
} else {
client = new OutRayClient(
Expand All @@ -324,6 +326,7 @@ async function handleStartFromConfig(
apiKey,
tunnel.subdomain,
tunnel.customDomain,
tunnel.ipAllowlist
);
}

Expand Down Expand Up @@ -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 <ip/cidr> Allow IP or CIDR (repeatable)"));
console.log(chalk.cyan(" -v, --version Show version"));
console.log(chalk.cyan(" -h, --help Show this help message"));
}
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -705,7 +725,8 @@ async function main() {
apiKey,
"localhost",
remotePort,
noLogs,
ipAllowlist,
noLogs
);
} else if (tunnelProtocol === "udp") {
client = new UDPTunnelClient(
Expand All @@ -714,7 +735,8 @@ async function main() {
apiKey,
"localhost",
remotePort,
noLogs,
ipAllowlist,
noLogs
);
} else {
client = new OutRayClient(
Expand All @@ -723,7 +745,8 @@ async function main() {
apiKey,
subdomain,
customDomain,
noLogs,
ipAllowlist,
noLogs
);
}

Expand Down
4 changes: 4 additions & 0 deletions apps/cli/src/tcp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export class TCPTunnelClient {
private shouldReconnect = true;
private assignedPort: number | null = null;
private connections = new Map<string, net.Socket>();
private ipAllowlist?: string[];
private noLog: boolean;

constructor(
Expand All @@ -29,6 +30,7 @@ export class TCPTunnelClient {
apiKey?: string,
localHost: string = "localhost",
remotePort?: number,
ipAllowlist?: string[],
noLog: boolean = false,
) {
this.localPort = localPort;
Expand All @@ -37,6 +39,7 @@ export class TCPTunnelClient {
this.apiKey = apiKey;
this.remotePort = remotePort;
this.noLog = noLog;
this.ipAllowlist = ipAllowlist;
}

public start(): void {
Expand Down Expand Up @@ -90,6 +93,7 @@ export class TCPTunnelClient {
apiKey: this.apiKey,
protocol: "tcp" as TunnelProtocol,
remotePort: this.remotePort,
ipAllowlist: this.ipAllowlist,
});
this.ws?.send(handshake);
}
Expand Down
24 changes: 14 additions & 10 deletions apps/cli/src/toml-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface TunnelConfig {
custom_domain?: string;
remote_port?: number;
org?: string;
ip_allowlist?: string[];
}

export interface GlobalConfig {
Expand All @@ -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();
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -189,6 +192,7 @@ export class TomlConfigParser {
customDomain: tunnel.custom_domain,
remotePort: tunnel.remote_port,
org: tunnel.org || globalConfig?.org,
ipAllowlist: tunnel.ip_allowlist,
});
}

Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/src/udp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -24,6 +25,7 @@ export class UDPTunnelClient {
apiKey?: string,
localHost: string = "localhost",
remotePort?: number,
ipAllowlist?: string[],
noLog: boolean = false,
) {
this.localPort = localPort;
Expand All @@ -32,6 +34,7 @@ export class UDPTunnelClient {
this.apiKey = apiKey;
this.remotePort = remotePort;
this.noLog = noLog;
this.ipAllowlist = ipAllowlist;
}

public start(): void {
Expand Down Expand Up @@ -87,6 +90,7 @@ export class UDPTunnelClient {
apiKey: this.apiKey,
protocol: "udp" as TunnelProtocol,
remotePort: this.remotePort,
ipAllowlist: this.ipAllowlist,
});
this.ws?.send(handshake);
}
Expand Down
11 changes: 7 additions & 4 deletions apps/tunnel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
}
}
}
32 changes: 14 additions & 18 deletions apps/tunnel/src/core/HTTPProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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("<h1>403 Forbidden</h1><p>Access denied by IP Allowlist.</p>");
return;
}
}

const redis = this.router.getRedis();
const bandwidthKey =
metadata?.organizationId && redis
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading