ghostdock 0.0.16 → 0.0.17

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.
@@ -259,7 +259,7 @@ The private network: reach your sandboxes by address and name from this machine.
259
259
 
260
260
  ### `ghostdock network connect`
261
261
 
262
- Put this machine into the private network through one provider: WireGuard and split DNS. The organization (--org, else the default) becomes this machine's short-name organization. Asks for sudo once.
262
+ Put this machine into the private network through one provider: WireGuard and split DNS. The organization (--org, else the default) becomes this machine's short-name organization. Also re-arms a paused device: with the tunnel up and nothing changed, without sudo.
263
263
 
264
264
  ```
265
265
  ghostdock network connect [provider] [flags]
@@ -292,7 +292,7 @@ ghostdock network devices remove <id>
292
292
 
293
293
  ### `ghostdock network disconnect`
294
294
 
295
- Take this machine out of the private network.
295
+ Take this machine out of the private network, and remove split DNS an earlier connection left (macOS).
296
296
 
297
297
  ```
298
298
  ghostdock network disconnect
@@ -942,6 +942,7 @@ ghostdock sandboxes create --source <object> [flags]
942
942
  | `--sandbox-provider-id <string>` | Configured sandbox-provider instance id from providers list (not the provider kind). Omit to use the organization default (the org choice, else the platform default); if that provider lacks a capability this create needs, the first listed provider that accepts new sandboxes and has it is used. When no provider fits, the call fails with a not-configured error. |
943
943
  | `--environment <object>` | Environment to give this sandbox. organization: names of organization entries (from env list) to export under their own names. bindings: remap org entries onto slot names ({ name: slot, organization: orgEntryName }). local: sandbox-only variables/secrets to create with the sandbox (kind required for each). Both land as ordinary process environment variables ($NAME / process.env.NAME); a local entry overrides an organization entry of the same name. When booting from an organization snapshot, omit organization/bindings/local to keep manifest env defaults, but still supply every name in requiredEnvironment (via organization, bindings, or local). Otherwise a sandbox gets nothing unless listed here. (JSON object) |
944
944
  | `--ephemeral` | Mark the sandbox ephemeral: every stop destroys the machine and the next start boots fresh, while the record, URLs, and settings stay. Omit to use the image label ghostdock.ephemeral, or false when that label is absent. Does not change idle-stop or auto-delete. |
945
+ | `--image-base <string>` | How the disk is made for one of Ghostdock's VM images: auto (default) clones the host's image base, a disk with the image already on it, when there is one; off always starts from an empty disk that pulls the image. For tests and comparisons; other images always start empty. |
945
946
  | `--urls <array>` | Changes to the image’s or snapshot’s URLs, keyed by port. Omit for the image URLs as they are; otherwise you get the image URLs plus yours: an entry on the same port replaces that URL completely (omitted fields take their defaults), and { port, remove: true } drops it; removing a port the image does not declare does nothing. A required port cannot be removed. TCP URLs are always public. (JSON array) |
946
947
  | `--mcps <array>` | Changes to the image’s or snapshot’s MCP exposures, keyed by name. Omit for the image MCPs as they are; otherwise you get the image MCPs plus yours: an entry with the same name replaces that MCP completely, and { name, remove: true } drops it; removing a name the image does not declare does nothing. A required MCP (requiredMcps) cannot be removed. (JSON array) |
947
948
 
package/dist/cli.md CHANGED
@@ -607,8 +607,30 @@ ghostdock network disconnect
607
607
  (`iptables -A INPUT`, an nft `hook input` chain): `FORWARD` and `DOCKER-USER` rules no
608
608
  longer see them, because the guard connects to a published container port from inside
609
609
  the VM.
610
- - `ghostdock network devices` lists your devices, `ghostdock network devices remove <id>`
611
- removes one.
610
+ - **The network pause** ([ADR 0033](../../docs/adr/0033-gdo-net-dead-mans-switch.md)): a
611
+ device that sent no traffic to a sandbox for its idle timeout (default 4 h), or whose session
612
+ since the last connect reached its maximum (default 12 h), is paused at the gateway. Your
613
+ organizations set both (Settings → Security). If you are in several, the strictest value
614
+ applies. `connect` ends with this device's own numbers and names the organization that set a
615
+ stricter one.
616
+ - **While paused**, names still resolve, but every port of a sandbox answers `Connection
617
+ refused`, and UDP and ping get *administratively prohibited*. Browsers get a pause page
618
+ with a *Reconnect* button, and Docker gets `Ghostdock: network access for this device is
619
+ paused (idle 4 h)`. Every `ghostdock` command prints `! Network access for this device is
620
+ paused (…). Run: ghostdock network connect` once.
621
+ - **To resume**, run `ghostdock network connect`, or click *Reconnect* in the web app (User
622
+ settings → Network devices). With the tunnel up and nothing changed, `connect` only
623
+ re-arms: no sudo, *Re-armed: network access for this device runs until 22:14 (pauses after
624
+ 4 h idle).* *Pause now* in the web app pauses a device at once.
625
+ - DNS, WireGuard keepalives and ping do not count as traffic. A long stream does (a `docker
626
+ logs -f`, an open desktop tab).
627
+ - `ghostdock network devices` lists your devices with their state (*Active, pauses in 3 h 12
628
+ min*, *Paused: idle 4 h*, *Paused: 12 h session limit*, *Paused by you*, *Not connected
629
+ (…)*). `ghostdock network devices remove <id>` removes one.
630
+ - **`ghostdock network disconnect`** also removes the split-DNS files a reboot or a crashed
631
+ `wg-quick` left on macOS (`/etc/resolver/<zone>`), even when the tunnel is already down.
632
+ Without that, names of the private network hang while you are disconnected. `network status`
633
+ says when such a file is still there.
612
634
 
613
635
  ## Command index
614
636
 
@@ -663,7 +685,7 @@ or read [`cli-reference.md`](cli-reference.md) for every command's full descript
663
685
  | `ghostdock network connect [provider] [flags]` | Put this machine into the private network through one provider: WireGuard and… |
664
686
  | `ghostdock network devices <command>` | Your devices in the private network. |
665
687
  | `ghostdock network devices remove <id>` | Remove one of your devices from the private network. |
666
- | `ghostdock network disconnect` | Take this machine out of the private network. |
688
+ | `ghostdock network disconnect` | Take this machine out of the private network, and remove split DNS an earlier… |
667
689
  | `ghostdock network ls [flags]` | Sandboxes of an organization with their private addresses and names, and which… |
668
690
  | `ghostdock network status` | Whether this machine is connected, through which provider, and as which device. |
669
691
 
package/dist/index.js CHANGED
@@ -863,6 +863,14 @@ var GENERATED_TOOL_CATALOG = [
863
863
  description: "Mark the sandbox ephemeral: every stop destroys the machine and the next start boots fresh, while the record, URLs, and settings stay. Omit to use the image label ghostdock.ephemeral, or false when that label is absent. Does not change idle-stop or auto-delete.",
864
864
  type: "boolean"
865
865
  },
