@qpjoy/tunnel-cli 0.1.2 → 0.1.4

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 CHANGED
@@ -1,6 +1,7 @@
1
1
  # @qpjoy/tunnel-cli
2
2
 
3
- Global CLI wrapper for the QPJoy Linux `mihomo-client` server script.
3
+ Global CLI wrapper for the QPJoy Linux `mihomo-client` server script and
4
+ cross-platform headless HDO mesh enrollment.
4
5
 
5
6
  ```bash
6
7
  npm i -g @qpjoy/tunnel-cli
@@ -8,8 +9,62 @@ qp-tunnel-cli help
8
9
  ```
9
10
 
10
11
  The package ships the existing `scripts/mihomo-client.sh` file in the npm
11
- tarball. It does not reimplement the Linux systemd, proxy, SSH, daemon, or TUN
12
- orchestration in Node.
12
+ tarball. It also depends on `@qpjoy/electron-core-wireguard`, which resolves the
13
+ matching platform engine package such as darwin-arm64, linux-x64, or win32-x64.
14
+ The core WireGuard package is consumed as-is; this CLI does not modify its HDO
15
+ plugin behavior.
16
+
17
+ ## HDO Mesh Enrollment
18
+
19
+ For macOS, Windows, or a headless Linux machine such as an Internal/company
20
+ server, enroll into the HDO mesh without installing Electron:
21
+
22
+ ```bash
23
+ npm i -g @qpjoy/tunnel-cli
24
+ HDO_PASSWORD='<password>' qp-tunnel-cli hdo enroll \
25
+ --server-url 'https://domestic.example.com' \
26
+ --username 'internal-i' \
27
+ --device-id internal-i \
28
+ --label 'Internal I'
29
+ ```
30
+
31
+ On Linux, run the same command through `sudo -E` because it writes
32
+ `/etc/wireguard` and enables `wg-quick@hdo-internal`:
33
+
34
+ ```bash
35
+ HDO_PASSWORD='<password>' sudo -E qp-tunnel-cli hdo enroll \
36
+ --server-url 'https://domestic.example.com' \
37
+ --username 'internal-i'
38
+ ```
39
+
40
+ The command:
41
+
42
+ - logs in with username/password, or uses `--token` when provided
43
+ - uses `@qpjoy/electron-core-wireguard` to find/install the platform WireGuard engine
44
+ - registers the machine as an HDO device
45
+ - downloads the HDO manifest from `electron-server`
46
+ - writes a local WireGuard config
47
+ - stores local HDO state and refresh credentials
48
+ - starts a system-level tunnel at boot
49
+
50
+ Useful follow-up commands:
51
+
52
+ ```bash
53
+ qp-tunnel-cli hdo status
54
+ qp-tunnel-cli hdo refresh
55
+ qp-tunnel-cli hdo down
56
+ ```
57
+
58
+ Platform behavior:
59
+
60
+ - Linux: writes `/etc/wireguard/hdo-internal.conf` and enables `wg-quick@hdo-internal`
61
+ - macOS: installs a LaunchDaemon and may prompt for an administrator password
62
+ - Windows: installs a WireGuard tunnel service and may show a UAC prompt
63
+
64
+ Current MVP authentication accepts either username/password or the same bearer
65
+ token used by the HDO API. Production enrollment should move to short-lived
66
+ enrollment tokens and durable service tokens so external systems do not need to
67
+ handle user session JWTs.
13
68
 
14
69
  ## Server Usage
15
70
 
