zback 1.3.0 → 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 +1 -1
- package/README.md +8 -8
- package/bin/zback.js +59 -16
- package/package.json +2 -2
- package/src/agent.js +22 -13
- package/src/runtime.js +1 -1
package/DISCLOSURE
CHANGED
|
@@ -2,7 +2,7 @@ ZBack is an opt-in remote shell administration tool for devices owned or explici
|
|
|
2
2
|
|
|
3
3
|
Security-sensitive functionality:
|
|
4
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
|
-
-
|
|
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
6
|
- Remote shell commands execute with the operating-system permissions of the account that launched the agent.
|
|
7
7
|
- The worker connects outbound to the configured secure WebSocket endpoint. It does not open an inbound shell listener.
|
|
8
8
|
- The server exposes an MCP endpoint that routes authorized commands to connected workers when the caller presents the private session capability.
|
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# ZBack CLI
|
|
2
2
|
|
|
3
|
-
ZBack is
|
|
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
7
|
## Install and start
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
npm install -g zback@1.2.
|
|
10
|
+
npm install -g zback@1.2.1
|
|
11
11
|
zback
|
|
12
12
|
```
|
|
13
13
|
|
|
@@ -25,21 +25,21 @@ For development only:
|
|
|
25
25
|
zback --url ws://127.0.0.1:8787/ws
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
The client
|
|
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
29
|
|
|
30
30
|
## Terminal and process lifecycle
|
|
31
31
|
|
|
32
32
|
| Command | Behavior |
|
|
33
33
|
| --- | --- |
|
|
34
|
-
| `zback` |
|
|
35
|
-
| `zback --background` |
|
|
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
36
|
| `zback --status` | Show connection status, process ID and private session ID |
|
|
37
37
|
| `zback --logs` | Display recent connection and command activity |
|
|
38
|
-
| `zback --follow` |
|
|
38
|
+
| `zback --follow` | Watch new events from an existing background agent without replaying old logs |
|
|
39
39
|
| `zback --stop` | Stop the background agent |
|
|
40
40
|
| `zback --reset-key` | Change the device identity after the agent is stopped |
|
|
41
41
|
|
|
42
|
-
`Ctrl+C`
|
|
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.
|
|
43
43
|
|
|
44
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
45
|
|
|
@@ -56,7 +56,7 @@ Files are under `~/.local/state/zback` on Linux/Termux and `%LOCALAPPDATA%/zback
|
|
|
56
56
|
|
|
57
57
|
## MCP
|
|
58
58
|
|
|
59
|
-
The companion backend serves `POST /mcp` with `zback_status` and `zback_execute`.
|
|
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.
|
|
60
60
|
|
|
61
61
|
## Security disclosure and review
|
|
62
62
|
|
package/bin/zback.js
CHANGED
|
@@ -9,15 +9,15 @@ import { connectAgent, execute } from '../src/agent.js';
|
|
|
9
9
|
import { activeSession, agentKey, consentFile, hasConsent, initStateDir, logFile, readJson, resetKey, sameSession, saveJson, settingsFile, stateFile, alive } from '../src/runtime.js';
|
|
10
10
|
|
|
11
11
|
const ENTRY = fileURLToPath(import.meta.url);
|
|
12
|
-
const VERSION = '1.2.
|
|
12
|
+
const VERSION = '1.2.1';
|
|
13
13
|
const DEFAULT_URL = 'wss://zb.soymaycol.icu/ws';
|
|
14
14
|
|
|
15
15
|
function help() {
|
|
16
16
|
console.log(`ZBack ${VERSION} | Authorized remote shell administration
|
|
17
17
|
|
|
18
18
|
Usage:
|
|
19
|
-
zback Start the
|
|
20
|
-
zback --background
|
|
19
|
+
zback Start in the foreground and display live events
|
|
20
|
+
zback --background Run as a background agent
|
|
21
21
|
zback --status Show agent status, PID and private session ID
|
|
22
22
|
zback --logs Show recent agent events
|
|
23
23
|
zback --follow Follow live events from a running agent
|
|
@@ -34,8 +34,8 @@ Options:
|
|
|
34
34
|
|
|
35
35
|
ZBack runs shell commands with your account privileges. Initial interactive
|
|
36
36
|
consent is required once, not for every command. Keep the session ID private.
|
|
37
|
-
Ctrl+C
|
|
38
|
-
Use --stop to
|
|
37
|
+
Ctrl+C stops a foreground agent. Background mode must be requested explicitly.
|
|
38
|
+
Use --stop to disconnect a background agent.`);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function parseArgs(args) {
|
|
@@ -73,11 +73,11 @@ export function parseArgs(args) {
|
|
|
73
73
|
async function confirmOnce() {
|
|
74
74
|
if (hasConsent()) return true;
|
|
75
75
|
if (!stdin.isTTY || !stdout.isTTY) throw new Error('First-run shell authorization requires an interactive terminal');
|
|
76
|
-
console.log('
|
|
77
|
-
console.log('
|
|
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.');
|
|
78
78
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
79
79
|
try {
|
|
80
|
-
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] ');
|
|
81
81
|
if (!['y', 'yes'].includes(answer.trim().toLowerCase())) return false;
|
|
82
82
|
saveJson(consentFile, { approved: true, at: new Date().toISOString() });
|
|
83
83
|
return true;
|
|
@@ -94,17 +94,32 @@ async function worker() {
|
|
|
94
94
|
const mark = data => {
|
|
95
95
|
const previous = readJson(stateFile);
|
|
96
96
|
if (previous && previous.status !== 'stopped' && previous.pid !== process.pid && alive(previous.pid)) return;
|
|
97
|
-
saveJson(stateFile, { pid: process.pid, started, updated: new Date().toISOString(), ...data });
|
|
97
|
+
saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...data });
|
|
98
98
|
};
|
|
99
99
|
mark({ status: 'connecting', id: null });
|
|
100
100
|
try { await connectAgent(settings, mark, key); }
|
|
101
101
|
finally { if (sameSession(process.pid, started)) mark({ status: 'stopped', id: null }); }
|
|
102
102
|
}
|
|
103
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
|
+
|
|
104
118
|
function showStatus() {
|
|
105
119
|
const state = activeSession();
|
|
106
120
|
if (!state) { console.log('Status: stopped'); return false; }
|
|
107
|
-
console.log(`Status: ${state.status}\nPID: ${state.pid}
|
|
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);
|
|
108
123
|
return true;
|
|
109
124
|
}
|
|
110
125
|
|
|
@@ -122,8 +137,7 @@ function recentLog() {
|
|
|
122
137
|
|
|
123
138
|
async function follow() {
|
|
124
139
|
if (!stdin.isTTY || !stdout.isTTY) return;
|
|
125
|
-
console.log('
|
|
126
|
-
recentLog();
|
|
140
|
+
console.log('Monitoring background agent. Ctrl+C closes this view; the agent stays active.');
|
|
127
141
|
let position = existsSync(logFile) ? statSync(logFile).size : 0;
|
|
128
142
|
await new Promise(resolve => {
|
|
129
143
|
const readNew = () => {
|
|
@@ -153,14 +167,43 @@ async function follow() {
|
|
|
153
167
|
async function start(options) {
|
|
154
168
|
const existing = activeSession();
|
|
155
169
|
if (existing) {
|
|
156
|
-
|
|
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);
|
|
157
177
|
if (!options.background) await follow();
|
|
158
178
|
return;
|
|
159
179
|
}
|
|
160
180
|
if (!(await confirmOnce())) { console.log('Authorization declined'); return; }
|
|
161
181
|
initStateDir();
|
|
162
|
-
agentKey();
|
|
182
|
+
const key = agentKey();
|
|
163
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
|
+
}
|
|
164
207
|
const fd = openSync(logFile, 'a', 0o600);
|
|
165
208
|
const started = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
166
209
|
let child;
|
|
@@ -178,13 +221,13 @@ async function start(options) {
|
|
|
178
221
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
179
222
|
const state = readJson(stateFile);
|
|
180
223
|
if (state?.pid === child.pid && state.started === started && state.status === 'connected' && state.id) {
|
|
181
|
-
|
|
224
|
+
showConnection(state.id, options.url);
|
|
182
225
|
connected = true;
|
|
183
226
|
break;
|
|
184
227
|
}
|
|
185
228
|
if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) throw new Error(`Agent exited unexpectedly. Inspect logs with zback --logs`);
|
|
186
229
|
}
|
|
187
|
-
if (!connected) console.log(`Agent started (PID ${child.pid}). Waiting for backend;
|
|
230
|
+
if (!connected) console.log(`Agent started (PID ${child.pid}). Waiting for backend; run zback --status to inspect.`);
|
|
188
231
|
if (!options.background) await follow();
|
|
189
232
|
}
|
|
190
233
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zback",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "Opt-in remote shell administration with a persistent WebSocket agent
|
|
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"
|
package/src/agent.js
CHANGED
|
@@ -59,11 +59,9 @@ export async function connectAgent(options, onState, token) {
|
|
|
59
59
|
let retries = 0;
|
|
60
60
|
let socket;
|
|
61
61
|
let cancelDelay;
|
|
62
|
-
let
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (currentState !== status) { log(`Agent status: ${status}`); currentState = status; }
|
|
66
|
-
};
|
|
62
|
+
let lastId = null;
|
|
63
|
+
let lastError = null;
|
|
64
|
+
const update = (status, data = {}) => onState({ status, ...data });
|
|
67
65
|
const shutdown = () => {
|
|
68
66
|
stopped = true;
|
|
69
67
|
if (cancelDelay) cancelDelay();
|
|
@@ -75,7 +73,7 @@ export async function connectAgent(options, onState, token) {
|
|
|
75
73
|
try {
|
|
76
74
|
while (!stopped) {
|
|
77
75
|
try {
|
|
78
|
-
update(retries ? 'reconnecting' : 'connecting');
|
|
76
|
+
update(retries ? 'reconnecting' : 'connecting', { id: lastId });
|
|
79
77
|
const current = new WebSocket(options.url);
|
|
80
78
|
socket = current;
|
|
81
79
|
await new Promise((resolve, reject) => {
|
|
@@ -95,13 +93,22 @@ export async function connectAgent(options, onState, token) {
|
|
|
95
93
|
let message;
|
|
96
94
|
try { message = JSON.parse(event.data); }
|
|
97
95
|
catch { log('Ignored invalid server message'); return; }
|
|
98
|
-
if (message.type === 'welcome'
|
|
96
|
+
if (message.type === 'welcome') {
|
|
99
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
|
+
}
|
|
100
106
|
welcomed = true;
|
|
101
107
|
clearTimeout(timer);
|
|
102
108
|
retries = 0;
|
|
109
|
+
lastId = message.id;
|
|
110
|
+
lastError = null;
|
|
103
111
|
update('connected', { id: message.id });
|
|
104
|
-
log('Connected and ready for authorized requests');
|
|
105
112
|
return;
|
|
106
113
|
}
|
|
107
114
|
if (!welcomed || message.type !== 'command' || typeof message.requestId !== 'string') return;
|
|
@@ -120,16 +127,18 @@ export async function connectAgent(options, onState, token) {
|
|
|
120
127
|
current.addEventListener('close', event => {
|
|
121
128
|
clearTimeout(timer);
|
|
122
129
|
if (!welcomed) reject(new Error(`Backend rejected connection (WebSocket ${event.code}: ${event.reason || 'no reason'})`));
|
|
123
|
-
else { log(`Connection closed (${event.code}: ${event.reason || 'connection lost'})`); resolve(); }
|
|
130
|
+
else { if (!stopped) log(`Connection closed (${event.code}: ${event.reason || 'connection lost'})`); resolve(); }
|
|
124
131
|
}, { once: true });
|
|
125
132
|
current.addEventListener('error', () => { if (!welcomed) { clearTimeout(timer); reject(new Error('WebSocket connection failed')); } }, { once: true });
|
|
126
133
|
});
|
|
127
|
-
} catch (error) {
|
|
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
|
+
}
|
|
128
138
|
killRunningCommands();
|
|
129
139
|
if (stopped || !options.reconnect) break;
|
|
130
|
-
update('reconnecting', { id:
|
|
140
|
+
update('reconnecting', { id: lastId });
|
|
131
141
|
const delay = Math.min(30000, 1000 * 2 ** Math.min(retries++, 5));
|
|
132
|
-
log(`Retrying connection in ${Math.ceil(delay / 1000)}s`);
|
|
133
142
|
await new Promise(resolve => {
|
|
134
143
|
const timer = setTimeout(() => { cancelDelay = null; resolve(); }, delay);
|
|
135
144
|
cancelDelay = () => { clearTimeout(timer); cancelDelay = null; resolve(); };
|
|
@@ -138,6 +147,6 @@ export async function connectAgent(options, onState, token) {
|
|
|
138
147
|
} finally {
|
|
139
148
|
process.removeListener('SIGINT', shutdown);
|
|
140
149
|
process.removeListener('SIGTERM', shutdown);
|
|
141
|
-
update('stopped', { id:
|
|
150
|
+
update('stopped', { id: lastId });
|
|
142
151
|
}
|
|
143
152
|
}
|
package/src/runtime.js
CHANGED
|
@@ -46,7 +46,7 @@ export function alive(pid) {
|
|
|
46
46
|
try {
|
|
47
47
|
if (readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1]?.[0] === 'Z') return false;
|
|
48
48
|
const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
49
|
-
if (
|
|
49
|
+
if (!/(?:^|[\x00/\\])zback(?:\.js)?(?:\x00|$)/.test(cmd)) return false;
|
|
50
50
|
} catch {}
|
|
51
51
|
}
|
|
52
52
|
return true;
|