mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
Two costs grew for the life of an SSH session and never came back down. 1. The relay port scan walked every process in /proc and readlink'd every fd even after every listening socket already had an owner. Cost was O(host processes x fds) per scan, repeating for the session's life. Exit as soon as every inode is attributed. 2. SshPtyModelAdmission kept closed provider generations in a Set<number>. Provider generations are a process-global monotonic counter shared by every SSH target, so the set gained one entry per relay reconnect forever. After 500k reconnects main retains ~10,234 KB / 500,000 entries; with this change, 18 KB / 1 range. Closed generations now live in SshPtyClosedGenerationRanges, which collapses contiguous closed runs. Membership stays exact -- a generation below the high-water mark can still be live on another host, so a high-water approximation would reject a healthy target's output. The range container's has()/add() were a linear scan; both are now binary search. has() is on the per-output-chunk admission path, so a scan would have traded a bounded Set lookup for one that degrades with fragmentation. This also speeds up ssh-pty-output-generation-guard.ts, which already uses this container on main. Known limitation, deliberately not addressed here: the closed-generation set is bounded in the healthy case (one range) but unbounded when generations leak, since each leaked generation leaves a permanent gap. Sublinear is not bounded. A live-generation set would be bounded by construction and is the better long-term design; that is a follow-up.
284 lines
7.7 KiB
TypeScript
284 lines
7.7 KiB
TypeScript
import { readFile, readdir, readlink } from 'node:fs/promises'
|
|
import { getProcessOutputFields } from '../shared/process-output-field-scanner'
|
|
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
|
import { scanWindowsListeningPorts } from './windows-port-scan'
|
|
|
|
// Keep in sync with src/shared/ssh-types.ts — DetectedPort
|
|
export type DetectedPort = {
|
|
port: number
|
|
host: string
|
|
pid?: number
|
|
processName?: string
|
|
}
|
|
|
|
const SYSTEM_PORTS_TO_EXCLUDE = new Set([22])
|
|
|
|
const MAX_DETECTED_PORTS = 50
|
|
|
|
export class PortScanHandler {
|
|
constructor(dispatcher: Pick<RelayDispatcher, 'onRequest'>) {
|
|
dispatcher.onRequest('ports.detect', async (_params, context: RequestContext) => {
|
|
if (process.platform === 'linux') {
|
|
return {
|
|
ports: await this.scanLinuxListeningPorts(context.signal),
|
|
platform: process.platform
|
|
}
|
|
}
|
|
if (process.platform === 'win32') {
|
|
return {
|
|
ports: await scanWindowsListeningPorts(context.signal),
|
|
platform: process.platform
|
|
}
|
|
}
|
|
return {
|
|
ports: [],
|
|
platform: process.platform
|
|
}
|
|
})
|
|
}
|
|
|
|
private async scanLinuxListeningPorts(signal?: AbortSignal): Promise<DetectedPort[]> {
|
|
signal?.throwIfAborted()
|
|
const [tcp4, tcp6] = await Promise.all([
|
|
this.readProcNet('/proc/net/tcp', signal),
|
|
this.readProcNet('/proc/net/tcp6', signal)
|
|
])
|
|
signal?.throwIfAborted()
|
|
|
|
const listeningSockets = [...tcp4, ...tcp6]
|
|
if (listeningSockets.length === 0) {
|
|
return []
|
|
}
|
|
|
|
const inodeSet = new Set(listeningSockets.map((s) => s.inode))
|
|
const inodeToPid = await this.mapInodesToPids(inodeSet, signal)
|
|
|
|
const seen = new Set<string>()
|
|
const results: DetectedPort[] = []
|
|
const relayPid = process.pid
|
|
const relayParentPid = process.ppid
|
|
|
|
for (const socket of listeningSockets) {
|
|
signal?.throwIfAborted()
|
|
const key = `${socket.host}:${socket.port}`
|
|
if (seen.has(key)) {
|
|
continue
|
|
}
|
|
seen.add(key)
|
|
|
|
if (SYSTEM_PORTS_TO_EXCLUDE.has(socket.port)) {
|
|
continue
|
|
}
|
|
|
|
const pid = inodeToPid.get(socket.inode)
|
|
if (pid === relayPid || pid === relayParentPid) {
|
|
continue
|
|
}
|
|
|
|
const processName = pid != null ? await this.getProcessName(pid, signal) : undefined
|
|
|
|
if (processName === 'sshd') {
|
|
continue
|
|
}
|
|
|
|
results.push({
|
|
port: socket.port,
|
|
host: socket.host,
|
|
pid: pid ?? undefined,
|
|
processName
|
|
})
|
|
}
|
|
|
|
// Why: sort before capping so the visible set is deterministic (lowest
|
|
// port numbers first) regardless of /proc enumeration order.
|
|
results.sort((a, b) => a.port - b.port)
|
|
return results.slice(0, MAX_DETECTED_PORTS)
|
|
}
|
|
|
|
private async readProcNet(
|
|
path: string,
|
|
signal?: AbortSignal
|
|
): Promise<{ port: number; host: string; inode: number }[]> {
|
|
signal?.throwIfAborted()
|
|
let content: string
|
|
try {
|
|
content = await readFile(path, 'utf-8')
|
|
} catch {
|
|
signal?.throwIfAborted()
|
|
return []
|
|
}
|
|
signal?.throwIfAborted()
|
|
|
|
const lines = content.split('\n')
|
|
const results: { port: number; host: string; inode: number }[] = []
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const fields = getProcessOutputFields(lines[i], 10)
|
|
if (fields.length < 10) {
|
|
continue
|
|
}
|
|
|
|
// State field (index 3): 0A = TCP_LISTEN
|
|
if (fields[3] !== '0A') {
|
|
continue
|
|
}
|
|
|
|
const localAddress = fields[1]
|
|
const parsed = parseHexAddress(localAddress)
|
|
if (!parsed) {
|
|
continue
|
|
}
|
|
|
|
const inode = Number.parseInt(fields[9], 10)
|
|
if (Number.isNaN(inode) || inode === 0) {
|
|
continue
|
|
}
|
|
|
|
results.push({ port: parsed.port, host: parsed.host, inode })
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
private async mapInodesToPids(
|
|
inodes: Set<number>,
|
|
signal?: AbortSignal
|
|
): Promise<Map<number, number>> {
|
|
signal?.throwIfAborted()
|
|
const result = new Map<number, number>()
|
|
if (inodes.size === 0) {
|
|
return result
|
|
}
|
|
|
|
let pids: string[]
|
|
try {
|
|
pids = (await readdir('/proc')).filter((name) => /^\d+$/.test(name))
|
|
} catch {
|
|
signal?.throwIfAborted()
|
|
return result
|
|
}
|
|
signal?.throwIfAborted()
|
|
|
|
for (const pidStr of pids) {
|
|
signal?.throwIfAborted()
|
|
// Why: every remaining pid costs a readdir plus one readlink per fd, and this scan repeats for
|
|
// the life of the session. Without this the walk was O(all host processes x all fds) even once
|
|
// every listener was already attributed, so its cost grew with the remote's process count and
|
|
// never came back down — the shape behind "SSH gets slower the longer Orca stays open".
|
|
if (result.size === inodes.size) {
|
|
return result
|
|
}
|
|
const fdDir = `/proc/${pidStr}/fd`
|
|
let fds: string[]
|
|
try {
|
|
fds = await readdir(fdDir)
|
|
} catch {
|
|
signal?.throwIfAborted()
|
|
continue
|
|
}
|
|
signal?.throwIfAborted()
|
|
|
|
const pid = Number.parseInt(pidStr, 10)
|
|
|
|
for (const fd of fds) {
|
|
signal?.throwIfAborted()
|
|
let link: string
|
|
try {
|
|
link = await readlink(`${fdDir}/${fd}`)
|
|
} catch {
|
|
signal?.throwIfAborted()
|
|
continue
|
|
}
|
|
signal?.throwIfAborted()
|
|
|
|
const match = link.match(/^socket:\[(\d+)\]$/)
|
|
if (!match) {
|
|
continue
|
|
}
|
|
|
|
const inode = Number.parseInt(match[1], 10)
|
|
if (inodes.has(inode)) {
|
|
result.set(inode, pid)
|
|
if (result.size === inodes.size) {
|
|
return result
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private async getProcessName(pid: number, signal?: AbortSignal): Promise<string | undefined> {
|
|
signal?.throwIfAborted()
|
|
try {
|
|
const cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf-8')
|
|
signal?.throwIfAborted()
|
|
if (!cmdline) {
|
|
return undefined
|
|
}
|
|
|
|
const exe = cmdline.split('\0')[0]
|
|
if (!exe) {
|
|
return undefined
|
|
}
|
|
|
|
const parts = exe.split('/')
|
|
return parts.at(-1)
|
|
} catch {
|
|
signal?.throwIfAborted()
|
|
return undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
// Why: /proc/net/tcp encodes addresses as hex pairs in host-byte-order.
|
|
// IPv4: 8 hex chars for address + ':' + 4 hex chars for port.
|
|
// IPv6: 32 hex chars for address + ':' + 4 hex chars for port.
|
|
export function parseHexAddress(hexAddr: string): { host: string; port: number } | null {
|
|
const parts = hexAddr.split(':')
|
|
if (parts.length !== 2) {
|
|
return null
|
|
}
|
|
|
|
const port = Number.parseInt(parts[1], 16)
|
|
if (Number.isNaN(port) || port === 0) {
|
|
return null
|
|
}
|
|
|
|
const addrHex = parts[0]
|
|
|
|
if (addrHex.length === 8) {
|
|
const b1 = Number.parseInt(addrHex.substring(6, 8), 16)
|
|
const b2 = Number.parseInt(addrHex.substring(4, 6), 16)
|
|
const b3 = Number.parseInt(addrHex.substring(2, 4), 16)
|
|
const b4 = Number.parseInt(addrHex.substring(0, 2), 16)
|
|
const host = `${b1}.${b2}.${b3}.${b4}`
|
|
return { host, port }
|
|
}
|
|
|
|
if (addrHex.length === 32) {
|
|
if (addrHex === '00000000000000000000000000000000') {
|
|
return { host: '::', port }
|
|
}
|
|
if (addrHex === '00000000000000000000000001000000') {
|
|
return { host: '::1', port }
|
|
}
|
|
return { host: formatIPv6(addrHex), port }
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function formatIPv6(hex: string): string {
|
|
const groups: string[] = []
|
|
for (let i = 0; i < 32; i += 8) {
|
|
const chunk = hex.substring(i, i + 8)
|
|
const reversed =
|
|
chunk.substring(6, 8) + chunk.substring(4, 6) + chunk.substring(2, 4) + chunk.substring(0, 2)
|
|
groups.push(reversed.substring(0, 4))
|
|
groups.push(reversed.substring(4, 8))
|
|
}
|
|
return groups.map((g) => g.replace(/^0+/, '') || '0').join(':')
|
|
}
|