From 7364d675c129632d97aa55204d9e4f9f5c480f55 Mon Sep 17 00:00:00 2001 From: Prateek Jain <49508975+Prateek32177@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:42:55 +0530 Subject: [PATCH] Document hosted relay trust model and ignore local secrets/artifacts --- .gitignore | 4 + README.md | 69 ++++++++- src/cli.ts | 227 +++++++++++++++++++++++------- src/config.ts | 153 ++++++++++++++++++++ src/forwarder.ts | 293 +++++++++++++++++++++++++++++++++++---- src/logger.ts | 38 ++++- src/types.ts | 12 +- src/ui-server.ts | 8 +- tern-config.schema.json | 108 +++++++++++++++ tern.config.example.json | 23 +++ 10 files changed, 834 insertions(+), 101 deletions(-) create mode 100644 src/config.ts create mode 100644 tern-config.schema.json create mode 100644 tern.config.example.json diff --git a/.gitignore b/.gitignore index 76557b0..156dcc6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ dist/ node_modules/ src/ui-bundle.ts *.js.map +tern.config.json +tern-audit.log +*.pem +*.key diff --git a/README.md b/README.md index 5e56d34..1ec0acc 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,7 @@ npx @hookflo/tern-dev --port 3000 Output: ```text -tern ● https://tern-relay.hookflo-tern.workers.dev/s/abc12345 → localhost:3000 - ● Dashboard → http://localhost:2019 +Tunnel URL: https://abc123.relay.tern.hookflo.com ``` 1. Copy the tunnel URL. @@ -32,11 +31,68 @@ tern-dev opens a WebSocket to the relay server and receives a public tunnel URL. | Flag | Default | Description | |------|---------|-------------| | `--port` | required | Local port to forward to | -| `--path` | `/` | Path on local server | +| `--path` | `/` | Path prefix on local server | | `--ui-port` | `2019` | Dashboard port | | `--no-ui` | false | Disable dashboard | -| `--relay` | hosted relay | Custom relay URL (for self-hosting) | +| `--relay` | `wss://relay.tern.hookflo.com` | Custom relay URL (for self-hosting) | | `--max-events` | `500` | Events kept in memory | +| `--ttl` | unset | Auto-kill session after N minutes | +| `--rate-limit` | unset | Max incoming requests/min; excess returns 429 | +| `--allow-ip` | unset | Comma-separated allowlist of IP/CIDR ranges | +| `--block-paths` | unset | Comma-separated blocked path prefixes | +| `--block-methods` | unset | Comma-separated blocked methods | +| `--block-headers` | unset | Comma-separated `name:glob` block rules (supports `*`) | +| `--log` | unset | Append request audit lines to a file | +| `--local-cert` | unset | TLS certificate path for local forwarding | +| `--local-key` | unset | TLS private key path for local forwarding | + +## Configuration + +tern-dev also supports loading config from `tern.config.json` (or `.ternrc.json`) by searching from your current directory upward. + +Priority order is: + +1. CLI flags +2. `tern.config.json` +3. built-in defaults + +Example `tern.config.json`: + +```json +{ + "$schema": "./tern-config.schema.json", + "port": 3000, + "path": "/webhooks", + "uiPort": 2019, + "noUi": false, + "relay": "wss://relay.tern.hookflo.com", + "maxEvents": 500, + "ttl": 60, + "rateLimit": 100, + "allowIp": ["54.187.174.169", "192.30.252.0/22"], + "block": { + "paths": ["/admin", "/debug", "/metrics"], + "methods": ["DELETE"], + "headers": { + "user-agent": "curl*" + } + }, + "log": "./tern-audit.log" +} +``` + +Notes: + +- IP allowlisting supports exact IPv4 and CIDR ranges. +- Webhook signature verification (Stripe, GitHub, Clerk, etc.) belongs in your app handler; tern-dev intentionally does not do signature auth at the tunnel layer. +- The local dashboard at `localhost:2019` remains open and unauthenticated by design. +- Pressing `Ctrl+C` performs a graceful shutdown: relay WebSocket and dashboard server are closed cleanly, then tern-dev prints a final session-end confirmation. + +If `--log` is set, tern-dev writes one line per completed request in this format: + +```text +[] from ms +``` ## Dashboard features @@ -83,8 +139,9 @@ RELAY_URL=wss://your-relay.your-account.workers.dev \ ## Privacy -- Nothing is stored to disk -- Nothing is sent to Hookflo servers (except through the relay, which stores nothing) +- Nothing is stored to disk unless you explicitly set `--log` +- By default, traffic passes through a hosted relay on Cloudflare Workers, so Cloudflare can see in-transit traffic like any relay provider. +- For stricter trust boundaries, self-host the relay and point tern-dev to it with `--relay`. - All event data is session-scoped in memory — cleared when you close the terminal - The relay source is small and fully auditable diff --git a/src/cli.ts b/src/cli.ts index d2f5a95..3309805 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,15 +1,12 @@ #!/usr/bin/env node +import fs from "node:fs"; import minimist from "minimist"; +import { resolveConfig, TernConfig, validateConfig } from "./config"; import { EventStore } from "./event-store"; -import { forward } from "./forwarder"; -import { error, info, printBanner, warn } from "./logger"; +import { forward, setLocalTlsCredentials } from "./forwarder"; +import { error, info, printBanner, printSafetyBanner, warn } from "./logger"; import { RelayClient } from "./relay-client"; -import { - Config, - RelayConnectedMessage, - RelayMessage, - StatusPayload, -} from "./types"; +import { RelayConnectedMessage, RelayMessage, StatusPayload } from "./types"; import { UiServer } from "./ui-server"; import { WsServer } from "./ws-server"; @@ -17,36 +14,79 @@ interface PackageMeta { version?: string; } -function parseConfig(): Config { +function parseCliArgs(): Partial { const args = minimist(process.argv.slice(2), { boolean: ["no-ui"], - string: ["relay", "path"], - default: { - path: "/", - "ui-port": 2019, - "max-events": 500, - relay: - process.env.RELAY_URL ?? "wss://tern-relay.hookflo-tern.workers.dev", - }, + string: [ + "relay", + "path", + "allow-ip", + "block-paths", + "block-methods", + "block-headers", + "log", + "local-cert", + "local-key", + ], }); - const port = Number(args.port); - if (!Number.isFinite(port) || port <= 0) { - throw new Error("--port is required (example: --port 3000)"); - } + const parseList = (value: unknown): string[] | undefined => { + if (typeof value !== "string") return undefined; + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + }; + + const parseHeaders = (value: unknown): Record | undefined => { + if (typeof value !== "string") return undefined; + const pairs = value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); - const uiPort = Number(args["ui-port"]); - const maxEvents = Number(args["max-events"]); - - return { - port, - path: String(args.path || "/"), - uiPort, - wsPort: uiPort + 1, - noUi: Boolean(args["no-ui"]), - relayUrl: String(args.relay), - maxEvents: Number.isFinite(maxEvents) ? maxEvents : 500, + if (pairs.length === 0) return undefined; + + const mapped: Record = {}; + for (const pair of pairs) { + const [key, ...rest] = pair.split(":"); + if (!key || rest.length === 0) continue; + mapped[key.trim().toLowerCase()] = rest.join(":").trim(); + } + return Object.keys(mapped).length > 0 ? mapped : undefined; }; + + const toNumber = (value: unknown): number | undefined => { + if (value === undefined) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : Number.NaN; + }; + + const parsed: Partial = { + port: toNumber(args.port), + path: typeof args.path === "string" ? args.path : undefined, + uiPort: toNumber(args["ui-port"]), + noUi: args["no-ui"] === undefined ? undefined : Boolean(args["no-ui"]), + relay: typeof args.relay === "string" ? args.relay : undefined, + maxEvents: toNumber(args["max-events"]), + ttl: toNumber(args.ttl), + rateLimit: toNumber(args["rate-limit"]), + allowIp: parseList(args["allow-ip"]), + block: { + paths: parseList(args["block-paths"]), + methods: parseList(args["block-methods"]), + headers: parseHeaders(args["block-headers"]), + }, + log: typeof args.log === "string" ? args.log : undefined, + localCert: typeof args["local-cert"] === "string" ? args["local-cert"] : undefined, + localKey: typeof args["local-key"] === "string" ? args["local-key"] : undefined, + }; + + if (!parsed.block?.paths && !parsed.block?.methods && !parsed.block?.headers) { + delete parsed.block; + } + + return parsed; } function loadUiBundle(): string { @@ -67,10 +107,38 @@ function loadVersion(): string { } } +function appendAuditLog(config: TernConfig, event: { method: string; path: string; sourceIp: string; status: number | null; statusText?: string | null; latency: number | null; }): void { + if (!config.log) { + return; + } + + const timestamp = new Date().toISOString(); + const statusCode = event.status ?? 500; + const statusText = event.statusText ?? "ERR"; + const duration = event.latency ?? 0; + const line = `[${timestamp}] ${event.method} ${event.path} from ${event.sourceIp} → ${statusCode} ${statusText} ${duration}ms\n`; + + fs.appendFile(config.log, line, (err) => { + if (err) { + const message = err instanceof Error ? err.message : String(err); + console.log(`[tern] warning: audit log write failed — ${message}`); + } + }); +} + async function main(): Promise { - const config = parseConfig(); + const cliArgs = parseCliArgs(); + const config = resolveConfig(cliArgs); + validateConfig(config); + const version = loadVersion(); - const eventStore = new EventStore(config.maxEvents); + if (config.localCert && config.localKey) { + const cert = fs.readFileSync(config.localCert); + const key = fs.readFileSync(config.localKey); + setLocalTlsCredentials(cert, key); + } + + const eventStore = new EventStore(config.maxEvents ?? 500); const relayClient = new RelayClient(); const wsServer = new WsServer(); @@ -81,11 +149,35 @@ async function main(): Promise { sessionId: "", }; + let sessionExpiryTimer: NodeJS.Timeout | null = null; + let ttlWarningFive: NodeJS.Timeout | null = null; + let ttlWarningOne: NodeJS.Timeout | null = null; + const setStatus = (next: Partial) => { status = { ...status, ...next }; wsServer.setStatus(status); }; + const clearTimers = (): void => { + if (sessionExpiryTimer) clearTimeout(sessionExpiryTimer); + if (ttlWarningFive) clearTimeout(ttlWarningFive); + if (ttlWarningOne) clearTimeout(ttlWarningOne); + sessionExpiryTimer = null; + ttlWarningFive = null; + ttlWarningOne = null; + }; + + const shutdown = (message?: string) => { + if (message) { + console.log(message); + } + clearTimers(); + relayClient.close(); + wsServer.close(); + uiServer?.close(); + process.exit(0); + }; + relayClient.on("connected", (payload: RelayConnectedMessage) => { const tunnelChanged = Boolean(status.tunnelUrl) && status.tunnelUrl !== payload.url; @@ -101,7 +193,33 @@ async function main(): Promise { "Tunnel URL changed after reconnect — update your webhook endpoint.", ); } - printBanner(payload.url, config.port, config.uiPort, config.noUi); + + printBanner(payload.url, config.port ?? 0, config.uiPort ?? 2019, Boolean(config.noUi)); + printSafetyBanner(config.ttl); + + if (config.ttl !== undefined) { + clearTimers(); + const ttlMs = config.ttl * 60 * 1000; + const fiveMinMs = 5 * 60 * 1000; + const oneMinMs = 1 * 60 * 1000; + + if (ttlMs > fiveMinMs) { + ttlWarningFive = setTimeout(() => { + console.log("[tern] ⚠ session expires in 5 minutes"); + }, ttlMs - fiveMinMs); + } + + if (ttlMs > oneMinMs) { + ttlWarningOne = setTimeout(() => { + console.log("[tern] ⚠ session expires in 1 minute"); + }, ttlMs - oneMinMs); + } + + sessionExpiryTimer = setTimeout(() => { + console.log(`[tern] session expired after ${config.ttl} minutes — shutting down`); + shutdown(); + }, ttlMs); + } }); relayClient.on("reconnecting", ({ attempt, delayMs }) => { @@ -118,45 +236,54 @@ async function main(): Promise { }); relayClient.on("request", async (request: RelayMessage) => { - const event = await forward(request, config.port); + const event = await forward(request, config); eventStore.add(event); wsServer.broadcast({ type: "event", event }); + if (event.error && event.status === null) { + warn(event.error); + } const statusLabel = event.status ? `${event.status}` : "ERR"; info( `${event.method} ${event.path} → ${statusLabel} ${event.latency ?? 0}ms`, ); + appendAuditLog(config, { + method: event.method, + path: event.path, + sourceIp: event.sourceIp ?? "unknown", + status: event.status, + statusText: event.statusText, + latency: event.latency, + }); }); let uiServer: UiServer | null = null; if (!config.noUi) { uiServer = new UiServer({ eventStore, - localPort: config.port, + localPort: config.port ?? 0, + forwardConfig: config, uiHtml: loadUiBundle(), onReplay: (event) => wsServer.broadcast({ type: "event", event }), onClear: () => wsServer.broadcast({ type: "clear" }), getStatus: () => status, version, - wsPort: config.wsPort, + wsPort: (config.uiPort ?? 2019) + 1, }); - uiServer.start(config.uiPort); - wsServer.start(config.wsPort); + uiServer.start(config.uiPort ?? 2019); + wsServer.start((config.uiPort ?? 2019) + 1); wsServer.setStatus(status); - info(`Dashboard listening on http://localhost:${config.uiPort}`); + info(`Dashboard listening on http://localhost:${config.uiPort ?? 2019}`); } - const shutdown = () => { - relayClient.close(); - wsServer.close(); - uiServer?.close(); - process.exit(0); - }; + process.on("SIGINT", () => { + shutdown("\n[tern] session ended — tunnel closed, all event data cleared"); + }); + + process.on("SIGTERM", () => shutdown()); - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); + relayClient.connect(config.relay ?? "wss://relay.tern.hookflo.com"); - relayClient.connect(config.relayUrl); } main().catch((err: unknown) => { diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..c3e5759 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,153 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Runtime and file-based configuration for tern-dev. + */ +export interface TernConfig { + // Existing flags (must remain fully backward compatible) + port?: number; + path?: string; + uiPort?: number; + noUi?: boolean; + relay?: string; + maxEvents?: number; + + // New control flags + ttl?: number; + rateLimit?: number; + allowIp?: string[]; + block?: { + paths?: string[]; + methods?: string[]; + headers?: Record; + }; + log?: string; + localCert?: string; + localKey?: string; +} + +const CONFIG_FILE_NAMES = ["tern.config.json", ".ternrc.json"]; + +const DEFAULT_CONFIG: Required> = { + path: "/", + uiPort: 2019, + noUi: false, + relay: process.env.RELAY_URL ?? "wss://relay.tern.hookflo.com", + maxEvents: 500, +}; + +/** + * Finds the first supported config file by walking up from the current working directory. + */ +export function findConfigFile(): string | null { + let currentDir = process.cwd(); + + while (true) { + for (const fileName of CONFIG_FILE_NAMES) { + const candidate = path.join(currentDir, fileName); + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return candidate; + } + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} + +/** + * Loads and parses a supported tern config file if one exists. + */ +export function loadConfigFile(): TernConfig { + const configPath = findConfigFile(); + if (!configPath) { + return {}; + } + + try { + const raw = fs.readFileSync(configPath, "utf8"); + const parsed = JSON.parse(raw) as TernConfig; + console.log(`[tern] loaded config from ${configPath}`); + return parsed; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.log(`[tern] warning: failed to parse config — ${message}`); + return {}; + } +} + +/** + * Resolves configuration values in priority order: CLI args, config file, then defaults. + */ +export function resolveConfig(cliArgs: Partial): TernConfig { + const fileConfig = loadConfigFile(); + + return { + port: cliArgs.port ?? fileConfig.port, + path: cliArgs.path ?? fileConfig.path ?? DEFAULT_CONFIG.path, + uiPort: cliArgs.uiPort ?? fileConfig.uiPort ?? DEFAULT_CONFIG.uiPort, + noUi: cliArgs.noUi ?? fileConfig.noUi ?? DEFAULT_CONFIG.noUi, + relay: cliArgs.relay ?? fileConfig.relay ?? DEFAULT_CONFIG.relay, + maxEvents: cliArgs.maxEvents ?? fileConfig.maxEvents ?? DEFAULT_CONFIG.maxEvents, + ttl: cliArgs.ttl ?? fileConfig.ttl, + rateLimit: cliArgs.rateLimit ?? fileConfig.rateLimit, + allowIp: cliArgs.allowIp ?? fileConfig.allowIp, + block: cliArgs.block ?? fileConfig.block, + log: cliArgs.log ?? fileConfig.log, + localCert: cliArgs.localCert ?? fileConfig.localCert, + localKey: cliArgs.localKey ?? fileConfig.localKey, + }; +} + +/** + * Validates a resolved tern configuration and exits with all collected errors when invalid. + */ +export function validateConfig(config: TernConfig): void { + const errors: string[] = []; + + if (!Number.isInteger(config.port) || (config.port ?? 0) < 1 || (config.port ?? 0) > 65535) { + errors.push("--port is required and must be a valid port (1-65535)"); + } + + if ( + Number.isInteger(config.uiPort) && + Number.isInteger(config.port) && + config.uiPort === config.port + ) { + errors.push("--ui-port must be different from --port"); + } + + if (config.ttl !== undefined && config.ttl < 1) { + errors.push("--ttl must be at least 1 minute"); + } + + if (config.rateLimit !== undefined && config.rateLimit < 1) { + errors.push("--rate-limit must be at least 1 request per minute"); + } + + const hasLocalCert = Boolean(config.localCert); + const hasLocalKey = Boolean(config.localKey); + + if (hasLocalCert !== hasLocalKey) { + errors.push("--local-cert and --local-key must be provided together"); + } + + if (config.localCert && !fs.existsSync(config.localCert)) { + errors.push(`--local-cert file does not exist: ${config.localCert}`); + } + + if (config.localKey && !fs.existsSync(config.localKey)) { + errors.push(`--local-key file does not exist: ${config.localKey}`); + } + + if (errors.length > 0) { + for (const message of errors) { + console.log(`[tern] config error: ${message}`); + } + process.exit(1); + } +} diff --git a/src/forwarder.ts b/src/forwarder.ts index 22a8ca3..2fb9937 100644 --- a/src/forwarder.ts +++ b/src/forwarder.ts @@ -1,6 +1,18 @@ +import http from "node:http"; +import https from "node:https"; +import { TernConfig } from "./config"; import { RelayMessage, TernEvent } from "./types"; const STRIP_HEADERS = new Set(["content-length", "transfer-encoding", "host", "connection"]); +const RATE_LIMIT_WINDOW_MS = 60_000; + +const sessionRequestTimes: number[] = []; + +let localTlsCredentials: { cert: Buffer; key: Buffer } | null = null; + +export function setLocalTlsCredentials(cert: Buffer, key: Buffer): void { + localTlsCredentials = { cert, key }; +} function detectPlatform(headers: Record, body: string): string | null { const keys = Object.keys(headers).map((key) => key.toLowerCase()); @@ -20,20 +32,258 @@ function safeJsonParse(value: string): unknown | null { } } -function buildForwardHeaders(incomingHeaders: Record): Headers { - const headers = new Headers(); +function buildForwardHeaders(incomingHeaders: Record): Record { + const headers: Record = {}; for (const [key, value] of Object.entries(incomingHeaders)) { if (!STRIP_HEADERS.has(key.toLowerCase())) { - headers.set(key, String(value)); + headers[key] = String(value); } } return headers; } -export async function forward(request: RelayMessage, localPort: number): Promise { +function normalizeIp(value: string): string { + const trimmed = value.trim(); + return trimmed.startsWith("::ffff:") ? trimmed.slice(7) : trimmed; +} + +function getSourceIp(headers: Record): string { + const forwardedFor = headers["x-forwarded-for"] ?? headers["X-Forwarded-For"]; + if (forwardedFor) { + const firstIp = forwardedFor.split(",")[0]; + return normalizeIp(firstIp); + } + + const realIp = headers["x-real-ip"] ?? headers["X-Real-IP"]; + if (realIp) { + return normalizeIp(realIp); + } + + return "unknown"; +} + +function ipToInt(ip: string): number | null { + const parts = ip.split("."); + if (parts.length !== 4) { + return null; + } + + const numbers = parts.map((part) => Number(part)); + if (numbers.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) { + return null; + } + + return (((numbers[0] << 24) >>> 0) + (numbers[1] << 16) + (numbers[2] << 8) + numbers[3]) >>> 0; +} + +function ipMatchesCidr(ip: string, cidr: string): boolean { + const [network, prefixText] = cidr.split("/"); + if (!network || prefixText === undefined) { + return false; + } + + const prefix = Number(prefixText); + if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) { + return false; + } + + const ipInt = ipToInt(ip); + const networkInt = ipToInt(network); + if (ipInt === null || networkInt === null) { + return false; + } + + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + return (ipInt & mask) === (networkInt & mask); +} + +function isIpAllowed(ip: string, allowList: string[]): boolean { + if (allowList.length === 0) { + return true; + } + + for (const entry of allowList) { + const candidate = entry.trim(); + if (!candidate) { + continue; + } + + if (candidate.includes("/")) { + if (ipMatchesCidr(ip, candidate)) { + return true; + } + continue; + } + + if (normalizeIp(candidate) === ip) { + return true; + } + } + + return false; +} + +function globMatch(value: string, pattern: string): boolean { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + const regex = new RegExp(`^${escaped}$`, "i"); + return regex.test(value); +} + +function rateLimitExceeded(limit: number): boolean { + const now = Date.now(); + while (sessionRequestTimes.length > 0 && now - sessionRequestTimes[0] > RATE_LIMIT_WINDOW_MS) { + sessionRequestTimes.shift(); + } + + if (sessionRequestTimes.length >= limit) { + return true; + } + + sessionRequestTimes.push(now); + return false; +} + +function buildBlockedEvent( + request: RelayMessage, + start: number, + sourceIp: string, + status: number, + error: string, +): TernEvent { + return { + id: request.id, + receivedAt: request.receivedAt, + method: request.method, + path: request.path, + headers: request.headers, + body: request.body, + bodyParsed: safeJsonParse(request.body), + status, + statusText: status === 429 ? "Too Many Requests" : "Forbidden", + latency: Date.now() - start, + failed: true, + error, + platform: detectPlatform(request.headers, request.body), + replay: false, + replayOf: null, + sourceIp, + }; +} + +function sendLocalRequest( + request: RelayMessage, + config: TernConfig, + headers: Record, +): Promise<{ status: number; statusText: string }> { + return new Promise((resolve, reject) => { + const targetPath = `${config.path ?? "/"}${request.path.startsWith("/") ? request.path : `/${request.path}`}` + .replace(/\/\/{2,}/g, "/"); + + const options: https.RequestOptions = { + hostname: "127.0.0.1", + port: config.port, + path: targetPath, + method: request.method, + headers, + timeout: 30_000, + }; + + let client: typeof http | typeof https = http; + if (config.localCert && config.localKey) { + client = https; + options.cert = localTlsCredentials?.cert; + options.key = localTlsCredentials?.key; + options.rejectUnauthorized = false; + } + + const req = client.request(options, (res) => { + res.resume(); + res.on("end", () => { + resolve({ + status: res.statusCode ?? 500, + statusText: res.statusMessage ?? "", + }); + }); + }); + + req.on("timeout", () => { + req.destroy(new Error(`Forward timed out after 30s — is localhost:${config.port} running?`)); + }); + + req.on("error", reject); + + const hasBody = !["GET", "HEAD"].includes(request.method.toUpperCase()); + if (hasBody && request.body.length > 0) { + req.write(request.body); + } + req.end(); + }); +} + +export async function forward(request: RelayMessage, config: TernConfig): Promise { const start = Date.now(); const headers = buildForwardHeaders(request.headers); - const targetUrl = `http://127.0.0.1:${localPort}${request.path}`; + const sourceIp = getSourceIp(request.headers); + + if (config.rateLimit !== undefined && rateLimitExceeded(config.rateLimit)) { + console.log("[tern] rate limit exceeded — dropping request"); + return buildBlockedEvent( + request, + start, + sourceIp, + 429, + "rate limit exceeded — dropping request", + ); + } + + if (config.allowIp && config.allowIp.length > 0 && !isIpAllowed(sourceIp, config.allowIp)) { + console.log(`[tern] blocked request from unlisted IP: ${sourceIp}`); + return buildBlockedEvent( + request, + start, + sourceIp, + 403, + `blocked request from unlisted IP: ${sourceIp}`, + ); + } + + if (config.block?.paths?.some((entry) => request.path.toLowerCase().startsWith(entry.toLowerCase()))) { + console.log(`[tern] blocked request: path ${request.path} matched block rule`); + return buildBlockedEvent( + request, + start, + sourceIp, + 403, + `blocked request: path ${request.path} matched block rule`, + ); + } + + if (config.block?.methods?.some((entry) => entry.toLowerCase() === request.method.toLowerCase())) { + console.log(`[tern] blocked request: method ${request.method} matched block rule`); + return buildBlockedEvent( + request, + start, + sourceIp, + 403, + `blocked request: method ${request.method} matched block rule`, + ); + } + + if (config.block?.headers) { + for (const [name, pattern] of Object.entries(config.block.headers)) { + const headerValue = request.headers[name] ?? request.headers[name.toLowerCase()]; + if (headerValue && globMatch(String(headerValue), pattern)) { + console.log(`[tern] blocked request: header ${name} matched block rule`); + return buildBlockedEvent( + request, + start, + sourceIp, + 403, + `blocked request: header ${name} matched block rule`, + ); + } + } + } const event: TernEvent = { id: request.id, @@ -44,47 +294,34 @@ export async function forward(request: RelayMessage, localPort: number): Promise body: request.body, bodyParsed: safeJsonParse(request.body), status: null, + statusText: null, latency: null, failed: false, error: null, platform: detectPlatform(request.headers, request.body), replay: false, - replayOf: null + replayOf: null, + sourceIp, }; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 30_000); - try { - const hasBody = !["GET", "HEAD"].includes(request.method.toUpperCase()); - - const response = await fetch(targetUrl, { - method: request.method, - headers, - body: hasBody ? request.body : undefined, - signal: controller.signal - }); - + const response = await sendLocalRequest(request, config, headers); event.status = response.status; + event.statusText = response.statusText; event.failed = response.status >= 400; event.latency = Date.now() - start; } catch (err: unknown) { event.status = null; + event.statusText = null; event.latency = Date.now() - start; event.failed = true; - if (err instanceof Error && err.name === "AbortError") { - event.error = `Forward timed out after 30s — is localhost:${localPort} running?`; - } else { - event.error = err instanceof Error ? err.message : "Forward failed"; - } - } finally { - clearTimeout(timeout); + event.error = err instanceof Error ? err.message : "Forward failed"; } return event; } -export async function replay(event: TernEvent, localPort: number): Promise { +export async function replay(event: TernEvent, config: TernConfig): Promise { const replayId = `evt_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; const request: RelayMessage = { type: "request", @@ -93,10 +330,10 @@ export async function replay(event: TernEvent, localPort: number): Promise void; onClear: () => void; @@ -45,7 +47,7 @@ export class UiServer { tunnelUrl: status.tunnelUrl, sessionId: status.sessionId, port: this.options.localPort, - version: this.options.version + version: this.options.version, }); return; } @@ -89,7 +91,7 @@ export class UiServer { return; } - const replayed = await replayEvent(event, this.options.localPort); + const replayed = await replayEvent(event, this.options.forwardConfig); this.options.eventStore.add(replayed); this.options.onReplay(replayed); this.sendJson(res, 200, { event: replayed }); @@ -103,7 +105,7 @@ export class UiServer { if (err.code === "EADDRINUSE") { error( `Dashboard port ${port} is already in use. ` + - `Use --ui-port to choose a different port.` + `Use --ui-port to choose a different port.`, ); } else { error(`Dashboard server error: ${err.message}`); diff --git a/tern-config.schema.json b/tern-config.schema.json new file mode 100644 index 0000000..081eefa --- /dev/null +++ b/tern-config.schema.json @@ -0,0 +1,108 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://tern.hookflo.com/tern-config.schema.json", + "title": "Tern Config", + "description": "Configuration for tern-dev local webhook tunnel.", + "type": "object", + "additionalProperties": false, + "properties": { + "port": { + "type": "integer", + "description": "Local application port to forward webhook requests to.", + "minimum": 1, + "maximum": 65535 + }, + "path": { + "type": "string", + "description": "Base path prefix used when forwarding requests to localhost.", + "default": "/" + }, + "uiPort": { + "type": "integer", + "description": "Port used by the local dashboard.", + "default": 2019, + "minimum": 1, + "maximum": 65535 + }, + "noUi": { + "type": "boolean", + "description": "Disable the local dashboard UI.", + "default": false + }, + "relay": { + "type": "string", + "description": "Relay WebSocket URL used to establish the tunnel.", + "default": "wss://relay.tern.hookflo.com" + }, + "maxEvents": { + "type": "integer", + "description": "Maximum number of events retained in memory.", + "default": 500, + "minimum": 1 + }, + "ttl": { + "type": "integer", + "description": "Auto-kill session after N minutes.", + "minimum": 1 + }, + "rateLimit": { + "type": "integer", + "description": "Max incoming requests per minute.", + "minimum": 1 + }, + "allowIp": { + "type": "array", + "description": "IP allowlist entries; supports exact IPv4 and CIDR ranges.", + "items": { + "type": "string" + }, + "default": [] + }, + "block": { + "type": "object", + "description": "Block rules evaluated before forwarding.", + "additionalProperties": false, + "properties": { + "paths": { + "type": "array", + "description": "Path prefixes to block.", + "items": { + "type": "string" + }, + "default": [] + }, + "methods": { + "type": "array", + "description": "HTTP methods to block.", + "items": { + "type": "string" + }, + "default": [] + }, + "headers": { + "type": "object", + "description": "Header glob rules to block when matched.", + "default": {}, + "additionalProperties": { + "type": "string" + } + } + } + }, + "log": { + "type": "string", + "description": "File path for append-only audit log output.", + "default": "" + }, + "localCert": { + "type": "string", + "description": "TLS certificate path for relay-to-localhost forwarding leg.", + "default": "" + }, + "localKey": { + "type": "string", + "description": "TLS key path for relay-to-localhost forwarding leg.", + "default": "" + } + } +} diff --git a/tern.config.example.json b/tern.config.example.json new file mode 100644 index 0000000..c0e2916 --- /dev/null +++ b/tern.config.example.json @@ -0,0 +1,23 @@ +{ + "$schema": "./tern-config.schema.json", + "port": 3000, + "path": "/webhooks", + "uiPort": 2019, + "noUi": false, + "relay": "wss://relay.tern.hookflo.com", + "maxEvents": 500, + "ttl": 60, + "rateLimit": 100, + "allowIp": [ + "54.187.174.169", + "192.30.252.0/22" + ], + "block": { + "paths": ["/admin", "/debug", "/metrics"], + "methods": ["DELETE"], + "headers": { + "user-agent": "curl*" + } + }, + "log": "./tern-audit.log" +}