@qpjoy/tunnel-cli 0.1.8 → 0.1.10

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
@@ -23,6 +23,8 @@ server, enroll into the HDO mesh without installing Electron:
23
23
  npm i -g @qpjoy/tunnel-cli
24
24
  HDO_PASSWORD='<password>' qp-tunnel-cli hdo enroll \
25
25
  --server-url 'https://domestic.example.com' \
26
+ --internal-url 'http://127.0.0.1:18090' \
27
+ --product-id h2o \
26
28
  --username 'internal-i' \
27
29
  --device-id internal-i \
28
30
  --label 'Internal I'
@@ -40,6 +42,8 @@ HDO_PASSWORD='<password>' sudo -E qp-tunnel-cli hdo enroll \
40
42
  The command:
41
43
 
42
44
  - logs in with username/password, or uses `--token` when provided
45
+ - requests an MX Launcher Internal product lease when `--internal-url` is set
46
+ - uses that lease as the WireGuard client address and adds the product route CIDR
43
47
  - uses `@qpjoy/electron-core-wireguard` to find/install the platform WireGuard engine
44
48
  - registers the machine as an HDO device
45
49
  - downloads the HDO manifest from `electron-server`
@@ -49,6 +53,22 @@ The command:
49
53
  installed by the OS, the CLI writes a compatible systemd unit that uses the
50
54
  bundled WireGuard tools from the npm package.
51
55
 
56
+ To test the new Internal allocator without applying WireGuard yet, run lease-only
57
+ mode. If `--server-url` is omitted and `--internal-url` is present, the command
58
+ automatically behaves as lease-only:
59
+
60
+ ```bash
61
+ qp-tunnel-cli hdo enroll \
62
+ --internal-url 'http://127.0.0.1:18090' \
63
+ --product-id h2o \
64
+ --identity-kind anonymous \
65
+ --lease-only
66
+ ```
67
+
68
+ For H2O, Internal assigns logged-in users from `10.90.0.1-10.90.99.254` and
69
+ anonymous users from `10.90.100.1-10.90.254.254`, based on the Product Network
70
+ Registry.
71
+
52
72
  Useful follow-up commands:
53
73
 
54
74
  ```bash
@@ -81,16 +101,25 @@ Run commands directly through the bundled script:
81
101
 
82
102
  ```bash
83
103
  qp-tunnel-cli status
84
- qp-tunnel-cli server-on
104
+ qp-tunnel-cli egress-on
85
105
  qp-tunnel-cli update-subscription
86
106
  ```
87
107
 
88
108
  For server commands, `qp-tunnel-cli` re-runs itself with `sudo` when root is needed.
89
- Use `server-on` for public VPS hosts: it keeps Mihomo running as a local outbound
109
+ Use `egress-on` for public VPS hosts: it keeps Mihomo running as a local outbound
90
110
  proxy and configures shell, SSH, Docker/containerd/buildkit proxy drop-ins without
91
111
  enabling TUN route takeover. Reserve `tun-on` for machines that are not serving
92
112
  public inbound traffic.
93
113
 
114
+ Domestic bootstrap can install from an Internal-pushed subscription file before
115
+ the WG relay can reach Internal:
116
+
117
+ ```bash
118
+ sudo qp-tunnel-cli install \
119
+ --file /opt/mx/current/qp-tunnel-cli/domestic-bootstrap-subscription.yaml
120
+ sudo qp-tunnel-cli egress-on
121
+ ```
122
+
94
123
  Run any command through the active Mihomo local proxy:
95
124
 
96
125
  ```bash
@@ -109,7 +138,7 @@ Install the bundled script as a normal Linux command:
109
138
  sudo qp-tunnel-cli install-script
110
139
  sudo qp-tunnel-cli upgrade-systemd
111
140
  sudo mihomo-client status
