@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.
@@ -0,0 +1,231 @@
1
+ import { Client } from 'ssh2';
2
+ import { randomUUID } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { resolveJumpPath } from './jump-chain.js';
7
+ export class InMemoryHostKeyStore {
8
+ fingerprints = new Map();
9
+ get(serverId) {
10
+ return this.fingerprints.get(serverId);
11
+ }
12
+ set(serverId, fingerprint) {
13
+ this.fingerprints.set(serverId, fingerprint);
14
+ }
15
+ }
16
+ /**
17
+ * Persists TOFU host-key pinning in the Registry's sqlite database so pins
18
+ * survive daemon restarts (launchd restarts the daemon on login/crash).
19
+ */
20
+ export class RegistryHostKeyStore {
21
+ registry;
22
+ constructor(registry) {
23
+ this.registry = registry;
24
+ }
25
+ get(serverId) {
26
+ return this.registry.get(serverId)?.hostKeyFingerprint;
27
+ }
28
+ set(serverId, fingerprint) {
29
+ this.registry.setHostKeyFingerprint(serverId, fingerprint);
30
+ }
31
+ }
32
+ /**
33
+ * Reads a private key file, restricted to ~/.ssh so a malicious or mistaken
34
+ * keyPath value (registered via the dashboard) can't be used to read arbitrary
35
+ * files on the machine. realpathSync resolves the full symlink chain first,
36
+ * so a symlink inside ~/.ssh pointing outside it is rejected too.
37
+ */
38
+ function readPrivateKeyFile(keyPath) {
39
+ const sshDir = fs.realpathSync(path.join(os.homedir(), '.ssh'));
40
+ let resolved;
41
+ try {
42
+ resolved = fs.realpathSync(keyPath);
43
+ }
44
+ catch {
45
+ throw new Error(`key file not found: ${keyPath}`);
46
+ }
47
+ if (resolved !== sshDir && !resolved.startsWith(sshDir + path.sep)) {
48
+ throw new Error(`key path must be inside ~/.ssh (got: ${keyPath})`);
49
+ }
50
+ return fs.readFileSync(resolved, 'utf-8');
51
+ }
52
+ function defaultConnect(server, secret, hostKeyStore, viaStream, readyTimeoutMs) {
53
+ return new Promise((resolve, reject) => {
54
+ const client = new Client();
55
+ client.on('ready', () => resolve(client));
56
+ client.on('error', reject);
57
+ let authOpts;
58
+ if (server.authMethod === 'password') {
59
+ authOpts = { password: secret };
60
+ }
61
+ else {
62
+ if (!server.keyPath)
63
+ throw new Error('keyPath is required for key-based authentication');
64
+ authOpts = { privateKey: readPrivateKeyFile(server.keyPath), passphrase: secret };
65
+ }
66
+ const connectOpts = viaStream
67
+ ? { sock: viaStream, username: server.username, ...authOpts }
68
+ : { host: server.host, port: server.port, username: server.username, ...authOpts };
69
+ if (readyTimeoutMs !== undefined)
70
+ connectOpts.readyTimeout = readyTimeoutMs;
71
+ if (hostKeyStore) {
72
+ connectOpts.hostHash = 'sha256';
73
+ connectOpts.hostVerifier = (fingerprint) => {
74
+ const expected = hostKeyStore.get(server.id);
75
+ if (!expected) {
76
+ hostKeyStore.set(server.id, fingerprint);
77
+ return true;
78
+ }
79
+ return expected === fingerprint;
80
+ };
81
+ }
82
+ client.connect(connectOpts);
83
+ });
84
+ }
85
+ function closeChainClients(clients) {
86
+ // Unwind in reverse: target first, back to the first hop.
87
+ for (let i = clients.length - 1; i >= 0; i--) {
88
+ try {
89
+ clients[i].end();
90
+ }
91
+ catch {
92
+ // already closed; nothing to do
93
+ }
94
+ }
95
+ }
96
+ export class SshManager {
97
+ secretResolver;
98
+ connectFn;
99
+ hostKeyStore;
100
+ serverLookup;
101
+ static TEST_CONNECT_TIMEOUT_MS = 8000;
102
+ sessions = new Map();
103
+ constructor(secretResolver, connectFn = defaultConnect, hostKeyStore = new InMemoryHostKeyStore(), serverLookup = () => undefined) {
104
+ this.secretResolver = secretResolver;
105
+ this.connectFn = connectFn;
106
+ this.hostKeyStore = hostKeyStore;
107
+ this.serverLookup = serverLookup;
108
+ }
109
+ hasSession(sessionId) {
110
+ return this.sessions.has(sessionId);
111
+ }
112
+ /**
113
+ * Connects to `server`, tunneling through its fully-expanded jumpChain (if
114
+ * any) one hop at a time via `client.forwardOut`. Returns every connected
115
+ * Client in hop order, last entry being the connection to `server` itself.
116
+ * When there's no jumpChain this degenerates to a single direct connect.
117
+ *
118
+ * `targetSecretOverride`, when given, supplies the secret for `server`
119
+ * itself instead of `secretResolver` — used by testConnect() to test a
120
+ * not-yet-saved server (whose secret isn't in the Keychain yet). Every
121
+ * other hop in the chain is still an existing registered server, so its
122
+ * secret still comes from the resolver as usual.
123
+ */
124
+ async connectChain(server, targetSecretOverride, readyTimeoutMs) {
125
+ const hopIds = server.jumpChain && server.jumpChain.length > 0
126
+ ? resolveJumpPath(server.id, server.jumpChain, this.serverLookup)
127
+ : [server.id];
128
+ const clients = [];
129
+ try {
130
+ for (let i = 0; i < hopIds.length; i++) {
131
+ const id = hopIds[i];
132
+ const record = id === server.id ? server : this.serverLookup(id);
133
+ if (!record)
134
+ throw new Error(`jump chain references unknown server id "${id}"`);
135
+ const secret = id === server.id && targetSecretOverride !== undefined ? targetSecretOverride : this.secretResolver(id);
136
+ if (i === 0) {
137
+ clients.push(await this.connectFn(record, secret, this.hostKeyStore, undefined, readyTimeoutMs));
138
+ }
139
+ else {
140
+ const previousClient = clients[clients.length - 1];
141
+ const stream = await new Promise((resolve, reject) => {
142
+ previousClient.forwardOut('127.0.0.1', 0, record.host, record.port, (err, stream) => {
143
+ if (err)
144
+ return reject(err);
145
+ resolve(stream);
146
+ });
147
+ });
148
+ clients.push(await this.connectFn(record, secret, this.hostKeyStore, stream, readyTimeoutMs));
149
+ }
150
+ }
151
+ }
152
+ catch (err) {
153
+ // A later hop failed (bad auth, unreachable, etc.) — close whatever
154
+ // earlier hops already connected instead of leaking them.
155
+ closeChainClients(clients);
156
+ throw err;
157
+ }
158
+ return clients;
159
+ }
160
+ /**
161
+ * Opens a connection to `server` (through its jumpChain, if any) and
162
+ * immediately closes it — used to check reachability/credentials without
163
+ * running a command. `secretOverride` lets callers test a server that
164
+ * isn't registered yet (e.g. the dashboard's "test connection" button on
165
+ * an unsaved form), whose secret isn't in the Keychain.
166
+ */
167
+ async testConnect(server, secretOverride) {
168
+ const clients = await this.connectChain(server, secretOverride, SshManager.TEST_CONNECT_TIMEOUT_MS);
169
+ closeChainClients(clients);
170
+ }
171
+ async exec(server, command, onData) {
172
+ const clients = await this.connectChain(server);
173
+ const client = clients[clients.length - 1];
174
+ return new Promise((resolve, reject) => {
175
+ let settled = false;
176
+ client.exec(command, (err, channel) => {
177
+ if (err) {
178
+ settled = true;
179
+ closeChainClients(clients);
180
+ return reject(err);
181
+ }
182
+ channel.on('data', (data) => onData('stdout', data.toString()));
183
+ channel.stderr.on('data', (data) => onData('stderr', data.toString()));
184
+ channel.on('close', (code) => {
185
+ if (settled)
186
+ return;
187
+ settled = true;
188
+ closeChainClients(clients);
189
+ resolve(code);
190
+ });
191
+ channel.on('error', (chanErr) => {
192
+ if (settled)
193
+ return;
194
+ settled = true;
195
+ closeChainClients(clients);
196
+ reject(chanErr);
197
+ });
198
+ });
199
+ });
200
+ }
201
+ async startSession(server, onData) {
202
+ const clients = await this.connectChain(server);
203
+ const client = clients[clients.length - 1];
204
+ const channel = await new Promise((resolve, reject) => {
205
+ client.shell((err, ch) => (err ? reject(err) : resolve(ch)));
206
+ });
207
+ channel.on('data', (data) => onData(data.toString()));
208
+ const sessionId = randomUUID();
209
+ channel.on('error', (err) => {
210
+ console.error(`srvd: session channel error for ${sessionId}:`, err);
211
+ this.sessions.delete(sessionId);
212
+ closeChainClients(clients);
213
+ });
214
+ this.sessions.set(sessionId, { clients, channel });
215
+ return sessionId;
216
+ }
217
+ sendToSession(sessionId, command) {
218
+ const session = this.sessions.get(sessionId);
219
+ if (!session)
220
+ throw new Error(`No open session ${sessionId}`);
221
+ session.channel.write(command);
222
+ }
223
+ stopSession(sessionId) {
224
+ const session = this.sessions.get(sessionId);
225
+ if (!session)
226
+ return;
227
+ session.channel.end();
228
+ closeChainClients(session.clients);
229
+ this.sessions.delete(sessionId);
230
+ }
231
+ }
@@ -0,0 +1,17 @@
1
+ import { homedir } from 'node:os';
2
+ import path from 'node:path';
3
+ export function srvHome() {
4
+ return path.join(homedir(), '.srv');
5
+ }
6
+ export function srvSocketPath() {
7
+ return path.join(srvHome(), 'srv.sock');
8
+ }
9
+ export function srvRegistryDbPath() {
10
+ return path.join(srvHome(), 'registry.db');
11
+ }
12
+ export function srvLogDbPath() {
13
+ return path.join(srvHome(), 'log.db');
14
+ }
15
+ export function srvKeysDir() {
16
+ return path.join(srvHome(), 'keys');
17
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@nhic-lab/srv-wrapper",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Local daemon + CLI that lets AI agents run commands on registered servers by an opaque server-id only, never seeing real hostnames or credentials.",
8
+ "keywords": [
9
+ "ssh",
10
+ "cli",
11
+ "daemon",
12
+ "ai-agent",
13
+ "server-management",
14
+ "audit-log"
15
+ ],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/nhic-lab/srv-wrapper.git"
20
+ },
21
+ "homepage": "https://github.com/nhic-lab/srv-wrapper#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/nhic-lab/srv-wrapper/issues"
24
+ },
25
+ "os": [
26
+ "darwin"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "type": "module",
32
+ "bin": {
33
+ "srv": "dist/cli/index.js",
34
+ "srvd": "dist/daemon/index.js"
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "public",
39
+ "scripts/install-launchd.sh",
40
+ "scripts/com.srv-wrapper.daemon.plist",
41
+ "scripts/compact-log.mjs",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "scripts": {
46
+ "build": "tsc -p tsconfig.json",
47
+ "test": "vitest run",
48
+ "dev:daemon": "tsx src/daemon/index.ts",
49
+ "compact-log": "node scripts/compact-log.mjs",
50
+ "prepublishOnly": "npm run build && npm test"
51
+ },
52
+ "dependencies": {
53
+ "better-sqlite3": "^13.0.2",
54
+ "commander": "^15.0.0",
55
+ "express": "^5.2.1",
56
+ "ssh2": "^1.17.0",
57
+ "ws": "^8.21.1"
58
+ },
59
+ "devDependencies": {
60
+ "@types/better-sqlite3": "^9.6.0",
61
+ "@types/express": "^5.0.6",
62
+ "@types/node": "^26.1.2",
63
+ "@types/ssh2": "^1.15.5",
64
+ "@types/supertest": "^7.2.1",
65
+ "@types/ws": "^8.18.1",
66
+ "supertest": "^7.2.2",
67
+ "tsx": "^4.23.1",
68
+ "typescript": "^7.0.2",
69
+ "vitest": "^4.1.10"
70
+ }
71
+ }