UNPKG

@sellable/install

Version:

One-command installer for Sellable MCP in Claude Code, Codex, and Hermes

242 lines (228 loc) • 10.1 kB
import { createHash } from "node:crypto"; import { promises as dns } from "node:dns"; import { createServer as createHttpServer } from "node:http"; import { isIP } from "node:net"; import { createConnection } from "node:net"; import { domainToASCII } from "node:url"; import { lstatSync, readFileSync, readlinkSync } from "node:fs"; import { isAbsolute, resolve } from "node:path"; import { RUNTIME_EGRESS_HOSTS } from "./runtime-boundary.mjs"; export const RUNTIME_EGRESS_PROXY_VERSION = "sellable-agent-egress-proxy/v1"; const CONFIG_KEYS = new Set(["version", "listenHost", "listenPort", "uid", "gid", "allowedHosts"]); const sha256 = (value) => createHash("sha256").update(value).digest("hex"); function stable(value) { if (Array.isArray(value)) return value.map(stable); if (value && typeof value === "object") { return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])])); } return value; } export function validateRuntimeEgressProxyConfig(value) { if ( !value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== CONFIG_KEYS.size || Object.keys(value).some((key) => !CONFIG_KEYS.has(key)) || value.version !== RUNTIME_EGRESS_PROXY_VERSION || value.listenHost !== "127.0.0.1" || !Number.isSafeInteger(value.listenPort) || value.listenPort < 1024 || value.listenPort > 65535 || !Number.isSafeInteger(value.uid) || value.uid < 1 || !Number.isSafeInteger(value.gid) || value.gid < 1 || JSON.stringify(value.allowedHosts) !== JSON.stringify([...RUNTIME_EGRESS_HOSTS]) ) return { ok: false, code: "egress_proxy_config_rejected" }; const config = Object.freeze({ ...value, allowedHosts: Object.freeze([...value.allowedHosts]) }); return { ok: true, config, configDigest: sha256(JSON.stringify(stable(config))) }; } function normalizedHost(value) { if (typeof value !== "string" || !value || value.length > 253 || /[\0\s/@?#]/.test(value)) return null; const ascii = domainToASCII(value).toLowerCase(); if (!ascii || ascii.endsWith(".") || isIP(ascii) || !/^[a-z0-9.-]+$/.test(ascii)) return null; if (ascii.split(".").some((label) => !label || label.length > 63 || label.startsWith("-") || label.endsWith("-"))) { return null; } return ascii; } function hostAllowed(host, allowedHosts) { return allowedHosts.some((entry) => entry.startsWith(".") ? host.endsWith(entry) && host.length > entry.length : host === entry ); } export function authorizeRuntimeEgressTarget(config, authority) { const validated = validateRuntimeEgressProxyConfig(config); if (!validated.ok || typeof authority !== "string" || authority.length > 300) { return { ok: false, code: "egress_target_rejected" }; } const match = authority.match(/^([^:\[\]]+):(\d{1,5})$/); if (!match) return { ok: false, code: "egress_target_rejected" }; const host = normalizedHost(match[1]); const port = Number(match[2]); if (!host || port !== 443 || !hostAllowed(host, validated.config.allowedHosts)) { return { ok: false, code: "egress_target_rejected" }; } return { ok: true, host, port }; } function publicIpv4(address) { const parts = address.split(".").map(Number); if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false; const [a, b, c] = parts; if ( a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 0 && c === 0) || (a === 192 && b === 0 && c === 2) || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19)) || (a === 198 && b === 51 && c === 100) || (a === 203 && b === 0 && c === 113) ) return false; return true; } function publicIpv6(address) { const normalized = address.toLowerCase().split("%")[0]; return !( normalized === "::" || normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || /^fe[89ab]/.test(normalized) || normalized.startsWith("ff") || normalized.startsWith("2001:db8:") || normalized.startsWith("::ffff:") ); } export function isPublicRuntimeEgressAddress(address) { const family = isIP(address); return family === 4 ? publicIpv4(address) : family === 6 ? publicIpv6(address) : false; } function rejectConnect(socket, status = "403 Forbidden") { if (!socket.destroyed) socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`); } export function createRuntimeEgressProxy(config, dependencies = {}) { const validated = validateRuntimeEgressProxyConfig(config); if (!validated.ok) throw new Error("egress proxy config rejected"); const lookup = dependencies.lookup ?? ((host) => dns.lookup(host, { all: true, verbatim: true, family: 4 })); const dial = dependencies.dial ?? ((options) => createConnection(options)); const maxConnections = 256; let connections = 0; const server = createHttpServer({ maxHeaderSize: 8 * 1024 }, (_request, response) => { response.writeHead(405, { Connection: "close", "Content-Length": "0" }); response.end(); }); server.on("connect", async (request, client, head) => { const target = authorizeRuntimeEgressTarget(validated.config, request.url ?? ""); if (!target.ok || connections >= maxConnections) { rejectConnect(client, connections >= maxConnections ? "429 Too Many Requests" : "403 Forbidden"); return; } connections += 1; let upstream; const release = () => { connections = Math.max(0, connections - 1); }; try { const answers = await lookup(target.host); const selected = Array.isArray(answers) ? answers[0] : null; if ( !Array.isArray(answers) || !selected || answers.some((answer) => answer.family !== 4 || !publicIpv4(answer.address)) ) { throw new Error("egress DNS answer rejected"); } upstream = dial({ host: selected.address, port: target.port, family: selected.family }); upstream.setTimeout?.(300_000, () => upstream.destroy()); upstream.once("connect", () => { client.write("HTTP/1.1 200 Connection Established\r\nProxy-Agent: sellable-agent\r\n\r\n"); if (head?.length) upstream.write(head); client.pipe(upstream); upstream.pipe(client); }); upstream.once("error", () => rejectConnect(client, "502 Bad Gateway")); upstream.once("close", release); client.once("error", () => upstream?.destroy()); client.once("close", () => upstream?.destroy()); } catch { release(); rejectConnect(client, "502 Bad Gateway"); } }); server.on("clientError", (_error, socket) => rejectConnect(socket, "400 Bad Request")); server.maxConnections = maxConnections; return { server, listen() { return new Promise((resolveListen, rejectListen) => { const onError = (error) => rejectListen(error); server.once("error", onError); server.listen(validated.config.listenPort, validated.config.listenHost, () => { server.off("error", onError); resolveListen({ ok: true, version: RUNTIME_EGRESS_PROXY_VERSION, host: validated.config.listenHost, port: validated.config.listenPort, allowedHosts: [...validated.config.allowedHosts], configDigest: validated.configDigest, }); }); }); }, close() { return new Promise((resolveClose, rejectClose) => server.close((error) => error ? rejectClose(error) : resolveClose())); }, }; } function parseStatus(source) { return Object.fromEntries(source.split(/\r?\n/).filter(Boolean).map((line) => { const index = line.indexOf(":"); return [line.slice(0, index), line.slice(index + 1).trim()]; })); } export function observeRuntimeEgressProxy({ configPath, executablePath, pid, procRoot = "/proc", listenObserved }) { try { if ( !isAbsolute(configPath) || resolve(configPath) !== configPath || !isAbsolute(executablePath) || resolve(executablePath) !== executablePath || !Number.isSafeInteger(pid) || pid < 2 || listenObserved !== true ) throw new Error("proxy observation input rejected"); const configLink = lstatSync(configPath); const executableLink = lstatSync(executablePath); if ( configLink.isSymbolicLink() || !configLink.isFile() || (configLink.mode & 0o022) !== 0 || executableLink.isSymbolicLink() || !executableLink.isFile() || (executableLink.mode & 0o022) !== 0 ) throw new Error("proxy observation file rejected"); const configBytes = readFileSync(configPath); const validated = validateRuntimeEgressProxyConfig(JSON.parse(configBytes.toString("utf8"))); if (!validated.ok) throw new Error("proxy config rejected"); const processRoot = resolve(procRoot, String(pid)); const status = parseStatus(readFileSync(resolve(processRoot, "status"), "utf8")); const uid = Number(status.Uid?.split(/\s+/)[0]); const gid = Number(status.Gid?.split(/\s+/)[0]); const cmdline = readFileSync(resolve(processRoot, "cmdline")).toString("utf8").split("\0").filter(Boolean); if ( uid !== validated.config.uid || gid !== validated.config.gid || status.NoNewPrivs !== "1" || !/^0+$/.test(status.CapEff ?? "") || cmdline.length !== 2 || cmdline[0] !== "/usr/bin/node" || cmdline[1] !== executablePath ) throw new Error("proxy process identity rejected"); const nodeExecutable = readlinkSync(resolve(processRoot, "exe")); if (!isAbsolute(nodeExecutable)) throw new Error("proxy executable rejected"); return { ok: true, pid, uid, gid, host: validated.config.listenHost, port: validated.config.listenPort, executableDigest: sha256(readFileSync(executablePath)), configDigest: sha256(configBytes), allowedHosts: [...validated.config.allowedHosts], }; } catch { return { ok: false, code: "egress_proxy_observation_rejected" }; } }