112
- sudo mihomo-client server-on
141
+ sudo mihomo-client egress-on
113
142
  ```
114
143
 
115
144
  Use a custom target when needed:
package/README.setup.md CHANGED
@@ -1,15 +1,18 @@
1
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
2
+ npm i -g @qpjoy/tunnel-cli@0.1.9
3
+ qp-tunnel-cli hdo enroll --internal-url 'http://127.0.0.1:18090' --product-id h2o --identity-kind anonymous --lease-only
4
+ HDO_PASSWORD='...' qp-tunnel-cli hdo enroll --server-url 'https://domestic.example.com' --internal-url 'http://127.0.0.1:18090' --product-id h2o --username internal-i
4
5
 
5
6
  qp-tunnel-cli install --url 'http://user:pass@host:3434/peer_xxx.mihomo.yaml'
7
+ # Domestic bootstrap can use an Internal-pushed local YAML before WG relay reaches Internal.
8
+ qp-tunnel-cli install --file '/opt/mx/current/qp-tunnel-cli/domestic-bootstrap-subscription.yaml'
6
9
 
7
10
  sudo qp-tunnel-cli install-script
8
11
  sudo qp-tunnel-cli upgrade-systemd
9
- sudo qp-tunnel-cli server-on
12
+ sudo qp-tunnel-cli egress-on
10
13
 
11
14
  sudo qp-tunnel-cli tun-off
12
- sudo qp-tunnel-cli server-on
15
+ sudo qp-tunnel-cli egress-on
13
16
  sudo qp-tunnel-cli status
14
17
 
15
18
  qp-tunnel-cli curl google.com
package/dist/hdo.js CHANGED
@@ -46,6 +46,11 @@ Usage:
46
46
 
47
47
  Enroll options:
48
48
  --server-url URL HDO/electron-server base URL
49
+ --internal-url URL MX Launcher Internal URL for product IP lease allocation
50
+ --product-id ID Product network id. Default: h2o
51
+ --mode MODE Launcher mode: embed or standalone
52
+ --identity-kind KIND user or anonymous. Default: user when --username is set, else anonymous
53
+ --lease-only Only request/store the Internal lease; skip legacy HDO manifest/WireGuard apply
49
54
  --username USER Login username/email/phone
50
55
  --password PASS Login password. Prefer HDO_PASSWORD or --password-file
51
56
  --password-file PATH Read login password from a file
@@ -69,6 +74,8 @@ Enroll options:
69
74
 
70
75
  Environment:
71
76
  HDO_SERVER_URL / QPJOY_HDO_SERVER_URL
77
+ HDO_INTERNAL_URL / QPJOY_HDO_INTERNAL_URL / MX_INTERNAL_URL
78
+ HDO_PRODUCT_ID / QPJOY_HDO_PRODUCT_ID
72
79
  HDO_USERNAME / QPJOY_HDO_USERNAME
73
80
  HDO_PASSWORD / QPJOY_HDO_PASSWORD
74
81
  HDO_TOKEN / QPJOY_HDO_TOKEN
@@ -109,20 +116,87 @@ async function enrollCommand(args, refreshOnly) {
109
116
  process.env.HDO_SERVER_URL ??
110
117
  process.env.QPJOY_HDO_SERVER_URL ??
111
118
  previous.serverUrl);
119
+ const internalUrl = normalizeBaseUrl(options.internalUrl ??
120
+ process.env.HDO_INTERNAL_URL ??
121
+ process.env.QPJOY_HDO_INTERNAL_URL ??
122
+ process.env.MX_INTERNAL_URL ??
123
+ previous.internalUrl);
112
124
  const username = options.username ??
113
125
  process.env.HDO_USERNAME ??
114
126
  process.env.QPJOY_HDO_USERNAME ??
115
127
  previous.username;
116
- if (!serverUrl) {
117
- throw new Error('Missing --server-url or HDO_SERVER_URL.');
128
+ const productId = sanitizeId(options.productId ??
129
+ process.env.HDO_PRODUCT_ID ??
130
+ process.env.QPJOY_HDO_PRODUCT_ID ??
131
+ previous.productId ??
132
+ 'h2o');
133
+ const launcherMode = options.mode ?? previous.launcherMode ?? (productId === 'launcher' ? 'standalone' : 'embed');
134
+ const identityKind = options.identityKind ?? previous.identityKind ?? (username ? 'user' : 'anonymous');
135
+ const leaseOnly = Boolean(options.leaseOnly || (internalUrl && !serverUrl));
136
+ if (!serverUrl && !leaseOnly) {
137
+ throw new Error('Missing --server-url/HDO_SERVER_URL or use --internal-url with --lease-only.');
118
138
  }
119
- const auth = await resolveAuth(serverUrl, options, previous, username);
120
139
  const deviceId = options.deviceId ||
121
140
  previous.deviceId ||
122
141
  `hdo-${process.platform}-${sanitizeId((0, node_os_1.hostname)())}`;
123
142
  const label = options.label || previous.label || `${process.platform} ${(0, node_os_1.hostname)()}`;
124
143
  const keys = resolveKeypair(options, previous, installDir);
125
144
  const direct = resolveDirectEndpoint(options, previous);
145
+ const launcherNetworkLease = internalUrl
146
+ ? await enrollLauncherNetworkLease(internalUrl, {
147
+ productId,
148
+ mode: launcherMode,
149
+ identityKind,
150
+ installId: deviceId,
151
+ deviceId,
152
+ siteId: previous.launcherNetworkLease?.siteId,
153
+ userId: identityKind === 'user' ? username : undefined,
154
+ publicKey: keys.publicKey,
155
+ deviceLabel: label,
156
+ platform: `${process.platform}-${process.arch}`,
157
+ requestedBy: '@qpjoy/tunnel-cli',
158
+ requestId: `qp-tunnel-cli-hdo-enroll-${Date.now()}`,
159
+ })
160
+ : undefined;
161
+ if (leaseOnly) {
162
+ const now = new Date().toISOString();
163
+ writeState(stateFile, {
164
+ ...previous,
165
+ internalUrl,
166
+ productId,
167
+ launcherMode,
168
+ identityKind,
169
+ username,
170
+ deviceId,
171
+ label,
172
+ interfaceName,
173
+ configPath,
174
+ installDir,
175
+ privateKey: keys.privateKey,
176
+ publicKey: keys.publicKey,
177
+ overlayIp: launcherNetworkLease?.leaseIp ?? previous.overlayIp,
178
+ launcherNetworkLease: launcherNetworkLease ?? previous.launcherNetworkLease,
179
+ enrolledAt: previous.enrolledAt || now,
180
+ updatedAt: now,
181
+ });
182
+ process.stdout.write([
183
+ refreshOnly ? 'HDO Internal lease refreshed.' : 'HDO Internal lease enrolled.',
184
+ `Product: ${productId}`,
185
+ `Mode: ${launcherMode}`,
186
+ `Identity: ${identityKind}`,
187
+ `Device: ${deviceId}`,
188
+ `Lease IP: ${launcherNetworkLease?.leaseIp ?? previous.overlayIp ?? 'unassigned'}`,
189
+ `Lease CIDR: ${launcherNetworkLease?.cidr ?? 'unassigned'}`,
190
+ `State file: ${stateFile}`,
191
+ 'System tunnel not started (lease-only).',
192
+ '',
193
+ ].join('\n'));
194
+ return;
195
+ }
196
+ if (!serverUrl) {
197
+ throw new Error('Missing --server-url or HDO_SERVER_URL.');
198
+ }
199
+ const auth = await resolveAuth(serverUrl, options, previous, username);
126
200
  const registered = await apiJson(serverUrl, auth.accessToken, '/api/v1/hdo/devices/register', {
127
201
  method: 'POST',
128
202
  body: {
@@ -154,6 +228,8 @@ async function enrollCommand(args, refreshOnly) {
154
228
  });
155
229
  const runtime = hdoRuntimeFromManifest(manifest, registered, keys.privateKey, {
156
230
  allowEndpointlessDirectPeers: direct.directListener,
231
+ launcherNetworkLease,
232
+ ownPublicKey: keys.publicKey,
157
233
  });
158
234
  writeWireGuardConfig(configPath, (0, electron_core_wireguard_1.renderHdoClientWireGuardConfig)({
159
235
  privateKey: runtime.privateKey,
@@ -168,11 +244,15 @@ async function enrollCommand(args, refreshOnly) {
168
244
  const now = new Date().toISOString();
169
245
  writeState(stateFile, {
170
246
  serverUrl,
247
+ internalUrl,
171
248
  bearerToken: auth.accessToken,
172
249
  refreshToken: auth.refreshToken,
173
250
  accessExpiresAt: auth.accessExpiresAt,
174
251
  refreshExpiresAt: auth.refreshExpiresAt,
175
252
  username: auth.username ?? username,
253
+ productId,
254
+ launcherMode,
255
+ identityKind,
176
256
  deviceId,
177
257
  label,
178
258
  interfaceName,
@@ -181,6 +261,7 @@ async function enrollCommand(args, refreshOnly) {
181
261
  privateKey: keys.privateKey,
182
262
  publicKey: keys.publicKey,
183
263
  overlayIp: runtime.overlayIp,
264
+ launcherNetworkLease: launcherNetworkLease ?? previous.launcherNetworkLease,
184
265
  directListener: direct.directListener,
185
266
  preferDirectPeers: direct.preferDirectPeers,
186
267
  endpointHost: direct.endpointHost,
@@ -215,8 +296,13 @@ function statusCommand(input) {
215
296
  });
216
297
  process.stdout.write(`State file: ${stateFile}\n`);
217
298
  process.stdout.write(`Server URL: ${state.serverUrl || 'unset'}\n`);
299
+ process.stdout.write(`Internal URL: ${state.internalUrl || 'unset'}\n`);
300
+ process.stdout.write(`Product: ${state.productId || 'unset'} (${state.launcherMode || 'unset'} / ${state.identityKind || 'unset'})\n`);
218
301
  process.stdout.write(`Device: ${state.deviceId || 'unset'}\n`);
219
302
  process.stdout.write(`Overlay IP: ${state.overlayIp || 'unset'}\n`);
303
+ if (state.launcherNetworkLease) {
304
+ process.stdout.write(`Internal lease: ${state.launcherNetworkLease.leaseIp} ${state.launcherNetworkLease.cidr} (${state.launcherNetworkLease.leaseId})\n`);
305
+ }
220
306
  process.stdout.write(`WireGuard config: ${configPath}\n\n`);
221
307
  if (process.platform === 'linux') {
222
308
  if (commandAvailable('systemctl')) {
@@ -296,6 +382,7 @@ function parseEnrollOptions(args) {
296
382
  interfaceName: defaultInterfaceName,
297
383
  role: 'internal',
298
384
  start: true,
385
+ leaseOnly: false,
299
386
  rotateKey: false,
300
387
  directListener: false,
301
388
  preferDirectPeers: false,
@@ -313,6 +400,39 @@ function parseEnrollOptions(args) {
313
400
  case '--server-url':
314
401
  options.serverUrl = readValue();
315
402
  break;
403
+ case '--internal-url':
404
+ case '--mx-internal-url':
405
+ options.internalUrl = readValue();
406
+ break;
407
+ case '--product-id':
408
+ case '--app-id':
409
+ options.productId = readValue();
410
+ break;
411
+ case '--mode': {
412
+ const value = readValue();
413
+ if (value !== 'standalone' && value !== 'embed')
414
+ throw new Error(`Invalid --mode: ${value}`);
415
+ options.mode = value;
416
+ break;
417
+ }
418
+ case '--identity-kind': {
419
+ const value = readValue();
420
+ if (value !== 'user' && value !== 'anonymous')
421
+ throw new Error(`Invalid --identity-kind: ${value}`);
422
+ options.identityKind = value;
423
+ break;
424
+ }
425
+ case '--anonymous':
426
+ options.identityKind = 'anonymous';
427
+ break;
428
+ case '--user-identity':
429
+ case '--employee':
430
+ options.identityKind = 'user';
431
+ break;
432
+ case '--lease-only':
433
+ options.leaseOnly = true;
434
+ options.start = false;
435
+ break;
316
436
  case '--token':
317
437
  options.token = readValue();
318
438
  break;
@@ -498,6 +618,68 @@ async function refreshAuth(serverUrl, refreshToken, username) {
498
618
  username,
499
619
  };
500
620
  }
621
+ async function enrollLauncherNetworkLease(internalUrl, input) {
622
+ const raw = await apiJson(internalUrl, '', '/internal/v1/launcher-network/enrollments', {
623
+ method: 'POST',
624
+ auth: false,
625
+ body: input,
626
+ });
627
+ const root = requireRecord(raw, 'launcher network enroll response');
628
+ const lease = requireRecord(root.lease, 'launcher network lease');
629
+ return parseLauncherNetworkLease(lease);
630
+ }
631
+ function parseLauncherNetworkLease(lease) {
632
+ const leaseId = stringField(lease.leaseId);
633
+ const productId = stringField(lease.productId);
634
+ const launcherMode = stringField(lease.launcherMode);
635
+ const identityKind = stringField(lease.identityKind);
636
+ const installId = stringField(lease.installId);
637
+ const deviceId = stringField(lease.deviceId);
638
+ const siteId = stringField(lease.siteId);
639
+ const cidr = stringField(lease.cidr);
640
+ const leaseIp = stringField(lease.leaseIp);
641
+ if (!leaseId || !productId || !installId || !deviceId || !siteId || !cidr || !leaseIp) {
642
+ throw new Error('Launcher Network lease response is missing required fields.');
643
+ }
644
+ if (launcherMode !== 'standalone' && launcherMode !== 'embed') {
645
+ throw new Error(`Launcher Network lease response has invalid launcherMode: ${launcherMode ?? 'missing'}`);
646
+ }
647
+ if (identityKind !== 'user' && identityKind !== 'anonymous') {
648
+ throw new Error(`Launcher Network lease response has invalid identityKind: ${identityKind ?? 'missing'}`);
649
+ }
650
+ return {
651
+ leaseId,
652
+ leaseKey: stringField(lease.leaseKey) ?? undefined,
653
+ productId,
654
+ launcherMode,
655
+ identityKind,
656
+ sequence: numberField(lease.sequence),
657
+ installId,
658
+ deviceId,
659
+ siteId,
660
+ userId: stringField(lease.userId),
661
+ cidr,
662
+ leaseIp,
663
+ serviceVip: stringField(lease.serviceVip) ?? undefined,
664
+ internalControlIp: stringField(lease.internalControlIp) ?? undefined,
665
+ domesticGatewayIp: stringField(lease.domesticGatewayIp) ?? undefined,
666
+ domesticSiteId: stringField(lease.domesticSiteId) ?? undefined,
667
+ overseaSiteId: stringField(lease.overseaSiteId) ?? undefined,
668
+ publicKey: stringField(lease.publicKey),
669
+ status: stringField(lease.status) ?? undefined,
670
+ updatedAt: stringField(lease.updatedAt) ?? undefined,
671
+ };
672
+ }
673
+ function allowedIpsFromLauncherNetworkLease(lease) {
674
+ if (!lease)
675
+ return [];
676
+ return uniqueStrings([
677
+ lease.cidr,
678
+ lease.serviceVip ? `${lease.serviceVip}/32` : '',
679
+ lease.internalControlIp ? `${lease.internalControlIp}/32` : '',
680
+ lease.domesticGatewayIp ? `${lease.domesticGatewayIp}/32` : '',
681
+ ]).filter((value) => value.includes('/'));
682
+ }
501
683
  function resolveKeypair(options, previous, installDir) {
502
684
  if (!options.rotateKey && previous.privateKey && previous.publicKey) {
503
685
  return { privateKey: previous.privateKey, publicKey: previous.publicKey };
@@ -550,6 +732,8 @@ function directPeersFromManifest(wireGuard, ownOverlayIp, options = {}) {
550
732
  const overlayIp = stringField(row.overlayIp);
551
733
  if (!publicKey || !overlayIp || overlayIp === ownIp)
552
734
  return [];
735
+ if (options.ownPublicKey && publicKey === options.ownPublicKey)
736
+ return [];
553
737
  const allowedIps = stringArray(row.allowedIps);
554
738
  const endpoint = stringField(row.endpoint);
555
739
  if (!endpoint && options.allowEndpointlessDirectPeers !== true)
@@ -625,8 +809,9 @@ function hdoRuntimeFromManifest(manifest, registered, privateKey, options = {})
625
809
  const wireGuard = requireRecord(root.wireGuard, 'manifest.wireGuard');
626
810
  const client = requireRecord(wireGuard.client, 'manifest.wireGuard.client');
627
811
  const domestic = requireRecord(wireGuard.domestic, 'manifest.wireGuard.domestic');
628
- const overlayIp = stringField(client.overlayIp) ||
812
+ const manifestOverlayIp = stringField(client.overlayIp) ||
629
813
  stringField(requireRecord(registered, 'registered device').overlayIp);
814
+ const overlayIp = options.launcherNetworkLease?.leaseIp || manifestOverlayIp;
630
815
  const domesticPublicKey = stringField(domestic.publicKey);
631
816
  const domesticEndpoint = stringField(domestic.endpoint);
632
817
  if (!overlayIp)
@@ -638,6 +823,7 @@ function hdoRuntimeFromManifest(manifest, registered, privateKey, options = {})
638
823
  const domesticOverlay = stringField(domestic.overlayIp);
639
824
  const allowedIps = uniqueStrings([
640
825
  ...routeCidrs,
826
+ ...allowedIpsFromLauncherNetworkLease(options.launcherNetworkLease),
641
827
  ...(domesticOverlay ? [`${domesticOverlay}/32`] : []),
642
828
  ]).filter((value) => value.includes('/'));
643
829
  if (allowedIps.length === 0) {
package/dist/index.js CHANGED
@@ -24,14 +24,26 @@ const defaultNoProxyEntries = [
24
24
  'docker.for.mac.host.internal',
25
25
  'docker.for.win.localhost',
26
26
  'kubernetes.docker.internal',
27
+ 'kubernetes.default.svc',
28
+ '.cluster.local',
27
29
  '10.0.0.0/8',
28
30
  '172.16.0.0/12',
29
31
  '192.168.0.0/16',
32
+ '169.254.0.0/16',
33
+ '169.254.169.254',
34
+ '169.254.169.254/32',
35
+ '100.100.100.200',
36
+ '100.100.100.200/32',
30
37
  '100.64.0.0/10',
31
38
  '100.88.0.0/16',
32
39
  '100.89.0.0/16',
33
40
  '100.90.0.0/16',
41
+ '10.88.0.0/16',
42
+ '10.89.0.0/16',
43
+ '10.90.0.0/16',
44
+ '10.91.0.0/16',
34
45
  '.local',
46
+ '.lan',
35
47
  ];
36
48
  const clientCommands = new Set([
37
49
  'setup',
@@ -79,9 +91,10 @@ Usage:
79
91
 
80
92
  Common commands:
81
93
  qp-tunnel-cli install --url http://IP:3434/peer_user01.mihomo.yaml --user download --password pass
94
+ qp-tunnel-cli install --file /opt/mx/current/qp-tunnel-cli/domestic-bootstrap-subscription.yaml
82
95
  qp-tunnel-cli status
83
96
  qp-tunnel-cli start
84
- qp-tunnel-cli server-on
97
+ qp-tunnel-cli egress-on
85
98
  qp-tunnel-cli tun-on
86
99
  qp-tunnel-cli tun-off
87
100
  qp-tunnel-cli update-subscription
@@ -101,6 +114,7 @@ QP_TUNNEL_CONTAINER_HTTP_PROXY=http://host.docker.internal:<mixed-port>.
101
114
  Install the script as a normal server command:
102
115
  sudo qp-tunnel-cli install-script
103
116
  sudo mihomo-client status
117
+ sudo mihomo-client egress-on
104
118
 
105
119
  Enroll this machine into an HDO mesh:
106
120
  HDO_PASSWORD=... qp-tunnel-cli hdo enroll --server-url https://domestic.example.com --username user
@@ -173,7 +187,7 @@ function mixedPortFromConfig() {
173
187
  }
174
188
  const configFile = process.env.MIHOMO_CONFIG_FILE || defaultMihomoConfigFile;
175
189
  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`);
