dsh-mobile 0.1.0-alpha.2 → 0.1.0-alpha.21

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/lib/cli.js CHANGED
@@ -1,10 +1,32 @@
1
1
  #!/usr/bin/env node
2
- import { X509Certificate } from "node:crypto";
3
- import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { execFile } from "node:child_process";
3
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { homedir, networkInterfaces } from "node:os";
5
- import { join, resolve } from "node:path";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { X509Certificate, createPrivateKey, createPublicKey } from "node:crypto";
6
8
  import { generate } from "selfsigned";
7
- //#region src/cli.ts
9
+ //#region src/managed-setup.ts
10
+ const execFile$2 = promisify(execFile);
11
+ const VIRTUAL_INTERFACE_MARKERS = [
12
+ "bridge",
13
+ "docker",
14
+ "hyper-v",
15
+ "mihomo",
16
+ "radmin",
17
+ "tailscale",
18
+ "tap",
19
+ "tun",
20
+ "utun",
21
+ "vbox",
22
+ "veth",
23
+ "virtual",
24
+ "vmware",
25
+ "vpn",
26
+ "vethernet",
27
+ "wsl",
28
+ "zerotier"
29
+ ];
8
30
  function privateIpv4(value) {
9
31
  const parts = value.split(".").map(Number);
10
32
  return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
@@ -19,93 +41,201 @@ function networkCidr(address, cidr) {
19
41
  0
20
42
  ].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
21
43
  }
22
- function availableAddresses() {
23
- const candidates = Object.values(networkInterfaces()).flatMap((entries) => entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
44
+ /** List current private IPv4 candidates with their interface identity. */
45
+ function availableLanNetworks(table = networkInterfaces()) {
46
+ const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
47
+ name,
24
48
  address: entry.address,
25
49
  cidr: networkCidr(entry.address, entry.cidr)
26
- }));
27
- return [...new Map(candidates.map((entry) => [entry.address, entry])).values()];
50
+ })));
51
+ return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
28
52
  }