package/README.setup.md CHANGED
@@ -1,10 +1,16 @@
1
1
  ```bash
2
- npm i -g @qpjoy/tunnel-cli@0.1.1
2
+ npm i -g @qpjoy/tunnel-cli@0.1.4
3
+ HDO_PASSWORD='...' qp-tunnel-cli hdo enroll --server-url 'https://domestic.example.com' --username internal-i
4
+
5
+ qp-tunnel-cli install --url 'http://user:pass@host:3434/peer_xxx.mihomo.yaml'
3
6
 
4
7
  sudo qp-tunnel-cli install-script
5
8
  sudo qp-tunnel-cli upgrade-systemd
9
+ sudo qp-tunnel-cli server-on
6
10
 
7
11
  sudo qp-tunnel-cli tun-off
8
12
  sudo qp-tunnel-cli server-on
9
13
  sudo qp-tunnel-cli status
10
- ```
14
+
15
+ qp-tunnel-cli curl google.com
16
+ ```
package/dist/hdo.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ interface HdoCliContext {
2
+ isRoot(): boolean;
3
+ sudoSelf(args: string[]): never;
4
+ }
5
+ export declare function runHdoCli(args: string[], ctx: HdoCliContext): Promise<void>;
6
+ export {};
package/dist/hdo.js ADDED
@@ -0,0 +1,623 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runHdoCli = runHdoCli;
4
+ const node_child_process_1 = require("node:child_process");
5
+ const node_fs_1 = require("node:fs");
6
+ const node_os_1 = require("node:os");
7
+ const node_path_1 = require("node:path");
8
+ const electron_core_wireguard_1 = require("@qpjoy/electron-core-wireguard");
9
+ const defaultInterfaceName = 'hdo-internal';
10
+ async function runHdoCli(args, ctx) {
11
+ const command = args[0] ?? 'help';
12
+ const rest = args.slice(1);
13
+ if (command === 'help' || command === '--help' || command === '-h') {
14
+ hdoHelp();
15
+ return;
16
+ }
17
+ if (process.platform === 'linux' && !ctx.isRoot()) {
18
+ ctx.sudoSelf(['hdo', ...args]);
19
+ }
20
+ switch (command) {
21
+ case 'enroll':
22
+ case 'refresh':
23
+ await enrollCommand(rest, command === 'refresh');
24
+ return;
25
+ case 'status':
26
+ statusCommand(parseStateFileArg(rest));
27
+ return;
28
+ case 'down':
29
+ await downCommand(parseStateFileArg(rest));
30
+ return;
31
+ default:
32
+ process.stderr.write(`Unknown hdo command: ${command}\n\n`);
33
+ hdoHelp();
34
+ process.exitCode = 1;
35
+ }
36
+ }
37
+ function hdoHelp() {
38
+ process.stdout.write(`QPJoy HDO CLI
39
+
40
+ Usage:
41
+ qp-tunnel-cli hdo enroll --server-url URL --username USER [options]
42
+ qp-tunnel-cli hdo enroll --server-url URL --token TOKEN [options]
43
+ qp-tunnel-cli hdo refresh [--server-url URL] [--username USER]
44
+ qp-tunnel-cli hdo status
45
+ qp-tunnel-cli hdo down
46
+
47
+ Enroll options:
48
+ --server-url URL HDO/electron-server base URL
49
+ --username USER Login username/email/phone
50
+ --password PASS Login password. Prefer HDO_PASSWORD or --password-file
51
+ --password-file PATH Read login password from a file
52
+ --token TOKEN Existing bearer token for the HDO API
53
+ --token-file PATH Read bearer token from a file
54
+ --device-id ID Stable device id. Default: hdo-<platform>-<hostname>
55
+ --label LABEL Device label. Default: <platform> <hostname>
56
+ --interface NAME WireGuard interface/config name. Default: hdo-internal
57
+ --config-path PATH WireGuard config path
58
+ --install-dir PATH WireGuard engine install/cache directory
59
+ --state-file PATH HDO client state file
60
+ --role ROLE Metadata role. Default: internal
61
+ --rotate-key Generate a new WireGuard keypair
62
+ --no-start Write config without starting the system tunnel
63
+
64
+ Environment:
65
+ HDO_SERVER_URL / QPJOY_HDO_SERVER_URL
66
+ HDO_USERNAME / QPJOY_HDO_USERNAME
67
+ HDO_PASSWORD / QPJOY_HDO_PASSWORD
68
+ HDO_TOKEN / QPJOY_HDO_TOKEN
69
+
70
+ Examples:
71
+ qp-tunnel-cli hdo enroll \\
72
+ --server-url https://domestic.example.com \\
73
+ --username user@example.com
74
+
75
+ HDO_PASSWORD='...' qp-tunnel-cli hdo enroll \\
76
+ --server-url https://domestic.example.com \\
77
+ --username internal-i
78
+
79
+ Notes:
80
+ Linux writes /etc/wireguard and enables wg-quick@<interface>.
81
+ macOS installs a LaunchDaemon and may prompt for an administrator password.
82
+ Windows installs a WireGuard tunnel service and may show a UAC prompt.
83
+ `);
84
+ }
85
+ async function enrollCommand(args, refreshOnly) {
86
+ const options = parseEnrollOptions(args);
87
+ const stateFile = resolveStateFile(options.stateFile);
88
+ const previous = readState(stateFile);
89
+ const interfaceName = sanitizeInterfaceName(options.interfaceName || previous.interfaceName || defaultInterfaceName);
90
+ const configPath = resolveConfigPath(options.configPath || previous.configPath, interfaceName);
91
+ const installDir = resolveInstallDir(options.installDir || previous.installDir);
92
+ const serverUrl = normalizeBaseUrl(options.serverUrl ??
93
+ process.env.HDO_SERVER_URL ??
94
+ process.env.QPJOY_HDO_SERVER_URL ??
95
+ previous.serverUrl);
96
+ const username = options.username ??
97
+ process.env.HDO_USERNAME ??
98
+ process.env.QPJOY_HDO_USERNAME ??
99
+ previous.username;
100
+ if (!serverUrl) {
101
+ throw new Error('Missing --server-url or HDO_SERVER_URL.');
102
+ }
103
+ const auth = await resolveAuth(serverUrl, options, previous, username);
104
+ const deviceId = options.deviceId ||
105
+ previous.deviceId ||
106
+ `hdo-${process.platform}-${sanitizeId((0, node_os_1.hostname)())}`;
107
+ const label = options.label || previous.label || `${process.platform} ${(0, node_os_1.hostname)()}`;
108
+ const keys = resolveKeypair(options, previous, installDir);
109
+ const registered = await apiJson(serverUrl, auth.accessToken, '/api/v1/hdo/devices/register', {
110
+ method: 'POST',
111
+ body: {
112
+ id: deviceId,
113
+ label,
114
+ platform: `${process.platform}-${process.arch}`,
115
+ publicKey: keys.publicKey,
116
+ status: 'online',
117
+ metadata: {
118
+ source: '@qpjoy/tunnel-cli',
119
+ role: options.role,
120
+ hostname: (0, node_os_1.hostname)(),
121
+ wireGuard: {
122
+ publicKey: keys.publicKey,
123
+ interfaceName,
124
+ updatedAt: new Date().toISOString(),
125
+ },
126
+ },
127
+ },
128
+ });
129
+ const manifest = await apiJson(serverUrl, auth.accessToken, `/api/v1/hdo/manifest/${encodeURIComponent(deviceId)}`, {
130
+ method: 'GET',
131
+ });
132
+ const runtime = hdoRuntimeFromManifest(manifest, registered, keys.privateKey);
133
+ writeWireGuardConfig(configPath, (0, electron_core_wireguard_1.renderHdoClientWireGuardConfig)({
134
+ privateKey: runtime.privateKey,
135
+ address: runtime.address,
136
+ domesticPublicKey: runtime.domesticPublicKey,
137
+ domesticEndpoint: runtime.domesticEndpoint,
138
+ allowedIps: runtime.allowedIps,
139
+ persistentKeepalive: 25,
140
+ }));
141
+ const now = new Date().toISOString();
142
+ writeState(stateFile, {
143
+ serverUrl,
144
+ bearerToken: auth.accessToken,
145
+ refreshToken: auth.refreshToken,
146
+ accessExpiresAt: auth.accessExpiresAt,
147
+ refreshExpiresAt: auth.refreshExpiresAt,
148
+ username: auth.username ?? username,
149
+ deviceId,
150
+ label,
151
+ interfaceName,
152
+ configPath,
153
+ installDir,
154
+ privateKey: keys.privateKey,
155
+ publicKey: keys.publicKey,
156
+ overlayIp: runtime.overlayIp,
157
+ lastManifestGeneration: runtime.generation,
158
+ enrolledAt: previous.enrolledAt || now,
159
+ updatedAt: now,
160
+ });
161
+ let startMessage = 'System tunnel not started (--no-start).';
162
+ if (options.start) {
163
+ startMessage = await startSystemTunnel(interfaceName, configPath, installDir);
164
+ }
165
+ process.stdout.write([
166
+ refreshOnly ? 'HDO config refreshed.' : 'HDO device enrolled.',
167
+ `Device: ${deviceId}`,
168
+ `Overlay IP: ${runtime.overlayIp}`,
169
+ `WireGuard config: ${configPath}`,
170
+ `State file: ${stateFile}`,
171
+ startMessage,
172
+ '',
173
+ ].join('\n'));
174
+ }
175
+ function statusCommand(stateFileInput) {
176
+ const stateFile = resolveStateFile(stateFileInput);
177
+ const state = readState(stateFile);
178
+ const interfaceName = sanitizeInterfaceName(state.interfaceName || defaultInterfaceName);
179
+ const configPath = resolveConfigPath(state.configPath, interfaceName);
180
+ const installDir = resolveInstallDir(state.installDir);
181
+ process.stdout.write(`State file: ${stateFile}\n`);
182
+ process.stdout.write(`Server URL: ${state.serverUrl || 'unset'}\n`);
183
+ process.stdout.write(`Device: ${state.deviceId || 'unset'}\n`);
184
+ process.stdout.write(`Overlay IP: ${state.overlayIp || 'unset'}\n`);
185
+ process.stdout.write(`WireGuard config: ${configPath}\n\n`);
186
+ if (process.platform === 'linux') {
187
+ inherit('systemctl', ['status', `wg-quick@${interfaceName}`, '--no-pager']);
188
+ process.stdout.write('\n');
189
+ inherit('wg', ['show', interfaceName]);
190
+ return;
191
+ }
192
+ const runtime = (0, electron_core_wireguard_1.resolveWireGuardConnectionRuntime)({
193
+ installDir,
194
+ allowSystemFallback: true,
195
+ });
196
+ if (process.platform === 'darwin') {
197
+ const daemon = (0, electron_core_wireguard_1.getDarwinWireGuardLaunchDaemonStatus)({ runtime, configPath });
198
+ process.stdout.write(`LaunchDaemon: ${daemon.loaded ? 'loaded' : 'not loaded'}; running=${daemon.running}\n`);
199
+ if (daemon.plistPath)
200
+ process.stdout.write(`plist: ${daemon.plistPath}\n`);
201
+ }
202
+ const tunnel = (0, electron_core_wireguard_1.getWireGuardTunnelStatus)({ runtime, configPath });
203
+ process.stdout.write(`WireGuard active: ${tunnel.active}\n`);
204
+ process.stdout.write(`Runtime: ${runtime.method}\n`);
205
+ if (tunnel.realInterfaceName)
206
+ process.stdout.write(`Interface: ${tunnel.realInterfaceName}\n`);
207
+ if (tunnel.peers.length)
208
+ process.stdout.write(`Peers: ${tunnel.peers.length}\n`);
209
+ if (tunnel.error)
210
+ process.stdout.write(`Status detail: ${tunnel.error}\n`);
211
+ }
212
+ async function downCommand(stateFileInput) {
213
+ const stateFile = resolveStateFile(stateFileInput);
214
+ const state = readState(stateFile);
215
+ const interfaceName = sanitizeInterfaceName(state.interfaceName || defaultInterfaceName);
216
+ const configPath = resolveConfigPath(state.configPath, interfaceName);
217
+ const installDir = resolveInstallDir(state.installDir);
218
+ if (process.platform === 'linux') {
219
+ inheritRequired('systemctl', ['disable', '--now', `wg-quick@${interfaceName}`]);
220
+ return;
221
+ }
222
+ const runtime = (0, electron_core_wireguard_1.resolveWireGuardConnectionRuntime)({
223
+ installDir,
224
+ allowSystemFallback: true,
225
+ });
226
+ if (process.platform === 'darwin') {
227
+ const result = await (0, electron_core_wireguard_1.uninstallDarwinWireGuardLaunchDaemon)({ runtime, configPath });
228
+ if (!result.ok)
229
+ throw new Error(result.message);
230
+ process.stdout.write(`${result.message}\n`);
231
+ return;
232
+ }
233
+ const result = await (0, electron_core_wireguard_1.setWireGuardTunnelState)({ runtime, configPath, action: 'down' });
234
+ if (!result.ok)
235
+ throw new Error(result.message);
236
+ process.stdout.write(`${result.message}\n`);
237
+ }
238
+ function parseEnrollOptions(args) {
239
+ const options = {
240
+ interfaceName: defaultInterfaceName,
241
+ role: 'internal',
242
+ start: true,
243
+ rotateKey: false,
244
+ };
245
+ for (let index = 0; index < args.length; index += 1) {
246
+ const arg = args[index];
247
+ const readValue = () => {
248
+ const value = args[index + 1];
249
+ if (!value)
250
+ throw new Error(`Missing value for ${arg}`);
251
+ index += 1;
252
+ return value;
253
+ };
254
+ switch (arg) {
255
+ case '--server-url':
256
+ options.serverUrl = readValue();
257
+ break;
258
+ case '--token':
259
+ options.token = readValue();
260
+ break;
261
+ case '--token-file':
262
+ options.tokenFile = readValue();
263
+ break;
264
+ case '--username':
265
+ case '--user':
266
+ options.username = readValue();
267
+ break;
268
+ case '--password':
269
+ options.password = readValue();
270
+ break;
271
+ case '--password-file':
272
+ options.passwordFile = readValue();
273
+ break;
274
+ case '--device-id':
275
+ options.deviceId = readValue();
276
+ break;
277
+ case '--label':
278
+ options.label = readValue();
279
+ break;
280
+ case '--interface':
281
+ options.interfaceName = readValue();
282
+ break;
283
+ case '--config-path':
284
+ options.configPath = readValue();
285
+ break;
286
+ case '--state-file':
287
+ options.stateFile = readValue();
288
+ break;
289
+ case '--install-dir':
290
+ options.installDir = readValue();
291
+ break;
292
+ case '--role':
293
+ options.role = readValue();
294
+ break;
295
+ case '--rotate-key':
296
+ options.rotateKey = true;
297
+ break;
298
+ case '--no-start':
299
+ options.start = false;
300
+ break;
301
+ default:
302
+ throw new Error(`Unknown hdo enroll option: ${arg}`);
303
+ }
304
+ }
305
+ return options;
306
+ }
307
+ function parseStateFileArg(args) {
308
+ let stateFile;
309
+ for (let index = 0; index < args.length; index += 1) {
310
+ const arg = args[index];
311
+ if (arg === '--state-file') {
312
+ const value = args[index + 1];
313
+ if (!value)
314
+ throw new Error('Missing value for --state-file');
315
+ stateFile = value;
316
+ index += 1;
317
+ }
318
+ else {
319
+ throw new Error(`Unknown hdo option: ${arg}`);
320
+ }
321
+ }
322
+ return stateFile;
323
+ }
324
+ async function resolveAuth(serverUrl, options, previous, username) {
325
+ const token = resolveExplicitToken(options);
326
+ if (token) {
327
+ return {
328
+ accessToken: token,
329
+ refreshToken: previous.refreshToken,
330
+ accessExpiresAt: previous.accessExpiresAt,
331
+ refreshExpiresAt: previous.refreshExpiresAt,
332
+ username,
333
+ };
334
+ }
335
+ if (previous.refreshToken && !tokenExpired(previous.refreshExpiresAt)) {
336
+ try {
337
+ return await refreshAuth(serverUrl, previous.refreshToken, username ?? previous.username);
338
+ }
339
+ catch {
340
+ // Fall through to username/password login.
341
+ }
342
+ }
343
+ const identifier = username;
344
+ const password = resolvePassword(options);
345
+ if (identifier && password) {
346
+ return loginAuth(serverUrl, identifier, password);
347
+ }
348
+ if (previous.bearerToken && !tokenExpired(previous.accessExpiresAt)) {
349
+ return {
350
+ accessToken: previous.bearerToken,
351
+ refreshToken: previous.refreshToken,
352
+ accessExpiresAt: previous.accessExpiresAt,
353
+ refreshExpiresAt: previous.refreshExpiresAt,
354
+ username: username ?? previous.username,
355
+ };
356
+ }
357
+ if (!identifier) {
358
+ throw new Error('Missing --username, HDO_USERNAME, or --token.');
359
+ }
360
+ throw new Error('Missing --password, --password-file, or HDO_PASSWORD.');
361
+ }
362
+ function resolveExplicitToken(options) {
363
+ if (options.token)
364
+ return options.token;
365
+ if (options.tokenFile)
366
+ return (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(options.tokenFile), 'utf8').trim();
367
+ return process.env.HDO_TOKEN ?? process.env.QPJOY_HDO_TOKEN;
368
+ }
369
+ function resolvePassword(options) {
370
+ if (options.password)
371
+ return options.password;
372
+ if (options.passwordFile)
373
+ return (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(options.passwordFile), 'utf8').trim();
374
+ return process.env.HDO_PASSWORD ?? process.env.QPJOY_HDO_PASSWORD;
375
+ }
376
+ async function loginAuth(serverUrl, identifier, password) {
377
+ const raw = await apiJson(serverUrl, '', '/api/v1/auth/login', {
378
+ method: 'POST',
379
+ body: { identifier, password },
380
+ auth: false,
381
+ });
382
+ const root = requireRecord(raw, 'auth response');
383
+ const tokens = requireRecord(root.tokens, 'auth response tokens');
384
+ const accessToken = stringField(tokens.accessToken);
385
+ if (!accessToken)
386
+ throw new Error('Auth response did not include accessToken.');
387
+ return {
388
+ accessToken,
389
+ refreshToken: stringField(tokens.refreshToken) ?? undefined,
390
+ accessExpiresAt: stringField(tokens.accessExpiresAt) ?? undefined,
391
+ refreshExpiresAt: stringField(tokens.refreshExpiresAt) ?? undefined,
392
+ username: identifier,
393
+ };
394
+ }
395
+ async function refreshAuth(serverUrl, refreshToken, username) {
396
+ const raw = await apiJson(serverUrl, '', '/api/v1/auth/refresh', {
397
+ method: 'POST',
398
+ body: { refreshToken },
399
+ auth: false,
400
+ });
401
+ const root = requireRecord(raw, 'refresh response');
402
+ const accessToken = stringField(root.accessToken);
403
+ if (!accessToken)
404
+ throw new Error('Refresh response did not include accessToken.');
405
+ return {
406
+ accessToken,
407
+ refreshToken: stringField(root.refreshToken) ?? refreshToken,
408
+ accessExpiresAt: stringField(root.accessExpiresAt) ?? undefined,
409
+ refreshExpiresAt: stringField(root.refreshExpiresAt) ?? undefined,
410
+ username,
411
+ };
412
+ }
413
+ function resolveKeypair(options, previous, installDir) {
414
+ if (!options.rotateKey && previous.privateKey && previous.publicKey) {
415
+ return { privateKey: previous.privateKey, publicKey: previous.publicKey };
416
+ }
417
+ const runtime = (0, electron_core_wireguard_1.resolveWireGuardRuntime)({
418
+ installDir,
419
+ allowSystemFallback: true,
420
+ });
421
+ if (!runtime.command) {
422
+ throw new Error(runtime.error ?? 'WireGuard wg command unavailable.');
423
+ }
424
+ return (0, electron_core_wireguard_1.generateWireGuardKeyPairWithCli)(runtime.command);
425
+ }
426
+ async function startSystemTunnel(interfaceName, configPath, installDir) {
427
+ if (process.platform === 'linux') {
428
+ inheritRequired('systemctl', ['enable', `wg-quick@${interfaceName}`]);
429
+ inheritRequired('systemctl', ['restart', `wg-quick@${interfaceName}`]);
430
+ return `Enabled and restarted wg-quick@${interfaceName}.`;
431
+ }
432
+ const runtime = (0, electron_core_wireguard_1.resolveWireGuardConnectionRuntime)({
433
+ installDir,
434
+ allowSystemFallback: true,
435
+ });
436
+ if (process.platform === 'darwin') {
437
+ const result = await (0, electron_core_wireguard_1.installDarwinWireGuardLaunchDaemon)({ runtime, configPath });
438
+ if (!result.ok)
439
+ throw new Error(result.message);
440
+ return result.message;
441
+ }
442
+ if (process.platform === 'win32') {
443
+ const result = await (0, electron_core_wireguard_1.setWireGuardTunnelState)({ runtime, configPath, action: 'restart' });
444
+ if (!result.ok)
445
+ throw new Error(result.message);
446
+ return result.message;
447
+ }
448
+ throw new Error(`Unsupported platform for HDO system tunnel: ${process.platform}`);
449
+ }
450
+ function readState(path) {
451
+ if (!(0, node_fs_1.existsSync)(path))
452
+ return {};
453
+ const raw = (0, node_fs_1.readFileSync)(path, 'utf8');
454
+ const parsed = JSON.parse(raw);
455
+ if (!isRecord(parsed))
456
+ return {};
457
+ return parsed;
458
+ }
459
+ function writeState(path, state) {
460
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true, mode: 0o700 });
461
+ (0, node_fs_1.writeFileSync)(path, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
462
+ chmodSyncSafe(path, 0o600);
463
+ }
464
+ function writeWireGuardConfig(path, content) {
465
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true, mode: 0o700 });
466
+ (0, node_fs_1.writeFileSync)(path, content, { mode: 0o600 });
467
+ chmodSyncSafe(path, 0o600);
468
+ }
469
+ function hdoRuntimeFromManifest(manifest, registered, privateKey) {
470
+ const root = requireRecord(manifest, 'manifest');
471
+ const license = requireRecord(root.license, 'manifest.license');
472
+ if (license.active !== true) {
473
+ throw new Error('HDO mesh license is not active for this user/device.');
474
+ }
475
+ const wireGuard = requireRecord(root.wireGuard, 'manifest.wireGuard');
476
+ const client = requireRecord(wireGuard.client, 'manifest.wireGuard.client');
477
+ const domestic = requireRecord(wireGuard.domestic, 'manifest.wireGuard.domestic');
478
+ const overlayIp = stringField(client.overlayIp) ||
479
+ stringField(requireRecord(registered, 'registered device').overlayIp);
480
+ const domesticPublicKey = stringField(domestic.publicKey);
481
+ const domesticEndpoint = stringField(domestic.endpoint);
482
+ if (!overlayIp)
483
+ throw new Error('HDO manifest did not assign an overlay IP.');
484
+ if (!domesticPublicKey || !domesticEndpoint) {
485
+ throw new Error('HDO manifest is missing domestic WireGuard publicKey/endpoint.');
486
+ }
487
+ const routeCidrs = stringArray(wireGuard.routeCidrs);
488
+ const domesticOverlay = stringField(domestic.overlayIp);
489
+ const allowedIps = uniqueStrings([
490
+ ...routeCidrs,
491
+ ...(domesticOverlay ? [`${domesticOverlay}/32`] : []),
492
+ ]).filter((value) => value.includes('/'));
493
+ if (allowedIps.length === 0) {
494
+ throw new Error('HDO manifest did not include WireGuard AllowedIPs.');
495
+ }
496
+ return {
497
+ privateKey,
498
+ address: overlayIp.includes('/') ? overlayIp : `${overlayIp}/32`,
499
+ overlayIp: overlayIp.split('/')[0] || overlayIp,
500
+ domesticPublicKey,
501
+ domesticEndpoint,
502
+ allowedIps,
503
+ generation: numberField(root.generation),
504
+ };
505
+ }
506
+ async function apiJson(serverUrl, token, path, input) {
507
+ const headers = {};
508
+ if (input.auth !== false)
509
+ headers.authorization = `Bearer ${token}`;
510
+ if (input.body !== undefined)
511
+ headers['content-type'] = 'application/json';
512
+ const response = await fetch(`${serverUrl}${path}`, {
513
+ method: input.method,
514
+ headers,
515
+ body: input.body === undefined ? undefined : JSON.stringify(input.body),
516
+ });
517
+ const text = await response.text();
518
+ if (!response.ok) {
519
+ throw new Error(`HDO API ${input.method} ${path} failed: HTTP ${response.status} ${text}`);
520
+ }
521
+ return text ? JSON.parse(text) : null;
522
+ }
523
+ function resolveStateFile(input) {
524
+ return (0, node_path_1.resolve)(input || defaultStateFile());
525
+ }
526
+ function resolveConfigPath(input, interfaceName) {
527
+ return (0, node_path_1.resolve)(input || defaultConfigPath(interfaceName));
528
+ }
529
+ function resolveInstallDir(input) {
530
+ return (0, node_path_1.resolve)(input || defaultInstallDir());
531
+ }
532
+ function defaultStateFile() {
533
+ if (process.platform === 'linux')
534
+ return '/etc/qpjoy/hdo/client.json';
535
+ if (process.platform === 'win32')
536
+ return (0, node_path_1.join)(windowsUserDataDir(), 'client.json');
537
+ return (0, node_path_1.join)((0, node_os_1.homedir)(), '.qpjoy', 'hdo', 'client.json');
538
+ }
539
+ function defaultConfigPath(interfaceName) {
540
+ if (process.platform === 'linux')
541
+ return `/etc/wireguard/${interfaceName}.conf`;
542
+ if (process.platform === 'win32')
543
+ return (0, node_path_1.join)(windowsUserDataDir(), `${interfaceName}.conf`);
544
+ return (0, node_path_1.join)((0, node_os_1.homedir)(), '.qpjoy', 'hdo', `${interfaceName}.conf`);
545
+ }
546
+ function defaultInstallDir() {
547
+ if (process.platform === 'linux')
548
+ return '/usr/local/lib/qpjoy/hdo/bin';
549
+ if (process.platform === 'win32')
550
+ return (0, node_path_1.join)(windowsUserDataDir(), 'bin');
551
+ return (0, node_path_1.join)((0, node_os_1.homedir)(), '.qpjoy', 'hdo', 'bin');
552
+ }
553
+ function windowsUserDataDir() {
554
+ return (0, node_path_1.join)(process.env.LOCALAPPDATA || (0, node_path_1.join)((0, node_os_1.homedir)(), 'AppData', 'Local'), 'QPJoy', 'HDO');
555
+ }
556
+ function inherit(command, args) {
557
+ (0, node_child_process_1.spawnSync)(command, args, { stdio: 'inherit' });
558
+ }
559
+ function inheritRequired(command, args) {
560
+ const result = (0, node_child_process_1.spawnSync)(command, args, { stdio: 'inherit' });
561
+ assertSpawnOk(command, args, result);
562
+ }
563
+ function assertSpawnOk(command, args, result) {
564
+ if (result.error) {
565
+ throw new Error(`${command} failed: ${result.error.message}`);
566
+ }
567
+ if (result.status && result.status !== 0) {
568
+ const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString('utf8') : result.stderr;
569
+ throw new Error(`${command} ${args.join(' ')} failed with exit ${result.status}${stderr ? `: ${stderr}` : ''}`);
570
+ }
571
+ }
572
+ function normalizeBaseUrl(value) {
573
+ if (!value)
574
+ return undefined;
575
+ return value.replace(/\/+$/, '');
576
+ }
577
+ function sanitizeInterfaceName(value) {
578
+ const safe = value.trim();
579
+ if (!/^[a-zA-Z0-9_.-]{1,64}$/.test(safe)) {
580
+ throw new Error(`Invalid WireGuard interface name: ${value}`);
581
+ }
582
+ return safe;
583
+ }
584
+ function sanitizeId(value) {
585
+ return value
586
+ .toLowerCase()
587
+ .replace(/[^a-z0-9._:-]+/g, '-')
588
+ .replace(/^-+|-+$/g, '')
589
+ .slice(0, 80) || 'device';
590
+ }
591
+ function tokenExpired(value) {
592
+ if (!value)
593
+ return false;
594
+ const time = Date.parse(value);
595
+ return Number.isFinite(time) && Date.now() > time - 60_000;
596
+ }
597
+ function chmodSyncSafe(path, mode) {
598
+ if (process.platform === 'win32')
599
+ return;
600
+ (0, node_fs_1.chmodSync)(path, mode);
601
+ }
602
+ function requireRecord(value, label) {
603
+ if (!isRecord(value))
604
+ throw new Error(`${label} is not an object.`);
605
+ return value;
606
+ }
607
+ function isRecord(value) {
608
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
609
+ }
610
+ function stringField(value) {
611
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
612
+ }
613
+ function numberField(value) {
614
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
615
+ }
616
+ function stringArray(value) {
617
+ if (!Array.isArray(value))
618
+ return [];
619
+ return value.map((item) => stringField(item)).filter((item) => Boolean(item));
620
+ }
621
+ function uniqueStrings(values) {
622
+ return [...new Set(values)];
623
+ }
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ const node_child_process_1 = require("node:child_process");
5
5
  const node_fs_1 = require("node:fs");
6
6
  const promises_1 = require("node:fs/promises");
7
7
  const node_path_1 = require("node:path");
8
+ const hdo_1 = require("./hdo");
8
9
  const args = process.argv.slice(2);
9
10
  const packageRoot = (0, node_path_1.resolve)(__dirname, '..');
10
11
  const bundledClientScript = (0, node_path_1.resolve)(packageRoot, 'resources/mihomo-client.sh');
@@ -71,6 +72,7 @@ Usage:
71
72
  qp-tunnel-cli install-script [--target /usr/local/bin/mihomo-client]
72
73
  qp-tunnel-cli script-path
73
74
  qp-tunnel-cli client-help
75
+ qp-tunnel-cli hdo enroll --server-url https://domestic.example.com --username user
74
76
  qp-tunnel-cli <mihomo-client command> [options]
75
77
  qp-tunnel-cli -- <command> [args...]
76
78
  qp-tunnel-cli <command-path> [args...]
@@ -83,11 +85,13 @@ Common commands:
83
85
  qp-tunnel-cli tun-on
84
86
  qp-tunnel-cli tun-off
85
87
  qp-tunnel-cli update-subscription
88
+ qp-tunnel-cli hdo status
86
89
  qp-tunnel-cli uninstall --purge
87
90
  qp-tunnel-cli ./electron-server/scripts/manage.sh redeploy
88
91
 
89
- The npm package is a thin distributor for the Linux mihomo-client script. Client
90
- commands re-run through sudo when needed, then execute the bundled shell script.
92
+ The npm package distributes the Linux mihomo-client script and a cross-platform
93
+ HDO WireGuard enrollment command. Linux mihomo-client commands re-run through
94
+ sudo when needed, then execute the bundled shell script.
91
95
 
92
96
  Unknown commands are executed with QPJoy proxy variables injected. Host commands
93
97
  receive HTTP_PROXY=http://127.0.0.1:<mixed-port>; Docker/Compose build contexts
@@ -97,6 +101,9 @@ QP_TUNNEL_CONTAINER_HTTP_PROXY=http://host.docker.internal:<mixed-port>.
97
101
  Install the script as a normal server command:
98
102
  sudo qp-tunnel-cli install-script
99
103
  sudo mihomo-client status
104
+
105
+ Enroll this machine into an HDO mesh:
106
+ HDO_PASSWORD=... qp-tunnel-cli hdo enroll --server-url https://domestic.example.com --username user
100
107
  `);