190
+ process.stderr.write(`Mihomo config not found: ${configFile}\nRun: sudo qp-tunnel-cli install ... && sudo qp-tunnel-cli egress-on\n`);
177
191
  process.exit(1);
178
192
  }
179
193
  const content = (0, node_fs_1.readFileSync)(configFile, 'utf8');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qpjoy/tunnel-cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Global QPJoy Tunnel CLI for mihomo-client and cross-platform HDO mesh enrollment.",
5
5
  "private": false,
6
6
  "type": "commonjs",
@@ -22,13 +22,14 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@qpjoy/electron-core-wireguard": "^0.1.21"
25
+ "@qpjoy/electron-core-wireguard": "^0.1.28"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.10.7"
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsc -p tsconfig.json",
32
+ "sync:mx-launcher-fallback": "node scripts/sync-mx-launcher-fallback.mjs",
32
33
  "typecheck": "tsc -p tsconfig.json --noEmit",
33
34
  "lint": "tsc -p tsconfig.json --noEmit"
34
35
  }
@@ -16,7 +16,7 @@ MIHOMO_SERVICE_FILE="${MIHOMO_SERVICE_FILE:-/etc/systemd/system/$MIHOMO_SERVICE_
16
16
  MIHOMO_PROFILE_PROXY_FILE="${MIHOMO_PROFILE_PROXY_FILE:-/etc/profile.d/mihomo-client-proxy.sh}"
