@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nhic-lab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# srv-wrapper
|
|
2
|
+
|
|
3
|
+
A local daemon + CLI that lets AI coding agents run commands on your servers by an opaque **server-id** only — the agent never sees the real hostname, IP, port, username, or password/key. Everything an agent runs is visible live in a browser dashboard and permanently recorded in an audit log.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Giving an AI agent real SSH credentials means it can (accidentally or otherwise) leak them, and you lose visibility into what it actually did on the remote box. `srv-wrapper` sits between the agent and your servers: you register a server once via the dashboard, the agent only ever gets a short id like `srv-a1`, and the daemon resolves that id to the real connection internally.
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
- **`srvd`** — a background daemon that holds the server registry (SQLite), stores secrets in the macOS Keychain, manages SSH connections, and exposes two local-only surfaces:
|
|
12
|
+
- a Unix domain socket for the CLI
|
|
13
|
+
- an Express + WebSocket dashboard, bound to `127.0.0.1` only
|
|
14
|
+
- **`srv`** — the CLI an agent invokes. Supports one-shot commands and persistent interactive sessions.
|
|
15
|
+
- **Dashboard** — register servers (single or bulk JSON import), watch a live feed of what every agent is running right now, and browse history.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install -g @nhic-lab/srv-wrapper # exposes `srv` and `srvd` globally
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or, from a clone of this repo:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install
|
|
27
|
+
npm run build
|
|
28
|
+
npm link # exposes `srv` and `srvd` globally
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then, to have the daemon start automatically on login:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
./scripts/install-launchd.sh
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This installs a `launchd` agent (`~/Library/LaunchAgents/com.srv-wrapper.daemon.plist`) that runs the built daemon and restarts it if it crashes. Logs go to `~/.srv/daemon.log` and `~/.srv/daemon.error.log`.
|
|
38
|
+
|
|
39
|
+
Alternatively, run it directly without installing anything permanent:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm run dev:daemon
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
Open the dashboard at **http://127.0.0.1:4280** and register a server (id, host, port, username, auth method, password or key passphrase).
|
|
48
|
+
|
|
49
|
+
Then, from anywhere:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# one-shot command
|
|
53
|
+
srv exec srv-a1 "npm run build" --agent my-agent-label
|
|
54
|
+
|
|
55
|
+
# persistent session (state — cwd, env vars — persists across calls)
|
|
56
|
+
srv session start srv-a1 --agent my-agent-label # prints a session id
|
|
57
|
+
srv session send <session-id> "cd /var/www && ls"
|
|
58
|
+
srv session send <session-id> "pwd" # still /var/www
|
|
59
|
+
srv session stop <session-id>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`--agent <label>` is required on every `exec` and `session start` call — it's how the dashboard's live view distinguishes multiple agents running at once.
|
|
63
|
+
|
|
64
|
+
A Claude Code skill (`.claude/skills/srv-wrapper/SKILL.md`, also symlinked into `~/.claude/skills/`) documents this CLI so any Claude Code agent picks it up automatically.
|
|
65
|
+
|
|
66
|
+
## Security model
|
|
67
|
+
|
|
68
|
+
- Servers are referenced everywhere only by id — real connection details never reach the CLI process, its output, or the audit log (SSH errors are sanitized before being surfaced).
|
|
69
|
+
- Secrets live in the macOS Keychain, scoped to the daemon binary via an ACL — not in the SQLite registry.
|
|
70
|
+
- SSH host keys are pinned on first use (TOFU) and persisted across daemon restarts.
|
|
71
|
+
- Private key files are restricted to paths resolving inside `~/.ssh`.
|
|
72
|
+
- The dashboard has no authentication layer — it's a deliberate trade-off for a tool that only binds to `127.0.0.1` on your own machine, not a gap.
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
See `CLAUDE.md` for commands and architecture notes. Full design spec and implementation plan live under `docs/superpowers/`.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { encodeMessage, decodeMessages } from '../daemon/socket-protocol.js';
|
|
4
|
+
export function execCommand(opts) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const requestId = randomUUID();
|
|
7
|
+
const conn = net.createConnection(opts.socketPath);
|
|
8
|
+
let buffer = '';
|
|
9
|
+
conn.on('connect', () => {
|
|
10
|
+
conn.write(encodeMessage({
|
|
11
|
+
type: 'exec',
|
|
12
|
+
requestId,
|
|
13
|
+
serverId: opts.serverId,
|
|
14
|
+
agentLabel: opts.agentLabel,
|
|
15
|
+
command: opts.command,
|
|
16
|
+
}));
|
|
17
|
+
});
|
|
18
|
+
conn.on('data', (data) => {
|
|
19
|
+
buffer += data.toString();
|
|
20
|
+
const { messages, rest } = decodeMessages(buffer);
|
|
21
|
+
buffer = rest;
|
|
22
|
+
for (const msg of messages) {
|
|
23
|
+
if (msg.type === 'stream') {
|
|
24
|
+
opts.onStream(msg.stream, msg.chunk);
|
|
25
|
+
}
|
|
26
|
+
else if (msg.type === 'done') {
|
|
27
|
+
conn.end();
|
|
28
|
+
if (msg.error)
|
|
29
|
+
reject(new Error(msg.error));
|
|
30
|
+
else
|
|
31
|
+
resolve(msg.exitCode);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
conn.on('error', reject);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export function sessionStart(opts) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const requestId = randomUUID();
|
|
41
|
+
const conn = net.createConnection(opts.socketPath);
|
|
42
|
+
let buffer = '';
|
|
43
|
+
conn.on('connect', () => {
|
|
44
|
+
conn.write(encodeMessage({ type: 'session_start', requestId, serverId: opts.serverId, agentLabel: opts.agentLabel }));
|
|
45
|
+
});
|
|
46
|
+
conn.on('data', (data) => {
|
|
47
|
+
buffer += data.toString();
|
|
48
|
+
const { messages, rest } = decodeMessages(buffer);
|
|
49
|
+
buffer = rest;
|
|
50
|
+
for (const msg of messages) {
|
|
51
|
+
if (msg.type === 'session_started') {
|
|
52
|
+
conn.end();
|
|
53
|
+
resolve(msg.sessionId);
|
|
54
|
+
}
|
|
55
|
+
else if (msg.type === 'done' && msg.error) {
|
|
56
|
+
conn.end();
|
|
57
|
+
reject(new Error(msg.error));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
conn.on('error', reject);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
export function sessionSend(opts) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const requestId = randomUUID();
|
|
67
|
+
const conn = net.createConnection(opts.socketPath);
|
|
68
|
+
let buffer = '';
|
|
69
|
+
conn.on('connect', () => {
|
|
70
|
+
conn.write(encodeMessage({ type: 'session_send', requestId, sessionId: opts.sessionId, command: opts.command }));
|
|
71
|
+
});
|
|
72
|
+
conn.on('data', (data) => {
|
|
73
|
+
buffer += data.toString();
|
|
74
|
+
const { messages, rest } = decodeMessages(buffer);
|
|
75
|
+
buffer = rest;
|
|
76
|
+
for (const msg of messages) {
|
|
77
|
+
if (msg.type === 'stream')
|
|
78
|
+
opts.onStream(msg.stream, msg.chunk);
|
|
79
|
+
else if (msg.type === 'done') {
|
|
80
|
+
conn.end();
|
|
81
|
+
msg.error ? reject(new Error(msg.error)) : resolve();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
conn.on('error', reject);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
export function listServers(opts) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const requestId = randomUUID();
|
|
91
|
+
const conn = net.createConnection(opts.socketPath);
|
|
92
|
+
let buffer = '';
|
|
93
|
+
conn.on('connect', () => {
|
|
94
|
+
conn.write(encodeMessage({ type: 'list', requestId }));
|
|
95
|
+
});
|
|
96
|
+
conn.on('data', (data) => {
|
|
97
|
+
buffer += data.toString();
|
|
98
|
+
const { messages, rest } = decodeMessages(buffer);
|
|
99
|
+
buffer = rest;
|
|
100
|
+
for (const msg of messages) {
|
|
101
|
+
if (msg.type === 'list_result') {
|
|
102
|
+
conn.end();
|
|
103
|
+
resolve(msg.serverIds);
|
|
104
|
+
}
|
|
105
|
+
else if (msg.type === 'done' && msg.error) {
|
|
106
|
+
conn.end();
|
|
107
|
+
reject(new Error(msg.error));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
conn.on('error', reject);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
export function sessionStop(opts) {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const requestId = randomUUID();
|
|
117
|
+
const conn = net.createConnection(opts.socketPath);
|
|
118
|
+
let buffer = '';
|
|
119
|
+
conn.on('connect', () => {
|
|
120
|
+
conn.write(encodeMessage({ type: 'session_stop', requestId, sessionId: opts.sessionId }));
|
|
121
|
+
});
|
|
122
|
+
conn.on('data', (data) => {
|
|
123
|
+
buffer += data.toString();
|
|
124
|
+
const { messages, rest } = decodeMessages(buffer);
|
|
125
|
+
buffer = rest;
|
|
126
|
+
for (const msg of messages) {
|
|
127
|
+
if (msg.type === 'done') {
|
|
128
|
+
conn.end();
|
|
129
|
+
msg.error ? reject(new Error(msg.error)) : resolve();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
conn.on('error', reject);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { execCommand, sessionStart, sessionSend, sessionStop, listServers } from './client.js';
|
|
4
|
+
import { srvSocketPath } from '../shared/paths.js';
|
|
5
|
+
const program = new Command();
|
|
6
|
+
program
|
|
7
|
+
.name('srv')
|
|
8
|
+
.description('Run commands on registered servers by id, without ever seeing host/credentials');
|
|
9
|
+
program
|
|
10
|
+
.command('exec <server-id> <command>')
|
|
11
|
+
.requiredOption('--agent <label>', 'label identifying the calling agent/session')
|
|
12
|
+
.action(async (serverId, command, options) => {
|
|
13
|
+
try {
|
|
14
|
+
const exitCode = await execCommand({
|
|
15
|
+
socketPath: srvSocketPath(),
|
|
16
|
+
serverId,
|
|
17
|
+
agentLabel: options.agent,
|
|
18
|
+
command,
|
|
19
|
+
onStream: (stream, chunk) => {
|
|
20
|
+
(stream === 'stdout' ? process.stdout : process.stderr).write(chunk);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
process.exit(exitCode);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
process.stderr.write(`srv: ${err.message}\n`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
program
|
|
31
|
+
.command('list')
|
|
32
|
+
.description('List all registered server ids')
|
|
33
|
+
.action(async () => {
|
|
34
|
+
try {
|
|
35
|
+
const serverIds = await listServers({ socketPath: srvSocketPath() });
|
|
36
|
+
for (const id of serverIds)
|
|
37
|
+
process.stdout.write(id + '\n');
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
process.stderr.write(`srv: ${err.message}\n`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
const session = program.command('session').description('Manage a persistent interactive session on a server');
|
|
45
|
+
session
|
|
46
|
+
.command('start <server-id>')
|
|
47
|
+
.requiredOption('--agent <label>', 'label identifying the calling agent/session')
|
|
48
|
+
.action(async (serverId, options) => {
|
|
49
|
+
const sessionId = await sessionStart({ socketPath: srvSocketPath(), serverId, agentLabel: options.agent });
|
|
50
|
+
process.stdout.write(sessionId + '\n');
|
|
51
|
+
});
|
|
52
|
+
session
|
|
53
|
+
.command('send <session-id> <command>')
|
|
54
|
+
.action(async (sessionId, command) => {
|
|
55
|
+
await sessionSend({
|
|
56
|
+
socketPath: srvSocketPath(), sessionId, command: command + '\n',
|
|
57
|
+
onStream: (stream, chunk) => (stream === 'stdout' ? process.stdout : process.stderr).write(chunk),
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
session
|
|
61
|
+
.command('stop <session-id>')
|
|
62
|
+
.action(async (sessionId) => {
|
|
63
|
+
await sessionStop({ socketPath: srvSocketPath(), sessionId });
|
|
64
|
+
});
|
|
65
|
+
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import { resolveJumpPath } from './jump-chain.js';
|
|
4
|
+
import { sanitizeSshError } from './socket-server.js';
|
|
5
|
+
function validateServerInput(input) {
|
|
6
|
+
if (!input.id || typeof input.id !== 'string' || !/^[a-zA-Z0-9._-]{1,64}$/.test(input.id))
|
|
7
|
+
return 'invalid or missing id';
|
|
8
|
+
if (!input.host || typeof input.host !== 'string')
|
|
9
|
+
return 'missing host';
|
|
10
|
+
if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535)
|
|
11
|
+
return 'port must be an integer between 1 and 65535';
|
|
12
|
+
if (!input.username || typeof input.username !== 'string')
|
|
13
|
+
return 'missing username';
|
|
14
|
+
if (input.authMethod !== 'password' && input.authMethod !== 'key')
|
|
15
|
+
return 'authMethod must be password or key';
|
|
16
|
+
if (input.authMethod === 'key' && !input.keyPath)
|
|
17
|
+
return 'keyPath is required when authMethod is key';
|
|
18
|
+
if (input.authMethod === 'password' && input.keyPath)
|
|
19
|
+
return 'keyPath must not be set when authMethod is password';
|
|
20
|
+
if (input.jumpChain !== undefined) {
|
|
21
|
+
if (!Array.isArray(input.jumpChain) || !input.jumpChain.every((id) => typeof id === 'string' && id.length > 0)) {
|
|
22
|
+
return 'jumpChain must be an array of server ids';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Editing an existing server may omit the secret to keep the one already stored in Keychain.
|
|
26
|
+
// Key auth may omit it entirely: there the credential is the key file at
|
|
27
|
+
// keyPath, and the secret is only its (often absent) passphrase.
|
|
28
|
+
if (!input.secret && !input.isEdit && input.authMethod !== 'key')
|
|
29
|
+
return 'missing secret';
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
export function createDashboardApp(opts) {
|
|
33
|
+
const app = express();
|
|
34
|
+
app.use(express.json({ limit: '32kb' }));
|
|
35
|
+
const wsClients = new Set();
|
|
36
|
+
const broadcast = (event) => {
|
|
37
|
+
const payload = JSON.stringify(event);
|
|
38
|
+
for (const client of wsClients)
|
|
39
|
+
client.send(payload);
|
|
40
|
+
};
|
|
41
|
+
app.post('/api/servers', (req, res) => {
|
|
42
|
+
const input = req.body;
|
|
43
|
+
const error = validateServerInput(input);
|
|
44
|
+
if (error)
|
|
45
|
+
return res.status(400).json({ error });
|
|
46
|
+
const existing = opts.registry.get(input.id);
|
|
47
|
+
if (existing && !input.isEdit) {
|
|
48
|
+
return res.status(409).json({ error: `a server with id "${input.id}" already exists` });
|
|
49
|
+
}
|
|
50
|
+
if (!existing && input.isEdit) {
|
|
51
|
+
return res.status(404).json({ error: `no server with id "${input.id}" exists to edit` });
|
|
52
|
+
}
|
|
53
|
+
if (input.jumpChain && input.jumpChain.length > 0) {
|
|
54
|
+
try {
|
|
55
|
+
resolveJumpPath(input.id, input.jumpChain, (id) => opts.registry.get(id));
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
return res.status(400).json({ error: err?.message ?? String(err) });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const record = opts.registry.upsert({
|
|
62
|
+
id: input.id, host: input.host, port: input.port, username: input.username,
|
|
63
|
+
authMethod: input.authMethod, keyPath: input.keyPath, jumpChain: input.jumpChain,
|
|
64
|
+
});
|
|
65
|
+
let priorSecret;
|
|
66
|
+
try {
|
|
67
|
+
priorSecret = existing ? opts.keychain.getSecret(input.id) : undefined;
|
|
68
|
+
// A passphrase-less private key legitimately has no secret to store.
|
|
69
|
+
const keyFallback = input.authMethod === 'key' ? '' : undefined;
|
|
70
|
+
const secretToStore = input.secret || priorSecret || keyFallback;
|
|
71
|
+
if (secretToStore === undefined)
|
|
72
|
+
throw new Error('missing secret');
|
|
73
|
+
opts.keychain.setSecret(input.id, secretToStore);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (existing) {
|
|
77
|
+
opts.registry.upsert({
|
|
78
|
+
id: existing.id, host: existing.host, port: existing.port, username: existing.username,
|
|
79
|
+
authMethod: existing.authMethod, keyPath: existing.keyPath, jumpChain: existing.jumpChain,
|
|
80
|
+
});
|
|
81
|
+
if (priorSecret !== undefined)
|
|
82
|
+
opts.keychain.setSecret(existing.id, priorSecret);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
opts.registry.delete(input.id);
|
|
86
|
+
}
|
|
87
|
+
return res.status(500).json({ error: `failed to store secret: ${err?.message ?? err}` });
|
|
88
|
+
}
|
|
89
|
+
res.status(201).json(record);
|
|
90
|
+
});
|
|
91
|
+
app.post('/api/servers/bulk', (req, res) => {
|
|
92
|
+
if (!Array.isArray(req.body.servers)) {
|
|
93
|
+
return res.status(400).json({ error: 'servers must be an array' });
|
|
94
|
+
}
|
|
95
|
+
const servers = req.body.servers;
|
|
96
|
+
const seenIds = new Set();
|
|
97
|
+
const failed = [];
|
|
98
|
+
const valid = [];
|
|
99
|
+
for (const input of servers) {
|
|
100
|
+
const error = validateServerInput(input);
|
|
101
|
+
if (error) {
|
|
102
|
+
failed.push({ id: input.id, error });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (seenIds.has(input.id)) {
|
|
106
|
+
failed.push({ id: input.id, error: 'duplicate id in batch' });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
seenIds.add(input.id);
|
|
110
|
+
valid.push(input);
|
|
111
|
+
}
|
|
112
|
+
if (failed.length > 0) {
|
|
113
|
+
return res.json({ succeeded: [], failed });
|
|
114
|
+
}
|
|
115
|
+
const succeeded = [];
|
|
116
|
+
const committed = [];
|
|
117
|
+
try {
|
|
118
|
+
for (const input of valid) {
|
|
119
|
+
const priorRecord = opts.registry.get(input.id);
|
|
120
|
+
const priorSecret = priorRecord ? opts.keychain.getSecret(input.id) : undefined;
|
|
121
|
+
opts.registry.upsert({
|
|
122
|
+
id: input.id, host: input.host, port: input.port, username: input.username,
|
|
123
|
+
authMethod: input.authMethod, keyPath: input.keyPath,
|
|
124
|
+
});
|
|
125
|
+
committed.push({ id: input.id, priorRecord, priorSecret });
|
|
126
|
+
opts.keychain.setSecret(input.id, input.secret);
|
|
127
|
+
succeeded.push(input.id);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
for (const { id, priorRecord, priorSecret } of committed) {
|
|
132
|
+
if (priorRecord) {
|
|
133
|
+
opts.registry.upsert({
|
|
134
|
+
id: priorRecord.id, host: priorRecord.host, port: priorRecord.port, username: priorRecord.username,
|
|
135
|
+
authMethod: priorRecord.authMethod, keyPath: priorRecord.keyPath,
|
|
136
|
+
});
|
|
137
|
+
if (priorSecret !== undefined)
|
|
138
|
+
opts.keychain.setSecret(priorRecord.id, priorSecret);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
opts.registry.delete(id);
|
|
142
|
+
opts.keychain.deleteSecret(id);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return res.status(500).json({ error: `bulk import failed partway, rolled back: ${err?.message ?? err}`, succeeded: [], failed: [] });
|
|
146
|
+
}
|
|
147
|
+
res.json({ succeeded, failed: [] });
|
|
148
|
+
});
|
|
149
|
+
app.get('/api/servers', (_req, res) => {
|
|
150
|
+
res.json(opts.registry.list());
|
|
151
|
+
});
|
|
152
|
+
app.post('/api/servers/:id/test', async (req, res) => {
|
|
153
|
+
if (!opts.sshManager)
|
|
154
|
+
return res.status(501).json({ error: 'connection testing is not available' });
|
|
155
|
+
const server = opts.registry.get(req.params.id);
|
|
156
|
+
if (!server)
|
|
157
|
+
return res.status(404).json({ error: `unknown server: ${req.params.id}` });
|
|
158
|
+
try {
|
|
159
|
+
await opts.sshManager.testConnect(server);
|
|
160
|
+
opts.registry.setTestResult(server.id, true);
|
|
161
|
+
res.json({ ok: true });
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const safe = sanitizeSshError(err);
|
|
165
|
+
opts.registry.setTestResult(server.id, false, safe);
|
|
166
|
+
res.json({ ok: false, error: safe });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
app.post('/api/servers/test', async (req, res) => {
|
|
170
|
+
if (!opts.sshManager)
|
|
171
|
+
return res.status(501).json({ error: 'connection testing is not available' });
|
|
172
|
+
const input = req.body;
|
|
173
|
+
const existing = input.id ? opts.registry.get(input.id) : undefined;
|
|
174
|
+
const error = validateServerInput({ ...input, isEdit: Boolean(existing && !input.secret) });
|
|
175
|
+
if (error)
|
|
176
|
+
return res.status(400).json({ error });
|
|
177
|
+
if (input.jumpChain && input.jumpChain.length > 0) {
|
|
178
|
+
try {
|
|
179
|
+
resolveJumpPath(input.id, input.jumpChain, (id) => opts.registry.get(id));
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
return res.status(400).json({ error: err?.message ?? String(err) });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
let secret;
|
|
186
|
+
try {
|
|
187
|
+
secret = input.secret || (existing ? opts.keychain.getSecret(input.id) : undefined);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Keychain lookup can throw if the secret was never stored (or was deleted) —
|
|
191
|
+
// treat that the same as "no secret provided" rather than crashing the request.
|
|
192
|
+
secret = undefined;
|
|
193
|
+
}
|
|
194
|
+
if (secret === undefined && input.authMethod === 'key')
|
|
195
|
+
secret = '';
|
|
196
|
+
if (secret === undefined)
|
|
197
|
+
return res.status(400).json({ error: 'missing secret' });
|
|
198
|
+
const record = {
|
|
199
|
+
id: input.id, host: input.host, port: input.port, username: input.username,
|
|
200
|
+
authMethod: input.authMethod, keyPath: input.keyPath, jumpChain: input.jumpChain,
|
|
201
|
+
createdAt: 0, updatedAt: 0,
|
|
202
|
+
};
|
|
203
|
+
try {
|
|
204
|
+
await opts.sshManager.testConnect(record, secret);
|
|
205
|
+
res.json({ ok: true });
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
res.json({ ok: false, error: sanitizeSshError(err) });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
app.post('/api/servers/test-all', (_req, res) => {
|
|
212
|
+
if (!opts.sshManager)
|
|
213
|
+
return res.status(501).json({ error: 'connection testing is not available' });
|
|
214
|
+
const servers = opts.registry.list();
|
|
215
|
+
res.status(202).json({ count: servers.length });
|
|
216
|
+
for (const server of servers) {
|
|
217
|
+
opts.sshManager
|
|
218
|
+
.testConnect(server)
|
|
219
|
+
.then(() => {
|
|
220
|
+
opts.registry.setTestResult(server.id, true);
|
|
221
|
+
broadcast({ type: 'server_test_result', id: server.id, ok: true, at: Date.now() });
|
|
222
|
+
})
|
|
223
|
+
.catch((err) => {
|
|
224
|
+
const safe = sanitizeSshError(err);
|
|
225
|
+
opts.registry.setTestResult(server.id, false, safe);
|
|
226
|
+
broadcast({ type: 'server_test_result', id: server.id, ok: false, error: safe, at: Date.now() });
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
app.delete('/api/servers/:id', (req, res) => {
|
|
231
|
+
opts.registry.delete(req.params.id);
|
|
232
|
+
opts.keychain.deleteSecret(req.params.id);
|
|
233
|
+
res.status(204).end();
|
|
234
|
+
});
|
|
235
|
+
/**
|
|
236
|
+
* Run metadata only, newest first. Output is deliberately excluded: returning
|
|
237
|
+
* it for every run is what made this endpoint fail with "RangeError: Invalid
|
|
238
|
+
* string length" once stored output passed V8's string ceiling. The total row
|
|
239
|
+
* count rides along in X-Total-Count so the UI can say "showing N of M"
|
|
240
|
+
* without changing the array response shape.
|
|
241
|
+
*/
|
|
242
|
+
app.get('/api/history', (req, res) => {
|
|
243
|
+
const { serverId, agentLabel } = req.query;
|
|
244
|
+
const limit = Number.parseInt(String(req.query.limit ?? ''), 10);
|
|
245
|
+
const offset = Number.parseInt(String(req.query.offset ?? ''), 10);
|
|
246
|
+
const runs = opts.logStore.list({
|
|
247
|
+
serverId,
|
|
248
|
+
agentLabel,
|
|
249
|
+
limit: Number.isFinite(limit) ? limit : undefined,
|
|
250
|
+
offset: Number.isFinite(offset) ? offset : undefined,
|
|
251
|
+
});
|
|
252
|
+
res.set('X-Total-Count', String(opts.logStore.count({ serverId, agentLabel })));
|
|
253
|
+
res.json(runs);
|
|
254
|
+
});
|
|
255
|
+
/** One run including its (already capped) output. */
|
|
256
|
+
app.get('/api/history/:id', (req, res) => {
|
|
257
|
+
const run = opts.logStore.get(req.params.id);
|
|
258
|
+
if (!run)
|
|
259
|
+
return res.status(404).json({ error: `unknown run: ${req.params.id}` });
|
|
260
|
+
res.json(run);
|
|
261
|
+
});
|
|
262
|
+
app.use(express.static(new URL('../../public', import.meta.url).pathname));
|
|
263
|
+
const ALLOWED_ORIGINS = new Set(['http://127.0.0.1:4280', 'http://localhost:4280']);
|
|
264
|
+
const attachWebSocket = (server) => {
|
|
265
|
+
const wss = new WebSocketServer({
|
|
266
|
+
server,
|
|
267
|
+
path: '/api/live',
|
|
268
|
+
verifyClient: (info, cb) => {
|
|
269
|
+
if (!info.origin || !ALLOWED_ORIGINS.has(info.origin)) {
|
|
270
|
+
cb(false, 403, 'forbidden origin');
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
cb(true);
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
wss.on('connection', (ws) => {
|
|
277
|
+
wsClients.add(ws);
|
|
278
|
+
ws.on('close', () => wsClients.delete(ws));
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
app.attachWebSocket = attachWebSocket;
|
|
282
|
+
return { app, broadcast };
|
|
283
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { Registry } from './registry.js';
|
|
5
|
+
import { Keychain } from './keychain.js';
|
|
6
|
+
import { LogStore } from './logstore.js';
|
|
7
|
+
import { SshManager, RegistryHostKeyStore } from './ssh-manager.js';
|
|
8
|
+
import { SocketServer } from './socket-server.js';
|
|
9
|
+
import { createDashboardApp } from './dashboard-server.js';
|
|
10
|
+
import { srvHome, srvSocketPath, srvRegistryDbPath, srvLogDbPath } from '../shared/paths.js';
|
|
11
|
+
const DASHBOARD_PORT = 4280;
|
|
12
|
+
process.on('uncaughtException', (err) => {
|
|
13
|
+
console.error('srvd: uncaught exception (daemon continuing):', err);
|
|
14
|
+
});
|
|
15
|
+
async function main() {
|
|
16
|
+
fs.mkdirSync(srvHome(), { recursive: true, mode: 0o700 });
|
|
17
|
+
const registry = new Registry(srvRegistryDbPath());
|
|
18
|
+
const logStore = new LogStore(srvLogDbPath());
|
|
19
|
+
// Trust is scoped to THIS script's real path, not the generic `node`
|
|
20
|
+
// binary (process.execPath) — trusting `node` itself would grant Keychain
|
|
21
|
+
// access to any script run via that Node install, not just this daemon.
|
|
22
|
+
// realpathSync keeps the identity stable even if dist/ is reached via a
|
|
23
|
+
// symlink. This path is only stable for the BUILT daemon (dist/daemon/
|
|
24
|
+
// index.js) invoked the same way every time (e.g. via the launchd plist)
|
|
25
|
+
// — dev-mode (tsx) invocations don't have a stable script path and will
|
|
26
|
+
// still prompt on every run.
|
|
27
|
+
const keychain = new Keychain(fs.realpathSync(process.argv[1]));
|
|
28
|
+
const sshManager = new SshManager((serverId) => keychain.getSecret(serverId), undefined, new RegistryHostKeyStore(registry), (serverId) => registry.get(serverId));
|
|
29
|
+
const { app, broadcast } = createDashboardApp({ registry, keychain, logStore, sshManager });
|
|
30
|
+
const httpServer = http.createServer(app);
|
|
31
|
+
app.attachWebSocket(httpServer);
|
|
32
|
+
httpServer.listen(DASHBOARD_PORT, '127.0.0.1', () => {
|
|
33
|
+
console.log(`Dashboard listening on http://127.0.0.1:${DASHBOARD_PORT}`);
|
|
34
|
+
});
|
|
35
|
+
const socketServer = new SocketServer({
|
|
36
|
+
socketPath: srvSocketPath(),
|
|
37
|
+
registry,
|
|
38
|
+
logStore,
|
|
39
|
+
sshManager,
|
|
40
|
+
onBroadcast: (event) => broadcast(event),
|
|
41
|
+
});
|
|
42
|
+
await socketServer.start();
|
|
43
|
+
console.log(`Socket server listening on ${srvSocketPath()}`);
|
|
44
|
+
}
|
|
45
|
+
main().catch((err) => {
|
|
46
|
+
console.error('srvd failed to start:', err);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Given a target server id and its proposed (not-yet-persisted) jumpChain,
|
|
3
|
+
* resolves the fully-expanded ordered hop path — recursively following each
|
|
4
|
+
* hop's own stored jumpChain — and returns it as an array of server ids
|
|
5
|
+
* ordered first-hop-to-connect-to first, target last.
|
|
6
|
+
*
|
|
7
|
+
* Throws if:
|
|
8
|
+
* - any referenced id doesn't exist in the registry (via `lookup`)
|
|
9
|
+
* - the target's own id, or any id, appears more than once in the fully
|
|
10
|
+
* expanded path (including the trivial case of a server referencing
|
|
11
|
+
* itself, directly or indirectly)
|
|
12
|
+
*/
|
|
13
|
+
export function resolveJumpPath(targetId, proposedChain, lookup) {
|
|
14
|
+
const path = [];
|
|
15
|
+
const seen = new Set([targetId]);
|
|
16
|
+
const expandHop = (id) => {
|
|
17
|
+
if (seen.has(id)) {
|
|
18
|
+
throw new Error(`jump chain cycle detected: "${id}" would be reached more than once`);
|
|
19
|
+
}
|
|
20
|
+
seen.add(id);
|
|
21
|
+
const record = lookup(id);
|
|
22
|
+
if (!record) {
|
|
23
|
+
throw new Error(`jump chain references unknown server id "${id}"`);
|
|
24
|
+
}
|
|
25
|
+
for (const subHopId of record.jumpChain ?? []) {
|
|
26
|
+
expandHop(subHopId);
|
|
27
|
+
}
|
|
28
|
+
path.push(id);
|
|
29
|
+
};
|
|
30
|
+
for (const hopId of proposedChain) {
|
|
31
|
+
expandHop(hopId);
|
|
32
|
+
}
|
|
33
|
+
path.push(targetId);
|
|
34
|
+
return path;
|
|
35
|
+
}
|