diggylab 0.1.40 → 0.1.42
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 +20 -48
- package/cli.js +72 -390
- 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
|
|
@@ -58,60 +57,30 @@ npm publish apps/desktop-agent/dist-npm --access public
|
|
|
58
57
|
| Command | What it does |
|
|
59
58
|
| --- | --- |
|
|
60
59
|
| `pair --code <code> [--name <device name>] [--api <origin>]` | Pairs the server as a device. Create the code on `/settings/desktop-agent` in the web app. |
|
|
61
|
-
| `login` |
|
|
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. |
|
|
62
61
|
| `run` | Connects the WebSocket worker, receives assigned scans, scans headlessly, uploads results. |
|
|
63
62
|
| `status` | Shows the config directory, API origin, paired device, and Facebook session state. |
|
|
64
63
|
| `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-display` | On Debian/Ubuntu, installs `xvfb` and `x11vnc` for the login screen. |
|
|
66
64
|
| `unpair` | Removes device credentials, the Facebook session, and pending scans. |
|
|
67
65
|
|
|
68
|
-
Useful options: `--api <origin>` (default `https://api.diggylab.com`), `--
|
|
69
|
-
|
|
66
|
+
Useful options: `--api <origin>` (default `https://api.diggylab.com`), `--cookies <file>` to import
|
|
67
|
+
cookies directly.
|
|
70
68
|
|
|
71
|
-
## How `login` works
|
|
69
|
+
## How `login` works
|
|
72
70
|
|
|
73
|
-
|
|
74
|
-
carries that screen to the customer through the API connection it already uses for scans:
|
|
71
|
+
Two paths, depending on the machine:
|
|
75
72
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
Nothing has to be opened on the server: no port, no tunnel, no firewall change. The link points at
|
|
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.
|
|
97
|
-
|
|
98
|
-
Each process writes a log to `~/.diggylab/logs/` (`xvfb.log`, `x11vnc.log`). The tail is printed when
|
|
99
|
-
the screen fails or a process dies during the login, and the files stay there for later inspection.
|
|
100
|
-
|
|
101
|
-
Manual checks while `login` is running:
|
|
102
|
-
|
|
103
|
-
```bash
|
|
104
|
-
pgrep -a Xvfb; pgrep -a x11vnc # both must be running
|
|
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
|
-
```
|
|
73
|
+
| Machine | What `login` does |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| Desktop, or Linux with a display (`$DISPLAY` set) | Opens Chromium on that display with Facebook's real login page; you log in normally. |
|
|
76
|
+
| Server without a screen | Prints a short prompt and asks you to paste `facebook.com` cookies exported from your own browser. |
|
|
108
77
|
|
|
109
|
-
|
|
78
|
+
Both paths save the session encrypted with `~/.diggylab/key`, and `diggylab run` then uses it for scans.
|
|
110
79
|
|
|
111
80
|
### Logging in with cookies from your own browser
|
|
112
81
|
|
|
113
|
-
|
|
114
|
-
|
|
82
|
+
On a machine without a screen this is the only path, and you can also pick it on a desktop with
|
|
83
|
+
`diggylab login cookies`:
|
|
115
84
|
|
|
116
85
|
1. Get the `facebook.com` cookies, in whichever way is easiest:
|
|
117
86
|
- **DevTools → Network** (fastest): open facebook.com, click any request, copy the whole
|
|
@@ -119,12 +88,15 @@ cookies instead — no Xvfb, no link, no port:
|
|
|
119
88
|
- **DevTools → Application → Cookies → `https://www.facebook.com`**: select the table and copy the
|
|
120
89
|
rows.
|
|
121
90
|
- **Cookie-Editor** (Chrome, Edge, Firefox): *Export → JSON*.
|
|
122
|
-
2. On the server:
|
|
91
|
+
2. On the server, run the friendly form and paste when asked (Enter finishes the paste):
|
|
123
92
|
|
|
124
93
|
```bash
|
|
125
|
-
diggylab login
|
|
94
|
+
diggylab login cookies
|
|
126
95
|
```
|
|
127
96
|
|
|
97
|
+
The equivalents: `diggylab login --cookies 'name=value; ...'` for a quoted one-liner, or
|
|
98
|
+
`diggylab login --cookies cookies.json` for a saved file.
|
|
99
|
+
|
|
128
100
|
Only `name` and `value` are required; the agent fills in the rest (`.facebook.com`, `/`, session
|
|
129
101
|
expiry). Every `facebook.com` cookie is kept, and at least `c_user` plus `xs` must be present — those
|
|
130
102
|
two carry the session, and `datr` is worth keeping too.
|
|
@@ -169,8 +141,8 @@ WantedBy=multi-user.target
|
|
|
169
141
|
|
|
170
142
|
```bash
|
|
171
143
|
docker build -f apps/desktop-agent/Dockerfile.cli -t diggylab .
|
|
172
|
-
docker run -it --rm -
|
|
173
|
-
docker run -it --rm -
|
|
144
|
+
docker run -it --rm -v diggylab:/data diggylab pair --code ABC123
|
|
145
|
+
docker run -it --rm -v diggylab:/data diggylab login cookies
|
|
174
146
|
docker run -d --restart unless-stopped -v diggylab:/data diggylab run
|
|
175
147
|
```
|
|
176
148
|
|
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 } 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";
|
|
@@ -2315,9 +2315,7 @@ var FLAGS = {
|
|
|
2315
2315
|
"--code": { key: "code", type: "string" },
|
|
2316
2316
|
"--config": { key: "config", type: "string" },
|
|
2317
2317
|
"--cookies": { key: "cookies", type: "string" },
|
|
2318
|
-
"--
|
|
2319
|
-
"--name": { key: "name", type: "string" },
|
|
2320
|
-
"--web-url": { key: "webUrl", type: "string" }
|
|
2318
|
+
"--name": { key: "name", type: "string" }
|
|
2321
2319
|
};
|
|
2322
2320
|
function parseCliArgs(argv) {
|
|
2323
2321
|
const options = {};
|
|
@@ -2329,6 +2327,10 @@ function parseCliArgs(argv) {
|
|
|
2329
2327
|
const spec = FLAGS[flag];
|
|
2330
2328
|
if (!spec) return { error: `Unknown option "${flag}".`, options };
|
|
2331
2329
|
const raw = inline ?? argv[index + 1];
|
|
2330
|
+
if (flag === "--cookies" && (raw === void 0 || raw.startsWith("--"))) {
|
|
2331
|
+
options.cookies = "";
|
|
2332
|
+
continue;
|
|
2333
|
+
}
|
|
2332
2334
|
if (raw === void 0 || raw.startsWith("--")) return { error: `Option "${flag}" needs a value.`, options };
|
|
2333
2335
|
if (inline === void 0) index += 1;
|
|
2334
2336
|
if (spec.type === "number") {
|
|
@@ -2340,6 +2342,10 @@ function parseCliArgs(argv) {
|
|
|
2340
2342
|
}
|
|
2341
2343
|
continue;
|
|
2342
2344
|
}
|
|
2345
|
+
if (command === "login" && token === "cookies" && options.cookies === void 0) {
|
|
2346
|
+
options.cookies = "";
|
|
2347
|
+
continue;
|
|
2348
|
+
}
|
|
2343
2349
|
if (command === void 0) {
|
|
2344
2350
|
command = token;
|
|
2345
2351
|
continue;
|
|
@@ -2487,278 +2493,6 @@ function createFileCrypto(keyPath) {
|
|
|
2487
2493
|
};
|
|
2488
2494
|
}
|
|
2489
2495
|
|
|
2490
|
-
// src/cli/login-screen-relay.ts
|
|
2491
|
-
import { createConnection } from "node:net";
|
|
2492
|
-
import WebSocket from "ws";
|
|
2493
|
-
|
|
2494
|
-
// src/cli/relay-crypto.ts
|
|
2495
|
-
import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, hkdfSync, randomBytes as randomBytes2 } from "node:crypto";
|
|
2496
|
-
var FRAME_VERSION = 1;
|
|
2497
|
-
var IV_BYTES2 = 12;
|
|
2498
|
-
var KEY_BYTES2 = 32;
|
|
2499
|
-
var TAG_BYTES2 = 16;
|
|
2500
|
-
function createRelayKey() {
|
|
2501
|
-
return randomBytes2(KEY_BYTES2).toString("base64url");
|
|
2502
|
-
}
|
|
2503
|
-
function deriveRelayKeys(key, sessionId) {
|
|
2504
|
-
const secret = Buffer.from(key, "base64url");
|
|
2505
|
-
const salt = Buffer.from(sessionId, "utf8");
|
|
2506
|
-
const derive = (info) => Buffer.from(hkdfSync("sha256", secret, salt, Buffer.from(info, "utf8"), KEY_BYTES2));
|
|
2507
|
-
return { fromAgent: derive("agent-to-browser"), toAgent: derive("browser-to-agent") };
|
|
2508
|
-
}
|
|
2509
|
-
function sealFrame(key, payload) {
|
|
2510
|
-
const iv = randomBytes2(IV_BYTES2);
|
|
2511
|
-
const cipher = createCipheriv2("aes-256-gcm", key, iv);
|
|
2512
|
-
const sealed = Buffer.concat([cipher.update(payload), cipher.final()]);
|
|
2513
|
-
return Buffer.concat([Buffer.from([FRAME_VERSION]), iv, sealed, cipher.getAuthTag()]);
|
|
2514
|
-
}
|
|
2515
|
-
function openFrame(key, frame) {
|
|
2516
|
-
if (frame.length < 1 + IV_BYTES2 + TAG_BYTES2 || frame[0] !== FRAME_VERSION) {
|
|
2517
|
-
throw new Error("Invalid login screen frame.");
|
|
2518
|
-
}
|
|
2519
|
-
const decipher = createDecipheriv2("aes-256-gcm", key, frame.subarray(1, 1 + IV_BYTES2));
|
|
2520
|
-
decipher.setAuthTag(frame.subarray(frame.length - TAG_BYTES2));
|
|
2521
|
-
return Buffer.concat([
|
|
2522
|
-
decipher.update(frame.subarray(1 + IV_BYTES2, frame.length - TAG_BYTES2)),
|
|
2523
|
-
decipher.final()
|
|
2524
|
-
]);
|
|
2525
|
-
}
|
|
2526
|
-
|
|
2527
|
-
// src/cli/login-screen-relay.ts
|
|
2528
|
-
var RELAY_PATH = "/desktop-agent/login-screen";
|
|
2529
|
-
var READY_TIMEOUT_MS = 1e4;
|
|
2530
|
-
var DEFAULT_WEB_ORIGIN = "https://diggylab.com";
|
|
2531
|
-
async function openLoginScreenRelay(options) {
|
|
2532
|
-
const socket = new WebSocket(`${toWebSocketOrigin(options.apiOrigin)}${RELAY_PATH}`, {
|
|
2533
|
-
headers: { authorization: `Bearer ${options.accessToken}` }
|
|
2534
|
-
});
|
|
2535
|
-
let closed = false;
|
|
2536
|
-
let keys = null;
|
|
2537
|
-
let vnc = null;
|
|
2538
|
-
let resolveScreen = () => void 0;
|
|
2539
|
-
let rejectFailure = () => void 0;
|
|
2540
|
-
const failure = new Promise((_resolve, reject) => {
|
|
2541
|
-
rejectFailure = reject;
|
|
2542
|
-
});
|
|
2543
|
-
const screenConnected = new Promise((resolve) => {
|
|
2544
|
-
resolveScreen = resolve;
|
|
2545
|
-
});
|
|
2546
|
-
failure.catch(() => void 0);
|
|
2547
|
-
const close = () => {
|
|
2548
|
-
closed = true;
|
|
2549
|
-
vnc?.destroy();
|
|
2550
|
-
vnc = null;
|
|
2551
|
-
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close();
|
|
2552
|
-
};
|
|
2553
|
-
const fail = (message) => {
|
|
2554
|
-
close();
|
|
2555
|
-
rejectFailure(new Error(message));
|
|
2556
|
-
};
|
|
2557
|
-
const openScreen = () => {
|
|
2558
|
-
if (closed || vnc) return;
|
|
2559
|
-
const connection = createConnection({ host: "127.0.0.1", port: options.rfbPort });
|
|
2560
|
-
vnc = connection;
|
|
2561
|
-
connection.on("data", (chunk) => {
|
|
2562
|
-
if (closed || !keys || socket.readyState !== WebSocket.OPEN) return;
|
|
2563
|
-
socket.send(sealFrame(keys.fromAgent, chunk), { binary: true });
|
|
2564
|
-
});
|
|
2565
|
-
connection.once("error", () => {
|
|
2566
|
-
if (vnc === connection) vnc = null;
|
|
2567
|
-
fail("Could not reach the virtual screen.");
|
|
2568
|
-
});
|
|
2569
|
-
connection.once("close", () => {
|
|
2570
|
-
if (vnc === connection) vnc = null;
|
|
2571
|
-
});
|
|
2572
|
-
};
|
|
2573
|
-
socket.on("message", (data, isBinary) => {
|
|
2574
|
-
if (isBinary) {
|
|
2575
|
-
if (!keys || !vnc || vnc.destroyed) return;
|
|
2576
|
-
try {
|
|
2577
|
-
vnc.write(openFrame(keys.toAgent, Buffer.from(data)));
|
|
2578
|
-
} catch {
|
|
2579
|
-
fail("The login screen received a corrupted frame.");
|
|
2580
|
-
}
|
|
2581
|
-
return;
|
|
2582
|
-
}
|
|
2583
|
-
const message = parseRelayMessage(data.toString());
|
|
2584
|
-
if (message?.type === "screen-connected") {
|
|
2585
|
-
openScreen();
|
|
2586
|
-
resolveScreen();
|
|
2587
|
-
} else if (message?.type === "screen-disconnected") {
|
|
2588
|
-
vnc?.destroy();
|
|
2589
|
-
vnc = null;
|
|
2590
|
-
}
|
|
2591
|
-
});
|
|
2592
|
-
socket.once("error", () => fail("Could not reach the DiggyLab relay."));
|
|
2593
|
-
socket.once("close", () => {
|
|
2594
|
-
if (!closed) fail("The DiggyLab relay closed the login screen.");
|
|
2595
|
-
});
|
|
2596
|
-
const ready = await waitForReady(socket);
|
|
2597
|
-
keys = deriveRelayKeys(options.key, ready.id);
|
|
2598
|
-
const webOrigin = (options.webUrl ?? ready.webUrl ?? DEFAULT_WEB_ORIGIN).replace(/\/+$/, "");
|
|
2599
|
-
return {
|
|
2600
|
-
close,
|
|
2601
|
-
failure,
|
|
2602
|
-
screenConnected,
|
|
2603
|
-
url: `${webOrigin}/agent-login/${encodeURIComponent(ready.id)}#${options.key}`
|
|
2604
|
-
};
|
|
2605
|
-
}
|
|
2606
|
-
function parseRelayMessage(value) {
|
|
2607
|
-
try {
|
|
2608
|
-
const parsed = JSON.parse(value);
|
|
2609
|
-
if (parsed && typeof parsed === "object" && typeof parsed.type === "string") {
|
|
2610
|
-
return parsed;
|
|
2611
|
-
}
|
|
2612
|
-
} catch {
|
|
2613
|
-
}
|
|
2614
|
-
return null;
|
|
2615
|
-
}
|
|
2616
|
-
function waitForReady(socket) {
|
|
2617
|
-
return new Promise((resolve, reject) => {
|
|
2618
|
-
const timer = setTimeout(() => reject(new Error("The DiggyLab relay did not answer in time.")), READY_TIMEOUT_MS);
|
|
2619
|
-
timer.unref();
|
|
2620
|
-
socket.once("message", (data, isBinary) => {
|
|
2621
|
-
clearTimeout(timer);
|
|
2622
|
-
if (isBinary) {
|
|
2623
|
-
reject(new Error("The DiggyLab relay answered with an unexpected frame."));
|
|
2624
|
-
return;
|
|
2625
|
-
}
|
|
2626
|
-
const message = parseRelayMessage(data.toString());
|
|
2627
|
-
if (message?.type !== "ready") {
|
|
2628
|
-
reject(new Error("The DiggyLab relay did not accept the login screen."));
|
|
2629
|
-
return;
|
|
2630
|
-
}
|
|
2631
|
-
resolve(message);
|
|
2632
|
-
});
|
|
2633
|
-
socket.once("error", () => {
|
|
2634
|
-
clearTimeout(timer);
|
|
2635
|
-
reject(new Error("Could not reach the DiggyLab relay."));
|
|
2636
|
-
});
|
|
2637
|
-
socket.once("close", () => {
|
|
2638
|
-
clearTimeout(timer);
|
|
2639
|
-
reject(new Error("The DiggyLab relay refused the login screen."));
|
|
2640
|
-
});
|
|
2641
|
-
});
|
|
2642
|
-
}
|
|
2643
|
-
function toWebSocketOrigin(origin) {
|
|
2644
|
-
return origin.replace(/^http/i, "ws").replace(/\/+$/, "");
|
|
2645
|
-
}
|
|
2646
|
-
|
|
2647
|
-
// src/cli/qr.ts
|
|
2648
|
-
import qrcode from "qrcode-terminal";
|
|
2649
|
-
function terminalQr(value) {
|
|
2650
|
-
let output = "";
|
|
2651
|
-
qrcode.generate(value, { small: true }, (rendered) => {
|
|
2652
|
-
output = rendered;
|
|
2653
|
-
});
|
|
2654
|
-
return output;
|
|
2655
|
-
}
|
|
2656
|
-
|
|
2657
|
-
// src/cli/virtual-display.ts
|
|
2658
|
-
import { spawn } from "node:child_process";
|
|
2659
|
-
import { createWriteStream, mkdirSync as mkdirSync2 } from "node:fs";
|
|
2660
|
-
import { createConnection as createConnection3 } from "node:net";
|
|
2661
|
-
import { join as join3 } from "node:path";
|
|
2662
|
-
|
|
2663
|
-
// src/cli/vnc-probe.ts
|
|
2664
|
-
import { createConnection as createConnection2 } from "node:net";
|
|
2665
|
-
var PROBE_TIMEOUT_MS = 4e3;
|
|
2666
|
-
var GREETING_BYTES = 12;
|
|
2667
|
-
var GREETING_PATTERN = /^RFB \d{3}\.\d{3}\n$/;
|
|
2668
|
-
function probeRfb(host, port, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
2669
|
-
return new Promise((resolve) => {
|
|
2670
|
-
const socket = createConnection2({ host, port });
|
|
2671
|
-
const finish = (result) => {
|
|
2672
|
-
clearTimeout(timer);
|
|
2673
|
-
socket.destroy();
|
|
2674
|
-
resolve(result);
|
|
2675
|
-
};
|
|
2676
|
-
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
2677
|
-
let received = "";
|
|
2678
|
-
socket.on("data", (chunk) => {
|
|
2679
|
-
received += chunk.toString("latin1");
|
|
2680
|
-
if (received.length >= GREETING_BYTES) finish(GREETING_PATTERN.test(received.slice(0, GREETING_BYTES)));
|
|
2681
|
-
});
|
|
2682
|
-
socket.once("error", () => finish(false));
|
|
2683
|
-
socket.once("close", () => finish(false));
|
|
2684
|
-
});
|
|
2685
|
-
}
|
|
2686
|
-
|
|
2687
|
-
// src/cli/virtual-display.ts
|
|
2688
|
-
var START_TIMEOUT_MS = 15e3;
|
|
2689
|
-
function waitForPort(host, port, timeoutMs) {
|
|
2690
|
-
const deadline = Date.now() + timeoutMs;
|
|
2691
|
-
return new Promise((resolve, reject) => {
|
|
2692
|
-
const attempt = () => {
|
|
2693
|
-
const socket = createConnection3({ host, port });
|
|
2694
|
-
socket.once("connect", () => {
|
|
2695
|
-
socket.destroy();
|
|
2696
|
-
resolve();
|
|
2697
|
-
});
|
|
2698
|
-
socket.once("error", () => {
|
|
2699
|
-
socket.destroy();
|
|
2700
|
-
if (Date.now() >= deadline) {
|
|
2701
|
-
reject(new Error(`Timed out waiting for ${host}:${port}.`));
|
|
2702
|
-
return;
|
|
2703
|
-
}
|
|
2704
|
-
setTimeout(attempt, 250).unref();
|
|
2705
|
-
});
|
|
2706
|
-
};
|
|
2707
|
-
attempt();
|
|
2708
|
-
});
|
|
2709
|
-
}
|
|
2710
|
-
function start(name, command, args, env, deps, logDirectory) {
|
|
2711
|
-
const child = deps.spawnProcess(command, args, env);
|
|
2712
|
-
const log = createWriteStream(join3(logDirectory, `${name}.log`), { mode: 384 });
|
|
2713
|
-
const failure = new Promise((_resolve, reject) => {
|
|
2714
|
-
child.on("error", (error) => reject(error instanceof Error ? error : new Error(String(error))));
|
|
2715
|
-
child.on("exit", (code) => reject(new Error(`${command} exited with code ${String(code)}.`)));
|
|
2716
|
-
});
|
|
2717
|
-
log.on("error", () => void 0);
|
|
2718
|
-
log.write(`# ${command} ${args.join(" ")}
|
|
2719
|
-
`);
|
|
2720
|
-
child.stdout?.pipe?.(log, { end: false });
|
|
2721
|
-
child.stderr?.pipe?.(log, { end: false });
|
|
2722
|
-
return { child, failure };
|
|
2723
|
-
}
|
|
2724
|
-
async function startVirtualDisplay(options, overrides = {}) {
|
|
2725
|
-
const deps = {
|
|
2726
|
-
probeRfb,
|
|
2727
|
-
spawnProcess: spawn,
|
|
2728
|
-
waitForPort,
|
|
2729
|
-
...overrides
|
|
2730
|
-
};
|
|
2731
|
-
const displayNumber = options.displayNumber ?? 99;
|
|
2732
|
-
const display = `:${displayNumber}`;
|
|
2733
|
-
const height = options.height ?? 900;
|
|
2734
|
-
const rfbPort = 5900 + displayNumber;
|
|
2735
|
-
mkdirSync2(options.logDirectory, { mode: 448, recursive: true });
|
|
2736
|
-
const xvfb = start("xvfb", "Xvfb", [display, "-screen", "0", `${options.width ?? 1280}x${height}x24`, "-nolisten", "tcp"], process.env, deps, options.logDirectory);
|
|
2737
|
-
const vnc = start("x11vnc", "x11vnc", ["-display", display, "-localhost", "-rfbport", String(rfbPort), "-forever", "-shared", "-nopw", "-quiet"], process.env, deps, options.logDirectory);
|
|
2738
|
-
const failure = Promise.race([vnc.failure, xvfb.failure]);
|
|
2739
|
-
failure.catch(() => void 0);
|
|
2740
|
-
const stop = async () => {
|
|
2741
|
-
for (const { child } of [vnc, xvfb]) {
|
|
2742
|
-
child.kill("SIGTERM");
|
|
2743
|
-
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2744
|
-
child.kill("SIGKILL");
|
|
2745
|
-
}
|
|
2746
|
-
};
|
|
2747
|
-
try {
|
|
2748
|
-
await Promise.race([
|
|
2749
|
-
deps.waitForPort("127.0.0.1", rfbPort, options.timeoutMs ?? START_TIMEOUT_MS),
|
|
2750
|
-
failure
|
|
2751
|
-
]);
|
|
2752
|
-
if (!await deps.probeRfb("127.0.0.1", rfbPort)) {
|
|
2753
|
-
throw new Error(`The virtual screen did not answer on 127.0.0.1:${rfbPort}.`);
|
|
2754
|
-
}
|
|
2755
|
-
} catch (error) {
|
|
2756
|
-
await stop();
|
|
2757
|
-
throw error;
|
|
2758
|
-
}
|
|
2759
|
-
return { display, failure, logDirectory: options.logDirectory, rfbPort, stop };
|
|
2760
|
-
}
|
|
2761
|
-
|
|
2762
2496
|
// src/cli/cli.ts
|
|
2763
2497
|
var LOGIN_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
2764
2498
|
var CONFIG_DIRECTORY = ".diggylab";
|
|
@@ -2766,23 +2500,22 @@ var USAGE = `DiggyLab Desktop Agent CLI
|
|
|
2766
2500
|
|
|
2767
2501
|
Usage:
|
|
2768
2502
|
diggylab pair --code <code> [--name <device name>] [--api <https://api.diggylab.com>]
|
|
2769
|
-
diggylab login
|
|
2770
|
-
diggylab login
|
|
2503
|
+
diggylab login (opens a browser window when the machine has a screen)
|
|
2504
|
+
diggylab login cookies (no screen: paste facebook.com cookies when asked)
|
|
2505
|
+
diggylab login --cookies <file> (or --cookies 'name=value; ...')
|
|
2771
2506
|
diggylab run
|
|
2772
2507
|
diggylab status
|
|
2773
2508
|
diggylab install-browser
|
|
2774
|
-
diggylab install-display
|
|
2775
2509
|
diggylab unpair
|
|
2776
2510
|
|
|
2777
2511
|
Global options:
|
|
2778
2512
|
--config <directory> Agent state directory (default ~/.diggylab)
|
|
2779
2513
|
--api <origin> API origin (default $DIGGYLAB_API_URL or https://api.diggylab.com)`;
|
|
2780
2514
|
var MISSING_BROWSER_HINT = "Chromium for Playwright is not installed yet. Run `diggylab install-browser` once.";
|
|
2781
|
-
var MISSING_DISPLAY_HINT = "The login screen needs Xvfb and x11vnc. Run `diggylab install-display` (Debian/Ubuntu) or use the Docker image.";
|
|
2782
2515
|
function agentVersion() {
|
|
2783
2516
|
let directory = fileURLToPath(new URL(".", import.meta.url));
|
|
2784
2517
|
for (let depth = 0; depth < 4; depth += 1) {
|
|
2785
|
-
const manifest =
|
|
2518
|
+
const manifest = join3(directory, "package.json");
|
|
2786
2519
|
if (existsSync2(manifest)) {
|
|
2787
2520
|
try {
|
|
2788
2521
|
const version = JSON.parse(readFileSync2(manifest, "utf8")).version;
|
|
@@ -2799,7 +2532,7 @@ function resolvePlatform() {
|
|
|
2799
2532
|
return process.platform === "linux" ? "LINUX" : "WINDOWS";
|
|
2800
2533
|
}
|
|
2801
2534
|
function resolveConfigDirectory(options) {
|
|
2802
|
-
return options.config?.trim() || process.env.DIGGYLAB_AGENT_DIR?.trim() ||
|
|
2535
|
+
return options.config?.trim() || process.env.DIGGYLAB_AGENT_DIR?.trim() || join3(homedir(), CONFIG_DIRECTORY);
|
|
2803
2536
|
}
|
|
2804
2537
|
function resolveEndpoint(options) {
|
|
2805
2538
|
const configured = (options.api ?? process.env.DIGGYLAB_API_URL)?.trim() || "https://api.diggylab.com";
|
|
@@ -2809,29 +2542,16 @@ function resolveEndpoint(options) {
|
|
|
2809
2542
|
function createRuntime(options) {
|
|
2810
2543
|
const directory = resolveConfigDirectory(options);
|
|
2811
2544
|
const endpoint = resolveEndpoint(options);
|
|
2812
|
-
const crypto2 = createFileCrypto(
|
|
2813
|
-
const credentials = new CredentialStore(
|
|
2545
|
+
const crypto2 = createFileCrypto(join3(directory, "key"));
|
|
2546
|
+
const credentials = new CredentialStore(join3(directory, "credentials.json"), crypto2);
|
|
2814
2547
|
const sessions = new DeviceSessionStore(credentials);
|
|
2815
2548
|
const facebook = new FacebookSessionStore(credentials);
|
|
2816
|
-
const spool = new EncryptedScanSpool(
|
|
2817
|
-
const activity = new ActivityLog({ directory:
|
|
2549
|
+
const spool = new EncryptedScanSpool(join3(directory, "scan-spool"), crypto2);
|
|
2550
|
+
const activity = new ActivityLog({ directory: join3(directory, "activity") });
|
|
2818
2551
|
const http = new DesktopAgentHttpClient(endpoint.httpOrigin);
|
|
2819
2552
|
const manager = new DeviceCredentialManager(sessions, http, createDeviceIdentity);
|
|
2820
2553
|
return { activity, browserType: createBrowserLauncher(chromium), directory, endpoint, facebook, manager, sessions, spool };
|
|
2821
2554
|
}
|
|
2822
|
-
function printDisplayLogs(directory, io, lines = 12) {
|
|
2823
|
-
io.error(`Login screen logs: ${directory}`);
|
|
2824
|
-
try {
|
|
2825
|
-
for (const name of readdirSync(directory).sort()) {
|
|
2826
|
-
if (!name.endsWith(".log")) continue;
|
|
2827
|
-
const tail = readFileSync2(join4(directory, name), "utf8").split("\n").filter((line) => line.trim() !== "").slice(-lines);
|
|
2828
|
-
if (tail.length === 0) continue;
|
|
2829
|
-
io.error(`--- ${name} ---`);
|
|
2830
|
-
for (const line of tail) io.error(line);
|
|
2831
|
-
}
|
|
2832
|
-
} catch {
|
|
2833
|
-
}
|
|
2834
|
-
}
|
|
2835
2555
|
function waitForFacebookLogin(login, timeoutMs) {
|
|
2836
2556
|
return new Promise((resolve, reject) => {
|
|
2837
2557
|
const timer = setTimeout(() => {
|
|
@@ -2897,7 +2617,11 @@ async function commandPair(runtime, options, io) {
|
|
|
2897
2617
|
}
|
|
2898
2618
|
}
|
|
2899
2619
|
async function commandLogin(runtime, options, io) {
|
|
2900
|
-
if (options.cookies) return importFacebookCookies(runtime, options.cookies, io);
|
|
2620
|
+
if (options.cookies !== void 0) return importFacebookCookies(runtime, options.cookies || "-", io);
|
|
2621
|
+
if (process.platform === "linux" && !process.env.DISPLAY) {
|
|
2622
|
+
io.log("This server has no screen, so the login uses cookies from your browser.");
|
|
2623
|
+
return importFacebookCookies(runtime, "-", io);
|
|
2624
|
+
}
|
|
2901
2625
|
const login = new FacebookLoginService({
|
|
2902
2626
|
browserType: runtime.browserType,
|
|
2903
2627
|
clearTimer: (timer) => clearTimeout(timer),
|
|
@@ -2915,79 +2639,34 @@ async function commandLogin(runtime, options, io) {
|
|
|
2915
2639
|
io.log("Facebook is already connected.");
|
|
2916
2640
|
return 0;
|
|
2917
2641
|
}
|
|
2918
|
-
let display = null;
|
|
2919
|
-
let relay = null;
|
|
2920
2642
|
try {
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
io.log(process.env.DISPLAY ? `Opening the Facebook login page on display ${process.env.DISPLAY}.` : "Opening the Facebook login page in a browser window.");
|
|
2924
|
-
} else {
|
|
2925
|
-
const stored = await runtime.manager.load();
|
|
2926
|
-
if (!stored) {
|
|
2927
|
-
io.error("This server is not paired yet. Run `diggylab pair --code <code>` first.");
|
|
2928
|
-
return 1;
|
|
2929
|
-
}
|
|
2930
|
-
const access = await runtime.manager.refresh();
|
|
2931
|
-
const webUrl = options.webUrl ?? process.env.DIGGYLAB_WEB_URL?.trim();
|
|
2932
|
-
display = await startVirtualDisplay({
|
|
2933
|
-
displayNumber: options.display,
|
|
2934
|
-
logDirectory: join4(runtime.directory, "logs")
|
|
2935
|
-
});
|
|
2936
|
-
relay = await openLoginScreenRelay({
|
|
2937
|
-
accessToken: access.accessToken,
|
|
2938
|
-
apiOrigin: runtime.endpoint.httpOrigin,
|
|
2939
|
-
key: createRelayKey(),
|
|
2940
|
-
rfbPort: display.rfbPort,
|
|
2941
|
-
webUrl: webUrl || void 0
|
|
2942
|
-
});
|
|
2943
|
-
process.env.DISPLAY = display.display;
|
|
2944
|
-
io.log(terminalQr(relay.url));
|
|
2945
|
-
io.log(`Open this link on your phone, or scan the QR above:
|
|
2946
|
-
${relay.url}`);
|
|
2947
|
-
io.log("Log into Facebook on that screen; the agent saves the session automatically.");
|
|
2948
|
-
void relay.screenConnected.then(() => io.log("Login screen opened in the browser."));
|
|
2949
|
-
}
|
|
2950
|
-
const waiting = waitForFacebookLogin(login, LOGIN_TIMEOUT_MS + 6e4);
|
|
2951
|
-
const failures = [display?.failure, relay?.failure].filter((value) => Boolean(value));
|
|
2952
|
-
await (failures.length > 0 ? Promise.race([waiting, ...failures]) : waiting);
|
|
2643
|
+
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
|
+
await waitForFacebookLogin(login, LOGIN_TIMEOUT_MS + 6e4);
|
|
2953
2645
|
io.log("Facebook session saved.");
|
|
2954
2646
|
return 0;
|
|
2955
2647
|
} catch (error) {
|
|
2956
2648
|
const message = error instanceof Error ? error.message : String(error);
|
|
2957
|
-
if (/spawn (?:Xvfb|x11vnc) ENOENT/.test(message)) {
|
|
2958
|
-
io.error(MISSING_DISPLAY_HINT);
|
|
2959
|
-
return 1;
|
|
2960
|
-
}
|
|
2961
|
-
if (/CREDENTIAL_REVOKED|not paired/i.test(message)) {
|
|
2962
|
-
io.error("This server is not paired (or was unpaired). Run `diggylab pair --code <code>` again.");
|
|
2963
|
-
return 1;
|
|
2964
|
-
}
|
|
2965
|
-
if (/NETWORK_ERROR|SERVER_UNAVAILABLE|fetch failed/i.test(message)) {
|
|
2966
|
-
io.error(`Could not reach the API: ${message}`);
|
|
2967
|
-
return 1;
|
|
2968
|
-
}
|
|
2969
|
-
if (/virtual screen|login screen|DiggyLab relay|Xvfb exited|x11vnc exited/i.test(message)) {
|
|
2970
|
-
io.error(`The login screen failed: ${message}`);
|
|
2971
|
-
printDisplayLogs(join4(runtime.directory, "logs"), io);
|
|
2972
|
-
return 1;
|
|
2973
|
-
}
|
|
2974
2649
|
if (/Executable doesn't exist|playwright install/i.test(message)) {
|
|
2975
2650
|
io.error(MISSING_BROWSER_HINT);
|
|
2976
2651
|
return 1;
|
|
2977
2652
|
}
|
|
2978
2653
|
throw error;
|
|
2979
2654
|
} finally {
|
|
2980
|
-
relay?.close();
|
|
2981
2655
|
await login.close();
|
|
2982
|
-
await display?.stop();
|
|
2983
2656
|
}
|
|
2984
2657
|
}
|
|
2985
2658
|
async function importFacebookCookies(runtime, source, io) {
|
|
2986
2659
|
let input;
|
|
2987
|
-
|
|
2988
|
-
input =
|
|
2989
|
-
}
|
|
2990
|
-
|
|
2660
|
+
if (source === "-") {
|
|
2661
|
+
input = await readPastedCookies(io);
|
|
2662
|
+
} else if (existsSync2(source)) {
|
|
2663
|
+
input = readFileSync2(source, "utf8");
|
|
2664
|
+
} else if (source.includes("=")) {
|
|
2665
|
+
input = source;
|
|
2666
|
+
} else {
|
|
2667
|
+
io.error(`Could not read "${source}".`);
|
|
2668
|
+
io.error("Paste the cookies in quotes: diggylab login --cookies 'datr=...; c_user=...; xs=...'");
|
|
2669
|
+
io.error("Or save them to a file (diggylab login --cookies cookies.txt), or use --cookies - and press Ctrl-D after pasting.");
|
|
2991
2670
|
return 1;
|
|
2992
2671
|
}
|
|
2993
2672
|
const cookies = parseCookieInput(input);
|
|
@@ -2997,9 +2676,11 @@ async function importFacebookCookies(runtime, source, io) {
|
|
|
2997
2676
|
return 1;
|
|
2998
2677
|
}
|
|
2999
2678
|
const state = toFacebookStorageState(cookies);
|
|
2679
|
+
io.log(`Read ${cookies.length} cookie${cookies.length === 1 ? "" : "s"}.`);
|
|
3000
2680
|
if (!state.cookies.some((cookie) => cookie.name === "c_user")) {
|
|
3001
2681
|
io.error("Those cookies have no `c_user` value, so they are not a Facebook login.");
|
|
3002
|
-
io.error("
|
|
2682
|
+
io.error("If you pasted them on the command line, wrap the whole string in quotes so the shell keeps it together.");
|
|
2683
|
+
io.error("Open facebook.com in your browser, make sure you are logged in, then copy the `cookie:` request header again.");
|
|
3003
2684
|
return 1;
|
|
3004
2685
|
}
|
|
3005
2686
|
try {
|
|
@@ -3047,6 +2728,33 @@ function readStdin() {
|
|
|
3047
2728
|
process.stdin.on("error", reject);
|
|
3048
2729
|
});
|
|
3049
2730
|
}
|
|
2731
|
+
function readPastedCookies(io) {
|
|
2732
|
+
if (!process.stdin.isTTY) return readStdin();
|
|
2733
|
+
io.log("Paste the facebook.com cookies here, then press Enter.");
|
|
2734
|
+
io.log("In your browser: F12 \u2192 Network \u2192 click any facebook.com request \u2192 copy the whole `cookie:` value.");
|
|
2735
|
+
return new Promise((resolve) => {
|
|
2736
|
+
let value = "";
|
|
2737
|
+
let idle;
|
|
2738
|
+
const finish = () => {
|
|
2739
|
+
if (idle) clearTimeout(idle);
|
|
2740
|
+
process.stdin.off("data", handleData);
|
|
2741
|
+
process.stdin.pause();
|
|
2742
|
+
resolve(value);
|
|
2743
|
+
};
|
|
2744
|
+
const handleData = (chunk) => {
|
|
2745
|
+
value += chunk;
|
|
2746
|
+
if (idle) clearTimeout(idle);
|
|
2747
|
+
if (value.includes("\n") && value.includes("c_user")) {
|
|
2748
|
+
finish();
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
idle = setTimeout(finish, 350);
|
|
2752
|
+
};
|
|
2753
|
+
process.stdin.setEncoding("utf8");
|
|
2754
|
+
process.stdin.on("data", handleData);
|
|
2755
|
+
process.stdin.resume();
|
|
2756
|
+
});
|
|
2757
|
+
}
|
|
3050
2758
|
function playwrightCliPath() {
|
|
3051
2759
|
try {
|
|
3052
2760
|
return fileURLToPath(new URL("cli.js", import.meta.resolve("playwright")));
|
|
@@ -3063,7 +2771,7 @@ async function commandInstallBrowser(io) {
|
|
|
3063
2771
|
}
|
|
3064
2772
|
io.log("Downloading Chromium for Playwright...");
|
|
3065
2773
|
const run = (args) => new Promise((resolve, reject) => {
|
|
3066
|
-
const child =
|
|
2774
|
+
const child = spawn(process.execPath, [cli, ...args], { stdio: "inherit" });
|
|
3067
2775
|
child.once("error", reject);
|
|
3068
2776
|
child.once("exit", (value) => resolve(value ?? 1));
|
|
3069
2777
|
});
|
|
@@ -3080,31 +2788,6 @@ async function commandInstallBrowser(io) {
|
|
|
3080
2788
|
io.log("Chromium installed.");
|
|
3081
2789
|
return 0;
|
|
3082
2790
|
}
|
|
3083
|
-
async function commandInstallDisplay(io) {
|
|
3084
|
-
if (process.platform !== "linux") {
|
|
3085
|
-
io.log("The shared login screen is only needed on a Linux server; desktops open a browser window.");
|
|
3086
|
-
return 0;
|
|
3087
|
-
}
|
|
3088
|
-
if (!existsSync2("/usr/bin/apt-get")) {
|
|
3089
|
-
io.error("Automatic installation supports Debian/Ubuntu. Install xvfb and x11vnc with your package manager.");
|
|
3090
|
-
return 1;
|
|
3091
|
-
}
|
|
3092
|
-
const sudo = typeof process.getuid === "function" && process.getuid() !== 0 ? ["sudo"] : [];
|
|
3093
|
-
const run = (args) => new Promise((resolve, reject) => {
|
|
3094
|
-
const child = spawn2(args[0], args.slice(1), { stdio: "inherit" });
|
|
3095
|
-
child.once("error", reject);
|
|
3096
|
-
child.once("exit", (value) => resolve(value ?? 1));
|
|
3097
|
-
});
|
|
3098
|
-
io.log("Installing xvfb and x11vnc...");
|
|
3099
|
-
await run([...sudo, "apt-get", "update"]).catch(() => 1);
|
|
3100
|
-
const code = await run([...sudo, "apt-get", "install", "-y", "xvfb", "x11vnc"]);
|
|
3101
|
-
if (code !== 0) {
|
|
3102
|
-
io.error("Installation failed. Run the same command with apt manually.");
|
|
3103
|
-
return code;
|
|
3104
|
-
}
|
|
3105
|
-
io.log("Login screen packages installed.");
|
|
3106
|
-
return 0;
|
|
3107
|
-
}
|
|
3108
2791
|
async function commandRun(runtime, io) {
|
|
3109
2792
|
const stored = await runtime.manager.load();
|
|
3110
2793
|
if (!stored) {
|
|
@@ -3119,7 +2802,7 @@ async function commandRun(runtime, io) {
|
|
|
3119
2802
|
agentVersion: agentVersion(),
|
|
3120
2803
|
clearTimer: (timer) => clearTimeout(timer),
|
|
3121
2804
|
createSocket: (url, accessToken) => {
|
|
3122
|
-
const socket = new
|
|
2805
|
+
const socket = new WebSocket(url, {
|
|
3123
2806
|
headers: { authorization: `Bearer ${accessToken}` },
|
|
3124
2807
|
maxPayload: 16 * 1024
|
|
3125
2808
|
});
|
|
@@ -3227,13 +2910,12 @@ async function main(argv, io = console) {
|
|
|
3227
2910
|
io.log(USAGE);
|
|
3228
2911
|
return 0;
|
|
3229
2912
|
}
|
|
3230
|
-
if (!["install-browser", "
|
|
2913
|
+
if (!["install-browser", "login", "pair", "run", "status", "unpair"].includes(command)) {
|
|
3231
2914
|
io.error(`Unknown command "${command}".`);
|
|
3232
2915
|
io.error(USAGE);
|
|
3233
2916
|
return 2;
|
|
3234
2917
|
}
|
|
3235
2918
|
if (command === "install-browser") return commandInstallBrowser(io);
|
|
3236
|
-
if (command === "install-display") return commandInstallDisplay(io);
|
|
3237
2919
|
const runtime = createRuntime(parsed.options);
|
|
3238
2920
|
switch (command) {
|
|
3239
2921
|
case "pair":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "diggylab",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.42",
|
|
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": {
|