@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 +58 -3
- package/README.setup.md +8 -2
- package/dist/hdo.d.ts +6 -0
- package/dist/hdo.js +623 -0
- package/dist/index.js +14 -2
- package/package.json +5 -2
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
|
|
12
|
-
|
|
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.
|
|
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
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
|
|
90
|
-
|
|
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.
|
|
4
|
-
"description": "Global QPJoy Tunnel CLI for
|
|
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
|
},
|