zback 1.1.1 → 1.3.1
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/DISCLOSURE +14 -0
- package/README.md +49 -20
- package/bin/zback.js +169 -58
- package/package.json +9 -3
- package/src/agent.js +100 -46
- package/src/runtime.js +31 -8
- package/test/client.test.js +0 -28
package/DISCLOSURE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
ZBack is an opt-in remote shell administration tool for devices owned or explicitly authorized by the operator. It supports sending a shell command to a local Node.js agent through a WebSocket relay using a private, high-entropy session capability and the Model Context Protocol (MCP).
|
|
2
|
+
|
|
3
|
+
Security-sensitive functionality:
|
|
4
|
+
- A user manually starts the ZBack command and approves remote shell administration on the first run. The decision is stored in a local user-scoped file; subsequent commands do not require repeated prompts.
|
|
5
|
+
- The agent runs in the foreground by default and stops on Ctrl+C. An explicitly requested background worker may persist after the terminal detaches and can be stopped with `zback --stop`.
|
|
6
|
+
- Remote shell commands execute with the operating-system permissions of the account that launched the agent.
|
|
7
|
+
- The worker connects outbound to the configured secure WebSocket endpoint. It does not open an inbound shell listener.
|
|
8
|
+
- The server exposes an MCP endpoint that routes authorized commands to connected workers when the caller presents the private session capability.
|
|
9
|
+
- A randomly generated credential stored with restrictive file permissions stabilizes the session identifier across reconnects. The identifier is a bearer credential and must be kept secret.
|
|
10
|
+
- The operator can inspect activity with `zback`, `zback --status`, `zback --logs`, and rotate the credential with `zback --reset-key` after stopping the agent.
|
|
11
|
+
|
|
12
|
+
The package has no install-time scripts, no hidden persistence or system service installation, no credential collection, no code obfuscation, and no automatic execution on installation. Remote shell access is a powerful capability and should only be enabled on systems the operator is authorized to manage.
|
|
13
|
+
|
|
14
|
+
The separately deployed ZBack backend should be exposed via HTTPS/WSS, behind a TLS-terminating reverse proxy. Public access does not imply authorization to use another person's session. Access is controlled by possession of the private session ID.
|
package/README.md
CHANGED
|
@@ -1,40 +1,69 @@
|
|
|
1
1
|
# ZBack CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
ZBack is an opt-in remote administration agent for systems you own or are authorized to manage. The package uses Node.js 22 or later, has **no third-party runtime dependencies** and runs no installation scripts.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> **Security:** ZBack grants remote shell access with your local user privileges to anyone who possesses its private session ID. Treat that ID as a password. Do not publish it, paste it in issue reports, or share screenshots containing it. Do not run ZBack as root or as an account with unnecessary permissions.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
## Install and start
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g zback@1.2.1
|
|
9
11
|
zback
|
|
10
12
|
```
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
On the first run, ZBack explains the remote-shell capability and asks for authorization once. It stores that decision locally, then creates a private agent credential. There are no prompts for each command.
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
The default server is `wss://zb.soymaycol.icu/ws`. To use your own backend:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
zback --url wss://your-domain.example/ws
|
|
16
20
|
```
|
|
17
21
|
|
|
18
|
-
|
|
22
|
+
For development only:
|
|
19
23
|
|
|
20
|
-
```
|
|
21
|
-
zback --
|
|
22
|
-
zback --stop
|
|
24
|
+
```sh
|
|
25
|
+
zback --url ws://127.0.0.1:8787/ws
|
|
23
26
|
```
|
|
24
27
|
|
|
25
|
-
|
|
28
|
+
The client requires **backend 1.2.0 or later**. The client rejects older servers rather than accepting unstable legacy session IDs. Deploy the updated backend before connecting a remote client.
|
|
29
|
+
|
|
30
|
+
## Terminal and process lifecycle
|
|
31
|
+
|
|
32
|
+
| Command | Behavior |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `zback` | Keep the agent in the foreground; display the session ID, MCP endpoint and live activity |
|
|
35
|
+
| `zback --background` | Explicitly start a detached agent |
|
|
36
|
+
| `zback --status` | Show connection status, process ID and private session ID |
|
|
37
|
+
| `zback --logs` | Display recent connection and command activity |
|
|
38
|
+
| `zback --follow` | Watch new events from an existing background agent without replaying old logs |
|
|
39
|
+
| `zback --stop` | Stop the background agent |
|
|
40
|
+
| `zback --reset-key` | Change the device identity after the agent is stopped |
|
|
41
|
+
|
|
42
|
+
`Ctrl+C` in the default foreground mode **stops the agent**. In background mode, `Ctrl+C` detaches the log display only. Use `zback --stop` to disconnect a background agent. Before upgrading from version 1.1.x or an earlier running version, stop its existing background worker with `zback --stop`. The server session ID stays the same over ordinary network reconnections and backend restarts, as long as the private local agent credential remains unchanged.
|
|
26
43
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
44
|
+
After a device restart, ZBack does **not** silently install an autostart service. Launch it again when needed. Android/Termux process and battery restrictions can still terminate background tasks; a Node.js program cannot override the operating system's lifecycle.
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
zback --cwd ./workspace --timeout 300000 --max-output 524288
|
|
50
|
+
zback --url wss://your-domain.example/ws --background
|
|
30
51
|
```
|
|
31
52
|
|
|
32
|
-
|
|
53
|
+
The client validates TLS on remote connections. Only loopback addresses are allowed for unencrypted `ws://`. Server resource protection and command timeouts are operational safety controls, not per-user paid quotas.
|
|
54
|
+
|
|
55
|
+
Files are under `~/.local/state/zback` on Linux/Termux and `%LOCALAPPDATA%/zback` on Windows. To use a custom location, set `ZBACK_STATE_DIR`. `agent.key` and the local state files must not be shared. Use `zback --reset-key` to invalidate a previously disclosed session ID.
|
|
33
56
|
|
|
34
|
-
|
|
57
|
+
## MCP
|
|
35
58
|
|
|
36
|
-
|
|
59
|
+
The companion backend serves `POST /mcp` with `zback_status` and `zback_execute`. On connection, the client displays the MCP endpoint and a **private** session ID, which is supplied as the `id` argument to either tool. Only grant your own session ID to trusted applications. MCP clients, LLMs and tools that receive the ID can perform any command your operating-system account can perform.
|
|
37
60
|
|
|
38
|
-
|
|
61
|
+
## Security disclosure and review
|
|
39
62
|
|
|
40
|
-
|
|
63
|
+
The npm package declares its remote administration capabilities as dual-use software in `contentPolicy` and the root `DISCLOSURE` file. It does not attempt to conceal shell execution, evade security scanning or override npm's decisions. Review the source before running, and follow npm's official manual appeal process if a publication is flagged.
|
|
64
|
+
|
|
65
|
+
## Tests
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
npm test
|
|
69
|
+
```
|
package/bin/zback.js
CHANGED
|
@@ -1,34 +1,57 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { openSync } from 'node:fs';
|
|
3
|
+
import { closeSync, existsSync, openSync, readSync, realpathSync, statSync, watchFile, unwatchFile } from 'node:fs';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
5
5
|
import { createInterface } from 'node:readline/promises';
|
|
6
6
|
import { stdin, stdout } from 'node:process';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { existsSync, statSync } from 'node:fs';
|
|
9
8
|
import { connectAgent, execute } from '../src/agent.js';
|
|
10
|
-
import { activeSession,
|
|
9
|
+
import { activeSession, agentKey, consentFile, hasConsent, initStateDir, logFile, readJson, resetKey, sameSession, saveJson, settingsFile, stateFile, alive } from '../src/runtime.js';
|
|
11
10
|
|
|
12
11
|
const ENTRY = fileURLToPath(import.meta.url);
|
|
12
|
+
const VERSION = '1.2.1';
|
|
13
13
|
const DEFAULT_URL = 'wss://zb.soymaycol.icu/ws';
|
|
14
14
|
|
|
15
15
|
function help() {
|
|
16
|
-
console.log(`ZBack
|
|
16
|
+
console.log(`ZBack ${VERSION} | Authorized remote shell administration
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
zback Start in the foreground and display live events
|
|
20
|
+
zback --background Run as a background agent
|
|
21
|
+
zback --status Show agent status, PID and private session ID
|
|
22
|
+
zback --logs Show recent agent events
|
|
23
|
+
zback --follow Follow live events from a running agent
|
|
24
|
+
zback --stop Stop the agent
|
|
25
|
+
zback --reset-key Rotate local agent identity while stopped
|
|
26
|
+
|
|
27
|
+
Options:
|
|
28
|
+
--url <wss://.../ws> Server endpoint (default: ${DEFAULT_URL})
|
|
29
|
+
--cwd <directory> Working directory for commands
|
|
30
|
+
--timeout <ms> Command timeout (1000-3600000)
|
|
31
|
+
--max-output <bytes> Captured output (1024-786432)
|
|
32
|
+
--no-reconnect Stop after a connection is lost
|
|
33
|
+
--help Display this help
|
|
34
|
+
|
|
35
|
+
ZBack runs shell commands with your account privileges. Initial interactive
|
|
36
|
+
consent is required once, not for every command. Keep the session ID private.
|
|
37
|
+
Ctrl+C stops a foreground agent. Background mode must be requested explicitly.
|
|
38
|
+
Use --stop to disconnect a background agent.`);
|
|
17
39
|
}
|
|
18
40
|
|
|
19
41
|
export function parseArgs(args) {
|
|
20
|
-
const options = { url: DEFAULT_URL, cwd: process.cwd(), timeout:
|
|
42
|
+
const options = { url: DEFAULT_URL, cwd: process.cwd(), timeout: 120000, maxOutput: 262144, reconnect: true, background: false };
|
|
21
43
|
const values = new Set(['--url', '--cwd', '--timeout', '--max-output']);
|
|
22
44
|
let action = 'start';
|
|
23
45
|
for (let i = 0; i < args.length; i++) {
|
|
24
46
|
const flag = args[i];
|
|
25
47
|
if (flag === '--help' || flag === '-h') return { help: true };
|
|
26
48
|
if (flag === '--worker') return { worker: true };
|
|
27
|
-
if (
|
|
28
|
-
if (action !== 'start') throw new Error('
|
|
49
|
+
if (['--status', '--stop', '--logs', '--follow', '--reset-key'].includes(flag)) {
|
|
50
|
+
if (action !== 'start') throw new Error('Choose only one action');
|
|
29
51
|
action = flag.slice(2);
|
|
30
52
|
continue;
|
|
31
53
|
}
|
|
54
|
+
if (flag === '--background' || flag === '--detach') { options.background = true; continue; }
|
|
32
55
|
if (flag === '--no-reconnect') { options.reconnect = false; continue; }
|
|
33
56
|
if (!values.has(flag) || i + 1 >= args.length) throw new Error(`Unknown or incomplete argument: ${flag}`);
|
|
34
57
|
const value = args[++i];
|
|
@@ -38,21 +61,23 @@ export function parseArgs(args) {
|
|
|
38
61
|
if (flag === '--max-output') options.maxOutput = Number(value);
|
|
39
62
|
}
|
|
40
63
|
if (action !== 'start') return { action };
|
|
41
|
-
if (!Number.isSafeInteger(options.timeout) || options.timeout < 1000 || options.timeout >
|
|
42
|
-
if (!Number.isSafeInteger(options.maxOutput) || options.maxOutput < 1024 || options.maxOutput >
|
|
64
|
+
if (!Number.isSafeInteger(options.timeout) || options.timeout < 1000 || options.timeout > 3600000) throw new Error('--timeout must be between 1000 and 3600000 ms');
|
|
65
|
+
if (!Number.isSafeInteger(options.maxOutput) || options.maxOutput < 1024 || options.maxOutput > 786432) throw new Error('--max-output must be between 1024 and 786432 bytes');
|
|
43
66
|
if (!existsSync(options.cwd) || !statSync(options.cwd).isDirectory()) throw new Error('--cwd must be an existing directory');
|
|
44
67
|
const url = new URL(options.url);
|
|
45
68
|
if (url.protocol !== 'wss:' && !(url.protocol === 'ws:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) throw new Error('Use wss:// for remote connections or ws:// on localhost');
|
|
46
|
-
if (url.username || url.password || url.search || url.hash || url.pathname !== '/ws') throw new Error('Expected
|
|
69
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/ws') throw new Error('Expected an endpoint ending in /ws without credentials in the URL');
|
|
47
70
|
return { ...options, action };
|
|
48
71
|
}
|
|
49
72
|
|
|
50
73
|
async function confirmOnce() {
|
|
51
74
|
if (hasConsent()) return true;
|
|
52
|
-
if (!stdin.isTTY || !stdout.isTTY) throw new Error('
|
|
75
|
+
if (!stdin.isTTY || !stdout.isTTY) throw new Error('First-run shell authorization requires an interactive terminal');
|
|
76
|
+
console.log('This module grants remote shell access to anyone holding your private session ID.');
|
|
77
|
+
console.log('Commands run with your user permissions. Keep the session ID private.');
|
|
53
78
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
54
79
|
try {
|
|
55
|
-
const answer = await rl.question('
|
|
80
|
+
const answer = await rl.question('Do you allow remote access to your shell? This is asked only once. [y/N] ');
|
|
56
81
|
if (!['y', 'yes'].includes(answer.trim().toLowerCase())) return false;
|
|
57
82
|
saveJson(consentFile, { approved: true, at: new Date().toISOString() });
|
|
58
83
|
return true;
|
|
@@ -60,39 +85,125 @@ async function confirmOnce() {
|
|
|
60
85
|
}
|
|
61
86
|
|
|
62
87
|
async function worker() {
|
|
63
|
-
if (!hasConsent()) throw new Error('
|
|
88
|
+
if (!hasConsent()) throw new Error('Shell access is not authorized. Run zback interactively first');
|
|
64
89
|
const settings = readJson(settingsFile);
|
|
65
90
|
if (!settings || !settings.url || !settings.cwd) throw new Error('Missing agent settings');
|
|
66
91
|
const started = process.env.ZBACK_WORKER_NONCE;
|
|
67
|
-
if (!started) throw new Error('
|
|
92
|
+
if (!started) throw new Error('Worker must be launched through zback');
|
|
93
|
+
const key = agentKey();
|
|
68
94
|
const mark = data => {
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
saveJson(stateFile, { pid: process.pid, started, updated: new Date().toISOString(), ...data });
|
|
95
|
+
const previous = readJson(stateFile);
|
|
96
|
+
if (previous && previous.status !== 'stopped' && previous.pid !== process.pid && alive(previous.pid)) return;
|
|
97
|
+
saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...data });
|
|
72
98
|
};
|
|
73
99
|
mark({ status: 'connecting', id: null });
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
100
|
+
try { await connectAgent(settings, mark, key); }
|
|
101
|
+
finally { if (sameSession(process.pid, started)) mark({ status: 'stopped', id: null }); }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function mcpEndpoint(url) {
|
|
105
|
+
const endpoint = new URL(url);
|
|
106
|
+
endpoint.protocol = endpoint.protocol === 'wss:' ? 'https:' : 'http:';
|
|
107
|
+
endpoint.pathname = '/mcp';
|
|
108
|
+
return endpoint.toString();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function showConnection(id, url) {
|
|
112
|
+
console.log(`Session ID: ${id}`);
|
|
113
|
+
console.log(`MCP endpoint: ${mcpEndpoint(url)}`);
|
|
114
|
+
console.log('MCP tools: zback_status, zback_execute (provide the ID as the id argument)');
|
|
115
|
+
console.log('Keep the session ID private; it grants remote shell access.');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function showStatus() {
|
|
119
|
+
const state = activeSession();
|
|
120
|
+
if (!state) { console.log('Status: stopped'); return false; }
|
|
121
|
+
console.log(`Status: ${state.status}\nPID: ${state.pid}\nUpdated: ${state.updated || 'unknown'}`);
|
|
122
|
+
if (state.id) showConnection(state.id, readJson(settingsFile)?.url || DEFAULT_URL);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function recentLog() {
|
|
127
|
+
if (!existsSync(logFile)) { console.log('No agent logs yet'); return; }
|
|
128
|
+
const fd = openSync(logFile, 'r');
|
|
129
|
+
try {
|
|
130
|
+
const size = statSync(logFile).size;
|
|
131
|
+
const readSize = Math.min(size, 12000);
|
|
132
|
+
const buffer = Buffer.alloc(readSize);
|
|
133
|
+
readSync(fd, buffer, 0, readSize, size - readSize);
|
|
134
|
+
console.log(buffer.toString('utf8').replace(/^.*?\n/, size > readSize ? '' : '$&').trimEnd());
|
|
135
|
+
} finally { closeSync(fd); }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function follow() {
|
|
139
|
+
if (!stdin.isTTY || !stdout.isTTY) return;
|
|
140
|
+
console.log('Monitoring background agent. Ctrl+C closes this view; the agent stays active.');
|
|
141
|
+
let position = existsSync(logFile) ? statSync(logFile).size : 0;
|
|
142
|
+
await new Promise(resolve => {
|
|
143
|
+
const readNew = () => {
|
|
144
|
+
try {
|
|
145
|
+
if (!existsSync(logFile)) return;
|
|
146
|
+
const size = statSync(logFile).size;
|
|
147
|
+
if (size < position) position = 0;
|
|
148
|
+
if (size === position) return;
|
|
149
|
+
const fd = openSync(logFile, 'r');
|
|
150
|
+
try {
|
|
151
|
+
const bytes = Math.min(size - position, 512 * 1024);
|
|
152
|
+
const buffer = Buffer.alloc(bytes);
|
|
153
|
+
readSync(fd, buffer, 0, bytes, position);
|
|
154
|
+
position += bytes;
|
|
155
|
+
stdout.write(buffer.toString('utf8'));
|
|
156
|
+
} finally { closeSync(fd); }
|
|
157
|
+
} catch (error) { console.error(`Log monitor: ${error.message}`); }
|
|
158
|
+
};
|
|
159
|
+
const stop = () => { unwatchFile(logFile, readNew); process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); console.log('\nDetached. Agent continues in the background.'); resolve(); };
|
|
160
|
+
watchFile(logFile, { interval: 400 }, readNew);
|
|
161
|
+
process.once('SIGINT', stop);
|
|
162
|
+
process.once('SIGTERM', stop);
|
|
163
|
+
readNew();
|
|
164
|
+
});
|
|
84
165
|
}
|
|
85
166
|
|
|
86
167
|
async function start(options) {
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
168
|
+
const existing = activeSession();
|
|
169
|
+
if (existing) {
|
|
170
|
+
if (existing.version !== VERSION) {
|
|
171
|
+
console.error('An older ZBack agent is still running. Run zback --stop, then zback to start this version.');
|
|
172
|
+
process.exitCode = 1;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
console.log(`Agent already running (${existing.status}, PID ${existing.pid}).`);
|
|
176
|
+
if (existing.id) showConnection(existing.id, readJson(settingsFile)?.url || DEFAULT_URL);
|
|
177
|
+
if (!options.background) await follow();
|
|
91
178
|
return;
|
|
92
179
|
}
|
|
93
|
-
if (!(await confirmOnce())) { console.log('
|
|
180
|
+
if (!(await confirmOnce())) { console.log('Authorization declined'); return; }
|
|
94
181
|
initStateDir();
|
|
182
|
+
const key = agentKey();
|
|
95
183
|
saveJson(settingsFile, { url: options.url, cwd: options.cwd, timeout: options.timeout, maxOutput: options.maxOutput, reconnect: options.reconnect });
|
|
184
|
+
if (!options.background) {
|
|
185
|
+
const started = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
186
|
+
let lastId = null;
|
|
187
|
+
let previousStatus = null;
|
|
188
|
+
const mark = data => {
|
|
189
|
+
saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...data });
|
|
190
|
+
if (data.status === 'connected') {
|
|
191
|
+
if (data.id !== lastId) {
|
|
192
|
+
console.log('Connected.');
|
|
193
|
+
showConnection(data.id, options.url);
|
|
194
|
+
console.log('Monitoring requests and connection errors. Press Ctrl+C to disconnect.');
|
|
195
|
+
lastId = data.id;
|
|
196
|
+
} else if (previousStatus !== 'connected') console.log('Connection restored.');
|
|
197
|
+
} else if (data.status === 'reconnecting' && previousStatus !== 'reconnecting') console.error('Connection lost. Attempting to reconnect...');
|
|
198
|
+
else if (data.status === 'stopped' && previousStatus !== 'stopped' && lastId) console.log('Disconnected.');
|
|
199
|
+
previousStatus = data.status;
|
|
200
|
+
};
|
|
201
|
+
console.log(`Connecting to ${options.url}...`);
|
|
202
|
+
mark({ status: 'connecting', id: null });
|
|
203
|
+
try { await connectAgent(options, mark, key); }
|
|
204
|
+
finally { if (sameSession(process.pid, started)) saveJson(stateFile, { pid: process.pid, started, version: VERSION, status: 'stopped', id: lastId, updated: new Date().toISOString() }); }
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
96
207
|
const fd = openSync(logFile, 'a', 0o600);
|
|
97
208
|
const started = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
98
209
|
let child;
|
|
@@ -103,37 +214,34 @@ async function start(options) {
|
|
|
103
214
|
env: { ...process.env, ZBACK_WORKER_NONCE: started }
|
|
104
215
|
});
|
|
105
216
|
child.unref();
|
|
106
|
-
} finally {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
for (let i = 0; i < 80; i++) {
|
|
112
|
-
await new Promise(r => setTimeout(r, 100));
|
|
217
|
+
} finally { closeSync(fd); }
|
|
218
|
+
if (!child?.pid) throw new Error('Unable to start background agent');
|
|
219
|
+
let connected = false;
|
|
220
|
+
for (let i = 0; i < 60; i++) {
|
|
221
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
113
222
|
const state = readJson(stateFile);
|
|
114
|
-
if (state?.pid === child.pid && state.started === started && state.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) {
|
|
119
|
-
throw new Error(`Background process exited. See ${logFile}`);
|
|
223
|
+
if (state?.pid === child.pid && state.started === started && state.status === 'connected' && state.id) {
|
|
224
|
+
showConnection(state.id, options.url);
|
|
225
|
+
connected = true;
|
|
226
|
+
break;
|
|
120
227
|
}
|
|
228
|
+
if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) throw new Error(`Agent exited unexpectedly. Inspect logs with zback --logs`);
|
|
121
229
|
}
|
|
122
|
-
console.log(`
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function status() {
|
|
127
|
-
const state = activeSession();
|
|
128
|
-
if (!state) return console.log('ZBack is stopped.');
|
|
129
|
-
console.log(`Status: ${state.status}\nPID: ${state.pid}${state.id ? `\nSession ID: ${state.id}` : ''}`);
|
|
230
|
+
if (!connected) console.log(`Agent started (PID ${child.pid}). Waiting for backend; run zback --status to inspect.`);
|
|
231
|
+
if (!options.background) await follow();
|
|
130
232
|
}
|
|
131
233
|
|
|
132
234
|
function stop() {
|
|
133
235
|
const state = activeSession();
|
|
134
|
-
if (!state)
|
|
236
|
+
if (!state) { console.log('Agent is already stopped'); return; }
|
|
135
237
|
process.kill(state.pid, 'SIGTERM');
|
|
136
|
-
console.log(
|
|
238
|
+
console.log(`Stop requested for agent PID ${state.pid}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function rotate() {
|
|
242
|
+
if (activeSession()) throw new Error('Stop the agent using zback --stop before rotating its credential');
|
|
243
|
+
resetKey();
|
|
244
|
+
console.log('Agent credential rotated. A new session ID will be assigned when it connects.');
|
|
137
245
|
}
|
|
138
246
|
|
|
139
247
|
export { execute };
|
|
@@ -142,10 +250,13 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
142
250
|
const options = parseArgs(args);
|
|
143
251
|
if (options.help) return help();
|
|
144
252
|
if (options.worker) return worker();
|
|
145
|
-
if (options.action === 'status') return
|
|
253
|
+
if (options.action === 'status') return showStatus();
|
|
146
254
|
if (options.action === 'stop') return stop();
|
|
255
|
+
if (options.action === 'logs') return recentLog();
|
|
256
|
+
if (options.action === 'follow') { showStatus(); return follow(); }
|
|
257
|
+
if (options.action === 'reset-key') return rotate();
|
|
147
258
|
return await start(options);
|
|
148
|
-
} catch (error) { console.error(`
|
|
259
|
+
} catch (error) { console.error(`ZBack error: ${error.message}`); process.exitCode = 1; }
|
|
149
260
|
}
|
|
150
261
|
|
|
151
|
-
if (process.argv[1] &&
|
|
262
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === ENTRY) main();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zback",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"description": "Opt-in foreground remote shell administration with a persistent WebSocket agent and private session credential",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"zback": "bin/zback.js"
|
|
@@ -12,5 +12,11 @@
|
|
|
12
12
|
"scripts": {
|
|
13
13
|
"test": "node --test"
|
|
14
14
|
},
|
|
15
|
-
"
|
|
15
|
+
"contentPolicy": {
|
|
16
|
+
"class": "dual-use"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["remote-administration", "websocket", "cli", "mcp", "opt-in"],
|
|
19
|
+
"files": ["bin/", "src/", "README.md", "DISCLOSURE", "LICENSE"],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "SoyMaycol"
|
|
16
22
|
}
|
package/src/agent.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
|
|
3
3
|
const children = new Set();
|
|
4
4
|
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
5
|
+
const log = message => console.log(`[${new Date().toISOString()}] ${message}`);
|
|
5
6
|
|
|
6
7
|
function killTree(child) {
|
|
7
8
|
if (!child?.pid) return;
|
|
@@ -24,17 +25,13 @@ export function execute(command, cwd, timeoutMs, maxOutput) {
|
|
|
24
25
|
try {
|
|
25
26
|
child = spawn(command, { cwd, shell: true, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
26
27
|
children.add(child);
|
|
27
|
-
} catch (err) {
|
|
28
|
-
resolveResult({ ok: false, code: null, stdout: '', stderr: String(err.message) });
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
28
|
+
} catch (err) { resolveResult({ ok: false, code: null, stdout: '', stderr: String(err.message) }); return; }
|
|
31
29
|
const add = (kind, chunk) => {
|
|
32
30
|
const remaining = maxOutput - collected;
|
|
33
31
|
if (remaining <= 0) { truncated = true; return; }
|
|
34
32
|
if (chunk.length > remaining) truncated = true;
|
|
35
|
-
|
|
36
|
-
collected +=
|
|
37
|
-
chunks[kind].push(part);
|
|
33
|
+
chunks[kind].push(chunk.subarray(0, remaining));
|
|
34
|
+
collected += Math.min(chunk.length, remaining);
|
|
38
35
|
};
|
|
39
36
|
child.stdout.on('data', b => add('stdout', b));
|
|
40
37
|
child.stderr.on('data', b => add('stderr', b));
|
|
@@ -44,55 +41,112 @@ export function execute(command, cwd, timeoutMs, maxOutput) {
|
|
|
44
41
|
finished = true;
|
|
45
42
|
children.delete(child);
|
|
46
43
|
clearTimeout(timer);
|
|
47
|
-
resolveResult({
|
|
44
|
+
resolveResult({
|
|
45
|
+
ok: code === 0 && !timedOut,
|
|
46
|
+
code: Number.isInteger(code) ? code : null,
|
|
48
47
|
stdout: Buffer.concat(chunks.stdout).toString('utf8'),
|
|
49
48
|
stderr: Buffer.concat(chunks.stderr).toString('utf8') + message,
|
|
50
|
-
truncated, timedOut
|
|
49
|
+
truncated, timedOut
|
|
50
|
+
});
|
|
51
51
|
};
|
|
52
52
|
child.once('error', error => finish(null, `\n${error.message}`));
|
|
53
53
|
child.once('close', code => finish(code));
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export async function connectAgent(options, onState) {
|
|
58
|
-
let stopped = false
|
|
59
|
-
|
|
57
|
+
export async function connectAgent(options, onState, token) {
|
|
58
|
+
let stopped = false;
|
|
59
|
+
let retries = 0;
|
|
60
|
+
let socket;
|
|
61
|
+
let cancelDelay;
|
|
62
|
+
let lastId = null;
|
|
63
|
+
let lastError = null;
|
|
64
|
+
const update = (status, data = {}) => onState({ status, ...data });
|
|
65
|
+
const shutdown = () => {
|
|
66
|
+
stopped = true;
|
|
67
|
+
if (cancelDelay) cancelDelay();
|
|
68
|
+
try { socket?.close(1000, 'Agent stopped'); } catch {}
|
|
69
|
+
killRunningCommands();
|
|
70
|
+
};
|
|
60
71
|
process.once('SIGINT', shutdown);
|
|
61
72
|
process.once('SIGTERM', shutdown);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
socket
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
73
|
+
try {
|
|
74
|
+
while (!stopped) {
|
|
75
|
+
try {
|
|
76
|
+
update(retries ? 'reconnecting' : 'connecting', { id: lastId });
|
|
77
|
+
const current = new WebSocket(options.url);
|
|
78
|
+
socket = current;
|
|
79
|
+
await new Promise((resolve, reject) => {
|
|
80
|
+
const timer = setTimeout(() => { try { current.close(); } catch {} reject(new Error('Connection timed out')); }, 15000);
|
|
81
|
+
const success = () => { clearTimeout(timer); resolve(); };
|
|
82
|
+
const failure = () => { clearTimeout(timer); reject(new Error('Could not reach backend')); };
|
|
83
|
+
current.addEventListener('open', success, { once: true });
|
|
84
|
+
current.addEventListener('error', failure, { once: true });
|
|
85
|
+
});
|
|
86
|
+
if (stopped) break;
|
|
87
|
+
current.send(JSON.stringify({ type: 'hello', protocolVersion: 2, token }));
|
|
88
|
+
await new Promise((resolve, reject) => {
|
|
89
|
+
let welcomed = false;
|
|
90
|
+
const timer = setTimeout(() => { try { current.close(); } catch {} reject(new Error('Server handshake timed out')); }, 10000);
|
|
91
|
+
let queue = Promise.resolve();
|
|
92
|
+
const messageListener = event => {
|
|
93
|
+
let message;
|
|
94
|
+
try { message = JSON.parse(event.data); }
|
|
95
|
+
catch { log('Ignored invalid server message'); return; }
|
|
96
|
+
if (message.type === 'welcome') {
|
|
97
|
+
if (welcomed) return;
|
|
98
|
+
if (message.protocolVersion !== 2 || typeof message.id !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(message.id)) {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
current.close(1000, 'Incompatible protocol');
|
|
101
|
+
const mismatch = new Error('Incompatible backend. Deploy ZBack backend 1.2.0 or later; this server issues legacy session IDs.');
|
|
102
|
+
mismatch.code = 'INCOMPATIBLE_BACKEND';
|
|
103
|
+
reject(mismatch);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
welcomed = true;
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
retries = 0;
|
|
109
|
+
lastId = message.id;
|
|
110
|
+
lastError = null;
|
|
111
|
+
update('connected', { id: message.id });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!welcomed || message.type !== 'command' || typeof message.requestId !== 'string') return;
|
|
115
|
+
queue = queue.then(async () => {
|
|
116
|
+
if (stopped || current.readyState !== WebSocket.OPEN) return;
|
|
117
|
+
const command = message.command;
|
|
118
|
+
if (typeof command !== 'string' || !command.trim() || command.length > 4096 || command.includes('\0')) return;
|
|
119
|
+
const timeout = Math.min(options.timeout, Number.isInteger(message.timeoutMs) && message.timeoutMs > 0 ? message.timeoutMs : options.timeout);
|
|
120
|
+
log(`Running authorized request ${message.requestId}`);
|
|
121
|
+
const result = await execute(command, options.cwd, timeout, options.maxOutput);
|
|
122
|
+
log(`Request ${message.requestId}: ${result.timedOut ? 'timed out' : result.ok ? 'completed' : 'failed'}`);
|
|
123
|
+
if (current.readyState === WebSocket.OPEN && !stopped) current.send(JSON.stringify({ type: 'result', requestId: message.requestId, ...result }));
|
|
124
|
+
}).catch(error => log(`Request processing error: ${error.message}`));
|
|
125
|
+
};
|
|
126
|
+
current.addEventListener('message', messageListener);
|
|
127
|
+
current.addEventListener('close', event => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
if (!welcomed) reject(new Error(`Backend rejected connection (WebSocket ${event.code}: ${event.reason || 'no reason'})`));
|
|
130
|
+
else { if (!stopped) log(`Connection closed (${event.code}: ${event.reason || 'connection lost'})`); resolve(); }
|
|
131
|
+
}, { once: true });
|
|
132
|
+
current.addEventListener('error', () => { if (!welcomed) { clearTimeout(timer); reject(new Error('WebSocket connection failed')); } }, { once: true });
|
|
133
|
+
});
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (!stopped && error.code === 'INCOMPATIBLE_BACKEND') throw error;
|
|
136
|
+
if (!stopped && error.message !== lastError) { log(`Connection error: ${error.message}`); lastError = error.message; }
|
|
137
|
+
}
|
|
138
|
+
killRunningCommands();
|
|
139
|
+
if (stopped || !options.reconnect) break;
|
|
140
|
+
update('reconnecting', { id: lastId });
|
|
141
|
+
const delay = Math.min(30000, 1000 * 2 ** Math.min(retries++, 5));
|
|
142
|
+
await new Promise(resolve => {
|
|
143
|
+
const timer = setTimeout(() => { cancelDelay = null; resolve(); }, delay);
|
|
144
|
+
cancelDelay = () => { clearTimeout(timer); cancelDelay = null; resolve(); };
|
|
90
145
|
});
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
146
|
+
}
|
|
147
|
+
} finally {
|
|
148
|
+
process.removeListener('SIGINT', shutdown);
|
|
149
|
+
process.removeListener('SIGTERM', shutdown);
|
|
150
|
+
update('stopped', { id: lastId });
|
|
96
151
|
}
|
|
97
|
-
onState({ status: 'stopped', id: null });
|
|
98
152
|
}
|
package/src/runtime.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
|
|
@@ -6,15 +7,35 @@ export const stateDir = process.env.ZBACK_STATE_DIR || join(process.env.XDG_STAT
|
|
|
6
7
|
export const consentFile = join(stateDir, 'consent.json');
|
|
7
8
|
export const stateFile = join(stateDir, 'session.json');
|
|
8
9
|
export const settingsFile = join(stateDir, 'settings.json');
|
|
10
|
+
export const agentKeyFile = join(stateDir, 'agent.key');
|
|
9
11
|
export const logFile = join(stateDir, 'zback.log');
|
|
10
12
|
|
|
11
13
|
export function initStateDir() { mkdirSync(stateDir, { recursive: true, mode: 0o700 }); }
|
|
12
14
|
export function readJson(file) { try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; } }
|
|
13
15
|
export function saveJson(file, data) {
|
|
14
16
|
initStateDir();
|
|
15
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
18
|
+
try {
|
|
19
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
20
|
+
renameSync(tmp, file);
|
|
21
|
+
} finally { try { unlinkSync(tmp); } catch {} }
|
|
22
|
+
}
|
|
23
|
+
export function agentKey() {
|
|
24
|
+
initStateDir();
|
|
25
|
+
if (!existsSync(agentKeyFile)) {
|
|
26
|
+
const key = randomBytes(32).toString('base64url');
|
|
27
|
+
try {
|
|
28
|
+
const fd = openSync(agentKeyFile, 'wx', 0o600);
|
|
29
|
+
try { writeFileSync(fd, key + '\n'); } finally { closeSync(fd); }
|
|
30
|
+
} catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
31
|
+
}
|
|
32
|
+
const key = readFileSync(agentKeyFile, 'utf8').trim();
|
|
33
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(key)) throw new Error('Invalid agent credential file; run zback --reset-key while stopped');
|
|
34
|
+
return key;
|
|
35
|
+
}
|
|
36
|
+
export function resetKey() {
|
|
37
|
+
try { unlinkSync(agentKeyFile); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
38
|
+
return agentKey();
|
|
18
39
|
}
|
|
19
40
|
export function hasConsent() { return readJson(consentFile)?.approved === true; }
|
|
20
41
|
export function alive(pid) {
|
|
@@ -22,11 +43,14 @@ export function alive(pid) {
|
|
|
22
43
|
try {
|
|
23
44
|
process.kill(pid, 0);
|
|
24
45
|
if (process.platform === 'linux') {
|
|
25
|
-
try {
|
|
46
|
+
try {
|
|
47
|
+
if (readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1]?.[0] === 'Z') return false;
|
|
48
|
+
const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
49
|
+
if (!/(?:^|[\x00/\\])zback(?:\.js)?(?:\x00|$)/.test(cmd)) return false;
|
|
50
|
+
} catch {}
|
|
26
51
|
}
|
|
27
52
|
return true;
|
|
28
|
-
}
|
|
29
|
-
catch (e) { return e.code === 'EPERM'; }
|
|
53
|
+
} catch (e) { return e.code === 'EPERM'; }
|
|
30
54
|
}
|
|
31
55
|
export function activeSession() {
|
|
32
56
|
const data = readJson(stateFile);
|
|
@@ -36,4 +60,3 @@ export function sameSession(pid, started) {
|
|
|
36
60
|
const data = readJson(stateFile);
|
|
37
61
|
return data?.pid === pid && data?.started === started;
|
|
38
62
|
}
|
|
39
|
-
export function hasState() { return existsSync(stateFile); }
|
package/test/client.test.js
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { test } from 'node:test';
|
|
3
|
-
import { parseArgs, execute } from '../bin/zback.js';
|
|
4
|
-
|
|
5
|
-
test('default secure endpoint', () => {
|
|
6
|
-
const args = parseArgs([]);
|
|
7
|
-
assert.equal(args.url, 'wss://zb.soymaycol.icu/ws');
|
|
8
|
-
assert.equal(args.reconnect, true);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
test('unsafe remote ws URL blocked', () => {
|
|
12
|
-
assert.throws(() => parseArgs(['--url', 'ws://example.com/ws']), /wss/);
|
|
13
|
-
assert.equal(parseArgs(['--url', 'ws://127.0.0.1:8787/ws']).timeout, 30000);
|
|
14
|
-
assert.throws(() => parseArgs(['--timeout', '0']), /timeout/);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test('subprocess respects success and returned output', async () => {
|
|
18
|
-
const res = await execute('node -p "3*7"', process.cwd(), 5000, 1024);
|
|
19
|
-
assert.equal(res.ok, true);
|
|
20
|
-
assert.equal(res.stdout.trim(), '21');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test('subprocess output limit is enforced', async () => {
|
|
24
|
-
const res = await execute('node -e "process.stdout.write(\'a\'.repeat(2500))"', process.cwd(), 5000, 1024);
|
|
25
|
-
assert.equal(res.ok, true);
|
|
26
|
-
assert.equal(res.stdout.length, 1024);
|
|
27
|
-
assert.equal(res.truncated, true);
|
|
28
|
-
});
|