866
+ imageBase: {
867
+ description: "How the disk is made for one of Ghostdock's VM images: auto (default) clones the host's image base, a disk with the image already on it, when there is one; off always starts from an empty disk that pulls the image. For tests and comparisons; other images always start empty.",
868
+ type: "string",
869
+ enum: [
870
+ "auto",
871
+ "off"
872
+ ]
873
+ },
866
874
  urls: {
867
875
  description: "Changes to the image’s or snapshot’s URLs, keyed by port. Omit for the image URLs as they are; otherwise you get the image URLs plus yours: an entry on the same port replaces that URL completely (omitted fields take their defaults), and { port, remove: true } drops it; removing a port the image does not declare does nothing. A required port cannot be removed. TCP URLs are always public.",
868
876
  maxItems: 100,
@@ -9176,7 +9184,7 @@ function getCliName(argv = process.argv) {
9176
9184
  return CLI_NAMES.find((name) => name === invoked) ?? "ghostdock";
9177
9185
  }
9178
9186
  // package.json
9179
- var version = "0.0.16";
9187
+ var version = "0.0.17";
9180
9188
  var package_default = {
9181
9189
  name: "ghostdock",
9182
9190
  description: "Ghostdock CLI — device login and agent tool invocation from the command line",
@@ -9284,6 +9292,376 @@ async function clearCredentials(path = getCredentialsPath()) {
9284
9292
  await rm(path, { force: true });
9285
9293
  }
9286
9294
 
9295
+ // ../../shared/lib/private-network-pause.ts
9296
+ function formatPauseLimit(seconds) {
9297
+ const minutes = Math.max(1, Math.round(seconds / 60));
9298
+ if (minutes >= 2 * 1440 && minutes % 1440 === 0)
9299
+ return `${minutes / 1440} days`;
9300
+ const hours = Math.floor(minutes / 60);
9301
+ const rest = minutes % 60;
9302
+ if (hours === 0)
9303
+ return `${rest} min`;
9304
+ return rest === 0 ? `${hours} h` : `${hours} h ${rest} min`;
9305
+ }
9306
+ function devicePauseReasonText(reason, limits) {
9307
+ switch (reason) {
9308
+ case "idle":
9309
+ return `idle ${formatPauseLimit(limits.idleTimeoutSeconds)}`;
9310
+ case "max_session":
9311
+ return `${formatPauseLimit(limits.maxSessionSeconds)} session limit`;
9312
+ case "manual":
9313
+ return "paused in the web app";
9314
+ }
9315
+ }
9316
+
9317
+ // src/output.ts
9318
+ var SYMBOLS = {
9319
+ ready: "⚓",
9320
+ done: "✓",
9321
+ url: "↗",
9322
+ stopped: "◌",
9323
+ failed: "✗",
9324
+ progress: "▂▄▆",
9325
+ item: "•"
9326
+ };
9327
+ var jsonOutput = false;
9328
+ function setJsonOutput(on) {
9329
+ jsonOutput = on;
9330
+ }
9331
+ function isJsonOutput() {
9332
+ return jsonOutput;
9333
+ }
9334
+ function colorEnabled(stream, env = process.env) {
9335
+ if (env.FORCE_COLOR !== undefined)
9336
+ return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "false";
9337
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== "")
9338
+ return false;
9339
+ if (env.TERM === "dumb")
9340
+ return false;
9341
+ return stream.isTTY === true;
9342
+ }
9343
+ function trueColor(env = process.env) {
9344
+ return env.COLORTERM === "truecolor" || env.COLORTERM === "24bit";
9345
+ }
9346
+ function accent(text, enabled) {
9347
+ if (!enabled)
9348
+ return text;
9349
+ const open = trueColor() ? "\x1B[38;2;124;255;203m" : "\x1B[38;5;122m";
9350
+ return `${open}${text}\x1B[39m`;
9351
+ }
9352
+ function red(text, enabled) {
9353
+ return enabled ? `\x1B[31m${text}\x1B[39m` : text;
9354
+ }
9355
+ function dim(text, enabled) {
9356
+ return enabled ? `\x1B[2m${text}\x1B[22m` : text;
9357
+ }
9358
+ function eventLine(symbol, message, color = colorEnabled(process.stdout)) {
9359
+ return ` ${accent(SYMBOLS[symbol], color)} ${message}`;
9360
+ }
9361
+ function errorLine(message, color = colorEnabled(process.stderr)) {
9362
+ return red(` ${SYMBOLS.failed} ${message}`, color);
9363
+ }
9364
+ function printNotice(message) {
9365
+ process.stderr.write(`${message}
9366
+ `);
9367
+ }
9368
+ function printError(message) {
9369
+ process.stderr.write(`${errorLine(message)}
9370
+ `);
9371
+ }
9372
+ function printResult(data, human, log = console.log) {
9373
+ if (jsonOutput) {
9374
+ log(JSON.stringify(data, null, 2));
9375
+ return;
9376
+ }
9377
+ for (const line of human())
9378
+ log(line);
9379
+ }
9380
+
9381
+ // src/network/pause.ts
9382
+ var DEVICE_HEADER = "ghostdock-device";
9383
+ var DEVICE_STATE_HEADER = "ghostdock-device-state";
9384
+ function parseDeviceStateHeader(value) {
9385
+ if (!value)
9386
+ return null;
9387
+ const [state, ...params] = value.split(";").map((part) => part.trim());
9388
+ if (state !== "paused")
9389
+ return null;
9390
+ const fields = new Map(params.map((param) => {
9391
+ const [key, ...rest] = param.split("=");
9392
+ return [key?.trim() ?? "", rest.join("=").trim()];
9393
+ }));
9394
+ const reason = fields.get("reason");
9395
+ if (reason !== "idle" && reason !== "max_session" && reason !== "manual")
9396
+ return null;
9397
+ const limit = Number(fields.get("limit"));
9398
+ return { reason, limitSeconds: Number.isInteger(limit) && limit > 0 ? limit : null };
9399
+ }
9400
+ function pausedNotice(paused) {
9401
+ const limit = paused.limitSeconds ?? 0;
9402
+ const why = paused.reason === "manual" || limit > 0 ? ` (${devicePauseReasonText(paused.reason, { idleTimeoutSeconds: limit, maxSessionSeconds: limit })})` : "";
9403
+ return `Network access for this device is paused${why}. Run: ${getCliName()} network connect`;
9404
+ }
9405
+ var noticeShown = false;
9406
+ var noticeSuppressed = false;
9407
+ function notePausedDevice(header, write = (line) => process.stderr.write(`${line}
9408
+ `)) {
9409
+ if (noticeShown || noticeSuppressed)
9410
+ return;
9411
+ const paused = parseDeviceStateHeader(header);
9412
+ if (!paused)
9413
+ return;
9414
+ noticeShown = true;
9415
+ write(`! ${pausedNotice(paused)}`);
9416
+ }
9417
+ function suppressPausedNotice() {
9418
+ noticeSuppressed = true;
9419
+ }
9420
+ function clockTime(iso, now = new Date) {
9421
+ const at = new Date(iso);
9422
+ const time = at.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
9423
+ if (at.toDateString() === now.toDateString())
9424
+ return time;
9425
+ return `${at.toLocaleDateString("en-GB", { weekday: "short" })} ${time}`;
9426
+ }
9427
+ function setBy(slug) {
9428
+ return slug ? `, set by ${slug}` : "";
9429
+ }
9430
+ function pauseHint(state, now = new Date) {
9431
+ const idle = `after ${formatPauseLimit(state.idleTimeoutSeconds)} without traffic${setBy(state.setBy.idle)}`;
9432
+ const sessionEnd = sessionEndsAt(state, now);
9433
+ const session = `${formatPauseLimit(state.maxSessionSeconds)} after connecting${sessionEnd ? ` (at ${clockTime(sessionEnd, now)})` : ""}${setBy(state.setBy.maxSession)}`;
9434
+ return [
9435
+ "ℹ For security, this device's network access pauses automatically:",
9436
+ ` ${idle}, and ${session}.`,
9437
+ ` Resume any time: ${getCliName()} network connect, or Reconnect in the web app.`
9438
+ ];
9439
+ }
9440
+ function sessionEndsAt(state, now) {
9441
+ if (state.status !== "active" || !state.pausesAt)
9442
+ return null;
9443
+ if (state.pauseCause === "max_session")
9444
+ return state.pausesAt;
9445
+ return new Date(now.getTime() + state.maxSessionSeconds * 1000).toISOString();
9446
+ }
9447
+ function bold2(text, enabled = colorEnabled(process.stderr)) {
9448
+ return enabled ? `\x1B[1m${text}\x1B[22m` : text;
9449
+ }
9450
+ function deviceStateText(state, now = new Date) {
9451
+ if (!state)
9452
+ return "Not connected (logged out)";
9453
+ if (state.status === "paused" && state.pauseReason) {
9454
+ const text = state.pauseReason === "manual" ? "Paused by you" : `Paused: ${devicePauseReasonText(state.pauseReason, state)}`;
9455
+ return state.connected ? text : `Not connected (${text.toLowerCase()})`;
9456
+ }
9457
+ const left = state.pausesAt ? new Date(state.pausesAt).getTime() - now.getTime() : 0;
9458
+ const active = `Active, pauses in ${formatRemaining(Math.max(left, 0) / 1000)}`;
9459
+ return state.connected ? active : `Not connected (armed, pauses in ${formatRemaining(Math.max(left, 0) / 1000)})`;
9460
+ }
9461
+ function formatRemaining(seconds) {
9462
+ const minutes = Math.floor(seconds / 60);
9463
+ const days = Math.floor(minutes / 1440);
9464
+ const hours = Math.floor(minutes % 1440 / 60);
9465
+ const rest = minutes % 60;
9466
+ const dayText = days === 1 ? "1 day" : `${days} days`;
9467
+ if (days > 0)
9468
+ return hours ? `${dayText} ${hours} h` : dayText;
9469
+ if (hours > 0)
9470
+ return rest ? `${hours} h ${rest} min` : `${hours} h`;
9471
+ return `${Math.max(rest, 1)} min`;
9472
+ }
9473
+
9474
+ // src/network/tunnel.ts
9475
+ import { createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
9476
+ import { existsSync } from "node:fs";
9477
+ import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
9478
+ import { dirname as dirname2, join as join2 } from "node:path";
9479
+ var INTERFACE = "gdock";
9480
+ function networkDir() {
9481
+ return join2(getConfigDir(), "network");
9482
+ }
9483
+ function configPath(dir = networkDir()) {
9484
+ return join2(dir, `${INTERFACE}.conf`);
9485
+ }
9486
+ function keyPath(dir) {
9487
+ return join2(dir, "private.key");
9488
+ }
9489
+ function statePath(dir) {
9490
+ return join2(dir, "device.json");
9491
+ }
9492
+ function isProvider(value) {
9493
+ const provider = value;
9494
+ return typeof provider?.id === "string" && typeof provider.name === "string";
9495
+ }
9496
+ var X25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex");
9497
+ function keyPairFromPrivate(privateKey) {
9498
+ const raw = Buffer.from(privateKey, "base64");
9499
+ if (raw.length !== 32)
9500
+ throw new Error("The stored WireGuard key is not 32 bytes.");
9501
+ const key = createPrivateKey({
9502
+ key: Buffer.concat([X25519_PKCS8_PREFIX, raw]),
9503
+ format: "der",
9504
+ type: "pkcs8"
9505
+ });
9506
+ const spki = createPublicKey(key).export({ format: "der", type: "spki" });
9507
+ return { privateKey, publicKey: spki.subarray(spki.length - 32).toString("base64") };
9508
+ }
9509
+ async function loadOrCreateKey(dir = networkDir()) {
9510
+ try {
9511
+ return keyPairFromPrivate((await readFile2(keyPath(dir), "utf8")).trim());
9512
+ } catch (err) {
9513
+ if (err.code !== "ENOENT")
9514
+ throw err;
9515
+ }
9516
+ const { privateKey } = generateKeyPairSync("x25519");
9517
+ const der = privateKey.export({ format: "der", type: "pkcs8" });
9518
+ const raw = der.subarray(der.length - 32).toString("base64");
9519
+ await mkdir2(dir, { recursive: true, mode: 448 });
9520
+ await writeFile2(keyPath(dir), `${raw}
9521
+ `, { mode: 384 });
9522
+ return keyPairFromPrivate(raw);
9523
+ }
9524
+ async function readDeviceState(dir = networkDir()) {
9525
+ try {
9526
+ const parsed = JSON.parse(await readFile2(statePath(dir), "utf8"));
9527
+ if (typeof parsed.deviceId === "string" && typeof parsed.baseUrl === "string") {
9528
+ return {
9529
+ baseUrl: parsed.baseUrl,
9530
+ deviceId: parsed.deviceId,
9531
+ address: typeof parsed.address === "string" ? parsed.address : "",
9532
+ gatewayProvider: isProvider(parsed.gatewayProvider) ? { id: parsed.gatewayProvider.id, name: parsed.gatewayProvider.name } : null,
9533
+ zones: Array.isArray(parsed.zones) ? parsed.zones.filter((zone) => typeof zone === "string" && isDnsZone(zone)) : []
9534
+ };
9535
+ }
9536
+ } catch {}
9537
+ return null;
9538
+ }
9539
+ async function writeDeviceState(state, dir = networkDir()) {
9540
+ await mkdir2(dir, { recursive: true, mode: 448 });
9541
+ await writeFile2(statePath(dir), `${JSON.stringify(state, null, 2)}
9542
+ `, { mode: 384 });
9543
+ }
9544
+ function detectDnsMode(platform = process.platform, exists = existsSync) {
9545
+ if (platform === "darwin")
9546
+ return "macos-resolver";
9547
+ if (platform === "linux" && exists("/run/systemd/resolve"))
9548
+ return "resolved";
9549
+ return "none";
9550
+ }
9551
+ function renderConfig(input) {
9552
+ const { device, network, gateway } = input.registered;
9553
+ const zones = [network.zone, ...network.netZone ? [network.netZone] : []];
9554
+ for (const zone of zones) {
9555
+ if (!isDnsZone(zone))
9556
+ throw new Error(`The server sent a zone that is not a DNS name: ${zone}`);
9557
+ }
9558
+ if (!device.address || !isIPv6Address(device.address)) {
9559
+ throw new Error(`The server sent no IPv6 address for this device: ${device.address}`);
9560
+ }
9561
+ if (!isIPv6Address(network.gatewayAddress)) {
9562
+ throw new Error(`The server sent a gateway that is not an IPv6 address: ${network.gatewayAddress}`);
9563
+ }
9564
+ const ipv4 = device.address4 && network.ingress4 ? { address: device.address4, ingress: network.ingress4 } : null;
9565
+ if (ipv4 && (!isIPv4Address(ipv4.address) || !isIPv4Address(ipv4.ingress))) {
9566
+ throw new Error(`The server sent an IPv4 address that is not one: ${ipv4.address}, ${ipv4.ingress}`);
9567
+ }
9568
+ const lines = [
9569
+ "# Written by `ghostdock network connect`. Run it again rather than editing this file.",
9570
+ "[Interface]",
9571
+ `PrivateKey = ${input.privateKey}`,
9572
+ `Address = ${device.address}/128${ipv4 ? `, ${ipv4.address}/32` : ""}`,
9573
+ `MTU = ${network.mtu}`
9574
+ ];
9575
+ if (input.dns === "macos-resolver") {
9576
+ for (const zone of zones) {
9577
+ const file = `/etc/resolver/${zone}`;
9578
+ lines.push(`PostUp = mkdir -p /etc/resolver && printf 'nameserver ${network.gatewayAddress}\\n' > ${file}`, `PostDown = rm -f ${file}`);
9579
+ }
9580
+ } else if (input.dns === "resolved") {
9581
+ const domains = zones.map((zone) => `'~${zone}'`).join(" ");
9582
+ lines.push(`PostUp = resolvectl dns %i ${network.gatewayAddress} && resolvectl domain %i ${domains}`);
9583
+ }
9584
+ lines.push("", "[Peer]", `PublicKey = ${gateway.publicKey}`, `Endpoint = ${gateway.endpoint}`, `AllowedIPs = ${network.prefix}${ipv4 ? `, ${ipv4.ingress}/32` : ""}`, "PersistentKeepalive = 25", "");
9585
+ return lines.join(`
9586
+ `);
9587
+ }
9588
+ var DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
9589
+ function isDnsZone(zone) {
9590
+ const labels = zone.split(".");
9591
+ return zone.length <= 253 && labels.length >= 2 && labels.every((label) => DNS_LABEL.test(label));
9592
+ }
9593
+ function isIPv4Address(text) {
9594
+ return /^(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$/.test(text);
9595
+ }
9596
+ function ipv4RouteOwner(platform, output) {
9597
+ if (platform === "darwin") {
9598
+ if (/^\s*destination: default$/m.test(output))
9599
+ return null;
9600
+ return /^\s*interface: (\S+)$/m.exec(output)?.[1] ?? null;
9601
+ }
9602
+ for (const line of output.split(`
9603
+ `).map((l) => l.trim())) {
9604
+ if (!line || /^(default|broadcast)\b/.test(line))
9605
+ continue;
9606
+ return /\bdev (\S+)/.exec(line)?.[1] ?? "another route";
9607
+ }
9608
+ return null;
9609
+ }
9610
+ function ipv4RouteCommand(platform, address) {
9611
+ return platform === "darwin" ? ["route", ["-n", "get", address]] : ["ip", ["-4", "route", "show", "table", "all", "match", address]];
9612
+ }
9613
+ function isIPv6Address(text) {
9614
+ return /^[0-9a-f:]{2,39}$/.test(text) && text.includes(":");
9615
+ }
9616
+ async function readConfig(path = configPath()) {
9617
+ try {
9618
+ return await readFile2(path, "utf8");
9619
+ } catch {
9620
+ return null;
9621
+ }
9622
+ }
9623
+ function configResolverZones(config) {
9624
+ if (!config)
9625
+ return [];
9626
+ const zones = [];
9627
+ for (const match of config.matchAll(/^PostDown = rm -f \/etc\/resolver\/(\S+)$/gm)) {
9628
+ const zone = match[1] ?? "";
9629
+ if (isDnsZone(zone) && !zones.includes(zone))
9630
+ zones.push(zone);
9631
+ }
9632
+ return zones;
9633
+ }
9634
+ function resolverFile(zone) {
9635
+ return `/etc/resolver/${zone}`;
9636
+ }
9637
+ async function readConfigMtu(path = configPath()) {
9638
+ let contents;
9639
+ try {
9640
+ contents = await readFile2(path, "utf8");
9641
+ } catch {
9642
+ return null;
9643
+ }
9644
+ const match = /^MTU = (\d+)$/m.exec(contents);
9645
+ return match ? Number(match[1]) : null;
9646
+ }
9647
+ async function writeConfig(contents, path = configPath()) {
9648
+ await mkdir2(dirname2(path), { recursive: true, mode: 448 });
9649
+ await writeFile2(path, contents, { mode: 384 });
9650
+ await chmod2(path, 384);
9651
+ }
9652
+ function tunnelUp(platform = process.platform, exists = existsSync) {
9653
+ if (platform === "darwin")
9654
+ return exists(`/var/run/wireguard/${INTERFACE}.name`);
9655
+ return exists(`/sys/class/net/${INTERFACE}`);
9656
+ }
9657
+ function installHint(platform = process.platform) {
9658
+ if (platform === "darwin")
9659
+ return "brew install wireguard-tools";
9660
+ if (platform === "linux")
9661
+ return "sudo apt install wireguard-tools (or your distribution’s package)";
9662
+ return "Install wireguard-tools. On Windows, run the CLI inside WSL.";
9663
+ }
9664
+
9287
9665
  // src/api-client.ts
9288
9666
  var CLI_USER_AGENT = `ghostdock-cli/${version}`;
9289
9667
 
@@ -9325,6 +9703,7 @@ async function request(path, init = {}) {
9325
9703
  ...init.headers
9326
9704
  }
9327
9705
  });
9706
+ notePausedDevice(res.headers.get(DEVICE_STATE_HEADER));
9328
9707
  if (!res.ok) {
9329
9708
  const payload = await readErrorPayload(res);
9330
9709
  throw new ApiError(res.status, payload.message, payload.data);
@@ -9356,6 +9735,11 @@ function deviceRevoke(body) {
9356
9735
  body: JSON.stringify(body)
9357
9736
  });
9358
9737
  }