17
17
  MIHOMO_DAEMON_PROXY_SERVICES="${MIHOMO_DAEMON_PROXY_SERVICES:-docker.service containerd.service buildkit.service}"
18
18
  MIHOMO_DAEMON_PROXY_DROPIN_NAME="${MIHOMO_DAEMON_PROXY_DROPIN_NAME:-mihomo-proxy.conf}"
19
- MIHOMO_NO_PROXY="${MIHOMO_NO_PROXY:-localhost,127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10,100.88.0.0/16,100.89.0.0/16,100.90.0.0/16,host.docker.internal,.local}"
19
+ MIHOMO_NO_PROXY="${MIHOMO_NO_PROXY:-localhost,127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,169.254.169.254,169.254.169.254/32,100.64.0.0/10,100.88.0.0/16,100.89.0.0/16,100.90.0.0/16,10.88.0.0/16,10.89.0.0/16,10.90.0.0/16,10.91.0.0/16,100.100.100.200,100.100.100.200/32,host.docker.internal,docker.for.mac.host.internal,docker.for.win.localhost,kubernetes.docker.internal,kubernetes.default.svc,.cluster.local,.local,.lan}"
20
20
  MIHOMO_SSH_PROXY_HELPER="${MIHOMO_SSH_PROXY_HELPER:-/usr/local/bin/mihomo-ssh-proxy}"