101
108
  }
102
109
  function clientHelp() {
@@ -332,6 +339,11 @@ async function main() {
332
339
  installClientScript(args.slice(1));
333
340
  return;
334
341
  }
342
+ if (command === 'hdo' || command === 'hdo-enroll' || command === 'hdo-refresh') {
343
+ const hdoArgs = command === 'hdo' ? args.slice(1) : [command.replace(/^hdo-/, ''), ...args.slice(1)];
344
+ await (0, hdo_1.runHdoCli)(hdoArgs, { isRoot, sudoSelf });
345
+ return;
346
+ }
335
347
  const passthroughCommand = command === '--verbose' ? args[1] : command;
336
348
  if (passthroughCommand && clientCommands.has(passthroughCommand)) {
337
349
  runClientCommand(args);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@qpjoy/tunnel-cli",
3
- "version": "0.1.2",
4
- "description": "Global QPJoy Tunnel CLI for installing and running the Linux mihomo-client script.",
3
+ "version": "0.1.4",
4
+ "description": "Global QPJoy Tunnel CLI for mihomo-client and cross-platform HDO mesh enrollment.",
5
5
  "private": false,
6
6
  "type": "commonjs",
7
7
  "main": "dist/index.js",
@@ -21,6 +21,9 @@
21
21
  "publishConfig": {
22
22
  "access": "public"
23
23
  },
24
+ "dependencies": {
25
+ "@qpjoy/electron-core-wireguard": "^0.1.18"
26
+ },
24
27
  "devDependencies": {
25
28
  "@types/node": "^22.10.7"
26
29
  },