Files
OpenTerminal/src/main/sysinfo.ts
T
Bill 35583b2c15 feat(lock): main-window lock screen; harden env gating, sandbox and ssh teardown
Lock screen (main-window overlay, no second window):
- scrypt password verifier in <userData>/lock.json (per-write salt,
  timingSafeEqual); salt/hash/password never leave the main process
- lock now / idle auto-lock / lock at startup, growing failure cooldown,
  lock flags persisted so a quit-and-relaunch cannot bypass the lock
- locked shell and body portals go inert while sessions keep running;
  menu accelerators (reload, DevTools, zoom) are swallowed while locked
- settings gains a Lock tab; all copy in zh-CN/zh-TW/en/ja

Security and stability:
- packaged builds ignore ELECTRON_RENDERER_URL / OT_UPDATE_URL (devEnv)
- renderer preload runs with sandbox: true
- unreadable known_hosts store fails closed instead of being overwritten
- connect-time secrets gated by the bookmark's auth method (connectPromptFor)
- ssh stream teardown is idempotent: PTY_EXIT broadcasts exactly once
- sysinfo polling is refcounted for split panes (forceStopPolling on close)
- session-log index entries are path-contained; settings store writes
  atomically with EPERM/EBUSY retry
- sync-changelog tolerates CRLF checkouts (was a silent no-op)
- retry ssh2 host-key generation (flaky malformed key, ~1/500)

Tests: lock-store + lock-controller suites; transport-death PTY_EXIT e2e;
GitHub Actions CI (typecheck + 10 offline tests + build)
2026-09-24 22:16:43 +08:00

478 lines
14 KiB
TypeScript