21
21
  MIHOMO_SSH_CONFIG_DIR="${MIHOMO_SSH_CONFIG_DIR:-/etc/ssh/ssh_config.d}"
22
22
  MIHOMO_SSH_CONFIG_FILE="${MIHOMO_SSH_CONFIG_FILE:-$MIHOMO_SSH_CONFIG_DIR/99-mihomo-proxy.conf}"
@@ -45,11 +45,11 @@ Commands:
45
45
  upgrade-systemd Refresh the installed systemd unit from this script
46
46
  server-on Enable persistent server-safe outbound proxy mode, without TUN
47
47
  server-off Disable server-safe proxy integrations, keeping the service installed
48
- egress-on Alias for server-on
49
- egress-off Alias for server-off
48
+ egress-on Preferred server-safe outbound proxy mode for Domestic bootstrap
49
+ egress-off Disable server-safe outbound proxy integrations
50
50
  proxy-on Write /etc/profile.d proxy exports for login shells
51
51
  proxy-off Remove /etc/profile.d proxy exports
52
- tun-on Enable Mihomo TUN mode and also turn proxy-on on
52
+ tun-on Enable persistent Mihomo TUN mode with cn-direct and local/private route bypasses
53
53
  tun-off Disable Mihomo TUN mode and also turn proxy-on off
54
54
  ssh-proxy-on Configure OpenSSH client to use local Mihomo SOCKS for common Git hosts
55
55
  ssh-proxy-off Remove OpenSSH proxy override
@@ -65,6 +65,7 @@ Commands:
65
65
 
66
66
  Install options:
67
67
  --url URL Subscription URL, e.g. http://IP:3434/peer_user01.mihomo.yaml
68
+ --file FILE Local subscription YAML file, for Internal-pushed bootstrap
68
69
  --user USER Basic Auth username for subscription
69
70
  --password PASS Basic Auth password for subscription
70
71
  --version TAG Mihomo version tag. Default: latest stable release
@@ -73,6 +74,7 @@ Install options:
73
74
 
