@qpjoy/tunnel-cli 0.1.1 → 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 +70 -3
- package/README.setup.md +16 -0
- package/dist/hdo.d.ts +6 -0
- package/dist/hdo.js +623 -0
- package/dist/index.js +138 -4
- package/package.json +5 -2
- package/resources/mihomo-client.sh +32 -9
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
|
|
|
@@ -27,6 +82,18 @@ proxy and configures shell, SSH, Docker/containerd/buildkit proxy drop-ins witho
|
|
|
27
82
|
enabling TUN route takeover. Reserve `tun-on` for machines that are not serving
|
|
28
83
|
public inbound traffic.
|
|
29
84
|
|
|
85
|
+
Run any command through the active Mihomo local proxy:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
qp-tunnel-cli ./electron-server/scripts/manage.sh redeploy
|
|
89
|
+
qp-tunnel-cli -- docker compose build
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
For host commands, `HTTP_PROXY` points at `127.0.0.1:<mixed-port>`. For
|
|
93
|
+
Docker/Compose build containers, the CLI also injects container-facing variables
|
|
94
|
+
such as `MARKET_CONTAINER_HTTP_PROXY` and `QP_TUNNEL_CONTAINER_HTTP_PROXY`
|
|
95
|
+
pointing at `host.docker.internal:<mixed-port>`.
|
|
96
|
+
|
|
30
97
|
Install the bundled script as a normal Linux command:
|
|
31
98
|
|
|
32
99
|
```bash
|
package/README.setup.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
```bash
|
|
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'
|
|
6
|
+
|
|
7
|
+
sudo qp-tunnel-cli install-script
|
|
8
|
+
sudo qp-tunnel-cli upgrade-systemd
|
|
9
|
+
sudo qp-tunnel-cli server-on
|
|
10
|
+
|
|
11
|
+
sudo qp-tunnel-cli tun-off
|
|
12
|
+
sudo qp-tunnel-cli server-on
|
|
13
|
+
sudo qp-tunnel-cli status
|
|
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,11 +5,34 @@ 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');
|
|
11
12
|
const repoClientScript = (0, node_path_1.resolve)(packageRoot, '../../../scripts/mihomo-client.sh');
|
|
12
13
|
const defaultInstallTarget = '/usr/local/bin/mihomo-client';
|
|
14
|
+
const defaultMihomoConfigFile = '/etc/mihomo-client/config.yaml';
|
|
15
|
+
const defaultNoProxyEntries = [
|
|
16
|
+
'localhost',
|
|
17
|
+
'127.0.0.1',
|
|
18
|
+
'::1',
|
|
19
|
+
'postgres',
|
|
20
|
+
'market',
|
|
21
|
+
'db',
|
|
22
|
+
'redis',
|
|
23
|
+
'host.docker.internal',
|
|
24
|
+
'docker.for.mac.host.internal',
|
|
25
|
+
'docker.for.win.localhost',
|
|
26
|
+
'kubernetes.docker.internal',
|
|
27
|
+
'10.0.0.0/8',
|
|
28
|
+
'172.16.0.0/12',
|
|
29
|
+
'192.168.0.0/16',
|
|
30
|
+
'100.64.0.0/10',
|
|
31
|
+
'100.88.0.0/16',
|
|
32
|
+
'100.89.0.0/16',
|
|
33
|
+
'100.90.0.0/16',
|
|
34
|
+
'.local',
|
|
35
|
+
];
|
|
13
36
|
const clientCommands = new Set([
|
|
14
37
|
'setup',
|
|
15
38
|
'install',
|
|
@@ -49,7 +72,10 @@ Usage:
|
|
|
49
72
|
qp-tunnel-cli install-script [--target /usr/local/bin/mihomo-client]
|
|
50
73
|
qp-tunnel-cli script-path
|
|
51
74
|
qp-tunnel-cli client-help
|
|
75
|
+
qp-tunnel-cli hdo enroll --server-url https://domestic.example.com --username user
|
|
52
76
|
qp-tunnel-cli <mihomo-client command> [options]
|
|
77
|
+
qp-tunnel-cli -- <command> [args...]
|
|
78
|
+
qp-tunnel-cli <command-path> [args...]
|
|
53
79
|
|
|
54
80
|
Common commands:
|
|
55
81
|
qp-tunnel-cli install --url http://IP:3434/peer_user01.mihomo.yaml --user download --password pass
|
|
@@ -59,14 +85,25 @@ Common commands:
|
|
|
59
85
|
qp-tunnel-cli tun-on
|
|
60
86
|
qp-tunnel-cli tun-off
|
|
61
87
|
qp-tunnel-cli update-subscription
|
|
88
|
+
qp-tunnel-cli hdo status
|
|
62
89
|
qp-tunnel-cli uninstall --purge
|
|
90
|
+
qp-tunnel-cli ./electron-server/scripts/manage.sh redeploy
|
|
63
91
|
|
|
64
|
-
The npm package
|
|
65
|
-
|
|
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.
|
|
95
|
+
|
|
96
|
+
Unknown commands are executed with QPJoy proxy variables injected. Host commands
|
|
97
|
+
receive HTTP_PROXY=http://127.0.0.1:<mixed-port>; Docker/Compose build contexts
|
|
98
|
+
also receive container-facing variables such as MARKET_CONTAINER_HTTP_PROXY and
|
|
99
|
+
QP_TUNNEL_CONTAINER_HTTP_PROXY=http://host.docker.internal:<mixed-port>.
|
|
66
100
|
|
|
67
101
|
Install the script as a normal server command:
|
|
68
102
|
sudo qp-tunnel-cli install-script
|
|
69
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
|
|
70
107
|
`);
|
|
71
108
|
}
|
|
72
109
|
function clientHelp() {
|
|
@@ -129,6 +166,94 @@ function runClientCommand(scriptArgs) {
|
|
|
129
166
|
}
|
|
130
167
|
runScriptWithoutSudo(scriptArgs);
|
|
131
168
|
}
|
|
169
|
+
function mixedPortFromConfig() {
|
|
170
|
+
const explicit = process.env.QP_TUNNEL_MIXED_PORT || process.env.MIHOMO_MIXED_PORT;
|
|
171
|
+
if (explicit && /^\d+$/.test(explicit)) {
|
|
172
|
+
return explicit;
|
|
173
|
+
}
|
|
174
|
+
const configFile = process.env.MIHOMO_CONFIG_FILE || defaultMihomoConfigFile;
|
|
175
|
+
if (!(0, node_fs_1.existsSync)(configFile)) {
|
|
176
|
+
process.stderr.write(`Mihomo config not found: ${configFile}\nRun: sudo qp-tunnel-cli install ... && sudo qp-tunnel-cli server-on\n`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
const content = (0, node_fs_1.readFileSync)(configFile, 'utf8');
|
|
180
|
+
const match = /^\s*mixed-port\s*:\s*(\d+)/m.exec(content);
|
|
181
|
+
if (!match) {
|
|
182
|
+
process.stderr.write(`Could not detect mixed-port from ${configFile}\n`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
return match[1];
|
|
186
|
+
}
|
|
187
|
+
function mergeCsvValues(...values) {
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
const merged = [];
|
|
190
|
+
for (const value of values) {
|
|
191
|
+
for (const item of (value || '').split(',')) {
|
|
192
|
+
const trimmed = item.trim();
|
|
193
|
+
if (!trimmed || seen.has(trimmed))
|
|
194
|
+
continue;
|
|
195
|
+
seen.add(trimmed);
|
|
196
|
+
merged.push(trimmed);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return merged.join(',');
|
|
200
|
+
}
|
|
201
|
+
function proxyEnvironment() {
|
|
202
|
+
const port = mixedPortFromConfig();
|
|
203
|
+
const hostProxy = `http://127.0.0.1:${port}`;
|
|
204
|
+
const hostSocksProxy = `socks5://127.0.0.1:${port}`;
|
|
205
|
+
const containerHost = process.env.QP_TUNNEL_CONTAINER_HOST || 'host.docker.internal';
|
|
206
|
+
const containerProxy = `http://${containerHost}:${port}`;
|
|
207
|
+
const noProxy = mergeCsvValues(process.env.NO_PROXY, process.env.no_proxy, defaultNoProxyEntries.join(','));
|
|
208
|
+
const containerNoProxy = mergeCsvValues(process.env.MARKET_CONTAINER_NO_PROXY, process.env.QP_TUNNEL_CONTAINER_NO_PROXY, noProxy);
|
|
209
|
+
return {
|
|
210
|
+
...process.env,
|
|
211
|
+
HTTP_PROXY: hostProxy,
|
|
212
|
+
HTTPS_PROXY: hostProxy,
|
|
213
|
+
ALL_PROXY: hostSocksProxy,
|
|
214
|
+
http_proxy: hostProxy,
|
|
215
|
+
https_proxy: hostProxy,
|
|
216
|
+
all_proxy: hostSocksProxy,
|
|
217
|
+
NO_PROXY: noProxy,
|
|
218
|
+
no_proxy: noProxy,
|
|
219
|
+
npm_config_proxy: hostProxy,
|
|
220
|
+
npm_config_https_proxy: hostProxy,
|
|
221
|
+
npm_config_noproxy: noProxy,
|
|
222
|
+
pnpm_config_proxy: hostProxy,
|
|
223
|
+
pnpm_config_https_proxy: hostProxy,
|
|
224
|
+
pnpm_config_noproxy: noProxy,
|
|
225
|
+
QP_TUNNEL_MIXED_PORT: port,
|
|
226
|
+
QP_TUNNEL_HOST_HTTP_PROXY: hostProxy,
|
|
227
|
+
QP_TUNNEL_HOST_HTTPS_PROXY: hostProxy,
|
|
228
|
+
QP_TUNNEL_HOST_ALL_PROXY: hostSocksProxy,
|
|
229
|
+
QP_TUNNEL_CONTAINER_HTTP_PROXY: containerProxy,
|
|
230
|
+
QP_TUNNEL_CONTAINER_HTTPS_PROXY: containerProxy,
|
|
231
|
+
QP_TUNNEL_CONTAINER_NO_PROXY: containerNoProxy,
|
|
232
|
+
CONTAINER_HTTP_PROXY: containerProxy,
|
|
233
|
+
CONTAINER_HTTPS_PROXY: containerProxy,
|
|
234
|
+
CONTAINER_NO_PROXY: containerNoProxy,
|
|
235
|
+
BUILD_CONTAINER_HTTP_PROXY: containerProxy,
|
|
236
|
+
BUILD_CONTAINER_HTTPS_PROXY: containerProxy,
|
|
237
|
+
BUILD_CONTAINER_NO_PROXY: containerNoProxy,
|
|
238
|
+
MARKET_CONTAINER_HTTP_PROXY: process.env.MARKET_CONTAINER_HTTP_PROXY || containerProxy,
|
|
239
|
+
MARKET_CONTAINER_HTTPS_PROXY: process.env.MARKET_CONTAINER_HTTPS_PROXY || containerProxy,
|
|
240
|
+
MARKET_CONTAINER_NO_PROXY: containerNoProxy,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function runExternalCommand(commandArgs) {
|
|
244
|
+
if (commandArgs.length === 0) {
|
|
245
|
+
process.stderr.write('Missing command after qp-tunnel-cli --\n');
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
const [rawCommand, ...rawArgs] = commandArgs;
|
|
249
|
+
const command = rawCommand === 'sudo' && !rawArgs.includes('-E') ? 'sudo' : rawCommand;
|
|
250
|
+
const commandArgsWithSudoEnv = rawCommand === 'sudo' && !rawArgs.includes('-E') ? ['-E', ...rawArgs] : rawArgs;
|
|
251
|
+
const result = (0, node_child_process_1.spawnSync)(command, commandArgsWithSudoEnv, {
|
|
252
|
+
stdio: 'inherit',
|
|
253
|
+
env: proxyEnvironment(),
|
|
254
|
+
});
|
|
255
|
+
exitFromSpawn(result);
|
|
256
|
+
}
|
|
132
257
|
function parseInstallScriptArgs(scriptArgs) {
|
|
133
258
|
let target = defaultInstallTarget;
|
|
134
259
|
for (let index = 0; index < scriptArgs.length; index += 1) {
|
|
@@ -192,6 +317,9 @@ async function printScriptPath() {
|
|
|
192
317
|
}
|
|
193
318
|
async function main() {
|
|
194
319
|
const command = args[0] ?? 'help';
|
|
320
|
+
if (command === '--') {
|
|
321
|
+
runExternalCommand(args.slice(1));
|
|
322
|
+
}
|
|
195
323
|
if (command === 'help' || command === '--help' || command === '-h') {
|
|
196
324
|
help();
|
|
197
325
|
return;
|
|
@@ -211,13 +339,19 @@ async function main() {
|
|
|
211
339
|
installClientScript(args.slice(1));
|
|
212
340
|
return;
|
|
213
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
|
+
}
|
|
214
347
|
const passthroughCommand = command === '--verbose' ? args[1] : command;
|
|
215
348
|
if (passthroughCommand && clientCommands.has(passthroughCommand)) {
|
|
216
349
|
runClientCommand(args);
|
|
217
350
|
}
|
|
218
|
-
|
|
351
|
+
if (args.length > 0) {
|
|
352
|
+
runExternalCommand(args);
|
|
353
|
+
}
|
|
219
354
|
help();
|
|
220
|
-
process.exitCode = 1;
|
|
221
355
|
}
|
|
222
356
|
main().catch((error) => {
|
|
223
357
|
const message = error instanceof Error ? error.message : String(error);
|
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
|
},
|
|
@@ -97,6 +97,7 @@ Examples:
|
|
|
97
97
|
sudo bash ./scripts/mihomo-client.sh ssh-proxy-on
|
|
98
98
|
sudo bash ./scripts/mihomo-client.sh daemon-proxy-on
|
|
99
99
|
sudo bash ./scripts/mihomo-client.sh run curl -I https://www.google.com/generate_204
|
|
100
|
+
sudo bash ./scripts/mihomo-client.sh run ./electron-server/scripts/manage.sh redeploy
|
|
100
101
|
sudo bash ./scripts/mihomo-client.sh print-env
|
|
101
102
|
EOF
|
|
102
103
|
}
|
|
@@ -532,10 +533,10 @@ docker_proxy_env_lines() {
|
|
|
532
533
|
cat <<EOF
|
|
533
534
|
HTTP_PROXY=http://127.0.0.1:$port
|
|
534
535
|
HTTPS_PROXY=http://127.0.0.1:$port
|
|
535
|
-
NO_PROXY
|
|
536
|
+
NO_PROXY=$MIHOMO_NO_PROXY
|
|
536
537
|
http_proxy=http://127.0.0.1:$port
|
|
537
538
|
https_proxy=http://127.0.0.1:$port
|
|
538
|
-
no_proxy
|
|
539
|
+
no_proxy=$MIHOMO_NO_PROXY
|
|
539
540
|
EOF
|
|
540
541
|
}
|
|
541
542
|
|
|
@@ -814,16 +815,38 @@ server_off_command() {
|
|
|
814
815
|
}
|
|
815
816
|
|
|
816
817
|
run_command() {
|
|
817
|
-
local port
|
|
818
|
+
local port host_proxy host_socks_proxy container_host container_proxy
|
|
818
819
|
[[ $# -gt 0 ]] || die "Usage: sudo bash ./scripts/mihomo-client.sh run <command> [args...]"
|
|
819
820
|
port="$(mixed_port_from_config)"
|
|
820
821
|
[[ -n "$port" ]] || die "Could not detect mixed-port from $MIHOMO_CONFIG_FILE"
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
822
|
+
host_proxy="http://127.0.0.1:$port"
|
|
823
|
+
host_socks_proxy="socks5://127.0.0.1:$port"
|
|
824
|
+
container_host="${QP_TUNNEL_CONTAINER_HOST:-host.docker.internal}"
|
|
825
|
+
container_proxy="http://${container_host}:$port"
|
|
826
|
+
http_proxy="$host_proxy" \
|
|
827
|
+
https_proxy="$host_proxy" \
|
|
828
|
+
HTTP_PROXY="$host_proxy" \
|
|
829
|
+
HTTPS_PROXY="$host_proxy" \
|
|
830
|
+
all_proxy="$host_socks_proxy" \
|
|
831
|
+
ALL_PROXY="$host_socks_proxy" \
|
|
832
|
+
no_proxy="$MIHOMO_NO_PROXY" \
|
|
833
|
+
NO_PROXY="$MIHOMO_NO_PROXY" \
|
|
834
|
+
QP_TUNNEL_MIXED_PORT="$port" \
|
|
835
|
+
QP_TUNNEL_HOST_HTTP_PROXY="$host_proxy" \
|
|
836
|
+
QP_TUNNEL_HOST_HTTPS_PROXY="$host_proxy" \
|
|
837
|
+
QP_TUNNEL_HOST_ALL_PROXY="$host_socks_proxy" \
|
|
838
|
+
QP_TUNNEL_CONTAINER_HTTP_PROXY="$container_proxy" \
|
|
839
|
+
QP_TUNNEL_CONTAINER_HTTPS_PROXY="$container_proxy" \
|
|
840
|
+
QP_TUNNEL_CONTAINER_NO_PROXY="$MIHOMO_NO_PROXY" \
|
|
841
|
+
CONTAINER_HTTP_PROXY="$container_proxy" \
|
|
842
|
+
CONTAINER_HTTPS_PROXY="$container_proxy" \
|
|
843
|
+
CONTAINER_NO_PROXY="$MIHOMO_NO_PROXY" \
|
|
844
|
+
BUILD_CONTAINER_HTTP_PROXY="$container_proxy" \
|
|
845
|
+
BUILD_CONTAINER_HTTPS_PROXY="$container_proxy" \
|
|
846
|
+
BUILD_CONTAINER_NO_PROXY="$MIHOMO_NO_PROXY" \
|
|
847
|
+
MARKET_CONTAINER_HTTP_PROXY="${MARKET_CONTAINER_HTTP_PROXY:-$container_proxy}" \
|
|
848
|
+
MARKET_CONTAINER_HTTPS_PROXY="${MARKET_CONTAINER_HTTPS_PROXY:-$container_proxy}" \
|
|
849
|
+
MARKET_CONTAINER_NO_PROXY="${MARKET_CONTAINER_NO_PROXY:-$MIHOMO_NO_PROXY}" \
|
|
827
850
|
"$@"
|
|
828
851
|
}
|
|
829
852
|
|