/**
* Remote server hardware monitoring (M3): ssh sessions only.
*
* Polls a remote via an ssh2 `exec` (a single command returns CPU / memory /
* load / uptime / disk / network in one transport), parses the cat'd /proc
* files, diffs successive samples to compute usage and throughput, and
* broadcasts SYSINFO_SAMPLE to every live window. SYSINFO_META (hostname + os)
* is emitted once per session.
*
* The ssh `Client` for a session is injected by pty.ts (registerSysinfoClient);
* the broadcast sink is injected by pty.ts at runtime configure time, so this
* module stays Electron-free and testable through the same session-layer bundle
* as the ssh harness.
*/
import { Ipc } from '../shared/ipc'
import { t } from '../shared/i18n'
import type { Client } from 'ssh2'
import type { SysinfoMeta, SysinfoSample } from '../shared/sysinfo'
/** One exec returns stat+mem+load+uptime, then df, then net, then host/uname. */
const COLLECT_CMD =
'cat /proc/stat /proc/meminfo /proc/loadavg /proc/uptime 2>/dev/null; ' +
'echo __DF__; df -kP 2>/dev/null; ' +
'echo __NET__; cat /proc/net/dev 2>/dev/null; ' +
'echo __HOST__; hostname 2>/dev/null; uname -sr 2>/dev/null'
/** Failures this many in a row before auto-stopping the poll. */
const MAX_CONSECUTIVE_FAILS = 3
/** File-system types produced by `df -kP` that are not real disks. */
const FAKE_FS = new Set(['tmpfs', 'overlay', 'udev', 'devtmpfs', 'none', 'squashfs', 'shm'])
// Some df versions key the type column slightly differently; 'overlay'/'shm'
// are the common container hosts. sysfs/procfs never appear in df -kP.
/** Mirrors main/broadcast.ts but injectable so the loopback harness can capture it. */
interface SysinfoDeps {
broadcast(channel: string, ...args: unknown[]): void
}
let deps: SysinfoDeps | undefined
/** Resolve an ssh `Client` for a session id (injected by pty.ts). */
type ClientProvider = (id: string) => Client | undefined
let clientProvider: ClientProvider | undefined
/** Wire the broadcast sink. Called once during app startup (from pty.ts). */
export function configureSysinfo(d: SysinfoDeps): void {
deps = d
}
/** Register the session-id -> ssh client resolver (called by pty.ts). */
export function registerSysinfoClient(provider: ClientProvider): void {
clientProvider = provider
}
interface Prev {
/** cpu `idle`+`iowait` ticks from the previous sample */
idle: number
/** total ticks from the previous sample */
total: number
/** cumulative rx/tx bytes from the previous sample */
rx: number
tx: number
}
interface PollState {
id: string
intervalMs: number
stopped: boolean
consecutiveFails: number
prev?: Prev
/** most recent successful sample (kept for error frames) */
lastSample?: SysinfoSample
prevAt: number
metaSent: boolean
timer: NodeJS.Timeout
/**
* Live renderer subscriptions on this poll. Two MonitorPanels can share one
* sessionId (split view); each start/stop pair adjusts the count and only a
* count of zero (or a forced stop) tears the poll down.
*/
refs: number
}
const polls = new Map<string, PollState>()
/**
* Start polling a session on behalf of one renderer subscriber.
*
* The first call creates the poll; further calls for an already-polling id
* only bump the reference count (the existing interval keeps running) so a
* second panel joining a split view cannot restart or reset the shared poll.
*/
export function startPolling(id: string, intervalMs = 3000): void {
const existing = polls.get(id)
if (existing) {
existing.refs += 1
return
}
const state: PollState = {
id,
intervalMs,
stopped: false,
consecutiveFails: 0,
prev: undefined,
lastSample: undefined,
prevAt: 0,
metaSent: false,
refs: 1,
timer: setTimeout(() => pollOnce(id, state), 0)
}
polls.set(id, state)
}
/**
* Drop one renderer subscription. The poll itself stops only when the last
* reference is gone — any other panel sharing the sessionId keeps it alive.
* No-op when nothing is polling (e.g. after a forced stop).
*/
export function stopPolling(id: string): void {
const state = polls.get(id)
if (!state) return
state.refs -= 1
if (state.refs <= 0) haltPolling(id)
}
/**
* Stop unconditionally, discarding the reference count. For session
* close/kill: after the underlying ssh client is gone nothing must keep
* polling, regardless of how many panels still hold references.
*/
export function forceStopPolling(id: string): void {
haltPolling(id)
}
function haltPolling(id: string): void {
const state = polls.get(id)
if (!state) return
state.stopped = true
clearTimeout(state.timer)
polls.delete(id)
}
function armNext(id: string, state: PollState): void {
if (state.stopped) return
state.timer = setTimeout(() => pollOnce(id, state), state.intervalMs)
}
function pollOnce(id: string, state: PollState): void {
if (state.stopped) return
const client = clientProvider?.(id)
if (!client) {
handleError(id, state, t('main.sysinfo.sessionGone'))
return
}
try {
client.exec(COLLECT_CMD, (err: Error | undefined, stream) => {
if (state.stopped) return
if (err || !stream) {
handleError(id, state, err?.message || t('main.sysinfo.execFailed'))
return
}
let out = ''
let done = false
// A wedged remote command (e.g. df on a hung NFS mount) never closes the
// stream; without this watchdog the poll loop freezes silently forever.
const watchdog = setTimeout(() => {
if (done) return
done = true
try {
stream.close()
} catch {
// best effort
}
handleError(id, state, t('main.sysinfo.pollTimeout'))
}, state.intervalMs * 2)
stream.on('data', (d: Buffer) => {
out += d.toString('utf8')
})
const onEnd = (): void => {
if (done) return
done = true
clearTimeout(watchdog)
if (state.stopped) return
try {
stream.close()
} catch {
// best effort
}
handleOutput(id, state, out)
}
stream.on('close', onEnd)
stream.on('error', (streamErr: Error) => {
if (done) return
done = true
clearTimeout(watchdog)
if (state.stopped) return
try {
stream.close()
} catch {
// best effort
}
handleError(id, state, streamErr?.message || t('main.sysinfo.execFailed'))
})
})
} catch (err) {
handleError(id, state, (err as Error).message)
}
}
function handleError(id: string, state: PollState, message: string): void {
if (state.stopped) return
state.consecutiveFails += 1
const last = state.lastSample
const sample: SysinfoSample = last
? {
...last,
ts: Date.now(),
error: message
}
: {
ts: Date.now(),
cpu: { usage: 0, cores: 0, loadavg: [0, 0, 0] },
mem: { totalMb: 0, usedMb: 0 },
disks: [],
net: { rxKbs: 0, txKbs: 0 },
uptimeSec: 0,
error: message
}
broadcastSample(id, sample)
if (state.consecutiveFails >= MAX_CONSECUTIVE_FAILS) {
console.warn(`[sysinfo] session ${id} failed ${state.consecutiveFails} polls, stopping`)
// Engine-side decision: halt the poll outright (not a refcount decrement —
// that would leave sibling panels pointing at a dead loop with no timer).
// A later renderer stop is then a no-op and a fresh start can re-create it.
forceStopPolling(id)
return
}
armNext(id, state)
}
function handleOutput(id: string, state: PollState, out: string): void {
if (state.stopped) return
const now = Date.now()
try {
const parsed = parseOutput(out)
const sample: SysinfoSample = {
ts: now,
cpu: parsed.cpu,
mem: parsed.mem,
disks: parsed.disks,
net: parsed.net,
uptimeSec: parsed.uptimeSec
}
state.consecutiveFails = 0
// Diff against the previous sample when one exists and rates are computable.
if (state.prev && state.prevAt > 0) {
const dtSec = (now - state.prevAt) / 1000
const totalDelta = parsed.cpuTotal - state.prev.total
const idleDelta = parsed.cpuIdle - state.prev.idle
if (totalDelta > 0) {
sample.cpu.usage = Math.max(0, Math.min(100, 100 * (1 - idleDelta / totalDelta)))
}
if (dtSec > 0) {
sample.net.rxKbs = Math.max(0, (parsed.netRx - state.prev.rx) / 1024 / dtSec)
sample.net.txKbs = Math.max(0, (parsed.netTx - state.prev.tx) / 1024 / dtSec)
}
}
state.prevAt = now
state.prev = {
idle: parsed.cpuIdle,
total: parsed.cpuTotal,
rx: parsed.netRx,
tx: parsed.netTx
}
state.lastSample = sample
broadcastSample(id, sample)
if (!state.metaSent && (parsed.hostname || parsed.osInfo)) {
state.metaSent = true
broadcastMeta(id, { hostname: parsed.hostname, os: parsed.osInfo })
}
} catch (err) {
handleError(id, state, t('main.sysinfo.parseFailed', { message: (err as Error).message }))
return
}
armNext(id, state)
}
function broadcastSample(id: string, sample: SysinfoSample): void {
try {
deps?.broadcast(Ipc.SYSINFO_SAMPLE, { id, sample })
} catch {
// never crash the event loop
}
}
function broadcastMeta(id: string, meta: SysinfoMeta): void {
try {
deps?.broadcast(Ipc.SYSINFO_META, { id, meta })
} catch {
// never crash the event loop
}
}
// ---- parsing ------------------------------------------------------------------
interface Parsed {
cpu: SysinfoSample['cpu']
cpuIdle: number
cpuTotal: number
mem: SysinfoSample['mem']
disks: SysinfoSample['disks']
net: SysinfoSample['net']
netRx: number
netTx: number
uptimeSec: number
hostname: string
osInfo: string
}
function parseOutput(raw: string): Parsed {
// Split into the four delimited regions.
const parts = raw.split(/__DF__|__NET__|__HOST__/)
const procBlob = parts[0] ?? ''
const dfBlob = parts[1] ?? ''
const netBlob = parts[2] ?? ''
const hostBlob = parts[3] ?? ''
return {
...parseProc(procBlob),
disks: parseDf(dfBlob),
...parseNet(netBlob),
...parseHost(hostBlob)
}
}
function toNumber(s: string | undefined): number {
const n = Number(s)
return Number.isFinite(n) ? n : 0
}
function parseProc(blob: string): { cpu: SysinfoSample['cpu']; cpuIdle: number; cpuTotal: number; mem: SysinfoSample['mem']; uptimeSec: number } {
const lines = blob.split('\n')
let total = 0
let idle = 0
let cores = 0
let user = 0
const loadavg: [number, number, number] = [0, 0, 0]
let memTotal = 0
let memAvailable = 0
let uptimeSec = 0
for (const raw of lines) {
const line = raw.trimEnd()
if (line.startsWith('cpu')) {
if (line.startsWith('cpu ')) {
const f = line.split(/\s+/).slice(1).map(toNumber)
user = f[0] ?? 0
const nice = f[1] ?? 0
const system = f[2] ?? 0
const idleTicks = f[3] ?? 0
const iowait = f[4] ?? 0
const irq = f[5] ?? 0
const softirq = f[6] ?? 0
const steal = f[7] ?? 0
// guest/guest_nice (f[8]/f[9]) are already included in user/nice per
// proc(5) — summing them in would double-count and understate CPU%.
idle = idleTicks + iowait
total =
user +
nice +
system +
idle +
irq +
softirq +
steal
} else {
cores += 1
}
} else if (line.startsWith('MemTotal:')) {
memTotal = toNumber(line.split(/\s+/)[1])
} else if (line.startsWith('MemAvailable:')) {
memAvailable = toNumber(line.split(/\s+/)[1])
} else if (line.includes('/')) {
// /proc/loadavg: "0.00 0.01 0.05 1/234 5678"
const loadMatch = /^\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)/.exec(line)
if (loadMatch) {
loadavg[0] = toNumber(loadMatch[1])
loadavg[1] = toNumber(loadMatch[2])
loadavg[2] = toNumber(loadMatch[3])
}
} else if (/^\d/.test(line) && line.split(/\s+/).length === 2) {
// /proc/uptime: "12345.67 9876.54"
uptimeSec = toNumber(line.split(/\s+/)[0])
}
}
return {
cpu: {
usage: 0,
cores,
loadavg
},
cpuIdle: idle,
cpuTotal: total,
mem: {
// meminfo columns are kB; sample schema is MiB.
totalMb: Math.round(memTotal / 1024),
usedMb: memAvailable > 0 && memTotal >= memAvailable
? Math.round((memTotal - memAvailable) / 1024)
: 0
},
uptimeSec: Math.round(uptimeSec)
}
}
function parseDf(blob: string): SysinfoSample['disks'] {
const disks: SysinfoSample['disks'] = []
for (const raw of blob.split('\n')) {
const line = raw.trim()
if (!line || line.startsWith('Filesystem')) continue
const f = line.split(/\s+/)
if (f.length < 6) continue
// Columns: Filesystem 1K-blocks Used Available Use% Mounted on
const fs = f[0]
const mount = f.slice(5).join(' ')
// df -kP has no type column; pseudofs appear as the device name (tmpfs,
// overlay, udev, ...) or as a mount point under the shared /dev harness.
if (FAKE_FS.has(fs) || FAKE_FS.has(mount)) continue
const totalKb = toNumber(f[1])
const usedKb = toNumber(f[2])
if (totalKb <= 0) continue
disks.push({
mount,
totalMb: Math.round(totalKb / 1024),
usedMb: Math.round(usedKb / 1024)
})
}
return disks
}
function parseNet(
blob: string
): { net: SysinfoSample['net']; netRx: number; netTx: number } {
let rx = 0
let tx = 0
for (const raw of blob.split('\n')) {
const line = raw.trim()
if (!line || line.startsWith('Inter')) continue
const colon = line.indexOf(':')
if (colon <= 0) continue
const iface = line.slice(0, colon)
const fields = line
.slice(colon + 1)
.trim()
.split(/\s+/)
.map(Number)
// /proc/net/dev rows: rx_bytes rx_packets ... rx_multicast tx_bytes ...
if (fields.length < 10 || fields.some((n) => !Number.isFinite(n))) continue
if (iface === 'lo') continue
rx += fields[0]
tx += fields[8]
}
return { net: { rxKbs: 0, txKbs: 0 }, netRx: rx, netTx: tx }
}
function parseHost(blob: string): { hostname: string; osInfo: string } {
const lines = blob.split('\n').map((l) => l.trim()).filter(Boolean)
return {
hostname: lines[0] ?? '',
osInfo: lines.slice(1).join(' ').trim()
}
}