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.
- package/dist/cli-reference.md +3 -2
- package/dist/cli.md +25 -3
- package/dist/index.js +499 -256
- package/package.json +1 -1
package/dist/cli-reference.md
CHANGED
|
@@ -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.
|
|
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
|
-
-
|
|
611
|
-
|
|
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.
|
|
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
|
|
9474
|
-
import { dirname as
|
|
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
|
|
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
|
|
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
|
|
9494
|
-
await
|
|
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({
|
|
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
|
-
|
|
11005
|
-
|
|
11006
|
-
|
|
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
|
-
|
|
11009
|
-
if (code !== 0) {
|
|
11010
|
-
deps.error(`wg-quick exited ${code}.`);
|
|
11244
|
+
if (failed)
|
|
11011
11245
|
return 1;
|
|
11012
|
-
}
|
|
11013
|
-
|
|
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
|
-
|
|
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.
|
|
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: {
|
|
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
|
}
|