Files
clawdbot/src/infra/ports-inspect.ts
2026-01-21 18:52:55 +00:00

241 lines
7.9 KiB
TypeScript

import net from "node:net";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveLsofCommand } from "./ports-lsof.js";
import { buildPortHints } from "./ports-format.js";
import type { PortListener, PortUsage, PortUsageStatus } from "./ports-types.js";
type CommandResult = {
stdout: string;
stderr: string;
code: number;
error?: string;
};
function isErrno(err: unknown): err is NodeJS.ErrnoException {
return Boolean(err && typeof err === "object" && "code" in err);
}
async function runCommandSafe(argv: string[], timeoutMs = 5_000): Promise<CommandResult> {
try {
const res = await runCommandWithTimeout(argv, { timeoutMs });
return {
stdout: res.stdout,
stderr: res.stderr,
code: res.code ?? 1,
};
} catch (err) {
return {
stdout: "",
stderr: "",
code: 1,
error: String(err),
};
}
}
function parseLsofFieldOutput(output: string): PortListener[] {
const lines = output.split(/\r?\n/).filter(Boolean);
const listeners: PortListener[] = [];
let current: PortListener = {};
for (const line of lines) {
if (line.startsWith("p")) {
if (current.pid || current.command) listeners.push(current);
const pid = Number.parseInt(line.slice(1), 10);
current = Number.isFinite(pid) ? { pid } : {};
} else if (line.startsWith("c")) {
current.command = line.slice(1);
} else if (line.startsWith("n")) {
// TCP 127.0.0.1:18789 (LISTEN)
// TCP *:18789 (LISTEN)
if (!current.address) current.address = line.slice(1);
}
}
if (current.pid || current.command) listeners.push(current);
return listeners;
}
async function resolveUnixCommandLine(pid: number): Promise<string | undefined> {
const res = await runCommandSafe(["ps", "-p", String(pid), "-o", "command="]);
if (res.code !== 0) return undefined;
const line = res.stdout.trim();
return line || undefined;
}
async function resolveUnixUser(pid: number): Promise<string | undefined> {
const res = await runCommandSafe(["ps", "-p", String(pid), "-o", "user="]);
if (res.code !== 0) return undefined;
const line = res.stdout.trim();
return line || undefined;
}
async function readUnixListeners(
port: number,
): Promise<{ listeners: PortListener[]; detail?: string; errors: string[] }> {
const errors: string[] = [];
const lsof = await resolveLsofCommand();
const res = await runCommandSafe([lsof, "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-FpFcn"]);
if (res.code === 0) {
const listeners = parseLsofFieldOutput(res.stdout);
await Promise.all(
listeners.map(async (listener) => {
if (!listener.pid) return;
const [commandLine, user] = await Promise.all([
resolveUnixCommandLine(listener.pid),
resolveUnixUser(listener.pid),
]);
if (commandLine) listener.commandLine = commandLine;
if (user) listener.user = user;
}),
);
return { listeners, detail: res.stdout.trim() || undefined, errors };
}
const stderr = res.stderr.trim();
if (res.code === 1 && !res.error && !stderr) {
return { listeners: [], detail: undefined, errors };
}
if (res.error) errors.push(res.error);
const detail = [stderr, res.stdout.trim()].filter(Boolean).join("\n");
if (detail) errors.push(detail);
return { listeners: [], detail: undefined, errors };
}
function parseNetstatListeners(output: string, port: number): PortListener[] {
const listeners: PortListener[] = [];
const portToken = `:${port}`;
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
if (!line.toLowerCase().includes("listen")) continue;
if (!line.includes(portToken)) continue;
const parts = line.split(/\s+/);
if (parts.length < 4) continue;
const pidRaw = parts.at(-1);
const pid = pidRaw ? Number.parseInt(pidRaw, 10) : NaN;
const localAddr = parts[1];
const listener: PortListener = {};
if (Number.isFinite(pid)) listener.pid = pid;
if (localAddr?.includes(portToken)) listener.address = localAddr;
listeners.push(listener);
}
return listeners;
}
async function resolveWindowsImageName(pid: number): Promise<string | undefined> {
const res = await runCommandSafe(["tasklist", "/FI", `PID eq ${pid}`, "/FO", "LIST"]);
if (res.code !== 0) return undefined;
for (const rawLine of res.stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line.toLowerCase().startsWith("image name:")) continue;
const value = line.slice("image name:".length).trim();
return value || undefined;
}
return undefined;
}
async function resolveWindowsCommandLine(pid: number): Promise<string | undefined> {
const res = await runCommandSafe([
"wmic",
"process",
"where",
`ProcessId=${pid}`,
"get",
"CommandLine",
"/value",
]);
if (res.code !== 0) return undefined;
for (const rawLine of res.stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line.toLowerCase().startsWith("commandline=")) continue;
const value = line.slice("commandline=".length).trim();
return value || undefined;
}
return undefined;
}
async function readWindowsListeners(
port: number,
): Promise<{ listeners: PortListener[]; detail?: string; errors: string[] }> {
const errors: string[] = [];
const res = await runCommandSafe(["netstat", "-ano", "-p", "tcp"]);
if (res.code !== 0) {
if (res.error) errors.push(res.error);
const detail = [res.stderr.trim(), res.stdout.trim()].filter(Boolean).join("\n");
if (detail) errors.push(detail);
return { listeners: [], errors };
}
const listeners = parseNetstatListeners(res.stdout, port);
await Promise.all(
listeners.map(async (listener) => {
if (!listener.pid) return;
const [imageName, commandLine] = await Promise.all([
resolveWindowsImageName(listener.pid),
resolveWindowsCommandLine(listener.pid),
]);
if (imageName) listener.command = imageName;
if (commandLine) listener.commandLine = commandLine;
}),
);
return { listeners, detail: res.stdout.trim() || undefined, errors };
}
async function tryListenOnHost(port: number, host: string): Promise<PortUsageStatus | "skip"> {
try {
await new Promise<void>((resolve, reject) => {
const tester = net
.createServer()
.once("error", (err) => reject(err))
.once("listening", () => {
tester.close(() => resolve());
})
.listen({ port, host, exclusive: true });
});
return "free";
} catch (err) {
if (isErrno(err) && err.code === "EADDRINUSE") return "busy";
if (isErrno(err) && (err.code === "EADDRNOTAVAIL" || err.code === "EAFNOSUPPORT")) {
return "skip";
}
return "unknown";
}
}
async function checkPortInUse(port: number): Promise<PortUsageStatus> {
const hosts = ["127.0.0.1", "0.0.0.0", "::1", "::"];
let sawUnknown = false;
for (const host of hosts) {
const result = await tryListenOnHost(port, host);
if (result === "busy") return "busy";
if (result === "unknown") sawUnknown = true;
}
return sawUnknown ? "unknown" : "free";
}
export async function inspectPortUsage(port: number): Promise<PortUsage> {
const errors: string[] = [];
const result =
process.platform === "win32" ? await readWindowsListeners(port) : await readUnixListeners(port);
errors.push(...result.errors);
let listeners = result.listeners;
let status: PortUsageStatus = listeners.length > 0 ? "busy" : "unknown";
if (listeners.length === 0) {
status = await checkPortInUse(port);
}
if (status !== "busy") {
listeners = [];
}
const hints = buildPortHints(listeners, port);
if (status === "busy" && listeners.length === 0) {
hints.push(
"Port is in use but process details are unavailable (install lsof or run as an admin user).",
);
}
return {
port,
status,
listeners,
hints,
detail: result.detail,
errors: errors.length > 0 ? errors : undefined,
};
}