+49
-35
@@ -16,3 +16,3 @@ /** | ||
| import { cleanupDaemons } from './daemon.js'; | ||
| import { readLog, logRequest, getLogPath } from './request-log.js'; | ||
| import { readLog, getLogPath } from './request-log.js'; | ||
| import { getProvider } from './providers.js'; | ||
@@ -55,3 +55,8 @@ /* ── ANSI helpers (mirrors index.ts) ── */ | ||
| 'proxy-authenticate', | ||
| // Edge-injected metadata: replaying these back at Cloudflare trips its | ||
| // WAF (a 403 before the request ever reaches the endpoint). | ||
| 'x-real-ip', | ||
| 'cdn-loop', | ||
| ]); | ||
| const SKIP_HEADER_PREFIXES = ['cf-', 'x-forwarded-']; | ||
| const REPLAY_TIMEOUT_MS = 30_000; | ||
@@ -110,9 +115,18 @@ /** Thrown by replayCore for expected failures; carries a user-facing hint. */ | ||
| } | ||
| /** | ||
| * Accepts `host:port`, `http://host:port`, and a full `https://host` URL - | ||
| * the last one matters for replaying back through a public endpoint, where | ||
| * the scheme (and its default port) must survive. | ||
| */ | ||
| function parseTarget(target) { | ||
| const match = target.match(/^(?:https?:\/\/)?([^:/]+)(?::(\d+))?\/?$/); | ||
| const port = match?.[2] ? parseInt(match[2], 10) : NaN; | ||
| if (!match || isNaN(port) || port < 1 || port > 65535) { | ||
| const match = target.match(/^(?:(https?):\/\/)?([^:/]+)(?::(\d+))?\/?$/); | ||
| if (!match) { | ||
| throw new ReplayError(`Invalid target "${target}"`, 'Expected host:port or a URL, e.g. --target 127.0.0.1:3000'); | ||
| } | ||
| const scheme = match[1] ?? 'http'; | ||
| const port = match[3] ? parseInt(match[3], 10) : scheme === 'https' ? 443 : NaN; | ||
| if (isNaN(port) || port < 1 || port > 65535) { | ||
| throw new ReplayError(`Invalid target "${target}"`, 'Expected host:port, e.g. --target 127.0.0.1:3000'); | ||
| } | ||
| return { host: match[1], port }; | ||
| return { scheme, host: match[2], port }; | ||
| } | ||
@@ -124,3 +138,3 @@ function resolveTarget(subdomain, targetOpt) { | ||
| if (daemon) | ||
| return { host: daemon.host, port: daemon.port }; | ||
| return { scheme: 'http', host: daemon.host, port: daemon.port }; | ||
| throw new ReplayError('No target to replay to', `No running tunnel daemon for "${subdomain}". Pass --target <host:port>.`); | ||
@@ -146,3 +160,3 @@ } | ||
| const { entry, n } = pickEntry(entries, options.index ?? '-1'); | ||
| const { host, port } = resolveTarget(subdomain, options.target); | ||
| const { scheme, host, port } = resolveTarget(subdomain, options.target); | ||
| // Rebuild the request, applying any overrides. | ||
@@ -153,4 +167,6 @@ const method = (options.method || entry.method).toUpperCase(); | ||
| for (const [key, value] of Object.entries(entry.headers)) { | ||
| if (!SKIP_HEADERS.has(key.toLowerCase())) | ||
| headers[key.toLowerCase()] = value; | ||
| const k = key.toLowerCase(); | ||
| if (SKIP_HEADERS.has(k) || SKIP_HEADER_PREFIXES.some(prefix => k.startsWith(prefix))) | ||
| continue; | ||
| headers[k] = value; | ||
| } | ||
@@ -213,15 +229,24 @@ for (const override of options.header ?? []) { | ||
| } | ||
| const targetUrl = `http://${host}:${port}${path}`; | ||
| // Dev servers often bind IPv6 localhost only - try both loopbacks. | ||
| const hosts = host === '127.0.0.1' || host === 'localhost' ? [host, '[::1]'] : [host]; | ||
| const started = Date.now(); | ||
| let resp; | ||
| try { | ||
| resp = await fetch(targetUrl, { | ||
| method, | ||
| headers, | ||
| body, | ||
| redirect: 'manual', | ||
| signal: AbortSignal.timeout(REPLAY_TIMEOUT_MS), | ||
| }); | ||
| let lastErr; | ||
| for (const candidate of hosts) { | ||
| try { | ||
| resp = await fetch(`${scheme}://${candidate}:${port}${path}`, { | ||
| method, | ||
| headers, | ||
| body, | ||
| redirect: 'manual', | ||
| signal: AbortSignal.timeout(REPLAY_TIMEOUT_MS), | ||
| }); | ||
| break; | ||
| } | ||
| catch (err) { | ||
| lastErr = err; | ||
| } | ||
| } | ||
| catch (err) { | ||
| if (!resp) { | ||
| const err = lastErr; | ||
| const reason = err instanceof Error && err.name === 'TimeoutError' ? 'Timed out' : 'Failed'; | ||
@@ -236,18 +261,7 @@ throw new ReplayError(`${reason} replaying to ${host}:${port}`, `Is the target running? ${err instanceof Error ? (err.cause instanceof Error ? err.cause.message : err.message) : ''}`); | ||
| }); | ||
| // Append the replayed exchange to the log so `otterkit inspect` shows it, | ||
| // matching how desktop replay emits a fresh traffic entry. | ||
| await logRequest(subdomain, { | ||
| ts: new Date().toISOString(), | ||
| method, | ||
| path, | ||
| headers, | ||
| body: body ? body.toString('base64') : null, | ||
| status: resp.status, | ||
| durationMs, | ||
| bodySize: body?.length ?? 0, | ||
| response: { | ||
| headers: respHeaders, | ||
| body: respBody.length > 0 ? respBody.toString('base64') : null, | ||
| }, | ||
| }).catch(() => { }); | ||
| // The replay is NOT written back to the capture log. The log records what | ||
| // arrived at the endpoint: a replay to the public URL is captured there | ||
| // for real (writing it too would double it), and a replay straight to | ||
| // localhost never reached the endpoint at all - logging it would invent | ||
| // traffic. Callers get the outcome from the return value below. | ||
| return { | ||
@@ -254,0 +268,0 @@ index: n, |
+1
-1
| { | ||
| "name": "otterkit", | ||
| "version": "0.31.0", | ||
| "version": "0.32.0", | ||
| "description": "OtterKit CLI - provision and connect tunnels for AI agents", | ||
@@ -5,0 +5,0 @@ "mcpName": "io.github.useotterkit/otterkit", |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
271900
0.33%6339
0.22%