29
- function parseOptions(args) {
30
- let address;
31
- let port = 3443;
32
- let dshPort = 3080;
33
- for (let index = 0; index < args.length; index += 1) {
34
- const name = args[index];
35
- const value = args[index + 1];
36
- if (name === "--address" && value !== void 0) {
37
- address = value;
38
- index += 1;
39
- continue;
40
- }
41
- if (name === "--port" && value !== void 0) {
42
- port = Number(value);
43
- index += 1;
44
- continue;
53
+ function likelyVirtualInterface(name) {
54
+ const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, " ");
55
+ return VIRTUAL_INTERFACE_MARKERS.some((marker) => normalized.includes(marker.replaceAll("-", " "))) || /^(?:br|wg)\d*\b/u.test(normalized);
56
+ }
57
+ async function runRouteCommand(file, args) {
58
+ return (await execFile$2(file, [...args], {
59
+ encoding: "utf8",
60
+ windowsHide: true
61
+ })).stdout;
62
+ }
63
+ function uniqueLines(output) {
64
+ return [...new Set(output.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean))];
65
+ }
66
+ /** Return operating-system default-route interfaces in routing preference order. */
67
+ async function preferredLanInterfaceNames(platform = process.platform, run = runRouteCommand) {
68
+ try {
69
+ if (platform === "win32") return uniqueLines(await run("powershell.exe", [
70
+ "-NoProfile",
71
+ "-NonInteractive",
72
+ "-Command",
73
+ [
74
+ "$routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop",
75
+ "$ranked = $routes | Where-Object { $_.State -eq 'Alive' -and $_.NextHop -ne '0.0.0.0' } | ForEach-Object {",
76
+ " $route = $_",
77
+ " $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
78
+ " $ip = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue",
79
+ " if ($adapter -and $ip -and $adapter.Status -eq 'Up' -and $adapter.HardwareInterface -eq $true -and $adapter.Virtual -ne $true) {",
80
+ " [pscustomobject]@{ Name = $route.InterfaceAlias; Metric = [int]$route.RouteMetric + [int]$ip.InterfaceMetric }",
81
+ " }",
82
+ "}",
83
+ "$ranked | Sort-Object Metric | Select-Object -ExpandProperty Name -Unique"
84
+ ].join("; ")
85
+ ]));
86
+ if (platform === "linux") {
87
+ const routes = uniqueLines(await run("ip", [
88
+ "-o",
89
+ "-4",
90
+ "route",
91
+ "show",
92
+ "default"
93
+ ])).map((line) => ({
94
+ name: /(?:^|\s)dev\s+(\S+)/u.exec(line)?.[1],
95
+ metric: Number(/(?:^|\s)metric\s+(\d+)/u.exec(line)?.[1] ?? 0)
96
+ })).filter((route) => route.name !== void 0 && !likelyVirtualInterface(route.name)).sort((left, right) => left.metric - right.metric);
97
+ return [...new Set(routes.map((route) => route.name))];
45
98
  }
46
- if (name === "--dsh-port" && value !== void 0) {
47
- dshPort = Number(value);
48
- index += 1;
49
- continue;
99
+ if (platform === "darwin") {
100
+ const name = /^\s*interface:\s*(\S+)\s*$/mu.exec(await run("route", [
101
+ "-n",
102
+ "get",
103
+ "default"
104
+ ]))?.[1];
105
+ return name === void 0 || likelyVirtualInterface(name) ? [] : [name];
50
106
  }
51
- throw new Error(`unknown setup option: ${name ?? ""}`);
52
- }
53
- if (!Number.isSafeInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be from 1024 through 65535");
54
- if (!Number.isSafeInteger(dshPort) || dshPort < 1024 || dshPort > 65535) throw new Error("--dsh-port must be from 1024 through 65535");
55
- if (address !== void 0 && !privateIpv4(address)) throw new Error("--address must be a private IPv4 address");
56
- return {
57
- ...address === void 0 ? {} : { address },
58
- port,
59
- dshPort
60
- };
107
+ } catch {}
108
+ return [];
61
109
  }
62
- function selectNetwork(requested) {
63
- const candidates = availableAddresses();
64
- if (requested !== void 0) {
65
- const match = candidates.find((candidate) => candidate.address === requested);
66
- if (match === void 0) throw new Error(`--address ${requested} is not an active private LAN address`);
110
+ /** Select an active LAN, optionally by address or by a previously saved interface name. */
111
+ function selectLanNetwork(requestedAddress, requestedInterface, table, preferredInterfaces = []) {
112
+ const candidates = availableLanNetworks(table);
113
+ if (requestedAddress !== void 0) {
114
+ const match = candidates.find((candidate) => candidate.address === requestedAddress);
115
+ if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
67
116
  return match;
68
117
  }
118
+ if (requestedInterface !== void 0) {
119
+ const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
120
+ if (matches.length === 1) return matches[0];
121
+ if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
122
+ throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
123
+ }
69
124
  if (candidates.length === 1) return candidates[0];
70
125
  if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
71
- throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => entry.address).join(", ")}`);
126
+ for (const name of preferredInterfaces) {
127
+ const matches = candidates.filter((candidate) => candidate.name === name);
128
+ if (matches.length === 1) return matches[0];
129
+ }
130
+ const physicalCandidates = candidates.filter((candidate) => !likelyVirtualInterface(candidate.name));
131
+ if (physicalCandidates.length === 1) return physicalCandidates[0];
132
+ throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => `${entry.name}=${entry.address}`).join(", ")}`);
72
133
  }
73
- function dshHome() {
74
- return resolve(process.env.DSH_HOME ?? join(homedir(), ".dsh"));
134
+ function assertMatchingCa(certPem, keyPem) {
135
+ const certificate = new X509Certificate(certPem);
136
+ if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
137
+ const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
138
+ format: "der",
139
+ type: "spki"
140
+ });
141
+ const certificatePublic = certificate.publicKey.export({
142
+ format: "der",
143
+ type: "spki"
144
+ });
145
+ if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
146
+ if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
147
+ return certificate;
75
148
  }
