@nhic-lab/srv-wrapper 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/cli/client.js +135 -0
- package/dist/cli/index.js +65 -0
- package/dist/daemon/dashboard-server.js +283 -0
- package/dist/daemon/index.js +48 -0
- package/dist/daemon/jump-chain.js +35 -0
- package/dist/daemon/keychain.js +46 -0
- package/dist/daemon/logstore.js +202 -0
- package/dist/daemon/registry.js +103 -0
- package/dist/daemon/socket-protocol.js +19 -0
- package/dist/daemon/socket-server.js +199 -0
- package/dist/daemon/ssh-manager.js +231 -0
- package/dist/shared/paths.js +17 -0
- package/dist/shared/types.js +1 -0
- package/package.json +71 -0
- package/public/app.js +1802 -0
- package/public/index.html +189 -0
- package/public/styles.css +838 -0
- package/scripts/com.srv-wrapper.daemon.plist +21 -0
- package/scripts/compact-log.mjs +140 -0
- package/scripts/install-launchd.sh +21 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
const SERVICE = 'srv-wrapper';
|
|
3
|
+
function defaultExec(cmd, args) {
|
|
4
|
+
const result = spawnSync(cmd, args, { encoding: 'utf-8' });
|
|
5
|
+
return { stdout: result.stdout ?? '', status: result.status ?? 1 };
|
|
6
|
+
}
|
|
7
|
+
export class Keychain {
|
|
8
|
+
daemonBinaryPath;
|
|
9
|
+
exec;
|
|
10
|
+
constructor(daemonBinaryPath, exec = defaultExec) {
|
|
11
|
+
this.daemonBinaryPath = daemonBinaryPath;
|
|
12
|
+
this.exec = exec;
|
|
13
|
+
}
|
|
14
|
+
setSecret(serverId, secret) {
|
|
15
|
+
const result = this.exec('security', [
|
|
16
|
+
'add-generic-password',
|
|
17
|
+
'-a', serverId,
|
|
18
|
+
'-s', SERVICE,
|
|
19
|
+
'-w', secret,
|
|
20
|
+
'-T', this.daemonBinaryPath,
|
|
21
|
+
'-U',
|
|
22
|
+
]);
|
|
23
|
+
if (result.status !== 0) {
|
|
24
|
+
throw new Error(`Failed to store secret for ${serverId} in Keychain (status ${result.status})`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
getSecret(serverId) {
|
|
28
|
+
const result = this.exec('security', [
|
|
29
|
+
'find-generic-password',
|
|
30
|
+
'-a', serverId,
|
|
31
|
+
'-s', SERVICE,
|
|
32
|
+
'-w',
|
|
33
|
+
]);
|
|
34
|
+
if (result.status !== 0) {
|
|
35
|
+
throw new Error(`No secret found for ${serverId} in Keychain (status ${result.status})`);
|
|
36
|
+
}
|
|
37
|
+
return result.stdout.trim();
|
|
38
|
+
}
|
|
39
|
+
deleteSecret(serverId) {
|
|
40
|
+
this.exec('security', [
|
|
41
|
+
'delete-generic-password',
|
|
42
|
+
'-a', serverId,
|
|
43
|
+
'-s', SERVICE,
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
/**
|
|
3
|
+
* Stored output is capped per run: the first HEAD_CHARS and last TAIL_CHARS are
|
|
4
|
+
* kept with an elision marker between them. A handful of `mysql`/`docker exec`
|
|
5
|
+
* dumps had grown to ~97 MB each and accounted for 96% of a 1.8 GB log.db,
|
|
6
|
+
* which made `GET /api/history` fail outright with "RangeError: Invalid string
|
|
7
|
+
* length" once the combined output passed V8's ~512 MB string ceiling.
|
|
8
|
+
*
|
|
9
|
+
* The cap also removes a quadratic write cost: `output = output || chunk`
|
|
10
|
+
* rewrites the entire column on every chunk, so a 97 MB run rewrote ~97 MB
|
|
11
|
+
* thousands of times. With the cap the column never exceeds ~256 KB.
|
|
12
|
+
*/
|
|
13
|
+
const HEAD_CHARS = 128 * 1024;
|
|
14
|
+
const TAIL_CHARS = 128 * 1024;
|
|
15
|
+
const DEFAULT_LIST_LIMIT = 500;
|
|
16
|
+
function fmtBytes(n) {
|
|
17
|
+
if (n < 1024)
|
|
18
|
+
return `${n} B`;
|
|
19
|
+
if (n < 1024 * 1024)
|
|
20
|
+
return `${(n / 1024).toFixed(0)} KB`;
|
|
21
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
22
|
+
}
|
|
23
|
+
function elisionMarker(bytes) {
|
|
24
|
+
return `\n\n… ${fmtBytes(bytes)} of output elided by srv-wrapper (head and tail kept) …\n\n`;
|
|
25
|
+
}
|
|
26
|
+
export class LogStore {
|
|
27
|
+
db;
|
|
28
|
+
/** Runs whose stored output has hit the cap: we hold head/tail in memory and
|
|
29
|
+
* stop touching the `output` column until the run finishes. */
|
|
30
|
+
capped = new Map();
|
|
31
|
+
constructor(dbPath) {
|
|
32
|
+
this.db = new Database(dbPath);
|
|
33
|
+
this.db.pragma('journal_mode = WAL');
|
|
34
|
+
this.db.exec(`
|
|
35
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
server_id TEXT NOT NULL,
|
|
38
|
+
agent_label TEXT NOT NULL,
|
|
39
|
+
kind TEXT NOT NULL CHECK(kind IN ('exec','session')),
|
|
40
|
+
command TEXT,
|
|
41
|
+
output TEXT NOT NULL DEFAULT '',
|
|
42
|
+
exit_code INTEGER,
|
|
43
|
+
started_at INTEGER NOT NULL,
|
|
44
|
+
ended_at INTEGER
|
|
45
|
+
)
|
|
46
|
+
`);
|
|
47
|
+
const cols = this.db.prepare(`PRAGMA table_info(runs)`).all();
|
|
48
|
+
if (!cols.some((c) => c.name === 'output_bytes')) {
|
|
49
|
+
this.db.exec(`ALTER TABLE runs ADD COLUMN output_bytes INTEGER NOT NULL DEFAULT 0`);
|
|
50
|
+
}
|
|
51
|
+
if (!cols.some((c) => c.name === 'truncated')) {
|
|
52
|
+
this.db.exec(`ALTER TABLE runs ADD COLUMN truncated INTEGER NOT NULL DEFAULT 0`);
|
|
53
|
+
}
|
|
54
|
+
// Backfill sizes for rows written before output_bytes existed. This is
|
|
55
|
+
// keyed off user_version rather than "did we just add the column", because
|
|
56
|
+
// scripts/compact-log.mjs may have created the column first — in which case
|
|
57
|
+
// a creation-time backfill never runs and every older row reports 0 bytes.
|
|
58
|
+
// user_version guarantees this happens exactly once per database.
|
|
59
|
+
const schemaVersion = this.db.pragma('user_version', { simple: true });
|
|
60
|
+
if (schemaVersion < 1) {
|
|
61
|
+
this.db.exec(`UPDATE runs SET output_bytes = length(output) WHERE output_bytes = 0 AND length(output) > 0`);
|
|
62
|
+
this.db.pragma('user_version = 1');
|
|
63
|
+
}
|
|
64
|
+
// History is always read newest-first, and always filtered by server id on
|
|
65
|
+
// the server-detail pane; without this every read was a full table scan.
|
|
66
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_runs_started_at ON runs (started_at DESC)`);
|
|
67
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_runs_server_started ON runs (server_id, started_at DESC)`);
|
|
68
|
+
}
|
|
69
|
+
start(input) {
|
|
70
|
+
this.db
|
|
71
|
+
.prepare(`INSERT INTO runs (id, server_id, agent_label, kind, command, output, output_bytes, truncated, exit_code, started_at, ended_at)
|
|
72
|
+
VALUES (@id, @serverId, @agentLabel, @kind, @command, '', 0, 0, NULL, @startedAt, NULL)`)
|
|
73
|
+
.run({ ...input, startedAt: Date.now() });
|
|
74
|
+
}
|
|
75
|
+
appendOutput(id, chunk) {
|
|
76
|
+
const bytes = Buffer.byteLength(chunk);
|
|
77
|
+
const cap = this.capped.get(id);
|
|
78
|
+
if (cap) {
|
|
79
|
+
// Already capped: keep a rolling tail in memory, touch only the counter.
|
|
80
|
+
cap.tail += chunk;
|
|
81
|
+
if (cap.tail.length > TAIL_CHARS) {
|
|
82
|
+
const over = cap.tail.length - TAIL_CHARS;
|
|
83
|
+
cap.elided += Buffer.byteLength(cap.tail.slice(0, over));
|
|
84
|
+
cap.tail = cap.tail.slice(over);
|
|
85
|
+
}
|
|
86
|
+
this.db.prepare('UPDATE runs SET output_bytes = output_bytes + @bytes WHERE id = @id').run({ id, bytes });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.db
|
|
90
|
+
.prepare('UPDATE runs SET output = output || @chunk, output_bytes = output_bytes + @bytes WHERE id = @id')
|
|
91
|
+
.run({ id, chunk, bytes });
|
|
92
|
+
const row = this.db.prepare('SELECT length(output) AS len FROM runs WHERE id = ?').get(id);
|
|
93
|
+
if (!row || row.len <= HEAD_CHARS + TAIL_CHARS)
|
|
94
|
+
return;
|
|
95
|
+
const full = this.db.prepare('SELECT output FROM runs WHERE id = ?').get(id).output;
|
|
96
|
+
const entry = {
|
|
97
|
+
head: full.slice(0, HEAD_CHARS),
|
|
98
|
+
tail: full.slice(full.length - TAIL_CHARS),
|
|
99
|
+
elided: Buffer.byteLength(full.slice(HEAD_CHARS, full.length - TAIL_CHARS)),
|
|
100
|
+
};
|
|
101
|
+
this.capped.set(id, entry);
|
|
102
|
+
this.db.prepare('UPDATE runs SET truncated = 1 WHERE id = ?').run(id);
|
|
103
|
+
this.flushCapped(id);
|
|
104
|
+
}
|
|
105
|
+
flushCapped(id) {
|
|
106
|
+
const cap = this.capped.get(id);
|
|
107
|
+
if (!cap)
|
|
108
|
+
return;
|
|
109
|
+
this.db
|
|
110
|
+
.prepare('UPDATE runs SET output = @output WHERE id = @id')
|
|
111
|
+
.run({ id, output: cap.head + elisionMarker(cap.elided) + cap.tail });
|
|
112
|
+
}
|
|
113
|
+
finish(id, exitCode) {
|
|
114
|
+
if (this.capped.has(id)) {
|
|
115
|
+
this.flushCapped(id);
|
|
116
|
+
this.capped.delete(id);
|
|
117
|
+
}
|
|
118
|
+
this.db
|
|
119
|
+
.prepare('UPDATE runs SET exit_code = @exitCode, ended_at = @endedAt WHERE id = @id')
|
|
120
|
+
.run({ id, exitCode, endedAt: Date.now() });
|
|
121
|
+
}
|
|
122
|
+
get(id) {
|
|
123
|
+
const row = this.db.prepare('SELECT * FROM runs WHERE id = ?').get(id);
|
|
124
|
+
if (!row)
|
|
125
|
+
return undefined;
|
|
126
|
+
return this.rowToRecord(row);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Run metadata only, newest first — deliberately without `output`. The
|
|
130
|
+
* dashboard's list pane never needs output, and sending it for every run is
|
|
131
|
+
* what broke this endpoint. Fetch a single run (with output) via `get`.
|
|
132
|
+
*/
|
|
133
|
+
list(filter) {
|
|
134
|
+
let query = `SELECT id, server_id, agent_label, kind, command, exit_code, started_at, ended_at,
|
|
135
|
+
output_bytes, truncated, length(output) AS stored_chars
|
|
136
|
+
FROM runs`;
|
|
137
|
+
const clauses = [];
|
|
138
|
+
const params = {};
|
|
139
|
+
if (filter?.serverId) {
|
|
140
|
+
clauses.push('server_id = @serverId');
|
|
141
|
+
params.serverId = filter.serverId;
|
|
142
|
+
}
|
|
143
|
+
if (filter?.agentLabel) {
|
|
144
|
+
clauses.push('agent_label = @agentLabel');
|
|
145
|
+
params.agentLabel = filter.agentLabel;
|
|
146
|
+
}
|
|
147
|
+
if (clauses.length)
|
|
148
|
+
query += ' WHERE ' + clauses.join(' AND ');
|
|
149
|
+
query += ' ORDER BY started_at DESC LIMIT @limit OFFSET @offset';
|
|
150
|
+
params.limit = Math.max(1, Math.min(filter?.limit ?? DEFAULT_LIST_LIMIT, 5000));
|
|
151
|
+
params.offset = Math.max(0, filter?.offset ?? 0);
|
|
152
|
+
const rows = this.db.prepare(query).all(params);
|
|
153
|
+
return rows.map((r) => ({
|
|
154
|
+
id: r.id,
|
|
155
|
+
serverId: r.server_id,
|
|
156
|
+
agentLabel: r.agent_label,
|
|
157
|
+
kind: r.kind,
|
|
158
|
+
command: r.command,
|
|
159
|
+
exitCode: r.exit_code,
|
|
160
|
+
startedAt: r.started_at,
|
|
161
|
+
endedAt: r.ended_at,
|
|
162
|
+
outputBytes: r.output_bytes,
|
|
163
|
+
truncated: Boolean(r.truncated),
|
|
164
|
+
hasOutput: r.stored_chars > 0,
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
/** Total number of runs, for "showing N of M" in the UI. */
|
|
168
|
+
count(filter) {
|
|
169
|
+
let query = 'SELECT count(*) AS n FROM runs';
|
|
170
|
+
const clauses = [];
|
|
171
|
+
const params = {};
|
|
172
|
+
if (filter?.serverId) {
|
|
173
|
+
clauses.push('server_id = @serverId');
|
|
174
|
+
params.serverId = filter.serverId;
|
|
175
|
+
}
|
|
176
|
+
if (filter?.agentLabel) {
|
|
177
|
+
clauses.push('agent_label = @agentLabel');
|
|
178
|
+
params.agentLabel = filter.agentLabel;
|
|
179
|
+
}
|
|
180
|
+
if (clauses.length)
|
|
181
|
+
query += ' WHERE ' + clauses.join(' AND ');
|
|
182
|
+
return this.db.prepare(query).get(params).n;
|
|
183
|
+
}
|
|
184
|
+
rowToRecord(row) {
|
|
185
|
+
return {
|
|
186
|
+
id: row.id,
|
|
187
|
+
serverId: row.server_id,
|
|
188
|
+
agentLabel: row.agent_label,
|
|
189
|
+
kind: row.kind,
|
|
190
|
+
command: row.command,
|
|
191
|
+
output: row.output,
|
|
192
|
+
exitCode: row.exit_code,
|
|
193
|
+
startedAt: row.started_at,
|
|
194
|
+
endedAt: row.ended_at,
|
|
195
|
+
outputBytes: row.output_bytes ?? row.output?.length ?? 0,
|
|
196
|
+
truncated: Boolean(row.truncated),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
close() {
|
|
200
|
+
this.db.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
export class Registry {
|
|
3
|
+
db;
|
|
4
|
+
constructor(dbPath) {
|
|
5
|
+
this.db = new Database(dbPath);
|
|
6
|
+
this.db.pragma('journal_mode = WAL');
|
|
7
|
+
this.db.exec(`
|
|
8
|
+
CREATE TABLE IF NOT EXISTS servers (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
host TEXT NOT NULL,
|
|
11
|
+
port INTEGER NOT NULL,
|
|
12
|
+
username TEXT NOT NULL,
|
|
13
|
+
auth_method TEXT NOT NULL CHECK(auth_method IN ('password','key')),
|
|
14
|
+
key_path TEXT,
|
|
15
|
+
host_key_fingerprint TEXT,
|
|
16
|
+
created_at INTEGER NOT NULL,
|
|
17
|
+
updated_at INTEGER NOT NULL
|
|
18
|
+
)
|
|
19
|
+
`);
|
|
20
|
+
const cols = this.db.prepare(`PRAGMA table_info(servers)`).all();
|
|
21
|
+
if (!cols.some((c) => c.name === 'host_key_fingerprint')) {
|
|
22
|
+
this.db.exec(`ALTER TABLE servers ADD COLUMN host_key_fingerprint TEXT`);
|
|
23
|
+
}
|
|
24
|
+
if (!cols.some((c) => c.name === 'jump_chain')) {
|
|
25
|
+
this.db.exec(`ALTER TABLE servers ADD COLUMN jump_chain TEXT`);
|
|
26
|
+
}
|
|
27
|
+
// Reachability is persisted so the Servers view's online/offline filter is
|
|
28
|
+
// still meaningful after a refresh or a daemon restart — with 50+ servers,
|
|
29
|
+
// re-running "Test all" just to repopulate an in-memory map is not viable.
|
|
30
|
+
if (!cols.some((c) => c.name === 'last_test_at')) {
|
|
31
|
+
this.db.exec(`ALTER TABLE servers ADD COLUMN last_test_at INTEGER`);
|
|
32
|
+
}
|
|
33
|
+
if (!cols.some((c) => c.name === 'last_test_ok')) {
|
|
34
|
+
this.db.exec(`ALTER TABLE servers ADD COLUMN last_test_ok INTEGER`);
|
|
35
|
+
}
|
|
36
|
+
if (!cols.some((c) => c.name === 'last_test_error')) {
|
|
37
|
+
this.db.exec(`ALTER TABLE servers ADD COLUMN last_test_error TEXT`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
upsert(record) {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const existing = this.get(record.id);
|
|
43
|
+
const createdAt = existing?.createdAt ?? now;
|
|
44
|
+
this.db
|
|
45
|
+
.prepare(`INSERT INTO servers (id, host, port, username, auth_method, key_path, jump_chain, created_at, updated_at)
|
|
46
|
+
VALUES (@id, @host, @port, @username, @authMethod, @keyPath, @jumpChain, @createdAt, @updatedAt)
|
|
47
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
48
|
+
host=excluded.host, port=excluded.port, username=excluded.username,
|
|
49
|
+
auth_method=excluded.auth_method, key_path=excluded.key_path, jump_chain=excluded.jump_chain, updated_at=excluded.updated_at`)
|
|
50
|
+
.run({
|
|
51
|
+
id: record.id,
|
|
52
|
+
host: record.host,
|
|
53
|
+
port: record.port,
|
|
54
|
+
username: record.username,
|
|
55
|
+
authMethod: record.authMethod,
|
|
56
|
+
keyPath: record.keyPath ?? null,
|
|
57
|
+
jumpChain: record.jumpChain && record.jumpChain.length > 0 ? JSON.stringify(record.jumpChain) : null,
|
|
58
|
+
createdAt,
|
|
59
|
+
updatedAt: now,
|
|
60
|
+
});
|
|
61
|
+
return this.get(record.id);
|
|
62
|
+
}
|
|
63
|
+
get(id) {
|
|
64
|
+
const row = this.db.prepare('SELECT * FROM servers WHERE id = ?').get(id);
|
|
65
|
+
if (!row)
|
|
66
|
+
return undefined;
|
|
67
|
+
return {
|
|
68
|
+
id: row.id,
|
|
69
|
+
host: row.host,
|
|
70
|
+
port: row.port,
|
|
71
|
+
username: row.username,
|
|
72
|
+
authMethod: row.auth_method,
|
|
73
|
+
keyPath: row.key_path ?? undefined,
|
|
74
|
+
hostKeyFingerprint: row.host_key_fingerprint ?? undefined,
|
|
75
|
+
jumpChain: row.jump_chain ? JSON.parse(row.jump_chain) : undefined,
|
|
76
|
+
createdAt: row.created_at,
|
|
77
|
+
updatedAt: row.updated_at,
|
|
78
|
+
lastTestAt: row.last_test_at ?? undefined,
|
|
79
|
+
lastTestOk: row.last_test_ok == null ? undefined : Boolean(row.last_test_ok),
|
|
80
|
+
lastTestError: row.last_test_error ?? undefined,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
setHostKeyFingerprint(id, fingerprint) {
|
|
84
|
+
this.db.prepare('UPDATE servers SET host_key_fingerprint = ? WHERE id = ?').run(fingerprint, id);
|
|
85
|
+
}
|
|
86
|
+
/** Records the outcome of a reachability check. `error` is the already
|
|
87
|
+
* sanitized message — never a raw ssh2/Node error (see sanitizeSshError). */
|
|
88
|
+
setTestResult(id, ok, error) {
|
|
89
|
+
this.db
|
|
90
|
+
.prepare('UPDATE servers SET last_test_at = ?, last_test_ok = ?, last_test_error = ? WHERE id = ?')
|
|
91
|
+
.run(Date.now(), ok ? 1 : 0, ok ? null : (error ?? null), id);
|
|
92
|
+
}
|
|
93
|
+
list() {
|
|
94
|
+
const rows = this.db.prepare('SELECT id FROM servers ORDER BY id').all();
|
|
95
|
+
return rows.map((r) => this.get(r.id));
|
|
96
|
+
}
|
|
97
|
+
delete(id) {
|
|
98
|
+
this.db.prepare('DELETE FROM servers WHERE id = ?').run(id);
|
|
99
|
+
}
|
|
100
|
+
close() {
|
|
101
|
+
this.db.close();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function encodeMessage(msg) {
|
|
2
|
+
return JSON.stringify(msg) + '\n';
|
|
3
|
+
}
|
|
4
|
+
export function decodeMessages(buffer) {
|
|
5
|
+
const parts = buffer.split('\n');
|
|
6
|
+
const rest = parts.pop() ?? '';
|
|
7
|
+
const messages = [];
|
|
8
|
+
for (const line of parts) {
|
|
9
|
+
if (line.length === 0)
|
|
10
|
+
continue;
|
|
11
|
+
try {
|
|
12
|
+
messages.push(JSON.parse(line));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// malformed line — skip it rather than throwing and crashing the daemon
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return { messages, rest };
|
|
19
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { encodeMessage, decodeMessages } from './socket-protocol.js';
|
|
5
|
+
const KNOWN_SSH_ERROR_CODES = {
|
|
6
|
+
ECONNREFUSED: 'connection refused',
|
|
7
|
+
ETIMEDOUT: 'connection timed out',
|
|
8
|
+
EHOSTUNREACH: 'host unreachable',
|
|
9
|
+
ENOTFOUND: 'host not found',
|
|
10
|
+
ECONNRESET: 'connection reset',
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* SSH/Node connection-level errors routinely embed the literal host/IP and port
|
|
14
|
+
* (e.g. "connect ECONNREFUSED 10.0.0.5:22"). The CLI process must never receive
|
|
15
|
+
* host/port/credentials, so map known error codes to safe, generic messages and
|
|
16
|
+
* fall back to a fully generic message otherwise. The real error is logged
|
|
17
|
+
* server-side only.
|
|
18
|
+
*/
|
|
19
|
+
export function sanitizeSshError(err) {
|
|
20
|
+
const code = err?.code;
|
|
21
|
+
if (code && KNOWN_SSH_ERROR_CODES[code])
|
|
22
|
+
return `ssh error: ${KNOWN_SSH_ERROR_CODES[code]}`;
|
|
23
|
+
console.error('srvd: unclassified SSH error:', err);
|
|
24
|
+
return 'ssh error: connection failed';
|
|
25
|
+
}
|
|
26
|
+
export class SocketServer {
|
|
27
|
+
opts;
|
|
28
|
+
static SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
29
|
+
server;
|
|
30
|
+
sessions = new Map();
|
|
31
|
+
constructor(opts) {
|
|
32
|
+
this.opts = opts;
|
|
33
|
+
this.server = net.createServer((conn) => this.handleConnection(conn));
|
|
34
|
+
}
|
|
35
|
+
async start() {
|
|
36
|
+
if (fs.existsSync(this.opts.socketPath))
|
|
37
|
+
fs.unlinkSync(this.opts.socketPath);
|
|
38
|
+
await new Promise((resolve) => this.server.listen(this.opts.socketPath, resolve));
|
|
39
|
+
fs.chmodSync(this.opts.socketPath, 0o600);
|
|
40
|
+
}
|
|
41
|
+
async stop() {
|
|
42
|
+
await new Promise((resolve) => this.server.close(() => resolve()));
|
|
43
|
+
if (fs.existsSync(this.opts.socketPath))
|
|
44
|
+
fs.unlinkSync(this.opts.socketPath);
|
|
45
|
+
}
|
|
46
|
+
handleConnection(conn) {
|
|
47
|
+
let buffer = '';
|
|
48
|
+
conn.on('data', (data) => {
|
|
49
|
+
buffer += data.toString();
|
|
50
|
+
const { messages, rest } = decodeMessages(buffer);
|
|
51
|
+
buffer = rest;
|
|
52
|
+
for (const msg of messages) {
|
|
53
|
+
this.handleMessage(conn, msg).catch((err) => {
|
|
54
|
+
this.send(conn, {
|
|
55
|
+
type: 'done',
|
|
56
|
+
requestId: msg.requestId ?? 'unknown',
|
|
57
|
+
exitCode: null,
|
|
58
|
+
error: `internal error: ${err?.message ?? err}`,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async handleMessage(conn, msg) {
|
|
65
|
+
const requestId = msg.requestId ?? randomUUID();
|
|
66
|
+
if (msg.type === 'list') {
|
|
67
|
+
const serverIds = this.opts.registry.list().map((s) => s.id);
|
|
68
|
+
this.send(conn, { type: 'list_result', requestId, serverIds });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (msg.type === 'exec') {
|
|
72
|
+
const server = this.opts.registry.get(msg.serverId);
|
|
73
|
+
if (!server) {
|
|
74
|
+
this.send(conn, { type: 'done', requestId, exitCode: null, error: `unknown server: ${msg.serverId}` });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const runId = randomUUID();
|
|
78
|
+
this.opts.logStore.start({ id: runId, serverId: server.id, agentLabel: msg.agentLabel, kind: 'exec', command: msg.command });
|
|
79
|
+
try {
|
|
80
|
+
const exitCode = await this.opts.sshManager.exec(server, msg.command, (stream, chunk) => {
|
|
81
|
+
this.opts.logStore.appendOutput(runId, chunk);
|
|
82
|
+
const event = { type: 'stream', requestId, stream, chunk };
|
|
83
|
+
this.send(conn, event);
|
|
84
|
+
this.opts.onBroadcast?.({ ...event, serverId: server.id, agentLabel: msg.agentLabel, command: msg.command });
|
|
85
|
+
});
|
|
86
|
+
this.opts.logStore.finish(runId, exitCode);
|
|
87
|
+
const done = { type: 'done', requestId, exitCode };
|
|
88
|
+
this.send(conn, done);
|
|
89
|
+
this.opts.onBroadcast?.({ ...done, serverId: server.id, agentLabel: msg.agentLabel });
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
this.opts.logStore.finish(runId, null);
|
|
93
|
+
const safe = sanitizeSshError(err);
|
|
94
|
+
this.send(conn, { type: 'done', requestId, exitCode: null, error: safe });
|
|
95
|
+
// The dashboard needs this too — without it a run that failed to
|
|
96
|
+
// connect is left streaming forever in the live view.
|
|
97
|
+
this.opts.onBroadcast?.({ type: 'done', requestId, exitCode: null, error: safe, serverId: server.id, agentLabel: msg.agentLabel });
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (msg.type === 'session_start') {
|
|
102
|
+
const server = this.opts.registry.get(msg.serverId);
|
|
103
|
+
if (!server) {
|
|
104
|
+
this.send(conn, { type: 'done', requestId, exitCode: null, error: `unknown server: ${msg.serverId}` });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const runId = randomUUID();
|
|
108
|
+
this.opts.logStore.start({ id: runId, serverId: server.id, agentLabel: msg.agentLabel, kind: 'session', command: null });
|
|
109
|
+
try {
|
|
110
|
+
const sessionId = await this.opts.sshManager.startSession(server, (chunk) => {
|
|
111
|
+
this.opts.logStore.appendOutput(runId, chunk);
|
|
112
|
+
const session = this.sessions.get(sessionId);
|
|
113
|
+
if (session?.pendingConn && session.pendingRequestId) {
|
|
114
|
+
this.send(session.pendingConn, { type: 'stream', requestId: session.pendingRequestId, stream: 'stdout', chunk });
|
|
115
|
+
this.opts.onBroadcast?.({ type: 'stream', requestId: session.pendingRequestId, stream: 'stdout', chunk, serverId: server.id, agentLabel: msg.agentLabel });
|
|
116
|
+
if (session.idleTimer)
|
|
117
|
+
clearTimeout(session.idleTimer);
|
|
118
|
+
session.idleTimer = setTimeout(() => this.finalizePendingSend(sessionId), 300);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
this.sessions.set(sessionId, {
|
|
122
|
+
serverId: server.id, agentLabel: msg.agentLabel, runId,
|
|
123
|
+
pendingConn: null, pendingRequestId: null, idleTimer: null,
|
|
124
|
+
sessionTimeoutTimer: this.scheduleSessionTimeout(sessionId),
|
|
125
|
+
});
|
|
126
|
+
this.send(conn, { type: 'session_started', requestId, sessionId });
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
this.opts.logStore.finish(runId, null);
|
|
130
|
+
this.send(conn, { type: 'done', requestId, exitCode: null, error: sanitizeSshError(err) });
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (msg.type === 'session_send') {
|
|
135
|
+
const session = this.sessions.get(msg.sessionId);
|
|
136
|
+
if (!session) {
|
|
137
|
+
this.send(conn, { type: 'done', requestId, exitCode: null, error: `unknown session: ${msg.sessionId}` });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
session.pendingConn = conn;
|
|
141
|
+
session.pendingRequestId = requestId;
|
|
142
|
+
clearTimeout(session.sessionTimeoutTimer);
|
|
143
|
+
session.sessionTimeoutTimer = this.scheduleSessionTimeout(msg.sessionId);
|
|
144
|
+
this.opts.sshManager.sendToSession(msg.sessionId, msg.command);
|
|
145
|
+
if (session.idleTimer)
|
|
146
|
+
clearTimeout(session.idleTimer);
|
|
147
|
+
session.idleTimer = setTimeout(() => this.finalizePendingSend(msg.sessionId), 300);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (msg.type === 'session_stop') {
|
|
151
|
+
const session = this.sessions.get(msg.sessionId);
|
|
152
|
+
if (!session) {
|
|
153
|
+
this.send(conn, { type: 'done', requestId, exitCode: null, error: `unknown session: ${msg.sessionId}` });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
clearTimeout(session.sessionTimeoutTimer);
|
|
157
|
+
if (session.idleTimer)
|
|
158
|
+
clearTimeout(session.idleTimer);
|
|
159
|
+
this.finalizePendingSend(msg.sessionId);
|
|
160
|
+
this.opts.sshManager.stopSession(msg.sessionId);
|
|
161
|
+
this.opts.logStore.finish(session.runId, null);
|
|
162
|
+
this.sessions.delete(msg.sessionId);
|
|
163
|
+
this.send(conn, { type: 'done', requestId, exitCode: null });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.send(conn, { type: 'done', requestId, exitCode: null, error: `unsupported request type: ${msg.type}` });
|
|
167
|
+
}
|
|
168
|
+
scheduleSessionTimeout(sessionId) {
|
|
169
|
+
return setTimeout(() => {
|
|
170
|
+
const session = this.sessions.get(sessionId);
|
|
171
|
+
if (!session)
|
|
172
|
+
return;
|
|
173
|
+
if (session.idleTimer)
|
|
174
|
+
clearTimeout(session.idleTimer);
|
|
175
|
+
this.finalizePendingSend(sessionId);
|
|
176
|
+
this.opts.sshManager.stopSession(sessionId);
|
|
177
|
+
this.opts.logStore.finish(session.runId, null);
|
|
178
|
+
this.sessions.delete(sessionId);
|
|
179
|
+
}, SocketServer.SESSION_IDLE_TIMEOUT_MS);
|
|
180
|
+
}
|
|
181
|
+
finalizePendingSend(sessionId) {
|
|
182
|
+
const session = this.sessions.get(sessionId);
|
|
183
|
+
if (!session?.pendingConn || !session.pendingRequestId)
|
|
184
|
+
return;
|
|
185
|
+
this.send(session.pendingConn, { type: 'done', requestId: session.pendingRequestId, exitCode: null });
|
|
186
|
+
// Mirrored to the dashboard so the send's live row settles instead of
|
|
187
|
+
// streaming indefinitely.
|
|
188
|
+
this.opts.onBroadcast?.({
|
|
189
|
+
type: 'done', requestId: session.pendingRequestId, exitCode: null,
|
|
190
|
+
serverId: session.serverId, agentLabel: session.agentLabel,
|
|
191
|
+
});
|
|
192
|
+
session.pendingConn = null;
|
|
193
|
+
session.pendingRequestId = null;
|
|
194
|
+
session.idleTimer = null;
|
|
195
|
+
}
|
|
196
|
+
send(conn, event) {
|
|
197
|
+
conn.write(encodeMessage(event));
|
|
198
|
+
}
|
|
199
|
+
}
|