diggylab 0.1.41 → 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 +63 -50
- package/cli.js +237 -399
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -7,7 +7,6 @@ headless Chromium — the Electron app is not involved.
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
9
9
|
- Node.js 20+
|
|
10
|
-
- Linux packages: `xvfb` and `x11vnc` — run `diggylab install-display` (Debian/Ubuntu) or install them yourself. The Docker image below bundles them.
|
|
11
10
|
- Chromium for Playwright: downloaded automatically during `npm install`; rerun `diggylab install-browser` if that download failed.
|
|
12
11
|
|
|
13
12
|
## Install
|
|
@@ -37,7 +36,7 @@ npm publish apps/desktop-agent/dist-npm --access public
|
|
|
37
36
|
```
|
|
38
37
|
|
|
39
38
|
`cli:pack` bundles the workspace code into a single `cli.js`, so the published package only depends
|
|
40
|
-
on `playwright
|
|
39
|
+
on `playwright` and `ws`. The packed manifest copies the version from
|
|
41
40
|
`apps/desktop-agent/package.json`; use the override below when the app release has not caught up yet.
|
|
42
41
|
|
|
43
42
|
### Publishing ahead of an app release
|
|
@@ -57,61 +56,44 @@ npm publish apps/desktop-agent/dist-npm --access public
|
|
|
57
56
|
|
|
58
57
|
| Command | What it does |
|
|
59
58
|
| --- | --- |
|
|
60
|
-
| `pair --code <code> [--name <device name>]
|
|
61
|
-
| `login` |
|
|
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
|
+
| `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. |
|
|
62
63
|
| `run` | Connects the WebSocket worker, receives assigned scans, scans headlessly, uploads results. |
|
|
63
64
|
| `status` | Shows the config directory, API origin, paired device, and Facebook session state. |
|
|
64
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. |
|
|
65
|
-
| `install-
|
|
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. |
|
|
66
68
|
| `unpair` | Removes device credentials, the Facebook session, and pending scans. |
|
|
67
69
|
|
|
68
|
-
Useful options: `--api <origin>` (default `https://api.diggylab.com`), `--
|
|
69
|
-
|
|
70
|
+
Useful options: `--api <origin>` (default `https://api.diggylab.com`), `--cookies <file>` to import
|
|
71
|
+
cookies directly.
|
|
70
72
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
A server has no screen, so the agent starts `Xvfb` (virtual screen) and `x11vnc` (shares it), then
|
|
74
|
-
carries that screen to the customer through the API connection it already uses for scans:
|
|
73
|
+
`pair` and `login` finish by printing what is still missing, so you do not need to run `status`
|
|
74
|
+
between steps:
|
|
75
75
|
|
|
76
76
|
```
|
|
77
|
-
|
|
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.
|
|
78
80
|
```
|
|
79
81
|
|
|
80
|
-
|
|
81
|
-
the web app (`https://diggylab.com/agent-login/<session>#<key>`), and everything is torn down when the
|
|
82
|
-
login finishes or times out (10 minutes).
|
|
83
|
-
|
|
84
|
-
### The screen is encrypted end to end
|
|
85
|
-
|
|
86
|
-
The part of the link after `#` is the decryption key, and browsers never send a fragment to a server —
|
|
87
|
-
so the relay passes bytes it cannot read. DiggyLab stores nothing: no frames, no keystrokes, no
|
|
88
|
-
session. The Facebook cookies stay on the customer's server, sealed with `~/.diggylab/key`.
|
|
89
|
-
|
|
90
|
-
The link itself is the only way in: it is single use, expires with the login window, and the session
|
|
91
|
-
is created per `login` run.
|
|
92
|
-
|
|
93
|
-
### When the screen does not appear
|
|
94
|
-
|
|
95
|
-
`login` checks the virtual screen before printing the link: it reads x11vnc's greeting, so a broken
|
|
96
|
-
screen stops the command with the reason instead of leaving the customer with a blank page.
|
|
82
|
+
## How `login` works
|
|
97
83
|
|
|
98
|
-
|
|
99
|
-
the screen fails or a process dies during the login, and the files stay there for later inspection.
|
|
84
|
+
Two paths, depending on the machine:
|
|
100
85
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
ss -ltnp | grep :5999 # x11vnc listens on loopback
|
|
106
|
-
timeout 2 bash -c 'exec 3<>/dev/tcp/127.0.0.1/5999 && head -c 12 <&3' # expect: RFB 003.008
|
|
107
|
-
```
|
|
86
|
+
| Machine | What `login` does |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| Desktop, or Linux with a display (`$DISPLAY` set) | Opens Chromium on that display with Facebook's real login page; you log in normally. |
|
|
89
|
+
| Server without a screen | Prints a short prompt and asks you to paste `facebook.com` cookies exported from your own browser. |
|
|
108
90
|
|
|
109
|
-
|
|
91
|
+
Both paths save the session encrypted with `~/.diggylab/key`, and `diggylab run` then uses it for scans.
|
|
110
92
|
|
|
111
93
|
### Logging in with cookies from your own browser
|
|
112
94
|
|
|
113
|
-
|
|
114
|
-
|
|
95
|
+
On a machine without a screen this is the only path, and you can also pick it on a desktop with
|
|
96
|
+
`diggylab login cookies`:
|
|
115
97
|
|
|
116
98
|
1. Get the `facebook.com` cookies, in whichever way is easiest:
|
|
117
99
|
- **DevTools → Network** (fastest): open facebook.com, click any request, copy the whole
|
|
@@ -119,12 +101,15 @@ cookies instead — no Xvfb, no link, no port:
|
|
|
119
101
|
- **DevTools → Application → Cookies → `https://www.facebook.com`**: select the table and copy the
|
|
120
102
|
rows.
|
|
121
103
|
- **Cookie-Editor** (Chrome, Edge, Firefox): *Export → JSON*.
|
|
122
|
-
2. On the server:
|
|
104
|
+
2. On the server, run the friendly form and paste when asked (Enter finishes the paste):
|
|
123
105
|
|
|
124
106
|
```bash
|
|
125
|
-
diggylab login
|
|
107
|
+
diggylab login cookies
|
|
126
108
|
```
|
|
127
109
|
|
|
110
|
+
The equivalents: `diggylab login --cookies 'name=value; ...'` for a quoted one-liner, or
|
|
111
|
+
`diggylab login --cookies cookies.json` for a saved file.
|
|
112
|
+
|
|
128
113
|
Only `name` and `value` are required; the agent fills in the rest (`.facebook.com`, `/`, session
|
|
129
114
|
expiry). Every `facebook.com` cookie is kept, and at least `c_user` plus `xs` must be present — those
|
|
130
115
|
two carry the session, and `datr` is worth keeping too.
|
|
@@ -139,6 +124,16 @@ instead of failing during a scan.
|
|
|
139
124
|
Note: Facebook sometimes asks for a security check when a session moves to a server's IP. If that
|
|
140
125
|
happens the command says so — approve the login from your phone and import again.
|
|
141
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
|
+
|
|
142
137
|
## Security notes
|
|
143
138
|
|
|
144
139
|
- `~/.diggylab/key` (mode `600`) encrypts device credentials, the Facebook session, and the
|
|
@@ -149,28 +144,46 @@ happens the command says so — approve the login from your phone and import aga
|
|
|
149
144
|
|
|
150
145
|
## Run as a service
|
|
151
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
|
+
|
|
152
162
|
```ini
|
|
153
163
|
[Unit]
|
|
154
|
-
Description=DiggyLab Desktop Agent
|
|
164
|
+
Description=DiggyLab Desktop Agent
|
|
155
165
|
After=network-online.target
|
|
166
|
+
Wants=network-online.target
|
|
156
167
|
|
|
157
168
|
[Service]
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
ExecStart=/usr/bin/node /opt/diggylab/dist-cli/cli/cli.js run
|
|
161
|
-
Restart=always
|
|
169
|
+
ExecStart=/usr/local/bin/diggylab run
|
|
170
|
+
Environment=DIGGYLAB_AGENT_DIR=/root/.diggylab
|
|
162
171
|
Environment=DIGGYLAB_API_URL=https://api.diggylab.com
|
|
172
|
+
Restart=always
|
|
173
|
+
RestartSec=10
|
|
163
174
|
|
|
164
175
|
[Install]
|
|
165
176
|
WantedBy=multi-user.target
|
|
166
177
|
```
|
|
167
178
|
|
|
179
|
+
</details>
|
|
180
|
+
|
|
168
181
|
## Docker
|
|
169
182
|
|
|
170
183
|
```bash
|
|
171
184
|
docker build -f apps/desktop-agent/Dockerfile.cli -t diggylab .
|
|
172
|
-
docker run -it --rm -
|
|
173
|
-
docker run -it --rm -
|
|
185
|
+
docker run -it --rm -v diggylab:/data diggylab pair --code ABC123
|
|
186
|
+
docker run -it --rm -v diggylab:/data diggylab login cookies
|
|
174
187
|
docker run -d --restart unless-stopped -v diggylab:/data diggylab run
|
|
175
188
|
```
|
|
176
189
|
|
package/cli.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli/cli.ts
|
|
4
4
|
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
5
|
-
import { spawn
|
|
6
|
-
import { existsSync as existsSync2,
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
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
|
-
import { dirname as dirname3, join as
|
|
8
|
+
import { dirname as dirname3, join as join3 } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
|
|
11
11
|
// ../../packages/facebook-scanner/dist/parser.js
|
|
@@ -513,7 +513,7 @@ function withTimeout(promise, timeoutMs) {
|
|
|
513
513
|
|
|
514
514
|
// src/cli/cli.ts
|
|
515
515
|
import { chromium } from "playwright";
|
|
516
|
-
import
|
|
516
|
+
import WebSocket from "ws";
|
|
517
517
|
|
|
518
518
|
// src/main/activity-log.ts
|
|
519
519
|
import { gzip } from "node:zlib";
|
|
@@ -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,
|
|
@@ -2315,9 +2347,7 @@ var FLAGS = {
|
|
|
2315
2347
|
"--code": { key: "code", type: "string" },
|
|
2316
2348
|
"--config": { key: "config", type: "string" },
|
|
2317
2349
|
"--cookies": { key: "cookies", type: "string" },
|
|
2318
|
-
"--
|
|
2319
|
-
"--name": { key: "name", type: "string" },
|
|
2320
|
-
"--web-url": { key: "webUrl", type: "string" }
|
|
2350
|
+
"--name": { key: "name", type: "string" }
|
|
2321
2351
|
};
|
|
2322
2352
|
function parseCliArgs(argv) {
|
|
2323
2353
|
const options = {};
|
|
@@ -2495,276 +2525,25 @@ function createFileCrypto(keyPath) {
|
|
|
2495
2525
|
};
|
|
2496
2526
|
}
|
|
2497
2527
|
|
|
2498
|
-
// src/cli/
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
var KEY_BYTES2 = 32;
|
|
2507
|
-
var TAG_BYTES2 = 16;
|
|
2508
|
-
function createRelayKey() {
|
|
2509
|
-
return randomBytes2(KEY_BYTES2).toString("base64url");
|
|
2510
|
-
}
|
|
2511
|
-
function deriveRelayKeys(key, sessionId) {
|
|
2512
|
-
const secret = Buffer.from(key, "base64url");
|
|
2513
|
-
const salt = Buffer.from(sessionId, "utf8");
|
|
2514
|
-
const derive = (info) => Buffer.from(hkdfSync("sha256", secret, salt, Buffer.from(info, "utf8"), KEY_BYTES2));
|
|
2515
|
-
return { fromAgent: derive("agent-to-browser"), toAgent: derive("browser-to-agent") };
|
|
2516
|
-
}
|
|
2517
|
-
function sealFrame(key, payload) {
|
|
2518
|
-
const iv = randomBytes2(IV_BYTES2);
|
|
2519
|
-
const cipher = createCipheriv2("aes-256-gcm", key, iv);
|
|
2520
|
-
const sealed = Buffer.concat([cipher.update(payload), cipher.final()]);
|
|
2521
|
-
return Buffer.concat([Buffer.from([FRAME_VERSION]), iv, sealed, cipher.getAuthTag()]);
|
|
2522
|
-
}
|
|
2523
|
-
function openFrame(key, frame) {
|
|
2524
|
-
if (frame.length < 1 + IV_BYTES2 + TAG_BYTES2 || frame[0] !== FRAME_VERSION) {
|
|
2525
|
-
throw new Error("Invalid login screen frame.");
|
|
2526
|
-
}
|
|
2527
|
-
const decipher = createDecipheriv2("aes-256-gcm", key, frame.subarray(1, 1 + IV_BYTES2));
|
|
2528
|
-
decipher.setAuthTag(frame.subarray(frame.length - TAG_BYTES2));
|
|
2529
|
-
return Buffer.concat([
|
|
2530
|
-
decipher.update(frame.subarray(1 + IV_BYTES2, frame.length - TAG_BYTES2)),
|
|
2531
|
-
decipher.final()
|
|
2532
|
-
]);
|
|
2533
|
-
}
|
|
2534
|
-
|
|
2535
|
-
// src/cli/login-screen-relay.ts
|
|
2536
|
-
var RELAY_PATH = "/desktop-agent/login-screen";
|
|
2537
|
-
var READY_TIMEOUT_MS = 1e4;
|
|
2538
|
-
var DEFAULT_WEB_ORIGIN = "https://diggylab.com";
|
|
2539
|
-
async function openLoginScreenRelay(options) {
|
|
2540
|
-
const socket = new WebSocket(`${toWebSocketOrigin(options.apiOrigin)}${RELAY_PATH}`, {
|
|
2541
|
-
headers: { authorization: `Bearer ${options.accessToken}` }
|
|
2542
|
-
});
|
|
2543
|
-
let closed = false;
|
|
2544
|
-
let keys = null;
|
|
2545
|
-
let vnc = null;
|
|
2546
|
-
let resolveScreen = () => void 0;
|
|
2547
|
-
let rejectFailure = () => void 0;
|
|
2548
|
-
const failure = new Promise((_resolve, reject) => {
|
|
2549
|
-
rejectFailure = reject;
|
|
2550
|
-
});
|
|
2551
|
-
const screenConnected = new Promise((resolve) => {
|
|
2552
|
-
resolveScreen = resolve;
|
|
2553
|
-
});
|
|
2554
|
-
failure.catch(() => void 0);
|
|
2555
|
-
const close = () => {
|
|
2556
|
-
closed = true;
|
|
2557
|
-
vnc?.destroy();
|
|
2558
|
-
vnc = null;
|
|
2559
|
-
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close();
|
|
2560
|
-
};
|
|
2561
|
-
const fail = (message) => {
|
|
2562
|
-
close();
|
|
2563
|
-
rejectFailure(new Error(message));
|
|
2564
|
-
};
|
|
2565
|
-
const openScreen = () => {
|
|
2566
|
-
if (closed || vnc) return;
|
|
2567
|
-
const connection = createConnection({ host: "127.0.0.1", port: options.rfbPort });
|
|
2568
|
-
vnc = connection;
|
|
2569
|
-
connection.on("data", (chunk) => {
|
|
2570
|
-
if (closed || !keys || socket.readyState !== WebSocket.OPEN) return;
|
|
2571
|
-
socket.send(sealFrame(keys.fromAgent, chunk), { binary: true });
|
|
2572
|
-
});
|
|
2573
|
-
connection.once("error", () => {
|
|
2574
|
-
if (vnc === connection) vnc = null;
|
|
2575
|
-
fail("Could not reach the virtual screen.");
|
|
2576
|
-
});
|
|
2577
|
-
connection.once("close", () => {
|
|
2578
|
-
if (vnc === connection) vnc = null;
|
|
2579
|
-
});
|
|
2580
|
-
};
|
|
2581
|
-
socket.on("message", (data, isBinary) => {
|
|
2582
|
-
if (isBinary) {
|
|
2583
|
-
if (!keys || !vnc || vnc.destroyed) return;
|
|
2584
|
-
try {
|
|
2585
|
-
vnc.write(openFrame(keys.toAgent, Buffer.from(data)));
|
|
2586
|
-
} catch {
|
|
2587
|
-
fail("The login screen received a corrupted frame.");
|
|
2588
|
-
}
|
|
2589
|
-
return;
|
|
2590
|
-
}
|
|
2591
|
-
const message = parseRelayMessage(data.toString());
|
|
2592
|
-
if (message?.type === "screen-connected") {
|
|
2593
|
-
openScreen();
|
|
2594
|
-
resolveScreen();
|
|
2595
|
-
} else if (message?.type === "screen-disconnected") {
|
|
2596
|
-
vnc?.destroy();
|
|
2597
|
-
vnc = null;
|
|
2598
|
-
}
|
|
2599
|
-
});
|
|
2600
|
-
socket.once("error", () => fail("Could not reach the DiggyLab relay."));
|
|
2601
|
-
socket.once("close", () => {
|
|
2602
|
-
if (!closed) fail("The DiggyLab relay closed the login screen.");
|
|
2603
|
-
});
|
|
2604
|
-
const ready = await waitForReady(socket);
|
|
2605
|
-
keys = deriveRelayKeys(options.key, ready.id);
|
|
2606
|
-
const webOrigin = (options.webUrl ?? ready.webUrl ?? DEFAULT_WEB_ORIGIN).replace(/\/+$/, "");
|
|
2607
|
-
return {
|
|
2608
|
-
close,
|
|
2609
|
-
failure,
|
|
2610
|
-
screenConnected,
|
|
2611
|
-
url: `${webOrigin}/agent-login/${encodeURIComponent(ready.id)}#${options.key}`
|
|
2612
|
-
};
|
|
2613
|
-
}
|
|
2614
|
-
function parseRelayMessage(value) {
|
|
2615
|
-
try {
|
|
2616
|
-
const parsed = JSON.parse(value);
|
|
2617
|
-
if (parsed && typeof parsed === "object" && typeof parsed.type === "string") {
|
|
2618
|
-
return parsed;
|
|
2619
|
-
}
|
|
2620
|
-
} catch {
|
|
2621
|
-
}
|
|
2622
|
-
return null;
|
|
2623
|
-
}
|
|
2624
|
-
function waitForReady(socket) {
|
|
2625
|
-
return new Promise((resolve, reject) => {
|
|
2626
|
-
const timer = setTimeout(() => reject(new Error("The DiggyLab relay did not answer in time.")), READY_TIMEOUT_MS);
|
|
2627
|
-
timer.unref();
|
|
2628
|
-
socket.once("message", (data, isBinary) => {
|
|
2629
|
-
clearTimeout(timer);
|
|
2630
|
-
if (isBinary) {
|
|
2631
|
-
reject(new Error("The DiggyLab relay answered with an unexpected frame."));
|
|
2632
|
-
return;
|
|
2633
|
-
}
|
|
2634
|
-
const message = parseRelayMessage(data.toString());
|
|
2635
|
-
if (message?.type !== "ready") {
|
|
2636
|
-
reject(new Error("The DiggyLab relay did not accept the login screen."));
|
|
2637
|
-
return;
|
|
2638
|
-
}
|
|
2639
|
-
resolve(message);
|
|
2640
|
-
});
|
|
2641
|
-
socket.once("error", () => {
|
|
2642
|
-
clearTimeout(timer);
|
|
2643
|
-
reject(new Error("Could not reach the DiggyLab relay."));
|
|
2644
|
-
});
|
|
2645
|
-
socket.once("close", () => {
|
|
2646
|
-
clearTimeout(timer);
|
|
2647
|
-
reject(new Error("The DiggyLab relay refused the login screen."));
|
|
2648
|
-
});
|
|
2649
|
-
});
|
|
2650
|
-
}
|
|
2651
|
-
function toWebSocketOrigin(origin) {
|
|
2652
|
-
return origin.replace(/^http/i, "ws").replace(/\/+$/, "");
|
|
2653
|
-
}
|
|
2654
|
-
|
|
2655
|
-
// src/cli/qr.ts
|
|
2656
|
-
import qrcode from "qrcode-terminal";
|
|
2657
|
-
function terminalQr(value) {
|
|
2658
|
-
let output = "";
|
|
2659
|
-
qrcode.generate(value, { small: true }, (rendered) => {
|
|
2660
|
-
output = rendered;
|
|
2661
|
-
});
|
|
2662
|
-
return output;
|
|
2663
|
-
}
|
|
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
|
|
2664
2536
|
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
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
|
|
2670
2543
|
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
var GREETING_BYTES = 12;
|
|
2675
|
-
var GREETING_PATTERN = /^RFB \d{3}\.\d{3}\n$/;
|
|
2676
|
-
function probeRfb(host, port, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
2677
|
-
return new Promise((resolve) => {
|
|
2678
|
-
const socket = createConnection2({ host, port });
|
|
2679
|
-
const finish = (result) => {
|
|
2680
|
-
clearTimeout(timer);
|
|
2681
|
-
socket.destroy();
|
|
2682
|
-
resolve(result);
|
|
2683
|
-
};
|
|
2684
|
-
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
2685
|
-
let received = "";
|
|
2686
|
-
socket.on("data", (chunk) => {
|
|
2687
|
-
received += chunk.toString("latin1");
|
|
2688
|
-
if (received.length >= GREETING_BYTES) finish(GREETING_PATTERN.test(received.slice(0, GREETING_BYTES)));
|
|
2689
|
-
});
|
|
2690
|
-
socket.once("error", () => finish(false));
|
|
2691
|
-
socket.once("close", () => finish(false));
|
|
2692
|
-
});
|
|
2693
|
-
}
|
|
2694
|
-
|
|
2695
|
-
// src/cli/virtual-display.ts
|
|
2696
|
-
var START_TIMEOUT_MS = 15e3;
|
|
2697
|
-
function waitForPort(host, port, timeoutMs) {
|
|
2698
|
-
const deadline = Date.now() + timeoutMs;
|
|
2699
|
-
return new Promise((resolve, reject) => {
|
|
2700
|
-
const attempt = () => {
|
|
2701
|
-
const socket = createConnection3({ host, port });
|
|
2702
|
-
socket.once("connect", () => {
|
|
2703
|
-
socket.destroy();
|
|
2704
|
-
resolve();
|
|
2705
|
-
});
|
|
2706
|
-
socket.once("error", () => {
|
|
2707
|
-
socket.destroy();
|
|
2708
|
-
if (Date.now() >= deadline) {
|
|
2709
|
-
reject(new Error(`Timed out waiting for ${host}:${port}.`));
|
|
2710
|
-
return;
|
|
2711
|
-
}
|
|
2712
|
-
setTimeout(attempt, 250).unref();
|
|
2713
|
-
});
|
|
2714
|
-
};
|
|
2715
|
-
attempt();
|
|
2716
|
-
});
|
|
2717
|
-
}
|
|
2718
|
-
function start(name, command, args, env, deps, logDirectory) {
|
|
2719
|
-
const child = deps.spawnProcess(command, args, env);
|
|
2720
|
-
const log = createWriteStream(join3(logDirectory, `${name}.log`), { mode: 384 });
|
|
2721
|
-
const failure = new Promise((_resolve, reject) => {
|
|
2722
|
-
child.on("error", (error) => reject(error instanceof Error ? error : new Error(String(error))));
|
|
2723
|
-
child.on("exit", (code) => reject(new Error(`${command} exited with code ${String(code)}.`)));
|
|
2724
|
-
});
|
|
2725
|
-
log.on("error", () => void 0);
|
|
2726
|
-
log.write(`# ${command} ${args.join(" ")}
|
|
2727
|
-
`);
|
|
2728
|
-
child.stdout?.pipe?.(log, { end: false });
|
|
2729
|
-
child.stderr?.pipe?.(log, { end: false });
|
|
2730
|
-
return { child, failure };
|
|
2731
|
-
}
|
|
2732
|
-
async function startVirtualDisplay(options, overrides = {}) {
|
|
2733
|
-
const deps = {
|
|
2734
|
-
probeRfb,
|
|
2735
|
-
spawnProcess: spawn,
|
|
2736
|
-
waitForPort,
|
|
2737
|
-
...overrides
|
|
2738
|
-
};
|
|
2739
|
-
const displayNumber = options.displayNumber ?? 99;
|
|
2740
|
-
const display = `:${displayNumber}`;
|
|
2741
|
-
const height = options.height ?? 900;
|
|
2742
|
-
const rfbPort = 5900 + displayNumber;
|
|
2743
|
-
mkdirSync2(options.logDirectory, { mode: 448, recursive: true });
|
|
2744
|
-
const xvfb = start("xvfb", "Xvfb", [display, "-screen", "0", `${options.width ?? 1280}x${height}x24`, "-nolisten", "tcp"], process.env, deps, options.logDirectory);
|
|
2745
|
-
const vnc = start("x11vnc", "x11vnc", ["-display", display, "-localhost", "-rfbport", String(rfbPort), "-forever", "-shared", "-nopw", "-quiet"], process.env, deps, options.logDirectory);
|
|
2746
|
-
const failure = Promise.race([vnc.failure, xvfb.failure]);
|
|
2747
|
-
failure.catch(() => void 0);
|
|
2748
|
-
const stop = async () => {
|
|
2749
|
-
for (const { child } of [vnc, xvfb]) {
|
|
2750
|
-
child.kill("SIGTERM");
|
|
2751
|
-
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2752
|
-
child.kill("SIGKILL");
|
|
2753
|
-
}
|
|
2754
|
-
};
|
|
2755
|
-
try {
|
|
2756
|
-
await Promise.race([
|
|
2757
|
-
deps.waitForPort("127.0.0.1", rfbPort, options.timeoutMs ?? START_TIMEOUT_MS),
|
|
2758
|
-
failure
|
|
2759
|
-
]);
|
|
2760
|
-
if (!await deps.probeRfb("127.0.0.1", rfbPort)) {
|
|
2761
|
-
throw new Error(`The virtual screen did not answer on 127.0.0.1:${rfbPort}.`);
|
|
2762
|
-
}
|
|
2763
|
-
} catch (error) {
|
|
2764
|
-
await stop();
|
|
2765
|
-
throw error;
|
|
2766
|
-
}
|
|
2767
|
-
return { display, failure, logDirectory: options.logDirectory, rfbPort, stop };
|
|
2544
|
+
[Install]
|
|
2545
|
+
WantedBy=multi-user.target
|
|
2546
|
+
`;
|
|
2768
2547
|
}
|
|
2769
2548
|
|
|
2770
2549
|
// src/cli/cli.ts
|
|
@@ -2773,25 +2552,27 @@ var CONFIG_DIRECTORY = ".diggylab";
|
|
|
2773
2552
|
var USAGE = `DiggyLab Desktop Agent CLI
|
|
2774
2553
|
|
|
2775
2554
|
Usage:
|
|
2776
|
-
diggylab pair --code <code> [--name <device name>]
|
|
2777
|
-
diggylab login
|
|
2778
|
-
diggylab login cookies (
|
|
2779
|
-
diggylab login --cookies <file>
|
|
2555
|
+
diggylab pair [--code <code>] [--name <device name>] (asks for the code when you leave it out)
|
|
2556
|
+
diggylab login (opens a browser window when the machine has a screen)
|
|
2557
|
+
diggylab login cookies (no screen: paste facebook.com cookies when asked)
|
|
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)
|
|
2780
2561
|
diggylab run
|
|
2562
|
+
diggylab install-service (systemd: start "run" on boot)
|
|
2563
|
+
diggylab uninstall-service
|
|
2781
2564
|
diggylab status
|
|
2782
2565
|
diggylab install-browser
|
|
2783
|
-
diggylab install-display
|
|
2784
2566
|
diggylab unpair
|
|
2785
2567
|
|
|
2786
2568
|
Global options:
|
|
2787
2569
|
--config <directory> Agent state directory (default ~/.diggylab)
|
|
2788
2570
|
--api <origin> API origin (default $DIGGYLAB_API_URL or https://api.diggylab.com)`;
|
|
2789
2571
|
var MISSING_BROWSER_HINT = "Chromium for Playwright is not installed yet. Run `diggylab install-browser` once.";
|
|
2790
|
-
var MISSING_DISPLAY_HINT = "The login screen needs Xvfb and x11vnc. Run `diggylab install-display` (Debian/Ubuntu) or use the Docker image.";
|
|
2791
2572
|
function agentVersion() {
|
|
2792
2573
|
let directory = fileURLToPath(new URL(".", import.meta.url));
|
|
2793
2574
|
for (let depth = 0; depth < 4; depth += 1) {
|
|
2794
|
-
const manifest =
|
|
2575
|
+
const manifest = join3(directory, "package.json");
|
|
2795
2576
|
if (existsSync2(manifest)) {
|
|
2796
2577
|
try {
|
|
2797
2578
|
const version = JSON.parse(readFileSync2(manifest, "utf8")).version;
|
|
@@ -2808,7 +2589,7 @@ function resolvePlatform() {
|
|
|
2808
2589
|
return process.platform === "linux" ? "LINUX" : "WINDOWS";
|
|
2809
2590
|
}
|
|
2810
2591
|
function resolveConfigDirectory(options) {
|
|
2811
|
-
return options.config?.trim() || process.env.DIGGYLAB_AGENT_DIR?.trim() ||
|
|
2592
|
+
return options.config?.trim() || process.env.DIGGYLAB_AGENT_DIR?.trim() || join3(homedir(), CONFIG_DIRECTORY);
|
|
2812
2593
|
}
|
|
2813
2594
|
function resolveEndpoint(options) {
|
|
2814
2595
|
const configured = (options.api ?? process.env.DIGGYLAB_API_URL)?.trim() || "https://api.diggylab.com";
|
|
@@ -2818,28 +2599,18 @@ function resolveEndpoint(options) {
|
|
|
2818
2599
|
function createRuntime(options) {
|
|
2819
2600
|
const directory = resolveConfigDirectory(options);
|
|
2820
2601
|
const endpoint = resolveEndpoint(options);
|
|
2821
|
-
const crypto2 = createFileCrypto(
|
|
2822
|
-
const credentials = new CredentialStore(
|
|
2602
|
+
const crypto2 = createFileCrypto(join3(directory, "key"));
|
|
2603
|
+
const credentials = new CredentialStore(join3(directory, "credentials.json"), crypto2);
|
|
2823
2604
|
const sessions = new DeviceSessionStore(credentials);
|
|
2824
2605
|
const facebook = new FacebookSessionStore(credentials);
|
|
2825
|
-
const spool = new EncryptedScanSpool(
|
|
2826
|
-
const activity = new ActivityLog({ directory:
|
|
2606
|
+
const spool = new EncryptedScanSpool(join3(directory, "scan-spool"), crypto2);
|
|
2607
|
+
const activity = new ActivityLog({ directory: join3(directory, "activity") });
|
|
2827
2608
|
const http = new DesktopAgentHttpClient(endpoint.httpOrigin);
|
|
2828
2609
|
const manager = new DeviceCredentialManager(sessions, http, createDeviceIdentity);
|
|
2829
2610
|
return { activity, browserType: createBrowserLauncher(chromium), directory, endpoint, facebook, manager, sessions, spool };
|
|
2830
2611
|
}
|
|
2831
|
-
function
|
|
2832
|
-
|
|
2833
|
-
try {
|
|
2834
|
-
for (const name of readdirSync(directory).sort()) {
|
|
2835
|
-
if (!name.endsWith(".log")) continue;
|
|
2836
|
-
const tail = readFileSync2(join4(directory, name), "utf8").split("\n").filter((line) => line.trim() !== "").slice(-lines);
|
|
2837
|
-
if (tail.length === 0) continue;
|
|
2838
|
-
io.error(`--- ${name} ---`);
|
|
2839
|
-
for (const line of tail) io.error(line);
|
|
2840
|
-
}
|
|
2841
|
-
} catch {
|
|
2842
|
-
}
|
|
2612
|
+
function stamp() {
|
|
2613
|
+
return `[${(/* @__PURE__ */ new Date()).toTimeString().slice(0, 8)}]`;
|
|
2843
2614
|
}
|
|
2844
2615
|
function waitForFacebookLogin(login, timeoutMs) {
|
|
2845
2616
|
return new Promise((resolve, reject) => {
|
|
@@ -2885,9 +2656,9 @@ function pairErrorMessage(error, origin) {
|
|
|
2885
2656
|
}
|
|
2886
2657
|
}
|
|
2887
2658
|
async function commandPair(runtime, options, io) {
|
|
2888
|
-
const code = options.code?.trim();
|
|
2659
|
+
const code = options.code?.trim() || await promptForPairingCode(io);
|
|
2889
2660
|
if (!code) {
|
|
2890
|
-
io.error("
|
|
2661
|
+
io.error("No pairing code entered. Create one in the web app: Settings \u2192 Desktop Agent \u2192 Create pairing code.");
|
|
2891
2662
|
return 2;
|
|
2892
2663
|
}
|
|
2893
2664
|
try {
|
|
@@ -2898,15 +2669,73 @@ async function commandPair(runtime, options, io) {
|
|
|
2898
2669
|
platform: resolvePlatform()
|
|
2899
2670
|
});
|
|
2900
2671
|
io.log(`Paired as "${access.session.device.name}".`);
|
|
2901
|
-
|
|
2672
|
+
await printReadiness(runtime, io);
|
|
2902
2673
|
return 0;
|
|
2903
2674
|
} catch (error) {
|
|
2904
2675
|
io.error(pairErrorMessage(error, runtime.endpoint.httpOrigin));
|
|
2905
2676
|
return 1;
|
|
2906
2677
|
}
|
|
2907
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
|
+
}
|
|
2908
2733
|
async function commandLogin(runtime, options, io) {
|
|
2909
2734
|
if (options.cookies !== void 0) return importFacebookCookies(runtime, options.cookies || "-", io);
|
|
2735
|
+
if (process.platform === "linux" && !process.env.DISPLAY) {
|
|
2736
|
+
io.log("This server has no screen, so the login uses cookies from your browser.");
|
|
2737
|
+
return importFacebookCookies(runtime, "-", io);
|
|
2738
|
+
}
|
|
2910
2739
|
const login = new FacebookLoginService({
|
|
2911
2740
|
browserType: runtime.browserType,
|
|
2912
2741
|
clearTimer: (timer) => clearTimeout(timer),
|
|
@@ -2924,71 +2753,21 @@ async function commandLogin(runtime, options, io) {
|
|
|
2924
2753
|
io.log("Facebook is already connected.");
|
|
2925
2754
|
return 0;
|
|
2926
2755
|
}
|
|
2927
|
-
let display = null;
|
|
2928
|
-
let relay = null;
|
|
2929
2756
|
try {
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
io.log(process.env.DISPLAY ? `Opening the Facebook login page on display ${process.env.DISPLAY}.` : "Opening the Facebook login page in a browser window.");
|
|
2933
|
-
} else {
|
|
2934
|
-
const stored = await runtime.manager.load();
|
|
2935
|
-
if (!stored) {
|
|
2936
|
-
io.error("This server is not paired yet. Run `diggylab pair --code <code>` first.");
|
|
2937
|
-
return 1;
|
|
2938
|
-
}
|
|
2939
|
-
const access = await runtime.manager.refresh();
|
|
2940
|
-
const webUrl = options.webUrl ?? process.env.DIGGYLAB_WEB_URL?.trim();
|
|
2941
|
-
display = await startVirtualDisplay({
|
|
2942
|
-
displayNumber: options.display,
|
|
2943
|
-
logDirectory: join4(runtime.directory, "logs")
|
|
2944
|
-
});
|
|
2945
|
-
relay = await openLoginScreenRelay({
|
|
2946
|
-
accessToken: access.accessToken,
|
|
2947
|
-
apiOrigin: runtime.endpoint.httpOrigin,
|
|
2948
|
-
key: createRelayKey(),
|
|
2949
|
-
rfbPort: display.rfbPort,
|
|
2950
|
-
webUrl: webUrl || void 0
|
|
2951
|
-
});
|
|
2952
|
-
process.env.DISPLAY = display.display;
|
|
2953
|
-
io.log(terminalQr(relay.url));
|
|
2954
|
-
io.log(`Open this link on your phone, or scan the QR above:
|
|
2955
|
-
${relay.url}`);
|
|
2956
|
-
io.log("Log into Facebook on that screen; the agent saves the session automatically.");
|
|
2957
|
-
void relay.screenConnected.then(() => io.log("Login screen opened in the browser."));
|
|
2958
|
-
}
|
|
2959
|
-
const waiting = waitForFacebookLogin(login, LOGIN_TIMEOUT_MS + 6e4);
|
|
2960
|
-
const failures = [display?.failure, relay?.failure].filter((value) => Boolean(value));
|
|
2961
|
-
await (failures.length > 0 ? Promise.race([waiting, ...failures]) : waiting);
|
|
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.");
|
|
2758
|
+
await waitForFacebookLogin(login, LOGIN_TIMEOUT_MS + 6e4);
|
|
2962
2759
|
io.log("Facebook session saved.");
|
|
2760
|
+
await printReadiness(runtime, io);
|
|
2963
2761
|
return 0;
|
|
2964
2762
|
} catch (error) {
|
|
2965
2763
|
const message = error instanceof Error ? error.message : String(error);
|
|
2966
|
-
if (/spawn (?:Xvfb|x11vnc) ENOENT/.test(message)) {
|
|
2967
|
-
io.error(MISSING_DISPLAY_HINT);
|
|
2968
|
-
return 1;
|
|
2969
|
-
}
|
|
2970
|
-
if (/CREDENTIAL_REVOKED|not paired/i.test(message)) {
|
|
2971
|
-
io.error("This server is not paired (or was unpaired). Run `diggylab pair --code <code>` again.");
|
|
2972
|
-
return 1;
|
|
2973
|
-
}
|
|
2974
|
-
if (/NETWORK_ERROR|SERVER_UNAVAILABLE|fetch failed/i.test(message)) {
|
|
2975
|
-
io.error(`Could not reach the API: ${message}`);
|
|
2976
|
-
return 1;
|
|
2977
|
-
}
|
|
2978
|
-
if (/virtual screen|login screen|DiggyLab relay|Xvfb exited|x11vnc exited/i.test(message)) {
|
|
2979
|
-
io.error(`The login screen failed: ${message}`);
|
|
2980
|
-
printDisplayLogs(join4(runtime.directory, "logs"), io);
|
|
2981
|
-
return 1;
|
|
2982
|
-
}
|
|
2983
2764
|
if (/Executable doesn't exist|playwright install/i.test(message)) {
|
|
2984
2765
|
io.error(MISSING_BROWSER_HINT);
|
|
2985
2766
|
return 1;
|
|
2986
2767
|
}
|
|
2987
2768
|
throw error;
|
|
2988
2769
|
} finally {
|
|
2989
|
-
relay?.close();
|
|
2990
2770
|
await login.close();
|
|
2991
|
-
await display?.stop();
|
|
2992
2771
|
}
|
|
2993
2772
|
}
|
|
2994
2773
|
async function importFacebookCookies(runtime, source, io) {
|
|
@@ -3040,6 +2819,7 @@ async function importFacebookCookies(runtime, source, io) {
|
|
|
3040
2819
|
return 1;
|
|
3041
2820
|
}
|
|
3042
2821
|
io.log("Facebook session saved.");
|
|
2822
|
+
await printReadiness(runtime, io);
|
|
3043
2823
|
return 0;
|
|
3044
2824
|
}
|
|
3045
2825
|
async function validateImportedSession(runtime, state) {
|
|
@@ -3065,9 +2845,12 @@ function readStdin() {
|
|
|
3065
2845
|
});
|
|
3066
2846
|
}
|
|
3067
2847
|
function readPastedCookies(io) {
|
|
3068
|
-
if (!process.stdin.isTTY) return readStdin();
|
|
3069
2848
|
io.log("Paste the facebook.com cookies here, then press Enter.");
|
|
3070
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();
|
|
3071
2854
|
return new Promise((resolve) => {
|
|
3072
2855
|
let value = "";
|
|
3073
2856
|
let idle;
|
|
@@ -3079,11 +2862,11 @@ function readPastedCookies(io) {
|
|
|
3079
2862
|
};
|
|
3080
2863
|
const handleData = (chunk) => {
|
|
3081
2864
|
value += chunk;
|
|
3082
|
-
if (
|
|
3083
|
-
if (value.includes("\n") && value.includes("c_user")) {
|
|
2865
|
+
if (value.includes("\n") && (!options.until || options.until(value))) {
|
|
3084
2866
|
finish();
|
|
3085
2867
|
return;
|
|
3086
2868
|
}
|
|
2869
|
+
if (idle) clearTimeout(idle);
|
|
3087
2870
|
idle = setTimeout(finish, 350);
|
|
3088
2871
|
};
|
|
3089
2872
|
process.stdin.setEncoding("utf8");
|
|
@@ -3107,7 +2890,7 @@ async function commandInstallBrowser(io) {
|
|
|
3107
2890
|
}
|
|
3108
2891
|
io.log("Downloading Chromium for Playwright...");
|
|
3109
2892
|
const run = (args) => new Promise((resolve, reject) => {
|
|
3110
|
-
const child =
|
|
2893
|
+
const child = spawn(process.execPath, [cli, ...args], { stdio: "inherit" });
|
|
3111
2894
|
child.once("error", reject);
|
|
3112
2895
|
child.once("exit", (value) => resolve(value ?? 1));
|
|
3113
2896
|
});
|
|
@@ -3124,31 +2907,6 @@ async function commandInstallBrowser(io) {
|
|
|
3124
2907
|
io.log("Chromium installed.");
|
|
3125
2908
|
return 0;
|
|
3126
2909
|
}
|
|
3127
|
-
async function commandInstallDisplay(io) {
|
|
3128
|
-
if (process.platform !== "linux") {
|
|
3129
|
-
io.log("The shared login screen is only needed on a Linux server; desktops open a browser window.");
|
|
3130
|
-
return 0;
|
|
3131
|
-
}
|
|
3132
|
-
if (!existsSync2("/usr/bin/apt-get")) {
|
|
3133
|
-
io.error("Automatic installation supports Debian/Ubuntu. Install xvfb and x11vnc with your package manager.");
|
|
3134
|
-
return 1;
|
|
3135
|
-
}
|
|
3136
|
-
const sudo = typeof process.getuid === "function" && process.getuid() !== 0 ? ["sudo"] : [];
|
|
3137
|
-
const run = (args) => new Promise((resolve, reject) => {
|
|
3138
|
-
const child = spawn2(args[0], args.slice(1), { stdio: "inherit" });
|
|
3139
|
-
child.once("error", reject);
|
|
3140
|
-
child.once("exit", (value) => resolve(value ?? 1));
|
|
3141
|
-
});
|
|
3142
|
-
io.log("Installing xvfb and x11vnc...");
|
|
3143
|
-
await run([...sudo, "apt-get", "update"]).catch(() => 1);
|
|
3144
|
-
const code = await run([...sudo, "apt-get", "install", "-y", "xvfb", "x11vnc"]);
|
|
3145
|
-
if (code !== 0) {
|
|
3146
|
-
io.error("Installation failed. Run the same command with apt manually.");
|
|
3147
|
-
return code;
|
|
3148
|
-
}
|
|
3149
|
-
io.log("Login screen packages installed.");
|
|
3150
|
-
return 0;
|
|
3151
|
-
}
|
|
3152
2910
|
async function commandRun(runtime, io) {
|
|
3153
2911
|
const stored = await runtime.manager.load();
|
|
3154
2912
|
if (!stored) {
|
|
@@ -3163,12 +2921,13 @@ async function commandRun(runtime, io) {
|
|
|
3163
2921
|
agentVersion: agentVersion(),
|
|
3164
2922
|
clearTimer: (timer) => clearTimeout(timer),
|
|
3165
2923
|
createSocket: (url, accessToken) => {
|
|
3166
|
-
const socket = new
|
|
2924
|
+
const socket = new WebSocket(url, {
|
|
3167
2925
|
headers: { authorization: `Bearer ${accessToken}` },
|
|
3168
2926
|
maxPayload: 16 * 1024
|
|
3169
2927
|
});
|
|
2928
|
+
socket.on("error", (error) => io.log(`${stamp()} socket error: ${error.message}`));
|
|
3170
2929
|
socket.on("close", (code, reason) => {
|
|
3171
|
-
io.log(
|
|
2930
|
+
io.log(`${stamp()} socket closed: ${code}${reason?.length ? ` (${reason.toString()})` : ""}`);
|
|
3172
2931
|
});
|
|
3173
2932
|
return socket;
|
|
3174
2933
|
},
|
|
@@ -3212,9 +2971,18 @@ async function commandRun(runtime, io) {
|
|
|
3212
2971
|
spool: runtime.spool,
|
|
3213
2972
|
wait: abortableDelay
|
|
3214
2973
|
});
|
|
2974
|
+
let onReplaced = () => void 0;
|
|
2975
|
+
const replaced = new Promise((resolve) => {
|
|
2976
|
+
onReplaced = resolve;
|
|
2977
|
+
});
|
|
3215
2978
|
session.subscribe((snapshot) => {
|
|
3216
|
-
io.log(
|
|
2979
|
+
io.log(`${stamp()} session: ${snapshot.state}${snapshot.errorCode ? ` (${snapshot.errorCode})` : ""}`);
|
|
3217
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
|
+
}
|
|
3218
2986
|
});
|
|
3219
2987
|
scanRunner.subscribe((snapshot) => {
|
|
3220
2988
|
io.log(`scanner: ${snapshot.state}${snapshot.jobId ? ` (job ${snapshot.jobId})` : ""}`);
|
|
@@ -3223,13 +2991,16 @@ async function commandRun(runtime, io) {
|
|
|
3223
2991
|
scanRunner.start();
|
|
3224
2992
|
await scanRunner.recover();
|
|
3225
2993
|
await session.start();
|
|
3226
|
-
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.`);
|
|
3227
2995
|
const keepAlive = setInterval(() => void 0, 6e4);
|
|
3228
2996
|
try {
|
|
3229
|
-
await
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
2997
|
+
await Promise.race([
|
|
2998
|
+
new Promise((resolve) => {
|
|
2999
|
+
process.once("SIGINT", resolve);
|
|
3000
|
+
process.once("SIGTERM", resolve);
|
|
3001
|
+
}),
|
|
3002
|
+
replaced
|
|
3003
|
+
]);
|
|
3233
3004
|
} finally {
|
|
3234
3005
|
clearInterval(keepAlive);
|
|
3235
3006
|
session.stop();
|
|
@@ -3255,6 +3026,66 @@ async function commandUnpair(runtime, io) {
|
|
|
3255
3026
|
io.log("Device unpaired; local credentials, Facebook session, and pending scans were removed.");
|
|
3256
3027
|
return 0;
|
|
3257
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
|
+
}
|
|
3258
3089
|
async function main(argv, io = console) {
|
|
3259
3090
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
3260
3091
|
io.log(USAGE);
|
|
@@ -3271,19 +3102,26 @@ async function main(argv, io = console) {
|
|
|
3271
3102
|
io.log(USAGE);
|
|
3272
3103
|
return 0;
|
|
3273
3104
|
}
|
|
3274
|
-
if (!["install-browser", "install-
|
|
3105
|
+
if (!["install-browser", "install-service", "login", "logout", "pair", "rename", "run", "status", "uninstall-service", "unpair"].includes(command)) {
|
|
3275
3106
|
io.error(`Unknown command "${command}".`);
|
|
3276
3107
|
io.error(USAGE);
|
|
3277
3108
|
return 2;
|
|
3278
3109
|
}
|
|
3279
3110
|
if (command === "install-browser") return commandInstallBrowser(io);
|
|
3280
|
-
if (command === "install-display") return commandInstallDisplay(io);
|
|
3281
3111
|
const runtime = createRuntime(parsed.options);
|
|
3282
3112
|
switch (command) {
|
|
3283
3113
|
case "pair":
|
|
3284
3114
|
return commandPair(runtime, parsed.options, io);
|
|
3285
3115
|
case "login":
|
|
3286
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);
|
|
3287
3125
|
case "run":
|
|
3288
3126
|
return commandRun(runtime, io);
|
|
3289
3127
|
case "status":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "diggylab",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.43",
|
|
4
4
|
"description": "DiggyLab Desktop Agent collects posts and information from Facebook Groups on a paired computer. (headless CLI)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"playwright": "^1.62.0",
|
|
18
|
-
"qrcode-terminal": "^0.12.0",
|
|
19
18
|
"ws": "^8.21.3"
|
|
20
19
|
},
|
|
21
20
|
"scripts": {
|