74
75
  Update options:
75
76
  --url URL Override saved subscription URL
77
+ --file FILE Replace subscription from a local YAML file
76
78
  --user USER Override saved subscription username
77
79
  --password PASS Override saved subscription password
78
80
 
@@ -89,9 +91,13 @@ Examples:
89
91
  --url http://IP:3434/peer_user01.mihomo.yaml \
90
92
  --binary-path /tmp/mihomo
91
93
 
94
+ sudo bash ./scripts/mihomo-client.sh install \
95
+ --file /opt/mx/current/qp-tunnel-cli/domestic-bootstrap-subscription.yaml
96
+
92
97
  sudo bash ./scripts/mihomo-client.sh update-subscription
98
+ sudo bash ./scripts/mihomo-client.sh update-subscription --file /opt/mx/current/qp-tunnel-cli/domestic-bootstrap-subscription.yaml
93
99
  sudo bash ./scripts/mihomo-client.sh start
94
- sudo bash ./scripts/mihomo-client.sh server-on
100
+ sudo bash ./scripts/mihomo-client.sh egress-on
95
101
  sudo bash ./scripts/mihomo-client.sh proxy-on
96
102
  sudo bash ./scripts/mihomo-client.sh tun-on
97
103
  sudo bash ./scripts/mihomo-client.sh ssh-proxy-on
@@ -399,8 +405,43 @@ ensure_subscription_source() {
399
405
  [[ -f "$MIHOMO_SUBSCRIPTION_FILE" ]] || die "Subscription source not found. Please run install or update-subscription first."
400
406
  }
401
407
 