76
- async function setup(args) {
77
- const options = parseOptions(args);
78
- const network = selectNetwork(options.address);
79
- const home = dshHome();
80
- const directory = join(home, "mobile-access");
81
- const tls = join(directory, "tls");
82
- await mkdir(tls, {
149
+ async function atomicWrite(file, contents) {
150
+ const directory = dirname(file);
151
+ await mkdir(directory, {
83
152
  recursive: true,
84
153
  mode: 448
85
154
  });
155
+ const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
156
+ await writeFile(temporary, contents, { mode: 384 });
157
+ await chmod(temporary, 384);
158
+ await rename(temporary, file);
159
+ }
160
+ /** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
161
+ async function ensureManagedCa(setup, legacy) {
162
+ let certPem;
163
+ let keyPem;
164
+ try {
165
+ [certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
166
+ } catch (error) {
167
+ if (error.code !== "ENOENT") throw error;
168
+ let migrated = false;
169
+ if (legacy !== void 0) try {
170
+ [certPem, keyPem] = await Promise.all([readFile(legacy.certFile, "utf8"), readFile(legacy.keyFile, "utf8")]);
171
+ assertMatchingCa(certPem, keyPem);
172
+ migrated = true;
173
+ } catch (legacyError) {
174
+ if (legacyError.code !== "ENOENT") throw legacyError;
175
+ }
176
+ if (!migrated) {
177
+ const now = /* @__PURE__ */ new Date();
178
+ const notAfter = new Date(now);
179
+ notAfter.setFullYear(notAfter.getFullYear() + 5);
180
+ const generated = await generate([{
181
+ name: "commonName",
182
+ value: "DeepSeek Harness Mobile CA"
183
+ }], {
184
+ keyType: "ec",
185
+ curve: "P-256",
186
+ algorithm: "sha256",
187
+ notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
188
+ notAfterDate: notAfter,
189
+ extensions: [{
190
+ name: "basicConstraints",
191
+ cA: true,
192
+ critical: true
193
+ }, {
194
+ name: "keyUsage",
195
+ digitalSignature: true,
196
+ keyCertSign: true,
197
+ cRLSign: true,
198
+ critical: true
199
+ }]
200
+ });
201
+ certPem = generated.cert;
202
+ keyPem = generated.private;
203
+ }
204
+ if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
205
+ await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)]);
206
+ }
207
+ if (certPem === void 0 || keyPem === void 0) throw new Error("managed TLS CA creation did not produce key material");
208
+ return assertMatchingCa(certPem, keyPem);
209
+ }
210
+ /** Sign and atomically install a server leaf for the interface's current address. */
211
+ async function refreshManagedServerCertificate(setup, address) {
212
+ const [caCert, caKey] = await Promise.all([readFile(setup.tls.caCertFile, "utf8"), readFile(setup.tls.caKeyFile, "utf8")]);
213
+ assertMatchingCa(caCert, caKey);
86
214
  const now = /* @__PURE__ */ new Date();
87
215
  const notAfter = new Date(now);
88
216
  notAfter.setDate(notAfter.getDate() + 397);
89
- const certificate = await generate([{
217
+ const server = await generate([{
90
218
  name: "commonName",
91
- value: network.address
219
+ value: "DeepSeek Harness Mobile"
92
220
  }], {
93
221
  keyType: "ec",
94
222
  curve: "P-256",
95
223
  algorithm: "sha256",
96
224
  notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
97
225
  notAfterDate: notAfter,
226
+ ca: {
227
+ cert: caCert,
228
+ key: caKey
229
+ },
98
230
  extensions: [
99
231
  {
100
232
  name: "basicConstraints",
101
- cA: true,
233
+ cA: false,
102
234
  critical: true
103
235
  },
104
236
  {
105
237
  name: "keyUsage",
106
238
  digitalSignature: true,
107
- keyCertSign: true,
108
- cRLSign: true,
109
239
  critical: true
110
240
  },
111
241
  {
@@ -116,24 +246,138 @@ async function setup(args) {
116
246
  name: "subjectAltName",
117
247
  altNames: [{
118
248
  type: 7,
119
- ip: network.address
249
+ ip: address
120
250
  }]
121
251
  }
122
252
  ]
123
253
  });
124
- const certFile = join(tls, "cert.pem");
125
- const keyFile = join(tls, "key.pem");
254
+ await Promise.all([atomicWrite(setup.tls.certFile, server.cert), atomicWrite(setup.tls.keyFile, server.private)]);
255
+ }
256
+ //#endregion
257
+ //#region src/cli.ts
258
+ const execFile$1 = promisify(execFile);
259
+ const FIREWALL_TCP_RULE = "DSH Mobile HTTPS";
260
+ const FIREWALL_UDP_RULE = "DSH Mobile Discovery";
261
+ function parseOptions(args) {
262
+ let address;
263
+ let port = 3443;
264
+ let dshPort = 3080;
265
+ let configureFirewall = true;
266
+ for (let index = 0; index < args.length; index += 1) {
267
+ const name = args[index];
268
+ const value = args[index + 1];
269
+ if (name === "--address" && value !== void 0) {
270
+ address = value;
271
+ index += 1;
272
+ continue;
273
+ }
274
+ if (name === "--port" && value !== void 0) {
275
+ port = Number(value);
276
+ index += 1;
277
+ continue;
278
+ }
279
+ if (name === "--dsh-port" && value !== void 0) {
280
+ dshPort = Number(value);
281
+ index += 1;
282
+ continue;
283
+ }
284
+ if (name === "--no-firewall") {
285
+ configureFirewall = false;
286
+ continue;
287
+ }
288
+ throw new Error(`unknown setup option: ${name ?? ""}`);
289
+ }
290
+ if (!Number.isSafeInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be from 1024 through 65535");
291
+ if (!Number.isSafeInteger(dshPort) || dshPort < 1024 || dshPort > 65535) throw new Error("--dsh-port must be from 1024 through 65535");
292
+ return {
293
+ ...address === void 0 ? {} : { address },
294
+ port,
295
+ dshPort,
296
+ configureFirewall
297
+ };
298
+ }
299
+ function dshHome() {
300
+ return resolve(process.env.DSH_HOME ?? join(homedir(), ".dsh"));
301
+ }
302
+ async function runElevatedPowerShell(script) {
303
+ const launch = [
304
+ "$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru",
305
+ ` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${Buffer.from(script, "utf16le").toString("base64")}')`,
306
+ "; exit $process.ExitCode"
307
+ ].join(" ");
308
+ await execFile$1("powershell.exe", [
309
+ "-NoProfile",
310
+ "-NonInteractive",
311
+ "-Command",
312
+ launch
313
+ ], { windowsHide: true });
314
+ }
315
+ async function configureWindowsFirewall(port) {
316
+ if (process.platform !== "win32") return;
317
+ const script = [
318
+ "$ErrorActionPreference = 'Stop'",
319
+ `Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
320
+ `Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
321
+ `New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,
322
+ `New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`
323
+ ].join("; ");
324
+ console.log("Windows will request administrator approval for two LAN-only firewall rules.");
325
+ await runElevatedPowerShell(script);
326
+ }
327
+ async function removeWindowsFirewall() {
328
+ if (process.platform !== "win32") return;
329
+ await runElevatedPowerShell([
330
+ "$ErrorActionPreference = 'Stop'",
331
+ `Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
332
+ `Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`
333
+ ].join("; "));
334
+ }
335
+ async function setup(args) {
336
+ const options = parseOptions(args);
337
+ const preferredInterfaces = options.address === void 0 ? await preferredLanInterfaceNames() : [];
338
+ const network = selectLanNetwork(options.address, void 0, void 0, preferredInterfaces);
339
+ const home = dshHome();
340
+ const directory = join(home, "mobile-access");
341
+ const tls = join(directory, "tls");
342
+ await mkdir(tls, {
343
+ recursive: true,
344
+ mode: 448
345
+ });
346
+ const legacyCertFile = join(tls, "cert.pem");
347
+ const legacyKeyFile = join(tls, "key.pem");
348
+ const certFile = join(tls, "server-cert.pem");
349
+ const keyFile = join(tls, "server-key.pem");
350
+ const caCertFile = join(tls, "ca.pem");
351
+ const caKeyFile = join(tls, "ca-key.pem");
126
352
  const androidCertificate = join(tls, "dsh-mobile-ca.cer");
353
+ const managedTls = {
354
+ mode: "managed",
355
+ caCertFile,
356
+ caKeyFile,
357
+ certFile,
358
+ keyFile
359
+ };
360
+ const ca = await ensureManagedCa(managedTls, {
361
+ certFile: legacyCertFile,
362
+ keyFile: legacyKeyFile
363
+ });
364
+ const managedSetup = {
365
+ version: 2,
366
+ networkInterface: network.name,
367
+ listenPort: options.port,
368
+ upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,
369
+ tls: managedTls
370
+ };
371
+ await refreshManagedServerCertificate(managedSetup, network.address);
372
+ await writeFile(androidCertificate, ca.raw, { mode: 384 });
127
373
  await Promise.all([
128
- writeFile(certFile, certificate.cert, { mode: 384 }),
129
- writeFile(keyFile, certificate.private, { mode: 384 }),
130
- writeFile(androidCertificate, new X509Certificate(certificate.cert).raw, { mode: 384 })
131
- ]);
132
- await Promise.all([
374
+ chmod(caCertFile, 384),
375
+ chmod(caKeyFile, 384),
133
376
  chmod(certFile, 384),
134
377
  chmod(keyFile, 384),
135
378
  chmod(androidCertificate, 384)
136
379
  ]);
380
+ if (options.configureFirewall) await configureWindowsFirewall(options.port);
137
381
  const customCss = join(directory, "mobile.css");
138
382
  try {
139
383
  await readFile(customCss);
@@ -165,22 +409,14 @@ async function setup(args) {
165
409
  }
166
410
  const origin = `https://${network.address}:${String(options.port)}`;
167
411
  await Promise.all([writeFile(join(directory, "setup.json"), `${JSON.stringify({
168
- version: 1,
169
- publicOrigin: origin,
170
- listenHost: network.address,
171
- upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,
172
- allowedCidrs: [network.cidr],
173
- tls: {
174
- mode: "provided",
175
- certFile: certFile.replaceAll("\\", "/"),
176
- keyFile: keyFile.replaceAll("\\", "/")
177
- }
412
+ ...managedSetup,
413
+ tls: Object.fromEntries(Object.entries(managedSetup.tls).map(([key, value]) => [key, typeof value === "string" ? value.replaceAll("\\", "/") : value]))
178
414
  }, null, 2)}\n`, { mode: 384 }), writeFile(join(directory, "control.json"), "{\"version\":1,\"enabled\":true}\n", { mode: 384 })]);
179
- console.log(`DSH Mobile is configured for ${origin}`);
180
- console.log(`Install this certificate on Android before connecting: ${androidCertificate}`);
415
+ console.log(`DSH Mobile follows ${network.name} and is currently configured for ${origin}`);
416
+ console.log(`Install this CA certificate on Android once: ${androidCertificate}`);
181
417
  console.log(`Ask DSH to customize the mobile Web UI and features in: ${customCss} and ${customScript}`);
182
418
  console.log("Start DSH with: dsh --profile web");
183
- console.log("Then open the Mobile card in the lower-left corner and create a pairing link.");
419
+ console.log("Then open the Mobile card in the lower-left corner and create a pairing key.");
184
420
  }
185
421
  async function purge(args) {
186
422
  if (args.length !== 1 || args[0] !== "--yes") throw new Error("purge requires --yes");
@@ -189,11 +425,12 @@ async function purge(args) {
189
425
  recursive: true,
190
426
  force: true
191
427
  });
428
+ await removeWindowsFirewall();
192
429
  console.log("Removed DSH Mobile certificates, devices, preferences, and custom Web files.");
193
430
  }
194
431
  function help() {
195
432
  console.log([
196
- "dsh-mobile setup [--address 192.168.x.x] [--port 3443] [--dsh-port 3080]",
433
+ "dsh-mobile setup [--address 192.168.x.x] [--port 3443] [--dsh-port 3080] [--no-firewall]",
197
434
  "dsh-mobile purge --yes",
198
435
  "",
199
436
  "Run through the DSH profile:",