9738
+ var deviceIdOnce = null;
9739
+ function currentDeviceId() {
9740
+ deviceIdOnce ??= readDeviceState().then((state) => state && state.baseUrl === getBaseUrl() ? state.deviceId : null).catch(() => null);
9741
+ return deviceIdOnce;
9742
+ }
9359
9743
  var refreshInFlight = null;
9360
9744
  async function refreshStoredCredentials(refreshToken) {
9361
9745
  if (refreshInFlight)
@@ -9378,10 +9762,12 @@ async function authorizedRequest(path, init = {}) {
9378
9762
  if (!credentials) {
9379
9763
  throw new AuthRequiredError;
9380
9764
  }
9765
+ const deviceId = await currentDeviceId();
9381
9766
  const withAuth = (accessToken) => ({
9382
9767
  ...init,
9383
9768
  headers: {
9384
9769
  ...init.headers,
9770
+ ...deviceId ? { [DEVICE_HEADER]: deviceId } : {},
9385
9771
  authorization: `Bearer ${accessToken}`
9386
9772
  }
9387
9773
  });
@@ -9405,75 +9791,11 @@ async function authorizedRequest(path, init = {}) {
9405
9791
  }
9406
9792
  }
9407
9793
 
9408
- // src/output.ts
9409
- var SYMBOLS = {
9410
- ready: "⚓",
9411
- done: "✓",
9412
- url: "↗",
9413
- stopped: "◌",
9414
- failed: "✗",
9415
- progress: "▂▄▆",
9416
- item: "•"
9417
- };
9418
- var jsonOutput = false;
9419
- function setJsonOutput(on) {
9420
- jsonOutput = on;
9421
- }
9422
- function isJsonOutput() {
9423
- return jsonOutput;
9424
- }
9425
- function colorEnabled(stream, env = process.env) {
9426
- if (env.FORCE_COLOR !== undefined)
9427
- return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "false";
9428
- if (env.NO_COLOR !== undefined && env.NO_COLOR !== "")
9429
- return false;
9430
- if (env.TERM === "dumb")
9431
- return false;
9432
- return stream.isTTY === true;
9433
- }
9434
- function trueColor(env = process.env) {
9435
- return env.COLORTERM === "truecolor" || env.COLORTERM === "24bit";
9436
- }
9437
- function accent(text, enabled) {
9438
- if (!enabled)
9439
- return text;
9440
- const open = trueColor() ? "\x1B[38;2;124;255;203m" : "\x1B[38;5;122m";
9441
- return `${open}${text}\x1B[39m`;
9442
- }
9443
- function red(text, enabled) {
9444
- return enabled ? `\x1B[31m${text}\x1B[39m` : text;
9445
- }
9446
- function dim(text, enabled) {
9447
- return enabled ? `\x1B[2m${text}\x1B[22m` : text;
9448
- }
9449
- function eventLine(symbol, message, color = colorEnabled(process.stdout)) {
9450
- return ` ${accent(SYMBOLS[symbol], color)} ${message}`;
9451
- }
9452
- function errorLine(message, color = colorEnabled(process.stderr)) {
9453
- return red(` ${SYMBOLS.failed} ${message}`, color);
9454
- }
9455
- function printNotice(message) {
9456
- process.stderr.write(`${message}
9457
- `);
9458
- }
9459
- function printError(message) {
9460
- process.stderr.write(`${errorLine(message)}
9461
- `);
9462
- }
9463
- function printResult(data, human, log = console.log) {
9464
- if (jsonOutput) {
9465
- log(JSON.stringify(data, null, 2));
9466
- return;
9467
- }
9468
- for (const line of human())
9469
- log(line);
9470
- }
9471
-
9472
9794
  // src/settings.ts
9473
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
9474
- import { dirname as dirname2, join as join2 } from "node:path";
9795
+ import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
9796
+ import { dirname as dirname3, join as join3 } from "node:path";
9475
9797
  function getSettingsPath() {
9476
- return join2(dirname2(getCredentialsPath()), "settings.json");
9798
+ return join3(dirname3(getCredentialsPath()), "settings.json");
9477
9799
  }
9478
9800
  function isDefaultOrganization(value) {
9479
9801
  if (!value || typeof value !== "object")
@@ -9483,15 +9805,15 @@ function isDefaultOrganization(value) {
9483
9805
  }
9484
9806
  async function readSettings(path = getSettingsPath()) {
9485
9807
  try {
9486
- const parsed = JSON.parse(await readFile2(path, "utf8"));
9808
+ const parsed = JSON.parse(await readFile3(path, "utf8"));
9487
9809
  return isDefaultOrganization(parsed.defaultOrganization) ? { defaultOrganization: parsed.defaultOrganization } : {};
9488
9810
  } catch {
9489
9811
  return {};
9490
9812
  }
9491
9813
  }
9492
9814
  async function writeSettings(settings, path = getSettingsPath()) {
9493
- await mkdir2(dirname2(path), { recursive: true });
9494
- await writeFile2(path, `${JSON.stringify(settings, null, 2)}
9815
+ await mkdir3(dirname3(path), { recursive: true });
9816
+ await writeFile3(path, `${JSON.stringify(settings, null, 2)}
9495
9817
  `, "utf8");
9496
9818
  }
9497
9819
 
@@ -10157,6 +10479,11 @@ function listLines(items, color) {
10157
10479
  function bootPhase(value) {
10158
10480
  return isItem(value) && typeof value.ms === "number" && Number.isFinite(value.ms) ? { ms: value.ms } : null;
10159
10481
  }
10482
+ var DISK_SOURCE_TEXT = {
10483
+ fresh: "image pulled",
10484
+ "image-base": "image already on disk",
10485
+ "user-snapshot": "from a snapshot"
10486
+ };
10160
10487
  function bootLines(data, color) {
10161
10488
  if (!isItem(data) || !isItem(data.boot))
10162
10489
  return [];
@@ -10168,6 +10495,12 @@ function bootLines(data, color) {
10168
10495
  if (container) {
10169
10496
  lines.push(eventLine("done", `container started in ${formatElapsed(container.ms)}`, color));
10170
10497
  }
10498
+ const provision = bootPhase(data.boot.provision);
10499
+ if (provision) {
10500
+ const disk = typeof data.boot.disk === "string" ? DISK_SOURCE_TEXT[data.boot.disk] : undefined;
10501
+ const how = disk ? ` (${disk})` : "";
10502
+ lines.push(eventLine("done", `ready in ${formatElapsed(provision.ms)}${how}`, color));
10503
+ }
10171
10504
  return lines;
10172
10505
  }
10173
10506
  function renderResult(data, color) {
@@ -10689,178 +11022,8 @@ var logoutCommand = defineCommand({
10689
11022
 
10690
11023
  // src/commands/network.ts
10691
11024
  import { spawn, spawnSync } from "node:child_process";
11025
+ import { existsSync as existsSync2 } from "node:fs";
10692
11026
  import { hostname as hostname2 } from "node:os";
10693
-
10694
- // src/network/tunnel.ts
10695
- import { createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
10696
- import { existsSync } from "node:fs";
10697
- import { chmod as chmod2, mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
10698
- import { dirname as dirname3, join as join3 } from "node:path";
10699
- var INTERFACE = "gdock";
10700
- function networkDir() {
10701
- return join3(getConfigDir(), "network");
10702
- }
10703
- function configPath(dir = networkDir()) {
10704
- return join3(dir, `${INTERFACE}.conf`);
10705
- }
10706
- function keyPath(dir) {
10707
- return join3(dir, "private.key");
10708
- }
10709
- function statePath(dir) {
10710
- return join3(dir, "device.json");
10711
- }
10712
- function isProvider(value) {
10713
- const provider = value;
10714
- return typeof provider?.id === "string" && typeof provider.name === "string";
10715
- }
10716
- var X25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex");
10717
- function keyPairFromPrivate(privateKey) {
10718
- const raw = Buffer.from(privateKey, "base64");
10719
- if (raw.length !== 32)
10720
- throw new Error("The stored WireGuard key is not 32 bytes.");
10721
- const key = createPrivateKey({
10722
- key: Buffer.concat([X25519_PKCS8_PREFIX, raw]),
10723
- format: "der",
10724
- type: "pkcs8"
10725
- });
10726
- const spki = createPublicKey(key).export({ format: "der", type: "spki" });
10727
- return { privateKey, publicKey: spki.subarray(spki.length - 32).toString("base64") };
10728
- }
10729
- async function loadOrCreateKey(dir = networkDir()) {
10730
- try {
10731
- return keyPairFromPrivate((await readFile3(keyPath(dir), "utf8")).trim());
10732
- } catch (err) {
10733
- if (err.code !== "ENOENT")
10734
- throw err;
10735
- }
10736
- const { privateKey } = generateKeyPairSync("x25519");
10737
- const der = privateKey.export({ format: "der", type: "pkcs8" });
10738
- const raw = der.subarray(der.length - 32).toString("base64");
10739
- await mkdir3(dir, { recursive: true, mode: 448 });
10740
- await writeFile3(keyPath(dir), `${raw}
10741
- `, { mode: 384 });
10742
- return keyPairFromPrivate(raw);
10743
- }
10744
- async function readDeviceState(dir = networkDir()) {
10745
- try {
10746
- const parsed = JSON.parse(await readFile3(statePath(dir), "utf8"));
10747
- if (typeof parsed.deviceId === "string" && typeof parsed.baseUrl === "string") {
10748
- return {
10749
- baseUrl: parsed.baseUrl,
10750
- deviceId: parsed.deviceId,
10751
- address: typeof parsed.address === "string" ? parsed.address : "",
10752
- gatewayProvider: isProvider(parsed.gatewayProvider) ? { id: parsed.gatewayProvider.id, name: parsed.gatewayProvider.name } : null
10753
- };
10754
- }
10755
- } catch {}
10756
- return null;
10757
- }
10758
- async function writeDeviceState(state, dir = networkDir()) {
10759
- await mkdir3(dir, { recursive: true, mode: 448 });
10760
- await writeFile3(statePath(dir), `${JSON.stringify(state, null, 2)}
10761
- `, { mode: 384 });
10762
- }
10763
- function detectDnsMode(platform = process.platform, exists = existsSync) {
10764
- if (platform === "darwin")
10765
- return "macos-resolver";
10766
- if (platform === "linux" && exists("/run/systemd/resolve"))
10767
- return "resolved";
10768
- return "none";
10769
- }
10770
- function renderConfig(input) {
10771
- const { device, network, gateway } = input.registered;
10772
- const zones = [network.zone, ...network.netZone ? [network.netZone] : []];
10773
- for (const zone of zones) {
10774
- if (!isDnsZone(zone))
10775
- throw new Error(`The server sent a zone that is not a DNS name: ${zone}`);
10776
- }
10777
- if (!device.address || !isIPv6Address(device.address)) {
10778
- throw new Error(`The server sent no IPv6 address for this device: ${device.address}`);
10779
- }
10780
- if (!isIPv6Address(network.gatewayAddress)) {
10781
- throw new Error(`The server sent a gateway that is not an IPv6 address: ${network.gatewayAddress}`);
10782
- }
10783
- const ipv4 = device.address4 && network.ingress4 ? { address: device.address4, ingress: network.ingress4 } : null;
10784
- if (ipv4 && (!isIPv4Address(ipv4.address) || !isIPv4Address(ipv4.ingress))) {
10785
- throw new Error(`The server sent an IPv4 address that is not one: ${ipv4.address}, ${ipv4.ingress}`);
10786
- }
10787
- const lines = [
10788
- "# Written by `ghostdock network connect`. Run it again rather than editing this file.",
10789
- "[Interface]",
10790
- `PrivateKey = ${input.privateKey}`,
10791
- `Address = ${device.address}/128${ipv4 ? `, ${ipv4.address}/32` : ""}`,
10792
- `MTU = ${network.mtu}`
10793
- ];
10794
- if (input.dns === "macos-resolver") {
10795
- for (const zone of zones) {
10796
- const file = `/etc/resolver/${zone}`;
10797
- lines.push(`PostUp = mkdir -p /etc/resolver && printf 'nameserver ${network.gatewayAddress}\\n' > ${file}`, `PostDown = rm -f ${file}`);
10798
- }
10799
- } else if (input.dns === "resolved") {
10800
- const domains = zones.map((zone) => `'~${zone}'`).join(" ");
10801
- lines.push(`PostUp = resolvectl dns %i ${network.gatewayAddress} && resolvectl domain %i ${domains}`);
10802
- }
10803
- lines.push("", "[Peer]", `PublicKey = ${gateway.publicKey}`, `Endpoint = ${gateway.endpoint}`, `AllowedIPs = ${network.prefix}${ipv4 ? `, ${ipv4.ingress}/32` : ""}`, "PersistentKeepalive = 25", "");
10804
- return lines.join(`
10805
- `);
10806
- }
10807
- var DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
10808
- function isDnsZone(zone) {
10809
- const labels = zone.split(".");
10810
- return zone.length <= 253 && labels.length >= 2 && labels.every((label) => DNS_LABEL.test(label));
10811
- }
10812
- function isIPv4Address(text) {
10813
- return /^(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$/.test(text);
10814
- }
10815
- function ipv4RouteOwner(platform, output) {
10816
- if (platform === "darwin") {
10817
- if (/^\s*destination: default$/m.test(output))
10818
- return null;
10819
- return /^\s*interface: (\S+)$/m.exec(output)?.[1] ?? null;
10820
- }
10821
- for (const line of output.split(`
10822
- `).map((l) => l.trim())) {
10823
- if (!line || /^(default|broadcast)\b/.test(line))
10824
- continue;
10825
- return /\bdev (\S+)/.exec(line)?.[1] ?? "another route";
10826
- }
10827
- return null;
10828
- }
10829
- function ipv4RouteCommand(platform, address) {
10830
- return platform === "darwin" ? ["route", ["-n", "get", address]] : ["ip", ["-4", "route", "show", "table", "all", "match", address]];
10831
- }
10832
- function isIPv6Address(text) {
10833
- return /^[0-9a-f:]{2,39}$/.test(text) && text.includes(":");
10834
- }
10835
- async function readConfigMtu(path = configPath()) {
10836
- let contents;
10837
- try {
10838
- contents = await readFile3(path, "utf8");
10839
- } catch {
10840
- return null;
10841
- }
10842
- const match = /^MTU = (\d+)$/m.exec(contents);
10843
- return match ? Number(match[1]) : null;
10844
- }
10845
- async function writeConfig(contents, path = configPath()) {
10846
- await mkdir3(dirname3(path), { recursive: true, mode: 448 });
10847
- await writeFile3(path, contents, { mode: 384 });
10848
- await chmod2(path, 384);
10849
- }
10850
- function tunnelUp(platform = process.platform, exists = existsSync) {
10851
- if (platform === "darwin")
10852
- return exists(`/var/run/wireguard/${INTERFACE}.name`);
10853
- return exists(`/sys/class/net/${INTERFACE}`);
10854
- }
10855
- function installHint(platform = process.platform) {
10856
- if (platform === "darwin")
10857
- return "brew install wireguard-tools";
10858
- if (platform === "linux")
10859
- return "sudo apt install wireguard-tools (or your distribution’s package)";
10860
- return "Install wireguard-tools. On Windows, run the CLI inside WSL.";
10861
- }
10862
-
10863
- // src/commands/network.ts
10864
11027
  function defaultDeps() {
10865
11028
  return {
10866
11029
  request: authorizedRequest,
@@ -10877,6 +11040,7 @@ function defaultDeps() {
10877
11040
  has: (command) => spawnSync("sh", ["-c", `command -v ${command}`]).status === 0,
10878
11041
  dir: networkDir(),
10879
11042
  up: () => tunnelUp(),
11043
+ exists: existsSync2,
10880
11044
  log: console.log,
10881
11045
  error: printError,
10882
11046
  notice: printNotice
@@ -10896,6 +11060,7 @@ async function wgQuick(deps, verb) {
10896
11060
  return deps.run("sudo", args);
10897
11061
  }
10898
11062
  async function runNetworkConnect(args = {}, deps = defaultDeps()) {
11063
+ suppressPausedNotice();
10899
11064
  if (!deps.has("wg-quick")) {
10900
11065
  deps.error(`wg-quick is not installed. ${installHint()}`);
10901
11066
  return 1;
@@ -10929,14 +11094,37 @@ async function runNetworkConnect(args = {}, deps = defaultDeps()) {
10929
11094
  deps.error(message(err));
10930
11095
  return 1;
10931
11096
  }
11097
+ const previousConfig = await readConfig(configPath(deps.dir));
10932
11098
  await writeConfig(config, configPath(deps.dir));
10933
11099
  await writeDeviceState({
10934
11100
  baseUrl: getBaseUrl(),
10935
11101
  deviceId: registered.device.id,
10936
11102
  address: registered.device.address ?? "",
10937
- gatewayProvider: registered.provider
11103
+ gatewayProvider: registered.provider,
11104
+ zones: [
11105
+ registered.network.zone,
11106
+ ...registered.network.netZone ? [registered.network.netZone] : []
11107
+ ]
10938
11108
  }, deps.dir);
10939
11109
  const wasUp = deps.up();
11110
+ if (wasUp && previousConfig === config) {
11111
+ const { device, network, provider, shortNameOrganizationChanged } = registered;
11112
+ printResult({
11113
+ device,
11114
+ network,
11115
+ provider,
11116
+ shortNameOrganizationChanged,
11117
+ interface: INTERFACE,
11118
+ dns,
11119
+ rearmed: true,
11120
+ pause: device.state
11121
+ }, () => [
11122
+ eventLine("done", rearmedLine(device.state)),
11123
+ ...network.netZone && device.shortNameOrganization && shortNameOrganizationChanged ? clearingNotice(network.netCookieSite) : []
11124
+ ], deps.log);
11125
+ printPauseHint(device.state, deps);
11126
+ return 0;
11127
+ }
10940
11128
  if (wasUp) {
10941
11129
  const down = await wgQuick(deps, "down");
10942
11130
  if (down !== 0) {
@@ -10953,7 +11141,16 @@ async function runNetworkConnect(args = {}, deps = defaultDeps()) {
10953
11141
  const { device, network, provider, shortNameOrganizationChanged } = registered;
10954
11142
  const pinned = device.shortNameOrganization;
10955
11143
  const switched = wasUp && previous?.gatewayProvider && previous.gatewayProvider.id !== provider.id ? previous.gatewayProvider : null;
10956
- printResult({ device, network, provider, shortNameOrganizationChanged, interface: INTERFACE, dns }, () => [
11144
+ printResult({
11145
+ device,
11146
+ network,
11147
+ provider,
11148
+ shortNameOrganizationChanged,
11149
+ interface: INTERFACE,
11150
+ dns,
11151
+ rearmed: false,
11152
+ pause: device.state
11153
+ }, () => [
10957
11154
  eventLine("done", `Connected through ${provider.name}. This machine is ${device.address} in the private network.`),
10958
11155
  ...switched ? [
10959
11156
  ` Replaced the connection through ${switched.name}: its sandboxes are out of reach now.`
@@ -10978,8 +11175,22 @@ async function runNetworkConnect(args = {}, deps = defaultDeps()) {
10978
11175
  ] : [],
10979
11176
  ` → ${getCliName()} network ls`
10980
11177
  ], deps.log);
11178
+ printPauseHint(device.state, deps);
10981
11179
  return 0;
10982
11180
  }
11181
+ function rearmedLine(state, now = new Date) {
11182
+ if (!state)
11183
+ return "Re-armed.";
11184
+ const until = clockTime(new Date(now.getTime() + state.maxSessionSeconds * 1000).toISOString(), now);
11185
+ return `Re-armed: network access for this device runs until ${until} (pauses after ${formatPauseLimit(state.idleTimeoutSeconds)} idle).`;
11186
+ }
11187
+ function printPauseHint(state, deps) {
11188
+ if (!state || isJsonOutput())
11189
+ return;
11190
+ const [first = "", ...rest] = pauseHint(state);
11191
+ deps.notice(["", bold2(first), ...rest].join(`
11192
+ `));
11193
+ }
10983
11194
  function ipv4Clashes4(registered, deps) {
10984
11195
  const addresses = [registered.network.ingress4, registered.device.address4].filter((a) => typeof a === "string" && isIPv4Address(a));
10985
11196
  const clashes = [];
@@ -11000,17 +11211,41 @@ function clearingNotice(cookieSite) {
11000
11211
  " Reload or close browser tabs opened on short names before this connect."
11001
11212
  ];
11002
11213
  }
11214
+ async function staleResolverFiles(deps) {
11215
+ if (deps.platform !== "darwin")
11216
+ return [];
11217
+ const state = await readDeviceState(deps.dir);
11218
+ const zones = new Set([
11219
+ ...configResolverZones(await readConfig(configPath(deps.dir))),
11220
+ ...state?.zones ?? []
11221
+ ]);
11222
+ return [...zones].map(resolverFile).filter((file) => deps.exists(file));
11223
+ }
11003
11224
  async function runNetworkDisconnect(deps = defaultDeps()) {
11004
- if (!deps.up()) {
11005
- printResult({ connected: false }, () => [eventLine("item", "Not connected.")], deps.log);
11006
- return 0;
11225
+ const wasUp = deps.up();
11226
+ let failed = false;
11227
+ if (wasUp) {
11228
+ const code = await wgQuick(deps, "down");
11229
+ if (code !== 0) {
11230
+ deps.error(`wg-quick exited ${code}.`);
11231
+ failed = true;
11232
+ }
11233
+ }
11234
+ const stale = await staleResolverFiles(deps);
11235
+ if (stale.length) {
11236
+ const args = ["rm", "-f", ...stale];
11237
+ deps.notice(`Removing split DNS left from an earlier connection. Running: sudo ${args.join(" ")}`);
11238
+ const code = await deps.run("sudo", args);
11239
+ if (code !== 0) {
11240
+ deps.error(`Could not remove ${stale.join(", ")} (rm exited ${code}).`);
11241
+ return 1;
11242
+ }
11007
11243
  }
11008
- const code = await wgQuick(deps, "down");
11009
- if (code !== 0) {
11010
- deps.error(`wg-quick exited ${code}.`);
11244
+ if (failed)
11011
11245
  return 1;
11012
- }
11013
- printResult({ connected: false }, () => [eventLine("done", "Disconnected.")], deps.log);
11246
+ printResult({ connected: false, removedSplitDns: stale }, () => [
11247
+ wasUp ? eventLine("done", "Disconnected.") : eventLine(stale.length ? "done" : "item", stale.length ? "Not connected; removed the leftover split DNS." : "Not connected.")
11248
+ ], deps.log);
11014
11249
  return 0;
11015
11250
  }
11016
11251
  async function readConnection(deps) {
@@ -11048,7 +11283,11 @@ async function readConnection(deps) {
11048
11283
  }
11049
11284
  async function runNetworkStatus(deps = defaultDeps()) {
11050
11285
  const { state, up, device, asked, provider, staleMtu } = await readConnection(deps);
11051
- printResult({ connected: up, device, provider, staleMtu }, () => {
11286
+ const staleSplitDns = up ? [] : await staleResolverFiles(deps);
11287
+ for (const file of staleSplitDns) {
11288
+ deps.notice(`Split DNS from an earlier connection is still set (${file}): names of the private network will hang. Run: ${getCliName()} network disconnect`);
11289
+ }
11290
+ printResult({ connected: up, device, provider, staleMtu, staleSplitDns }, () => {
11052
11291
  if (!state) {
11053
11292
  return [eventLine("item", `Not set up. Run \`${getCliName()} network connect\`.`)];
11054
11293
  }
@@ -11058,6 +11297,7 @@ async function runNetworkStatus(deps = defaultDeps()) {
11058
11297
  ];
11059
11298
  if (device) {
11060
11299
  lines.push(` Device ${device.name}, valid until ${device.expiresAt.slice(0, 10)}.`);
11300
+ lines.push(` ${deviceStateText(device.state)}.`);
11061
11301
  if (device.shortNameOrganization) {
11062
11302
  lines.push(` Short names: ${device.shortNameOrganization.slug}'s sandboxes.`);
11063
11303
  }
@@ -11127,7 +11367,7 @@ async function runNetworkDevices(deps = defaultDeps()) {
11127
11367
  try {
11128
11368
  const { devices } = await deps.request("/api/v1/network/devices");
11129
11369
  const own = (await readDeviceState(deps.dir))?.deviceId;
11130
- printResult({ devices }, () => devices.length === 0 ? [eventLine("item", "No devices.")] : devices.map((d) => ` ${d.id === own ? "*" : " "} ${d.id} ${d.name} ${d.address} until ${d.expiresAt.slice(0, 10)}`), deps.log);
11370
+ printResult({ devices }, () => devices.length === 0 ? [eventLine("item", "No devices.")] : devices.map((d) => ` ${d.id === own ? "*" : " "} ${d.id} ${d.name} ${d.address} until ${d.expiresAt.slice(0, 10)} ${deviceStateText(d.state)}`), deps.log);
11131
11371
  return 0;
11132
11372
  } catch (err) {
11133
11373
  deps.error(message(err));
@@ -11149,7 +11389,7 @@ async function runNetworkDevicesRemove(args, deps = defaultDeps()) {
11149
11389
  var connectCommand = defineCommand({
11150
11390
  meta: {
11151
11391
  name: "connect",
11152
- description: "Put this machine into the private network through one provider: WireGuard and split DNS. The organization (--org, else the default) becomes this machine's short-name organization. Asks for sudo once."
11392
+ description: "Put this machine into the private network through one provider: WireGuard and split DNS. The organization (--org, else the default) becomes this machine's short-name organization. Also re-arms a paused device: with the tunnel up and nothing changed, without sudo."
11153
11393
  },
11154
11394
  args: {
11155
11395
  provider: {
@@ -11164,7 +11404,10 @@ var connectCommand = defineCommand({
11164
11404
  }
11165
11405
  });
11166
11406
  var disconnectCommand = defineCommand({
11167
- meta: { name: "disconnect", description: "Take this machine out of the private network." },
11407
+ meta: {
11408
+ name: "disconnect",
11409
+ description: "Take this machine out of the private network, and remove split DNS an earlier connection left (macOS)."
11410
+ },
11168
11411
  async run() {
11169
11412
  process.exitCode = await runNetworkDisconnect();
11170
11413
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ghostdock",
3
3
  "description": "Ghostdock CLI — device login and agent tool invocation from the command line",
4
- "version": "0.0.16",
4
+ "version": "0.0.17",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "ghostdock": "dist/index.js",