408
+ detect_tun_proxy_group_name() {
409
+ local configured="${MIHOMO_TUN_PROXY_GROUP:-}"
410
+ local detected=""
411
+ if [[ -n "$configured" ]]; then
412
+ echo "$configured"
413
+ return
414
+ fi
415
+ if [[ -f "$MIHOMO_SUBSCRIPTION_FILE" ]]; then
416
+ detected="$(awk '
417
+ /^[[:space:]]*proxy-groups:[[:space:]]*$/ { in_groups=1; next }
418
+ in_groups && /^[^[:space:]-]/ { in_groups=0 }
419
+ in_groups && /^[[:space:]]*-[[:space:]]*name:[[:space:]]*/ {
420
+ line=$0
421
+ sub(/^[[:space:]]*-[[:space:]]*name:[[:space:]]*/, "", line)
422
+ gsub(/^[[:space:]"]+/, "", line)
423
+ gsub(/[[:space:]"]+$/, "", line)
424
+ print line
425
+ exit
426
+ }
427
+ in_groups && /^[[:space:]]*name:[[:space:]]*/ {
428
+ line=$0
429
+ sub(/^[[:space:]]*name:[[:space:]]*/, "", line)
430
+ gsub(/^[[:space:]"]+/, "", line)
431
+ gsub(/[[:space:]"]+$/, "", line)
432
+ print line
433
+ exit
434
+ }
435
+ ' "$MIHOMO_SUBSCRIPTION_FILE" || true)"
436
+ fi
437
+ echo "${detected:-PROXY}"
438
+ }
439
+
402
440
  write_tun_overlay() {
403
- cat > "$MIHOMO_TUN_OVERLAY_FILE" <<'EOF'
441
+ ensure_subscription_source
442
+ local proxy_group
443
+ proxy_group="$(detect_tun_proxy_group_name)"
444
+ cat > "$MIHOMO_TUN_OVERLAY_FILE" <<EOF
404
445
  tun:
405
446
  enable: true
406
447
  stack: system
@@ -408,6 +449,17 @@ tun:
408
449
  auto-redirect: true
409
450
  auto-detect-interface: true
410
451
  strict-route: true
452
+ route-exclude-address:
453
+ - 10.0.0.0/8
454
+ - 172.16.0.0/12
455
+ - 192.168.0.0/16
456
+ - 169.254.0.0/16
457
+ - 100.64.0.0/10
458
+ - 10.88.0.0/16
459
+ - 10.89.0.0/16
460
+ - 10.90.0.0/16
461
+ - 10.91.0.0/16
462
+ - 100.100.100.200/32
411
463
  dns-hijack:
412
464
  - any:53
413
465
  - tcp://any:53
@@ -425,6 +477,7 @@ dns:
425
477
  - 223.5.5.5
426
478
  - 119.29.29.29
427
479
  - 1.1.1.1
480
+ - 8.8.8.8
428
481
  nameserver:
429
482
  - https://dns.alidns.com/dns-query
430
483
  - https://doh.pub/dns-query
@@ -436,6 +489,31 @@ dns:
436
489
  geoip-code: CN
437
490
  geosite:
438
491
  - gfw
492
+
493
+ rules:
494
+ - DOMAIN-SUFFIX,local,DIRECT
495
+ - DOMAIN-SUFFIX,lan,DIRECT
496
+ - DOMAIN-SUFFIX,internal,DIRECT
497
+ - DOMAIN-SUFFIX,cluster.local,DIRECT
498
+ - DOMAIN,metadata.google.internal,DIRECT
499
+ - DOMAIN,kubernetes.default.svc,DIRECT
500
+ - IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
501
+ - IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
502
+ - IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
503
+ - IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
504
+ - IP-CIDR,169.254.0.0/16,DIRECT,no-resolve
505
+ - IP-CIDR,100.64.0.0/10,DIRECT,no-resolve
506
+ - IP-CIDR,10.88.0.0/16,DIRECT,no-resolve
507
+ - IP-CIDR,10.89.0.0/16,DIRECT,no-resolve
508
+ - IP-CIDR,10.90.0.0/16,DIRECT,no-resolve
509
+ - IP-CIDR,10.91.0.0/16,DIRECT,no-resolve
510
+ - IP-CIDR,100.100.100.200/32,DIRECT,no-resolve
511
+ - IP-CIDR6,::1/128,DIRECT,no-resolve
512
+ - IP-CIDR6,fc00::/7,DIRECT,no-resolve
513
+ - IP-CIDR6,fe80::/10,DIRECT,no-resolve
514
+ - GEOSITE,CN,DIRECT
515
+ - GEOIP,CN,DIRECT
516
+ - MATCH,$proxy_group
439
517
  EOF
440
518
  chmod 600 "$MIHOMO_TUN_OVERLAY_FILE"
441
519
  }
@@ -503,6 +581,20 @@ fetch_subscription() {
503
581
  render_runtime_config
504
582
  }
505
583
 
584
+ install_subscription_file() {
585
+ local file_path="$1"
586
+
587
+ [[ -n "$file_path" ]] || die "Subscription file is required."
588
+ [[ -f "$file_path" ]] || die "Subscription file not found: $file_path"
589
+ [[ -s "$file_path" ]] || die "Subscription file is empty: $file_path"
590
+
591
+ ensure_dirs
592
+ log "Installing local subscription file: $file_path"
593
+ cp "$file_path" "$MIHOMO_SUBSCRIPTION_FILE"
594
+ chmod 600 "$MIHOMO_SUBSCRIPTION_FILE"
595
+ render_runtime_config
596
+ }
597
+
506
598
  mixed_port_from_config() {
507
599
  if [[ -f "$MIHOMO_CONFIG_FILE" ]]; then
508
600
  awk -F: '/^[[:space:]]*mixed-port[[:space:]]*:/ {gsub(/[[:space:]]/, "", $2); print $2; exit}' "$MIHOMO_CONFIG_FILE"
@@ -610,10 +702,29 @@ update_subscription_command() {
610
702
  local url="${1:-}"
611
703
  local username="${2:-}"
612
704
  local password="${3:-}"
705
+ local file_path="${4:-}"
613
706
  local -a normalized=()
614
707
 
615
708
  load_env
616
709
 
710
+ if [[ -n "$file_path" ]]; then
711
+ install_subscription_file "$file_path"
712
+ set_env_value MIHOMO_SUBSCRIPTION_SOURCE "local-file"
713
+ set_env_value MIHOMO_SUBSCRIPTION_LOCAL_FILE "$file_path"
714
+ set_env_value MIHOMO_SUBSCRIPTION_URL ""
715
+ set_env_value MIHOMO_SUBSCRIPTION_USER ""
716
+ set_env_value MIHOMO_SUBSCRIPTION_PASSWORD ""
717
+
718
+ if service_is_active; then
719
+ log "Restarting Mihomo service after local subscription update"
720
+ systemctl restart "$MIHOMO_SERVICE_NAME"
721
+ echo "Subscription updated from local file and service restarted."
722
+ else
723
+ echo "Subscription updated from local file."
724
+ fi
725
+ return
726
+ fi
727
+
617
728
  url="${url:-${MIHOMO_SUBSCRIPTION_URL:-}}"
618
729
  username="${username:-${MIHOMO_SUBSCRIPTION_USER:-}}"
619
730
  password="${password:-${MIHOMO_SUBSCRIPTION_PASSWORD:-}}"
@@ -625,6 +736,8 @@ update_subscription_command() {
625
736
  [[ -n "$url" ]] || die "No subscription URL configured. Use install or pass --url."
626
737
 
627
738
  fetch_subscription "$url" "$username" "$password"
739
+ set_env_value MIHOMO_SUBSCRIPTION_SOURCE "url"
740
+ set_env_value MIHOMO_SUBSCRIPTION_LOCAL_FILE ""
628
741
  set_env_value MIHOMO_SUBSCRIPTION_URL "$url"
629
742
  set_env_value MIHOMO_SUBSCRIPTION_USER "$username"
630
743
  set_env_value MIHOMO_SUBSCRIPTION_PASSWORD "$password"
@@ -759,10 +872,12 @@ tun_on_command() {
759
872
  daemon_proxy_on_command
760
873
  if service_is_active; then
761
874
  systemctl restart "$MIHOMO_SERVICE_NAME"
762
- echo "Mihomo TUN mode enabled and service restarted."
875
+ systemctl enable "$MIHOMO_SERVICE_NAME" >/dev/null 2>&1 || true
876
+ echo "Mihomo TUN mode enabled, persisted, and service restarted."
763
877
  else
764
878
  systemctl start "$MIHOMO_SERVICE_NAME"
765
- echo "Mihomo TUN mode enabled and service started."
879
+ systemctl enable "$MIHOMO_SERVICE_NAME" >/dev/null 2>&1 || true
880
+ echo "Mihomo TUN mode enabled, persisted, and service started."
766
881
  fi
767
882
  }
768
883
 
@@ -867,24 +982,27 @@ install_command() {
867
982
  local version="${4:-latest}"
868
983
  local autostart="${5:-true}"
869
984
  local binary_path="${6:-}"
985
+ local file_path="${7:-}"
870
986
  local -a normalized=()
871
987
 
872
988
  ensure_dirs
873
989
  load_env
874
990
  log "Starting Mihomo client install/setup"
875
991
 
876
- if [[ -z "$url" ]]; then
992
+ if [[ -z "$file_path" && -z "$url" ]]; then
877
993
  url="$(prompt_default "Subscription URL" "${MIHOMO_SUBSCRIPTION_URL:-}")"
878
994
  fi
879
- mapfile -t normalized < <(normalize_subscription_inputs "$url" "$username" "$password")
880
- url="${normalized[0]}"
881
- username="${normalized[1]}"
882
- password="${normalized[2]}"
883
- if [[ -z "$username" ]]; then
884
- username="$(prompt_default "Subscription username (empty if none)" "${MIHOMO_SUBSCRIPTION_USER:-}")"
885
- fi
886
- if [[ -z "$password" ]]; then
887
- password="$(prompt_password "Subscription password (empty if none)")"
995
+ if [[ -z "$file_path" ]]; then
996
+ mapfile -t normalized < <(normalize_subscription_inputs "$url" "$username" "$password")
997
+ url="${normalized[0]}"
998
+ username="${normalized[1]}"
999
+ password="${normalized[2]}"
1000
+ if [[ -z "$username" ]]; then
1001
+ username="$(prompt_default "Subscription username (empty if none)" "${MIHOMO_SUBSCRIPTION_USER:-}")"
1002
+ fi
1003
+ if [[ -z "$password" ]]; then
1004
+ password="$(prompt_password "Subscription password (empty if none)")"
1005
+ fi
888
1006
  fi
889
1007
 
890
1008
  if [[ -n "$binary_path" ]]; then
@@ -904,8 +1022,8 @@ install_command() {
904
1022
  set_env_value MIHOMO_SUBSCRIPTION_USER "$username"
905
1023
  set_env_value MIHOMO_SUBSCRIPTION_PASSWORD "$password"
906
1024
 
907
- log "Downloading initial subscription and rendering runtime config"
908
- update_subscription_command "$url" "$username" "$password"
1025
+ log "Installing initial subscription and rendering runtime config"
1026
+ update_subscription_command "$url" "$username" "$password" "$file_path"
909
1027
 
910
1028
  systemctl enable "$MIHOMO_SERVICE_NAME" >/dev/null 2>&1 || true
911
1029
  if [[ "$autostart" == "true" ]]; then
@@ -1006,9 +1124,11 @@ main() {
1006
1124
  local version="latest"
1007
1125
  local autostart="true"
1008
1126
  local binary_path=""
1127
+ local file_path=""
1009
1128
  while [[ $# -gt 0 ]]; do
1010
1129
  case "$1" in
1011
1130
  --url) url="$2"; shift 2 ;;
1131
+ --file) file_path="$2"; shift 2 ;;
1012
1132
  --user) username="$2"; shift 2 ;;
1013
1133
  --password) password="$2"; shift 2 ;;
1014
1134
  --version) version="$2"; shift 2 ;;
@@ -1017,21 +1137,23 @@ main() {
1017
1137
  *) die "Unknown install option: $1" ;;
1018
1138
  esac
1019
1139
  done
1020
- install_command "$url" "$username" "$password" "$version" "$autostart" "$binary_path"
1140
+ install_command "$url" "$username" "$password" "$version" "$autostart" "$binary_path" "$file_path"
1021
1141
  ;;
1022
1142
  update-subscription)
1023
1143
  local url=""
1024
1144
  local username=""
1025
1145
  local password=""
1146
+ local file_path=""
1026
1147
  while [[ $# -gt 0 ]]; do
1027
1148
  case "$1" in
1028
1149
  --url) url="$2"; shift 2 ;;
1150
+ --file) file_path="$2"; shift 2 ;;
1029
1151
  --user) username="$2"; shift 2 ;;
1030
1152
  --password) password="$2"; shift 2 ;;
1031
1153
  *) die "Unknown update-subscription option: $1" ;;
1032
1154
  esac
1033
1155
  done
1034
- update_subscription_command "$url" "$username" "$password"
1156
+ update_subscription_command "$url" "$username" "$password" "$file_path"
1035
1157
  ;;
1036
1158
  start)
1037
1159
  start_command
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from 'node:child_process';
4
+ import { existsSync, readdirSync, statSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
9
+ const repoRoot = resolve(packageRoot, '../../..');
10
+ const mxLauncherRoot = resolve(repoRoot, 'electron-dock/mx-launcher');
11
+ const refreshScript = resolve(mxLauncherRoot, 'server/scripts/site-slot-refresh-tunnel-cli.mjs');
12
+ const args = process.argv.slice(2);
13
+ const tarball = optionValue('--from-tarball') || optionValue('--tarball') || latestPreviewTarball();
14
+
15
+ if (!tarball) {
16
+ die('Missing @qpjoy/tunnel-cli tarball. Pass --from-tarball FILE or run pnpm pack first.');
17
+ }
18
+ if (!existsSync(tarball)) {
19
+ die(`Tarball not found: ${tarball}`);
20
+ }
21
+ if (!existsSync(refreshScript)) {
22
+ die(`MX Launcher refresh script not found: ${refreshScript}`);
23
+ }
24
+
25
+ const passThrough = [];
26
+ for (const name of ['--target-dir', '--temp-dir']) {
27
+ const value = optionValue(name);
28
+ if (value) passThrough.push(name, value);
29
+ }
30
+
31
+ execFileSync(process.execPath, [
32
+ refreshScript,
33
+ '--from-tarball',
34
+ tarball,
35
+ ...passThrough
36
+ ], {
37
+ cwd: mxLauncherRoot,
38
+ stdio: 'inherit'
39
+ });
40
+
41
+ function optionValue(name) {
42
+ const index = args.indexOf(name);
43
+ if (index < 0) return null;
44
+ const value = args[index + 1];
45
+ if (!value || value.startsWith('--')) die(`Missing value for ${name}`);
46
+ return resolve(value);
47
+ }
48
+
49
+ function latestPreviewTarball() {
50
+ const previewDir = '/tmp/qpjoy-publish-preview';
51
+ if (!existsSync(previewDir)) return null;
52
+ const tarballs = readdirSync(previewDir)
53
+ .filter((entry) => entry.endsWith('.tgz') && entry.includes('tunnel-cli'))
54
+ .map((entry) => {
55
+ const path = join(previewDir, entry);
56
+ return { path, mtimeMs: statSync(path).mtimeMs };
57
+ })
58
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
59
+ return tarballs[0]?.path ?? null;
60
+ }
61
+
62
+ function die(message) {
63
+ console.error(message);
64
+ process.exit(1);
65
+ }