#!/usr/bin/env node // SPDX-License-Identifier: AGPL-3.0-or-later "use strict"; // apps/probe/src/server.ts var import_node_http = require("node:http"); var import_node_net2 = require("node:net"); // apps/probe/src/checks.ts var import_node_crypto = require("node:crypto"); var import_node_dgram = require("node:dgram"); var import_node_net = require("node:net"); var import_node_tls = require("node:tls"); var TIMEOUT_MS = 4e3; var rand = () => (0, import_node_crypto.randomBytes)(8).toString("hex"); function exchange(ip, port2, opts) { return new Promise((resolve, reject) => { let data = ""; const onConnect = () => sock.write(opts.request); const sock = opts.tls ? (0, import_node_tls.connect)( { host: ip, port: port2, // The name to check the certificate against; SNI can't carry an IP. ...(0, import_node_net.isIP)(opts.host) ? {} : { servername: opts.host }, rejectUnauthorized: false }, onConnect ) : (0, import_node_net.connect)({ host: ip, port: port2 }, onConnect); const finish = (err) => { clearTimeout(timer); sock.destroy(); if (err) reject(err); else { const tls = sock; resolve({ data, certError: opts.tls && !tls.authorized ? String(tls.authorizationError ?? "not valid") : null }); } }; const timer = setTimeout(() => finish(new Error("no answer")), TIMEOUT_MS); sock.setEncoding("utf8"); sock.on("data", (d) => { data += d; if (opts.done.test(data)) finish(); }); sock.on("error", (e) => finish(e)); sock.on("close", () => opts.done.test(data) ? finish() : finish(new Error("closed"))); }); } var sipOptions = (transport, host, port2) => [ `OPTIONS sip:${host}:${port2};transport=${transport.toLowerCase()} SIP/2.0`, // rport: answer the address and port this came from (RFC 3581), not the made-up host. `Via: SIP/2.0/${transport} probe.invalid;branch=z9hG4bK${rand()};rport`, "Max-Forwards: 70", `From: ;tag=${rand()}`, `To: `, `Call-ID: ${rand()}@probe.invalid`, "CSeq: 1 OPTIONS", "User-Agent: selfphoneng-probe", "Content-Length: 0", "", "" ].join("\r\n"); function sipUdp(ip, port2, host) { return new Promise((resolve, reject) => { const sock = (0, import_node_dgram.createSocket)((0, import_node_net.isIP)(ip) === 6 ? "udp6" : "udp4"); const msg = Buffer.from(sipOptions("UDP", host, port2)); const timers = [0, 500, 1500].map( (ms) => setTimeout(() => sock.send(msg, port2, ip, () => { }), ms) ); const done = (fn) => { timers.forEach(clearTimeout); clearTimeout(timer); sock.close(); fn(); }; const timer = setTimeout(() => done(() => reject(new Error("no answer"))), TIMEOUT_MS); sock.on("message", (m) => { const line = m.toString("utf8").split("\r\n")[0]; if (line.startsWith("SIP/2.0 ")) done(() => resolve(line)); }); sock.on("error", (e) => done(() => reject(e))); }); } function sendUdp(ip, port2, token) { return new Promise((resolve) => { const sock = (0, import_node_dgram.createSocket)((0, import_node_net.isIP)(ip) === 6 ? "udp6" : "udp4"); const msg = Buffer.from(`selfphoneng-probe ${token}`); let sent = 0; const one = () => sock.send(msg, port2, ip, () => { if (++sent === 3) { sock.close(); resolve(); } }); one(); setTimeout(one, 300); setTimeout(one, 600); }); } var HTTP_STATUS = /^HTTP\/1\.[01] (\d{3})[^\r\n]*\r\n/; async function runTest(ip, host, t) { const start = Date.now(); const result = (ok, detail) => ({ id: t.id, ok, detail, ms: Date.now() - start }); try { switch (t.kind) { case "tcp": await new Promise((resolve, reject) => { const s = (0, import_node_net.connect)({ host: ip, port: t.port }, () => (s.destroy(), resolve())); s.setTimeout(TIMEOUT_MS, () => (s.destroy(), reject(new Error("no answer")))); s.on("error", reject); }); return result(true, "connected"); case "http": case "https": { const upgrade = t.websocket ? [ "Connection: Upgrade", "Upgrade: websocket", `Sec-WebSocket-Key: ${(0, import_node_crypto.randomBytes)(16).toString("base64")}`, "Sec-WebSocket-Version: 13", "Sec-WebSocket-Protocol: sip" ] : ["Connection: close"]; const request = [ `GET ${t.path ?? "/"} HTTP/1.1`, `Host: ${host}`, "User-Agent: selfphoneng-probe", ...upgrade, "", "" ].join("\r\n"); const r = await exchange(ip, t.port, { tls: t.kind === "https", host, request, done: HTTP_STATUS }); const status = Number(HTTP_STATUS.exec(r.data)?.[1]); const cert = r.certError ? `; the certificate isn't valid for ${host} (${r.certError})` : ""; if (t.websocket) return result(status === 101, `${status === 101 ? "upgraded" : `HTTP ${status}`}${cert}`); return result(true, `HTTP ${status}${cert}`); } case "sip-udp": return result(true, await sipUdp(ip, t.port, host)); case "sip-tcp": case "sip-tls": { const tls = t.kind === "sip-tls"; const r = await exchange(ip, t.port, { tls, host, request: sipOptions(tls ? "TLS" : "TCP", host, t.port), done: /^SIP\/2\.0 \d{3}[^\r\n]*\r\n/ }); return result(true, r.data.split("\r\n")[0]); } case "udp": await sendUdp(ip, t.port, t.token); return result(true, "sent"); } } catch (e) { const code = e.code; return result( false, code === "ECONNREFUSED" ? "refused" : code ? code.toLowerCase() : e.message ); } } // apps/probe/src/server.ts var HOST = /^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/; var KINDS = /* @__PURE__ */ new Set(["tcp", "http", "https", "sip-udp", "sip-tcp", "sip-tls", "udp"]); var MAX_TESTS = 20; function parseCheck(body) { const b = body; if (typeof b?.host !== "string" || !(HOST.test(b.host) || (0, import_node_net2.isIP)(b.host))) return "host: a host name"; if (!Array.isArray(b.tests) || !b.tests.length || b.tests.length > MAX_TESTS) return `tests: 1 to ${MAX_TESTS}`; const tests = []; for (const t of b.tests) { if (typeof t?.id !== "string" || !/^[a-z0-9-]{1,40}$/.test(t.id)) return "tests[].id"; if (typeof t.kind !== "string" || !KINDS.has(t.kind)) return `${t.id}: kind`; if (!Number.isInteger(t.port) || t.port < 1 || t.port > 65535) return `${t.id}: port`; if (t.path !== void 0 && (typeof t.path !== "string" || !/^\/[\w./~-]{0,100}$/.test(t.path))) return `${t.id}: path`; if (t.kind === "udp" && (typeof t.token !== "string" || !/^[A-Za-z0-9]{8,64}$/.test(t.token))) return `${t.id}: token`; tests.push({ id: t.id, kind: t.kind, port: t.port, ...t.path !== void 0 && { path: t.path }, ...t.websocket === true && { websocket: true }, ...t.kind === "udp" && { token: t.token } }); } return { host: b.host, tests }; } var clientIp = (req, trustProxy) => { const xff = req.headers["x-forwarded-for"]; const raw = trustProxy && typeof xff === "string" ? xff.split(",").at(-1).trim() : req.socket.remoteAddress; return raw.replace(/^::ffff:/, ""); }; function readJson(req) { return new Promise((resolve, reject) => { let size = 0; const chunks = []; req.on("data", (c) => { size += c.length; if (size > 8192) req.destroy(new Error("too big")); else chunks.push(c); }); req.on("end", () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch (e) { reject(e); } }); req.on("error", reject); }); } function createProbe(opts = {}) { const { trustProxy = false, maxChecks = 10, windowMs = 10 * 6e4 } = opts; const seen = /* @__PURE__ */ new Map(); const allow = (ip, now = Date.now()) => { for (const [k, v] of seen) if (now - v.at(-1) >= windowMs) seen.delete(k); const list = (seen.get(ip) ?? []).filter((t) => now - t < windowMs); if (list.length >= maxChecks) return false; seen.set(ip, [...list, now]); return true; }; const send = (res, code, body) => res.writeHead(code, { "content-type": "application/json" }).end(JSON.stringify(body)); return (0, import_node_http.createServer)((req, res) => { if (req.method === "GET" && req.url === "/healthz") return send(res, 200, { ok: true }); if (req.method !== "POST" || req.url !== "/v1/check") return send(res, 404, { error: "not found" }); const ip = clientIp(req, trustProxy); void readJson(req).then(async (body) => { const check = parseCheck(body); if (typeof check === "string") return send(res, 400, { error: check }); if (!allow(ip)) return send(res, 429, { error: "too many checks; try again in 10 minutes" }); const results = await Promise.all( check.tests.map((t) => runTest(ip, check.host, t)) ); send(res, 200, { ip, results }); }).catch(() => send(res, 400, { error: "a JSON body" })); }); } // apps/probe/src/index.ts var port = Number(process.env.PORT ?? 8080); var probe = createProbe({ trustProxy: process.env.TRUST_PROXY === "true" }); probe.listen(port, () => console.log(`selfphoneng-probe listening on ${port}`)); for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => probe.close(() => process.exit(0)));