diggylab 0.1.42 → 0.1.43
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/README.md +47 -6
- package/cli.js +221 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,16 +56,29 @@ npm publish apps/desktop-agent/dist-npm --access public
|
|
|
56
56
|
|
|
57
57
|
| Command | What it does |
|
|
58
58
|
| --- | --- |
|
|
59
|
-
| `pair --code <code> [--name <device name>]
|
|
59
|
+
| `pair [--code <code>] [--name <device name>]` | Pairs the server as a device. Create the code on `/settings/desktop-agent` in the web app; leave `--code` out and the command asks for it. |
|
|
60
60
|
| `login` | Opens Facebook's real login page in a browser window when the machine has a screen. On a server without one it asks for `facebook.com` cookies pasted from your own browser, and the session is stored encrypted. |
|
|
61
|
+
| `logout` | Removes the stored Facebook session from this server (the device pairing stays). |
|
|
62
|
+
| `rename [--name <new name>]` | Renames the device shown in the web app; leave `--name` out and the command asks for it. |
|
|
61
63
|
| `run` | Connects the WebSocket worker, receives assigned scans, scans headlessly, uploads results. |
|
|
62
64
|
| `status` | Shows the config directory, API origin, paired device, and Facebook session state. |
|
|
63
65
|
| `install-browser` | Downloads the Chromium build Playwright needs (~150 MB). It also runs during `npm install`, so you only need it if that download failed. |
|
|
66
|
+
| `install-service` | On a Linux server with systemd, writes the unit for `run`, enables it, and starts it now and on every boot. |
|
|
67
|
+
| `uninstall-service` | Stops that service and removes the unit. |
|
|
64
68
|
| `unpair` | Removes device credentials, the Facebook session, and pending scans. |
|
|
65
69
|
|
|
66
70
|
Useful options: `--api <origin>` (default `https://api.diggylab.com`), `--cookies <file>` to import
|
|
67
71
|
cookies directly.
|
|
68
72
|
|
|
73
|
+
`pair` and `login` finish by printing what is still missing, so you do not need to run `status`
|
|
74
|
+
between steps:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Paired: "DiggyLab" (device-1)
|
|
78
|
+
Facebook: missing
|
|
79
|
+
Next: run `diggylab login` — on a server without a screen it asks you to paste cookies.
|
|
80
|
+
```
|
|
81
|
+
|
|
69
82
|
## How `login` works
|
|
70
83
|
|
|
71
84
|
Two paths, depending on the machine:
|
|
@@ -111,6 +124,16 @@ instead of failing during a scan.
|
|
|
111
124
|
Note: Facebook sometimes asks for a security check when a session moves to a server's IP. If that
|
|
112
125
|
happens the command says so — approve the login from your phone and import again.
|
|
113
126
|
|
|
127
|
+
## Troubleshooting
|
|
128
|
+
|
|
129
|
+
- `socket closed: 4009` right after `session: ONLINE` means **two workers use the same device** — for
|
|
130
|
+
example the systemd service plus a manual `diggylab run`. The worker that connected last keeps the
|
|
131
|
+
device and the older one stops with a message. Check `pgrep -af "diggylab run"` and
|
|
132
|
+
`systemctl status diggylab --no-pager`, then keep only one.
|
|
133
|
+
- `socket closed: 1006` with reconnects is a dropped connection; the worker keeps retrying with
|
|
134
|
+
backoff. If it never settles, check that the server can reach the API origin.
|
|
135
|
+
- `Facebook: missing` after a login means the session did not survive; run `diggylab login` again.
|
|
136
|
+
|
|
114
137
|
## Security notes
|
|
115
138
|
|
|
116
139
|
- `~/.diggylab/key` (mode `600`) encrypts device credentials, the Facebook session, and the
|
|
@@ -121,22 +144,40 @@ happens the command says so — approve the login from your phone and import aga
|
|
|
121
144
|
|
|
122
145
|
## Run as a service
|
|
123
146
|
|
|
147
|
+
On a Linux server with systemd, let the CLI write the unit for you:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
diggylab install-service # writes /etc/systemd/system/diggylab.service, enables + starts it
|
|
151
|
+
diggylab uninstall-service # stops it and removes the unit
|
|
152
|
+
journalctl -u diggylab -f # follow the worker log
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The unit keeps the same state directory and API origin you are using, restarts the worker if it
|
|
156
|
+
crashes, and starts it on boot. It points at npm's `diggylab` symlink, so upgrading the package is
|
|
157
|
+
enough — no need to reinstall the service. Run it as root (or with `sudo`) to write the unit.
|
|
158
|
+
|
|
159
|
+
<details>
|
|
160
|
+
<summary>Equivalent unit, if you prefer to write it yourself</summary>
|
|
161
|
+
|
|
124
162
|
```ini
|
|
125
163
|
[Unit]
|
|
126
|
-
Description=DiggyLab Desktop Agent
|
|
164
|
+
Description=DiggyLab Desktop Agent
|
|
127
165
|
After=network-online.target
|
|
166
|
+
Wants=network-online.target
|
|
128
167
|
|
|
129
168
|
[Service]
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
ExecStart=/usr/bin/node /opt/diggylab/dist-cli/cli/cli.js run
|
|
133
|
-
Restart=always
|
|
169
|
+
ExecStart=/usr/local/bin/diggylab run
|
|
170
|
+
Environment=DIGGYLAB_AGENT_DIR=/root/.diggylab
|
|
134
171
|
Environment=DIGGYLAB_API_URL=https://api.diggylab.com
|
|
172
|
+
Restart=always
|
|
173
|
+
RestartSec=10
|
|
135
174
|
|
|
136
175
|
[Install]
|
|
137
176
|
WantedBy=multi-user.target
|
|
138
177
|
```
|
|
139
178
|
|
|
179
|
+
</details>
|
|
180
|
+
|
|
140
181
|
## Docker
|
|
141
182
|
|
|
142
183
|
```bash
|
package/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/cli/cli.ts
|
|
4
4
|
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync } from "node:fs";
|
|
6
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
7
7
|
import { hostname, homedir } from "node:os";
|
|
8
8
|
import { dirname as dirname3, join as join3 } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -727,6 +727,16 @@ var DeviceCredentialManager = class {
|
|
|
727
727
|
});
|
|
728
728
|
return this.refreshPromise;
|
|
729
729
|
}
|
|
730
|
+
/** Renames the device on the server and in the stored session, so `status` and the web agree. */
|
|
731
|
+
async rename(name) {
|
|
732
|
+
const access = this.access ?? await this.refresh();
|
|
733
|
+
const device = await this.http.rename(access.accessToken, name);
|
|
734
|
+
const session = { ...access.session, device };
|
|
735
|
+
await this.store.save(session);
|
|
736
|
+
this.stored = session;
|
|
737
|
+
this.access = { ...access, session };
|
|
738
|
+
return device;
|
|
739
|
+
}
|
|
730
740
|
getStoredSession() {
|
|
731
741
|
return this.stored;
|
|
732
742
|
}
|
|
@@ -816,6 +826,15 @@ var DesktopAgentHttpClient = class {
|
|
|
816
826
|
if (!isDeviceSummary(value)) throw new DesktopAgentClientError("DESKTOP_RESPONSE_INVALID");
|
|
817
827
|
return value;
|
|
818
828
|
}
|
|
829
|
+
async rename(accessToken, name) {
|
|
830
|
+
const value = await this.request("/desktop-agent-auth/me", {
|
|
831
|
+
body: JSON.stringify({ name }),
|
|
832
|
+
headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
|
|
833
|
+
method: "PATCH"
|
|
834
|
+
});
|
|
835
|
+
if (!isDeviceSummary(value)) throw new DesktopAgentClientError("DESKTOP_RESPONSE_INVALID");
|
|
836
|
+
return value;
|
|
837
|
+
}
|
|
819
838
|
async requestCredential(path, body) {
|
|
820
839
|
const value = await this.request(path, {
|
|
821
840
|
body: JSON.stringify(body),
|
|
@@ -1031,13 +1050,13 @@ var DesktopAgentSession = class {
|
|
|
1031
1050
|
this.socket = socket;
|
|
1032
1051
|
socket.on("message", (data) => this.handleMessage(socket, data, access, generation));
|
|
1033
1052
|
socket.on("error", () => this.handleDisconnect(socket, access.session, generation));
|
|
1034
|
-
socket.on("close", () => this.handleDisconnect(socket, access.session, generation));
|
|
1053
|
+
socket.on("close", (code) => this.handleDisconnect(socket, access.session, generation, typeof code === "number" ? code : void 0));
|
|
1035
1054
|
}
|
|
1036
1055
|
handleMessage(socket, data, access, generation) {
|
|
1037
1056
|
if (generation !== this.generation || socket !== this.socket) return;
|
|
1038
1057
|
const envelope = parseServerEnvelope(data);
|
|
1039
1058
|
if (!envelope || envelope.deviceId !== access.session.device.id || envelope.protocolVersion !== DESKTOP_AGENT_PROTOCOL_VERSION || Math.abs(Date.now() - Date.parse(envelope.sentAt)) > MESSAGE_FRESHNESS_MS || this.replayIds.has(envelope.messageId)) {
|
|
1040
|
-
this.protocolFailure(socket, access.session, generation);
|
|
1059
|
+
this.protocolFailure(socket, access.session, generation, describeEnvelopeProblem(envelope, access.session.device.id, this.replayIds));
|
|
1041
1060
|
return;
|
|
1042
1061
|
}
|
|
1043
1062
|
this.rememberMessage(envelope.messageId);
|
|
@@ -1147,19 +1166,23 @@ var DesktopAgentSession = class {
|
|
|
1147
1166
|
void this.start(false);
|
|
1148
1167
|
}, delay);
|
|
1149
1168
|
}
|
|
1150
|
-
handleDisconnect(socket, stored, generation) {
|
|
1169
|
+
handleDisconnect(socket, stored, generation, code) {
|
|
1151
1170
|
if (generation !== this.generation || socket !== this.socket) return;
|
|
1152
1171
|
this.socket = null;
|
|
1153
1172
|
this.clearHeartbeat();
|
|
1154
1173
|
this.clearAccessRefresh();
|
|
1174
|
+
if (code === 4009) {
|
|
1175
|
+
this.update(snapshotForSession("RECONNECTING", stored, "CONNECTION_REPLACED"));
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1155
1178
|
this.scheduleReconnect(stored, generation, "NETWORK_ERROR");
|
|
1156
1179
|
}
|
|
1157
|
-
protocolFailure(socket, stored, generation) {
|
|
1180
|
+
protocolFailure(socket, stored, generation, reason = "malformed message") {
|
|
1158
1181
|
if (socket === this.socket) this.socket = null;
|
|
1159
1182
|
socket.close();
|
|
1160
1183
|
this.clearHeartbeat();
|
|
1161
1184
|
this.clearAccessRefresh();
|
|
1162
|
-
this.scheduleReconnect(stored, generation,
|
|
1185
|
+
this.scheduleReconnect(stored, generation, `PROTOCOL_VIOLATION: ${reason}`);
|
|
1163
1186
|
}
|
|
1164
1187
|
scheduleReconnect(stored, generation, errorCode) {
|
|
1165
1188
|
if (generation !== this.generation || this.reconnectTimer) return;
|
|
@@ -1248,6 +1271,15 @@ function parseServerEnvelope(data) {
|
|
|
1248
1271
|
return null;
|
|
1249
1272
|
}
|
|
1250
1273
|
}
|
|
1274
|
+
function describeEnvelopeProblem(envelope, deviceId, replayIds) {
|
|
1275
|
+
if (!envelope) return "unreadable message";
|
|
1276
|
+
if (envelope.deviceId !== deviceId) return `wrong device ${envelope.deviceId}`;
|
|
1277
|
+
if (envelope.protocolVersion !== DESKTOP_AGENT_PROTOCOL_VERSION) return `protocol version ${envelope.protocolVersion}`;
|
|
1278
|
+
const offsetMs = Date.now() - Date.parse(envelope.sentAt);
|
|
1279
|
+
if (Math.abs(offsetMs) > MESSAGE_FRESHNESS_MS) return `stale message (sentAt ${envelope.sentAt}, offset ${Math.round(offsetMs / 1e3)}s)`;
|
|
1280
|
+
if (replayIds.has(envelope.messageId)) return `replayed message ${envelope.messageId}`;
|
|
1281
|
+
return "unknown";
|
|
1282
|
+
}
|
|
1251
1283
|
function emptySnapshot(state) {
|
|
1252
1284
|
return {
|
|
1253
1285
|
state,
|
|
@@ -2493,17 +2525,42 @@ function createFileCrypto(keyPath) {
|
|
|
2493
2525
|
};
|
|
2494
2526
|
}
|
|
2495
2527
|
|
|
2528
|
+
// src/cli/systemd.ts
|
|
2529
|
+
var SERVICE_NAME = "diggylab";
|
|
2530
|
+
var SERVICE_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
|
|
2531
|
+
function buildSystemdUnit(options) {
|
|
2532
|
+
return `[Unit]
|
|
2533
|
+
Description=DiggyLab Desktop Agent
|
|
2534
|
+
After=network-online.target
|
|
2535
|
+
Wants=network-online.target
|
|
2536
|
+
|
|
2537
|
+
[Service]
|
|
2538
|
+
ExecStart=${options.execStart} run
|
|
2539
|
+
Environment=DIGGYLAB_AGENT_DIR=${options.configDirectory}
|
|
2540
|
+
Environment=DIGGYLAB_API_URL=${options.apiOrigin}
|
|
2541
|
+
Restart=on-failure
|
|
2542
|
+
RestartSec=10
|
|
2543
|
+
|
|
2544
|
+
[Install]
|
|
2545
|
+
WantedBy=multi-user.target
|
|
2546
|
+
`;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2496
2549
|
// src/cli/cli.ts
|
|
2497
2550
|
var LOGIN_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
2498
2551
|
var CONFIG_DIRECTORY = ".diggylab";
|
|
2499
2552
|
var USAGE = `DiggyLab Desktop Agent CLI
|
|
2500
2553
|
|
|
2501
2554
|
Usage:
|
|
2502
|
-
diggylab pair --code <code> [--name <device name>]
|
|
2555
|
+
diggylab pair [--code <code>] [--name <device name>] (asks for the code when you leave it out)
|
|
2503
2556
|
diggylab login (opens a browser window when the machine has a screen)
|
|
2504
2557
|
diggylab login cookies (no screen: paste facebook.com cookies when asked)
|
|
2505
2558
|
diggylab login --cookies <file> (or --cookies 'name=value; ...')
|
|
2559
|
+
diggylab logout
|
|
2560
|
+
diggylab rename [--name <new name>] (asks for the name when you leave it out)
|
|
2506
2561
|
diggylab run
|
|
2562
|
+
diggylab install-service (systemd: start "run" on boot)
|
|
2563
|
+
diggylab uninstall-service
|
|
2507
2564
|
diggylab status
|
|
2508
2565
|
diggylab install-browser
|
|
2509
2566
|
diggylab unpair
|
|
@@ -2552,6 +2609,9 @@ function createRuntime(options) {
|
|
|
2552
2609
|
const manager = new DeviceCredentialManager(sessions, http, createDeviceIdentity);
|
|
2553
2610
|
return { activity, browserType: createBrowserLauncher(chromium), directory, endpoint, facebook, manager, sessions, spool };
|
|
2554
2611
|
}
|
|
2612
|
+
function stamp() {
|
|
2613
|
+
return `[${(/* @__PURE__ */ new Date()).toTimeString().slice(0, 8)}]`;
|
|
2614
|
+
}
|
|
2555
2615
|
function waitForFacebookLogin(login, timeoutMs) {
|
|
2556
2616
|
return new Promise((resolve, reject) => {
|
|
2557
2617
|
const timer = setTimeout(() => {
|
|
@@ -2596,9 +2656,9 @@ function pairErrorMessage(error, origin) {
|
|
|
2596
2656
|
}
|
|
2597
2657
|
}
|
|
2598
2658
|
async function commandPair(runtime, options, io) {
|
|
2599
|
-
const code = options.code?.trim();
|
|
2659
|
+
const code = options.code?.trim() || await promptForPairingCode(io);
|
|
2600
2660
|
if (!code) {
|
|
2601
|
-
io.error("
|
|
2661
|
+
io.error("No pairing code entered. Create one in the web app: Settings \u2192 Desktop Agent \u2192 Create pairing code.");
|
|
2602
2662
|
return 2;
|
|
2603
2663
|
}
|
|
2604
2664
|
try {
|
|
@@ -2609,13 +2669,67 @@ async function commandPair(runtime, options, io) {
|
|
|
2609
2669
|
platform: resolvePlatform()
|
|
2610
2670
|
});
|
|
2611
2671
|
io.log(`Paired as "${access.session.device.name}".`);
|
|
2612
|
-
|
|
2672
|
+
await printReadiness(runtime, io);
|
|
2613
2673
|
return 0;
|
|
2614
2674
|
} catch (error) {
|
|
2615
2675
|
io.error(pairErrorMessage(error, runtime.endpoint.httpOrigin));
|
|
2616
2676
|
return 1;
|
|
2617
2677
|
}
|
|
2618
2678
|
}
|
|
2679
|
+
async function promptForPairingCode(io) {
|
|
2680
|
+
if (process.stdin.isTTY) {
|
|
2681
|
+
io.log("Create a pairing code in the web app: Settings \u2192 Desktop Agent \u2192 Create pairing code.");
|
|
2682
|
+
io.log("It works once and expires after 10 minutes.");
|
|
2683
|
+
io.log("Paste the code here, then press Enter.");
|
|
2684
|
+
}
|
|
2685
|
+
return (await readAnswer()).trim();
|
|
2686
|
+
}
|
|
2687
|
+
async function printReadiness(runtime, io) {
|
|
2688
|
+
const stored = await runtime.sessions.load();
|
|
2689
|
+
const facebook = await runtime.facebook.load();
|
|
2690
|
+
const hasUser = Boolean(facebook?.cookies?.some((cookie) => cookie.name === "c_user" && cookie.value));
|
|
2691
|
+
io.log(`Paired: ${stored ? `"${stored.device.name}" (${stored.device.id})` : "no"}`);
|
|
2692
|
+
io.log(`Facebook: ${hasUser ? "connected" : "missing"}`);
|
|
2693
|
+
if (!stored) {
|
|
2694
|
+
io.log("Next: create a pairing code in the web app, then run `diggylab pair`.");
|
|
2695
|
+
} else if (!hasUser) {
|
|
2696
|
+
io.log("Next: run `diggylab login` \u2014 on a server without a screen it asks you to paste cookies.");
|
|
2697
|
+
} else {
|
|
2698
|
+
io.log("Ready: start the worker with `diggylab run`.");
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
async function commandLogout(runtime, io) {
|
|
2702
|
+
await runtime.facebook.clear();
|
|
2703
|
+
io.log("Facebook session removed from this server.");
|
|
2704
|
+
await printReadiness(runtime, io);
|
|
2705
|
+
return 0;
|
|
2706
|
+
}
|
|
2707
|
+
async function commandRename(runtime, options, io) {
|
|
2708
|
+
const stored = await runtime.manager.load();
|
|
2709
|
+
if (!stored) {
|
|
2710
|
+
io.error("This server is not paired yet. Run `diggylab pair` first.");
|
|
2711
|
+
return 1;
|
|
2712
|
+
}
|
|
2713
|
+
const requested = options.name?.trim() || await promptForName(io);
|
|
2714
|
+
const name = sanitizeDeviceName(requested);
|
|
2715
|
+
if (!name) {
|
|
2716
|
+
io.error('A device name is required, for example `diggylab rename --name "VPS H\xE0 N\u1ED9i"`.');
|
|
2717
|
+
return 2;
|
|
2718
|
+
}
|
|
2719
|
+
try {
|
|
2720
|
+
const device = await runtime.manager.rename(name);
|
|
2721
|
+
io.log(`Renamed to "${device.name}".`);
|
|
2722
|
+
await printReadiness(runtime, io);
|
|
2723
|
+
return 0;
|
|
2724
|
+
} catch (error) {
|
|
2725
|
+
io.error(`Could not rename this device: ${error instanceof Error ? error.message : String(error)}`);
|
|
2726
|
+
return 1;
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
async function promptForName(io) {
|
|
2730
|
+
if (process.stdin.isTTY) io.log("Type the new device name, then press Enter.");
|
|
2731
|
+
return (await readAnswer()).trim();
|
|
2732
|
+
}
|
|
2619
2733
|
async function commandLogin(runtime, options, io) {
|
|
2620
2734
|
if (options.cookies !== void 0) return importFacebookCookies(runtime, options.cookies || "-", io);
|
|
2621
2735
|
if (process.platform === "linux" && !process.env.DISPLAY) {
|
|
@@ -2643,6 +2757,7 @@ async function commandLogin(runtime, options, io) {
|
|
|
2643
2757
|
io.log(process.env.DISPLAY ? `Opening the Facebook login page on display ${process.env.DISPLAY}.` : "Opening the Facebook login page in a browser window.");
|
|
2644
2758
|
await waitForFacebookLogin(login, LOGIN_TIMEOUT_MS + 6e4);
|
|
2645
2759
|
io.log("Facebook session saved.");
|
|
2760
|
+
await printReadiness(runtime, io);
|
|
2646
2761
|
return 0;
|
|
2647
2762
|
} catch (error) {
|
|
2648
2763
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -2704,6 +2819,7 @@ async function importFacebookCookies(runtime, source, io) {
|
|
|
2704
2819
|
return 1;
|
|
2705
2820
|
}
|
|
2706
2821
|
io.log("Facebook session saved.");
|
|
2822
|
+
await printReadiness(runtime, io);
|
|
2707
2823
|
return 0;
|
|
2708
2824
|
}
|
|
2709
2825
|
async function validateImportedSession(runtime, state) {
|
|
@@ -2729,9 +2845,12 @@ function readStdin() {
|
|
|
2729
2845
|
});
|
|
2730
2846
|
}
|
|
2731
2847
|
function readPastedCookies(io) {
|
|
2732
|
-
if (!process.stdin.isTTY) return readStdin();
|
|
2733
2848
|
io.log("Paste the facebook.com cookies here, then press Enter.");
|
|
2734
2849
|
io.log("In your browser: F12 \u2192 Network \u2192 click any facebook.com request \u2192 copy the whole `cookie:` value.");
|
|
2850
|
+
return readAnswer({ until: (value) => value.includes("c_user") });
|
|
2851
|
+
}
|
|
2852
|
+
function readAnswer(options = {}) {
|
|
2853
|
+
if (!process.stdin.isTTY) return readStdin();
|
|
2735
2854
|
return new Promise((resolve) => {
|
|
2736
2855
|
let value = "";
|
|
2737
2856
|
let idle;
|
|
@@ -2743,11 +2862,11 @@ function readPastedCookies(io) {
|
|
|
2743
2862
|
};
|
|
2744
2863
|
const handleData = (chunk) => {
|
|
2745
2864
|
value += chunk;
|
|
2746
|
-
if (
|
|
2747
|
-
if (value.includes("\n") && value.includes("c_user")) {
|
|
2865
|
+
if (value.includes("\n") && (!options.until || options.until(value))) {
|
|
2748
2866
|
finish();
|
|
2749
2867
|
return;
|
|
2750
2868
|
}
|
|
2869
|
+
if (idle) clearTimeout(idle);
|
|
2751
2870
|
idle = setTimeout(finish, 350);
|
|
2752
2871
|
};
|
|
2753
2872
|
process.stdin.setEncoding("utf8");
|
|
@@ -2806,8 +2925,9 @@ async function commandRun(runtime, io) {
|
|
|
2806
2925
|
headers: { authorization: `Bearer ${accessToken}` },
|
|
2807
2926
|
maxPayload: 16 * 1024
|
|
2808
2927
|
});
|
|
2928
|
+
socket.on("error", (error) => io.log(`${stamp()} socket error: ${error.message}`));
|
|
2809
2929
|
socket.on("close", (code, reason) => {
|
|
2810
|
-
io.log(
|
|
2930
|
+
io.log(`${stamp()} socket closed: ${code}${reason?.length ? ` (${reason.toString()})` : ""}`);
|
|
2811
2931
|
});
|
|
2812
2932
|
return socket;
|
|
2813
2933
|
},
|
|
@@ -2851,9 +2971,18 @@ async function commandRun(runtime, io) {
|
|
|
2851
2971
|
spool: runtime.spool,
|
|
2852
2972
|
wait: abortableDelay
|
|
2853
2973
|
});
|
|
2974
|
+
let onReplaced = () => void 0;
|
|
2975
|
+
const replaced = new Promise((resolve) => {
|
|
2976
|
+
onReplaced = resolve;
|
|
2977
|
+
});
|
|
2854
2978
|
session.subscribe((snapshot) => {
|
|
2855
|
-
io.log(
|
|
2979
|
+
io.log(`${stamp()} session: ${snapshot.state}${snapshot.errorCode ? ` (${snapshot.errorCode})` : ""}`);
|
|
2856
2980
|
recordActivity(`Session state changed to ${snapshot.state}.`, snapshot.state);
|
|
2981
|
+
if (snapshot.errorCode === "CONNECTION_REPLACED") {
|
|
2982
|
+
io.error("Another worker is already running with this device, so this one stopped.");
|
|
2983
|
+
io.error("Stop the other worker (for example: systemctl stop diggylab), then start this one again.");
|
|
2984
|
+
onReplaced();
|
|
2985
|
+
}
|
|
2857
2986
|
});
|
|
2858
2987
|
scanRunner.subscribe((snapshot) => {
|
|
2859
2988
|
io.log(`scanner: ${snapshot.state}${snapshot.jobId ? ` (job ${snapshot.jobId})` : ""}`);
|
|
@@ -2862,13 +2991,16 @@ async function commandRun(runtime, io) {
|
|
|
2862
2991
|
scanRunner.start();
|
|
2863
2992
|
await scanRunner.recover();
|
|
2864
2993
|
await session.start();
|
|
2865
|
-
io.log(`Worker running as "${stored.device.name}". Press Ctrl+C to stop.`);
|
|
2994
|
+
io.log(`Worker running as "${stored.device.name}" (API ${runtime.endpoint.httpOrigin}). Press Ctrl+C to stop.`);
|
|
2866
2995
|
const keepAlive = setInterval(() => void 0, 6e4);
|
|
2867
2996
|
try {
|
|
2868
|
-
await
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2997
|
+
await Promise.race([
|
|
2998
|
+
new Promise((resolve) => {
|
|
2999
|
+
process.once("SIGINT", resolve);
|
|
3000
|
+
process.once("SIGTERM", resolve);
|
|
3001
|
+
}),
|
|
3002
|
+
replaced
|
|
3003
|
+
]);
|
|
2872
3004
|
} finally {
|
|
2873
3005
|
clearInterval(keepAlive);
|
|
2874
3006
|
session.stop();
|
|
@@ -2894,6 +3026,66 @@ async function commandUnpair(runtime, io) {
|
|
|
2894
3026
|
io.log("Device unpaired; local credentials, Facebook session, and pending scans were removed.");
|
|
2895
3027
|
return 0;
|
|
2896
3028
|
}
|
|
3029
|
+
function runCommand(command, args) {
|
|
3030
|
+
return new Promise((resolve, reject) => {
|
|
3031
|
+
const child = spawn(command, args, { stdio: "inherit" });
|
|
3032
|
+
child.once("error", reject);
|
|
3033
|
+
child.once("exit", (value) => resolve(value ?? 1));
|
|
3034
|
+
});
|
|
3035
|
+
}
|
|
3036
|
+
function resolveServiceExecStart() {
|
|
3037
|
+
const candidates = [join3(dirname3(process.execPath), "diggylab"), "/usr/local/bin/diggylab", "/usr/bin/diggylab"];
|
|
3038
|
+
const link = candidates.find((candidate) => existsSync2(candidate));
|
|
3039
|
+
return link ?? `${process.execPath} ${fileURLToPath(import.meta.url)}`;
|
|
3040
|
+
}
|
|
3041
|
+
async function commandInstallService(runtime, io) {
|
|
3042
|
+
if (process.platform !== "linux") {
|
|
3043
|
+
io.error("Automatic service setup supports Linux (systemd). Use the Docker image or your platform's service manager.");
|
|
3044
|
+
return 1;
|
|
3045
|
+
}
|
|
3046
|
+
if (!existsSync2("/usr/bin/systemctl") && !existsSync2("/bin/systemctl")) {
|
|
3047
|
+
io.error("systemd is not available on this machine.");
|
|
3048
|
+
return 1;
|
|
3049
|
+
}
|
|
3050
|
+
const unit = buildSystemdUnit({
|
|
3051
|
+
apiOrigin: runtime.endpoint.httpOrigin,
|
|
3052
|
+
configDirectory: runtime.directory,
|
|
3053
|
+
execStart: resolveServiceExecStart()
|
|
3054
|
+
});
|
|
3055
|
+
try {
|
|
3056
|
+
writeFileSync2(SERVICE_PATH, unit, { mode: 420 });
|
|
3057
|
+
} catch {
|
|
3058
|
+
io.error(`Could not write ${SERVICE_PATH}. Run this command as root (or with sudo).`);
|
|
3059
|
+
return 1;
|
|
3060
|
+
}
|
|
3061
|
+
io.log(`Wrote ${SERVICE_PATH}.`);
|
|
3062
|
+
await runCommand("systemctl", ["daemon-reload"]).catch(() => 1);
|
|
3063
|
+
const code = await runCommand("systemctl", ["enable", "--now", SERVICE_NAME]);
|
|
3064
|
+
if (code !== 0) {
|
|
3065
|
+
io.error(`systemctl could not enable the service. Check it with: systemctl status ${SERVICE_NAME}`);
|
|
3066
|
+
return code;
|
|
3067
|
+
}
|
|
3068
|
+
io.log(`Service ${SERVICE_NAME} is enabled: it runs now and after every reboot.`);
|
|
3069
|
+
io.log(`Follow its log with: journalctl -u ${SERVICE_NAME} -f`);
|
|
3070
|
+
await printReadiness(runtime, io);
|
|
3071
|
+
return 0;
|
|
3072
|
+
}
|
|
3073
|
+
async function commandUninstallService(io) {
|
|
3074
|
+
if (process.platform !== "linux") {
|
|
3075
|
+
io.error("Automatic service setup supports Linux (systemd).");
|
|
3076
|
+
return 1;
|
|
3077
|
+
}
|
|
3078
|
+
await runCommand("systemctl", ["disable", "--now", SERVICE_NAME]).catch(() => 1);
|
|
3079
|
+
try {
|
|
3080
|
+
rmSync(SERVICE_PATH, { force: true });
|
|
3081
|
+
} catch {
|
|
3082
|
+
io.error(`Could not remove ${SERVICE_PATH}. Run this command as root (or with sudo).`);
|
|
3083
|
+
return 1;
|
|
3084
|
+
}
|
|
3085
|
+
await runCommand("systemctl", ["daemon-reload"]).catch(() => 1);
|
|
3086
|
+
io.log(`Service ${SERVICE_NAME} removed.`);
|
|
3087
|
+
return 0;
|
|
3088
|
+
}
|
|
2897
3089
|
async function main(argv, io = console) {
|
|
2898
3090
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
2899
3091
|
io.log(USAGE);
|
|
@@ -2910,7 +3102,7 @@ async function main(argv, io = console) {
|
|
|
2910
3102
|
io.log(USAGE);
|
|
2911
3103
|
return 0;
|
|
2912
3104
|
}
|
|
2913
|
-
if (!["install-browser", "login", "pair", "run", "status", "unpair"].includes(command)) {
|
|
3105
|
+
if (!["install-browser", "install-service", "login", "logout", "pair", "rename", "run", "status", "uninstall-service", "unpair"].includes(command)) {
|
|
2914
3106
|
io.error(`Unknown command "${command}".`);
|
|
2915
3107
|
io.error(USAGE);
|
|
2916
3108
|
return 2;
|
|
@@ -2922,6 +3114,14 @@ async function main(argv, io = console) {
|
|
|
2922
3114
|
return commandPair(runtime, parsed.options, io);
|
|
2923
3115
|
case "login":
|
|
2924
3116
|
return commandLogin(runtime, parsed.options, io);
|
|
3117
|
+
case "logout":
|
|
3118
|
+
return commandLogout(runtime, io);
|
|
3119
|
+
case "rename":
|
|
3120
|
+
return commandRename(runtime, parsed.options, io);
|
|
3121
|
+
case "install-service":
|
|
3122
|
+
return commandInstallService(runtime, io);
|
|
3123
|
+
case "uninstall-service":
|
|
3124
|
+
return commandUninstallService(io);
|
|
2925
3125
|
case "run":
|
|
2926
3126
|
return commandRun(runtime, io);
|
|
2927
3127
|
case "status":
|