@hasna/machines 0.0.46 → 0.0.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/agent/index.js +1 -1
- package/dist/cli/index.js +399 -64
- package/dist/commands/hosts.d.ts +81 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -418,6 +418,32 @@ to be scoped to a short-lived approval token, or pass
|
|
|
418
418
|
`--trusted-local-mutation` to generate a process-local
|
|
419
419
|
`HASNA_MACHINES_ALLOW_MUTATIONS=1` prefix for local event recording.
|
|
420
420
|
|
|
421
|
+
## Fleet hostnames (`machines hosts`)
|
|
422
|
+
|
|
423
|
+
Make every fleet machine reachable by its bare name from any other machine —
|
|
424
|
+
`curl http://machine001:3000` works the same on every box — without depending on
|
|
425
|
+
Tailscale MagicDNS being configured. `machines hosts` writes a managed block into
|
|
426
|
+
`/etc/hosts` for each machine in the manifest, choosing the best address:
|
|
427
|
+
|
|
428
|
+
1. `metadata.lanAddress` from the manifest, when it is on the local machine's `/24`
|
|
429
|
+
2. the peer's live direct Tailscale LAN endpoint (`CurAddr`) on the local `/24`
|
|
430
|
+
3. the peer's tailnet IP (`100.64.0.0/10`) — always routable, auto-routed over the
|
|
431
|
+
LAN when co-located
|
|
432
|
+
|
|
433
|
+
```bash
|
|
434
|
+
machines hosts # dry-run plan (default)
|
|
435
|
+
machines hosts plan -j # JSON plan
|
|
436
|
+
machines hosts apply # write /etc/hosts (uses sudo when the file is root-owned)
|
|
437
|
+
machines hosts plan --no-warm # skip discovering LAN endpoints (faster, tailnet IPs)
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
By default the command first runs `tailscale ping` against online peers so their
|
|
441
|
+
LAN endpoints become visible and same-LAN machines resolve to their `192.168.x.x`
|
|
442
|
+
address (true LAN-direct) instead of the tailnet IP. Off-LAN or offline peers fall
|
|
443
|
+
back to the tailnet IP. The local machine is skipped. The managed block is delimited
|
|
444
|
+
by markers, so re-running `apply` only rewrites that block and leaves the rest of
|
|
445
|
+
`/etc/hosts` untouched.
|
|
446
|
+
|
|
421
447
|
## Dashboard
|
|
422
448
|
|
|
423
449
|
```bash
|
package/dist/agent/index.js
CHANGED
package/dist/cli/index.js
CHANGED
|
@@ -993,7 +993,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
|
|
|
993
993
|
this._exitCallback = (err) => {
|
|
994
994
|
if (err.code !== "commander.executeSubCommandAsync") {
|
|
995
995
|
throw err;
|
|
996
|
-
}
|
|
996
|
+
} else {}
|
|
997
997
|
};
|
|
998
998
|
}
|
|
999
999
|
return this;
|
|
@@ -2714,7 +2714,7 @@ import {
|
|
|
2714
2714
|
sanitizeChannelsForOutput as sanitizeChannelsForOutput2
|
|
2715
2715
|
} from "@hasna/events";
|
|
2716
2716
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
2717
|
-
import { existsSync as
|
|
2717
|
+
import { existsSync as existsSync14, readFileSync as readFileSync14, rmSync as rmSync3 } from "fs";
|
|
2718
2718
|
import { join as join12, resolve as resolve4 } from "path";
|
|
2719
2719
|
|
|
2720
2720
|
// node_modules/chalk/source/vendor/ansi-styles/index.js
|
|
@@ -9448,6 +9448,310 @@ function renderDomainMapping(domain) {
|
|
|
9448
9448
|
};
|
|
9449
9449
|
}
|
|
9450
9450
|
|
|
9451
|
+
// src/commands/hosts.ts
|
|
9452
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
9453
|
+
import { existsSync as existsSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
9454
|
+
import { networkInterfaces, platform as osPlatform } from "os";
|
|
9455
|
+
var HOSTS_BLOCK_BEGIN = "# >>> hasna machines fleet >>>";
|
|
9456
|
+
var HOSTS_BLOCK_END = "# <<< hasna machines fleet <<<";
|
|
9457
|
+
function defaultRunner2(command) {
|
|
9458
|
+
const result = spawnSync3("bash", ["-c", command], { encoding: "utf8", env: process.env });
|
|
9459
|
+
return { stdout: result.stdout || "", exitCode: result.status ?? 1 };
|
|
9460
|
+
}
|
|
9461
|
+
function getHostsPath() {
|
|
9462
|
+
const override = process.env["HASNA_MACHINES_HOSTS_PATH"];
|
|
9463
|
+
if (override)
|
|
9464
|
+
return override;
|
|
9465
|
+
if (osPlatform() === "win32") {
|
|
9466
|
+
return "C:\\Windows\\System32\\drivers\\etc\\hosts";
|
|
9467
|
+
}
|
|
9468
|
+
return "/etc/hosts";
|
|
9469
|
+
}
|
|
9470
|
+
function isIpv4(value) {
|
|
9471
|
+
const parts = value.split(".");
|
|
9472
|
+
if (parts.length !== 4)
|
|
9473
|
+
return false;
|
|
9474
|
+
return parts.every((part) => {
|
|
9475
|
+
if (!/^\d{1,3}$/.test(part))
|
|
9476
|
+
return false;
|
|
9477
|
+
const num = Number(part);
|
|
9478
|
+
return num >= 0 && num <= 255;
|
|
9479
|
+
});
|
|
9480
|
+
}
|
|
9481
|
+
function isPrivateIpv4(value) {
|
|
9482
|
+
if (!isIpv4(value))
|
|
9483
|
+
return false;
|
|
9484
|
+
const [a, b] = value.split(".").map(Number);
|
|
9485
|
+
if (a === 10)
|
|
9486
|
+
return true;
|
|
9487
|
+
if (a === 192 && b === 168)
|
|
9488
|
+
return true;
|
|
9489
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
9490
|
+
return true;
|
|
9491
|
+
return false;
|
|
9492
|
+
}
|
|
9493
|
+
function isTailnetIpv4(value) {
|
|
9494
|
+
if (!isIpv4(value))
|
|
9495
|
+
return false;
|
|
9496
|
+
const [a, b] = value.split(".").map(Number);
|
|
9497
|
+
return a === 100 && b >= 64 && b <= 127;
|
|
9498
|
+
}
|
|
9499
|
+
function subnet24(value) {
|
|
9500
|
+
if (!isIpv4(value))
|
|
9501
|
+
return null;
|
|
9502
|
+
return value.split(".").slice(0, 3).join(".");
|
|
9503
|
+
}
|
|
9504
|
+
function ipFromEndpoint(endpoint) {
|
|
9505
|
+
if (!endpoint)
|
|
9506
|
+
return null;
|
|
9507
|
+
const host = endpoint.replace(/:\d+$/, "").trim();
|
|
9508
|
+
return isIpv4(host) ? host : null;
|
|
9509
|
+
}
|
|
9510
|
+
function localPrivateSubnets() {
|
|
9511
|
+
const subnets = new Set;
|
|
9512
|
+
const interfaces = networkInterfaces();
|
|
9513
|
+
for (const addrs of Object.values(interfaces)) {
|
|
9514
|
+
for (const addr of addrs ?? []) {
|
|
9515
|
+
if (addr.family !== "IPv4" || addr.internal)
|
|
9516
|
+
continue;
|
|
9517
|
+
if (!isPrivateIpv4(addr.address))
|
|
9518
|
+
continue;
|
|
9519
|
+
const subnet = subnet24(addr.address);
|
|
9520
|
+
if (subnet)
|
|
9521
|
+
subnets.add(subnet);
|
|
9522
|
+
}
|
|
9523
|
+
}
|
|
9524
|
+
return [...subnets];
|
|
9525
|
+
}
|
|
9526
|
+
function shortName(value) {
|
|
9527
|
+
if (!value)
|
|
9528
|
+
return null;
|
|
9529
|
+
const trimmed = value.replace(/\.$/, "").trim();
|
|
9530
|
+
if (!trimmed)
|
|
9531
|
+
return null;
|
|
9532
|
+
return trimmed.split(".")[0]?.toLowerCase() || null;
|
|
9533
|
+
}
|
|
9534
|
+
function machineNames(machine) {
|
|
9535
|
+
const names = [];
|
|
9536
|
+
for (const candidate of [machine.id, machine.hostname, shortName(machine.tailscaleName)]) {
|
|
9537
|
+
const name = shortName(candidate);
|
|
9538
|
+
if (name && !names.includes(name))
|
|
9539
|
+
names.push(name);
|
|
9540
|
+
}
|
|
9541
|
+
return names;
|
|
9542
|
+
}
|
|
9543
|
+
function peerKeysForMachine(machine) {
|
|
9544
|
+
return [
|
|
9545
|
+
machine.id,
|
|
9546
|
+
machine.hostname,
|
|
9547
|
+
shortName(machine.tailscaleName),
|
|
9548
|
+
machine.tailscaleName,
|
|
9549
|
+
machine.sshAddress?.split("@").pop()
|
|
9550
|
+
].filter((value) => Boolean(value));
|
|
9551
|
+
}
|
|
9552
|
+
function indexPeers(tailscale) {
|
|
9553
|
+
const peers = new Map;
|
|
9554
|
+
if (!tailscale)
|
|
9555
|
+
return peers;
|
|
9556
|
+
const add = (peer) => {
|
|
9557
|
+
if (!peer)
|
|
9558
|
+
return;
|
|
9559
|
+
const id = shortName(peer.HostName) || shortName(peer.DNSName);
|
|
9560
|
+
if (id)
|
|
9561
|
+
peers.set(id, peer);
|
|
9562
|
+
};
|
|
9563
|
+
add(tailscale.Self);
|
|
9564
|
+
for (const peer of Object.values(tailscale.Peer ?? {}))
|
|
9565
|
+
add(peer);
|
|
9566
|
+
return peers;
|
|
9567
|
+
}
|
|
9568
|
+
function findPeer(machine, peers) {
|
|
9569
|
+
for (const key of peerKeysForMachine(machine)) {
|
|
9570
|
+
const peer = peers.get(shortName(key) || key);
|
|
9571
|
+
if (peer)
|
|
9572
|
+
return peer;
|
|
9573
|
+
}
|
|
9574
|
+
return null;
|
|
9575
|
+
}
|
|
9576
|
+
function tailnetIp(peer) {
|
|
9577
|
+
if (!peer)
|
|
9578
|
+
return null;
|
|
9579
|
+
for (const ip of peer.TailscaleIPs ?? []) {
|
|
9580
|
+
if (isTailnetIpv4(ip))
|
|
9581
|
+
return ip;
|
|
9582
|
+
}
|
|
9583
|
+
return null;
|
|
9584
|
+
}
|
|
9585
|
+
function metadataString2(metadata, key) {
|
|
9586
|
+
if (!metadata)
|
|
9587
|
+
return null;
|
|
9588
|
+
const value = metadata[key];
|
|
9589
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
9590
|
+
}
|
|
9591
|
+
function buildFleetHostEntries(input) {
|
|
9592
|
+
const { manifest, tailscale, localSubnets, localMachineId } = input;
|
|
9593
|
+
const peers = indexPeers(tailscale);
|
|
9594
|
+
const subnetSet = new Set(localSubnets);
|
|
9595
|
+
const entries = [];
|
|
9596
|
+
const unresolved = [];
|
|
9597
|
+
const warnings = [];
|
|
9598
|
+
for (const machine of manifest.machines) {
|
|
9599
|
+
if (localMachineId && machine.id === localMachineId)
|
|
9600
|
+
continue;
|
|
9601
|
+
const names = machineNames(machine);
|
|
9602
|
+
if (names.length === 0)
|
|
9603
|
+
continue;
|
|
9604
|
+
const peer = findPeer(machine, peers);
|
|
9605
|
+
const lanAddress = metadataString2(machine.metadata, "lanAddress");
|
|
9606
|
+
const explicitIp = metadataString2(machine.metadata, "ipAddress");
|
|
9607
|
+
const curLanIp = ipFromEndpoint(peer?.CurAddr);
|
|
9608
|
+
const tnet = tailnetIp(peer);
|
|
9609
|
+
let ip = null;
|
|
9610
|
+
let source = null;
|
|
9611
|
+
if (lanAddress && isPrivateIpv4(lanAddress) && subnetSet.has(subnet24(lanAddress) ?? "")) {
|
|
9612
|
+
ip = lanAddress;
|
|
9613
|
+
source = "manifest_lan";
|
|
9614
|
+
} else if (curLanIp && isPrivateIpv4(curLanIp) && subnetSet.has(subnet24(curLanIp) ?? "")) {
|
|
9615
|
+
ip = curLanIp;
|
|
9616
|
+
source = "tailscale_lan";
|
|
9617
|
+
} else if (tnet) {
|
|
9618
|
+
ip = tnet;
|
|
9619
|
+
source = "tailscale";
|
|
9620
|
+
} else if (explicitIp && isIpv4(explicitIp)) {
|
|
9621
|
+
ip = explicitIp;
|
|
9622
|
+
source = "manifest_ip";
|
|
9623
|
+
}
|
|
9624
|
+
if (!ip || !source) {
|
|
9625
|
+
unresolved.push(machine.id);
|
|
9626
|
+
continue;
|
|
9627
|
+
}
|
|
9628
|
+
entries.push({ id: machine.id, ip, names, source });
|
|
9629
|
+
}
|
|
9630
|
+
entries.sort((left, right) => left.id.localeCompare(right.id));
|
|
9631
|
+
return { entries, unresolved, warnings };
|
|
9632
|
+
}
|
|
9633
|
+
function renderHostsBlock(entries) {
|
|
9634
|
+
const lines = [
|
|
9635
|
+
HOSTS_BLOCK_BEGIN,
|
|
9636
|
+
"# Managed by `machines hosts apply`. Do not edit between these markers.",
|
|
9637
|
+
...entries.map((entry) => `${entry.ip} ${entry.names.join(" ")}`),
|
|
9638
|
+
HOSTS_BLOCK_END
|
|
9639
|
+
];
|
|
9640
|
+
return lines.join(`
|
|
9641
|
+
`);
|
|
9642
|
+
}
|
|
9643
|
+
function mergeHostsContent(existing, block) {
|
|
9644
|
+
const beginIndex = existing.indexOf(HOSTS_BLOCK_BEGIN);
|
|
9645
|
+
const endIndex = existing.indexOf(HOSTS_BLOCK_END);
|
|
9646
|
+
if (beginIndex !== -1 && endIndex !== -1 && endIndex > beginIndex) {
|
|
9647
|
+
const before = existing.slice(0, beginIndex).replace(/\n+$/, "");
|
|
9648
|
+
const after = existing.slice(endIndex + HOSTS_BLOCK_END.length).replace(/^\n+/, "");
|
|
9649
|
+
const head = before ? `${before}
|
|
9650
|
+
` : "";
|
|
9651
|
+
const tail = after ? `
|
|
9652
|
+
${after}` : `
|
|
9653
|
+
`;
|
|
9654
|
+
return `${head}${block}${tail}`;
|
|
9655
|
+
}
|
|
9656
|
+
const base = existing.replace(/\n+$/, "");
|
|
9657
|
+
const prefix = base ? `${base}
|
|
9658
|
+
|
|
9659
|
+
` : "";
|
|
9660
|
+
return `${prefix}${block}
|
|
9661
|
+
`;
|
|
9662
|
+
}
|
|
9663
|
+
function loadTailscale(runner, warnings) {
|
|
9664
|
+
if (runner("command -v tailscale >/dev/null 2>&1").exitCode !== 0) {
|
|
9665
|
+
warnings.push("tailscale_not_available");
|
|
9666
|
+
return null;
|
|
9667
|
+
}
|
|
9668
|
+
const result = runner("tailscale status --json");
|
|
9669
|
+
if (result.exitCode !== 0) {
|
|
9670
|
+
warnings.push("tailscale_status_failed");
|
|
9671
|
+
return null;
|
|
9672
|
+
}
|
|
9673
|
+
try {
|
|
9674
|
+
return JSON.parse(result.stdout);
|
|
9675
|
+
} catch {
|
|
9676
|
+
warnings.push("tailscale_status_invalid_json");
|
|
9677
|
+
return null;
|
|
9678
|
+
}
|
|
9679
|
+
}
|
|
9680
|
+
function collectPingTargets(tailscale, localSubnets) {
|
|
9681
|
+
if (!tailscale)
|
|
9682
|
+
return [];
|
|
9683
|
+
const subnetSet = new Set(localSubnets);
|
|
9684
|
+
const targets = [];
|
|
9685
|
+
for (const peer of Object.values(tailscale.Peer ?? {})) {
|
|
9686
|
+
if (!peer.Online)
|
|
9687
|
+
continue;
|
|
9688
|
+
const cur = ipFromEndpoint(peer.CurAddr);
|
|
9689
|
+
if (cur && isPrivateIpv4(cur) && subnetSet.has(subnet24(cur) ?? ""))
|
|
9690
|
+
continue;
|
|
9691
|
+
const tnet = tailnetIp(peer);
|
|
9692
|
+
if (tnet && !targets.includes(tnet))
|
|
9693
|
+
targets.push(tnet);
|
|
9694
|
+
}
|
|
9695
|
+
return targets;
|
|
9696
|
+
}
|
|
9697
|
+
function warmDirectPaths(runner, targets, timeoutSeconds = 2) {
|
|
9698
|
+
for (const target of targets) {
|
|
9699
|
+
runner(`tailscale ping --c 1 --timeout ${timeoutSeconds}s ${target} >/dev/null 2>&1 || true`);
|
|
9700
|
+
}
|
|
9701
|
+
}
|
|
9702
|
+
function resolveLocalMachineId(tailscale, explicit) {
|
|
9703
|
+
if (explicit)
|
|
9704
|
+
return explicit;
|
|
9705
|
+
return shortName(tailscale?.Self?.HostName) || shortName(process.env["HOSTNAME"]) || null;
|
|
9706
|
+
}
|
|
9707
|
+
function planFleetHosts(options = {}) {
|
|
9708
|
+
const runner = options.runner ?? defaultRunner2;
|
|
9709
|
+
const warnings = [];
|
|
9710
|
+
let tailscale = loadTailscale(runner, warnings);
|
|
9711
|
+
const manifest = readManifest();
|
|
9712
|
+
const localSubnets = options.localSubnets ?? localPrivateSubnets();
|
|
9713
|
+
if (options.warm !== false && tailscale && localSubnets.length > 0) {
|
|
9714
|
+
const targets = collectPingTargets(tailscale, localSubnets);
|
|
9715
|
+
if (targets.length > 0) {
|
|
9716
|
+
warmDirectPaths(runner, targets, options.warmTimeoutSeconds);
|
|
9717
|
+
tailscale = loadTailscale(runner, warnings) ?? tailscale;
|
|
9718
|
+
}
|
|
9719
|
+
}
|
|
9720
|
+
const localMachineId = resolveLocalMachineId(tailscale, options.localMachineId);
|
|
9721
|
+
const built = buildFleetHostEntries({ manifest, tailscale, localSubnets, localMachineId });
|
|
9722
|
+
const block = renderHostsBlock(built.entries);
|
|
9723
|
+
return {
|
|
9724
|
+
hostsPath: getHostsPath(),
|
|
9725
|
+
entries: built.entries,
|
|
9726
|
+
unresolved: built.unresolved,
|
|
9727
|
+
warnings: [...warnings, ...built.warnings],
|
|
9728
|
+
block,
|
|
9729
|
+
localSubnets
|
|
9730
|
+
};
|
|
9731
|
+
}
|
|
9732
|
+
function applyFleetHosts(options = {}) {
|
|
9733
|
+
const plan = planFleetHosts(options);
|
|
9734
|
+
const hostsPath = plan.hostsPath;
|
|
9735
|
+
const existing = existsSync7(hostsPath) ? readFileSync5(hostsPath, "utf8") : "";
|
|
9736
|
+
const merged = mergeHostsContent(existing, plan.block);
|
|
9737
|
+
let viaSudo = false;
|
|
9738
|
+
try {
|
|
9739
|
+
writeFileSync3(hostsPath, merged, "utf8");
|
|
9740
|
+
} catch (error) {
|
|
9741
|
+
const code = error?.code;
|
|
9742
|
+
if (code !== "EACCES" && code !== "EPERM")
|
|
9743
|
+
throw error;
|
|
9744
|
+
const runner = options.runner ?? defaultRunner2;
|
|
9745
|
+
const encoded = Buffer.from(merged, "utf8").toString("base64");
|
|
9746
|
+
const result = runner(`printf %s '${encoded}' | base64 -d | sudo tee ${hostsPath} >/dev/null`);
|
|
9747
|
+
if (result.exitCode !== 0) {
|
|
9748
|
+
throw new Error(`Failed to write ${hostsPath} (need sudo). Re-run with elevated privileges.`);
|
|
9749
|
+
}
|
|
9750
|
+
viaSudo = true;
|
|
9751
|
+
}
|
|
9752
|
+
return { ...plan, written: true, viaSudo };
|
|
9753
|
+
}
|
|
9754
|
+
|
|
9451
9755
|
// src/commands/diff.ts
|
|
9452
9756
|
function packageNames(machine) {
|
|
9453
9757
|
return (machine.packages || []).map((pkg) => pkg.name).sort();
|
|
@@ -9801,7 +10105,7 @@ function runTailscaleInstallPlan(plan, options = {}, runner = runMachineCommand)
|
|
|
9801
10105
|
}
|
|
9802
10106
|
|
|
9803
10107
|
// src/commands/notifications.ts
|
|
9804
|
-
import { accessSync, constants, existsSync as
|
|
10108
|
+
import { accessSync, constants, existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
|
|
9805
10109
|
import { delimiter, isAbsolute, join as join7 } from "path";
|
|
9806
10110
|
init_paths();
|
|
9807
10111
|
var notificationChannelSchema = exports_external.object({
|
|
@@ -9986,10 +10290,10 @@ function getDefaultNotificationConfig() {
|
|
|
9986
10290
|
};
|
|
9987
10291
|
}
|
|
9988
10292
|
function readNotificationConfig(path = getNotificationsPath()) {
|
|
9989
|
-
if (!
|
|
10293
|
+
if (!existsSync8(path)) {
|
|
9990
10294
|
return getDefaultNotificationConfig();
|
|
9991
10295
|
}
|
|
9992
|
-
return notificationConfigSchema.parse(JSON.parse(
|
|
10296
|
+
return notificationConfigSchema.parse(JSON.parse(readFileSync6(path, "utf8")));
|
|
9993
10297
|
}
|
|
9994
10298
|
function writeNotificationConfig(config, path = getNotificationsPath()) {
|
|
9995
10299
|
ensureParentDir(path);
|
|
@@ -9998,7 +10302,7 @@ function writeNotificationConfig(config, path = getNotificationsPath()) {
|
|
|
9998
10302
|
updatedAt: new Date().toISOString(),
|
|
9999
10303
|
channels: sortChannels(config.channels)
|
|
10000
10304
|
};
|
|
10001
|
-
|
|
10305
|
+
writeFileSync4(path, `${JSON.stringify(nextConfig, null, 2)}
|
|
10002
10306
|
`, "utf8");
|
|
10003
10307
|
return nextConfig;
|
|
10004
10308
|
}
|
|
@@ -10087,7 +10391,7 @@ async function testNotificationChannel(channelId, event = "manual.test", message
|
|
|
10087
10391
|
|
|
10088
10392
|
// src/commands/ports.ts
|
|
10089
10393
|
init_db();
|
|
10090
|
-
import { spawnSync as
|
|
10394
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
10091
10395
|
function parseSsOutput(output) {
|
|
10092
10396
|
return output.trim().split(`
|
|
10093
10397
|
`).map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
@@ -10129,7 +10433,7 @@ function listPorts(machineId) {
|
|
|
10129
10433
|
const isLocal = targetMachineId === getLocalMachineId();
|
|
10130
10434
|
const localCommand = "if command -v ss >/dev/null 2>&1; then ss -ltnpH; else lsof -nP -iTCP -sTCP:LISTEN; fi";
|
|
10131
10435
|
const command = isLocal ? localCommand : buildSshCommand(targetMachineId, localCommand);
|
|
10132
|
-
const result =
|
|
10436
|
+
const result = spawnSync4("bash", ["-lc", command], { encoding: "utf8" });
|
|
10133
10437
|
if (result.status !== 0) {
|
|
10134
10438
|
throw new Error(result.stderr || `Failed to list ports for ${targetMachineId}`);
|
|
10135
10439
|
}
|
|
@@ -10141,7 +10445,7 @@ function listPorts(machineId) {
|
|
|
10141
10445
|
}
|
|
10142
10446
|
|
|
10143
10447
|
// src/commands/runtime.ts
|
|
10144
|
-
import { spawnSync as
|
|
10448
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
10145
10449
|
import { setTimeout as sleep } from "timers/promises";
|
|
10146
10450
|
import { EventsClient } from "@hasna/events";
|
|
10147
10451
|
function shellQuote5(value) {
|
|
@@ -10186,7 +10490,7 @@ function buildTmuxPaneDiedHookPlan(options = {}) {
|
|
|
10186
10490
|
}
|
|
10187
10491
|
function probeTmuxPane(target, tmuxCommand = process.env["HASNA_MACHINES_TMUX_BIN"] || "tmux") {
|
|
10188
10492
|
const checkedAt = new Date().toISOString();
|
|
10189
|
-
const result =
|
|
10493
|
+
const result = spawnSync5(tmuxCommand, ["display-message", "-p", "-t", target, "#{pane_id}"], {
|
|
10190
10494
|
encoding: "utf8",
|
|
10191
10495
|
timeout: 5000
|
|
10192
10496
|
});
|
|
@@ -10278,7 +10582,7 @@ function shellQuote6(value) {
|
|
|
10278
10582
|
function shellCommand2(command) {
|
|
10279
10583
|
return command.map(shellQuote6).join(" ");
|
|
10280
10584
|
}
|
|
10281
|
-
function
|
|
10585
|
+
function metadataString3(metadata, keys) {
|
|
10282
10586
|
if (!metadata)
|
|
10283
10587
|
return null;
|
|
10284
10588
|
for (const key of keys) {
|
|
@@ -10328,8 +10632,8 @@ function resolveScreenCredentials(machineId, options = {}) {
|
|
|
10328
10632
|
const screen = resolveScreenTarget(machineId, { ...options, topology });
|
|
10329
10633
|
const entry = topology.machines.find((machine) => machine.machine_id === screen.machineId);
|
|
10330
10634
|
const metadata = entry?.metadata;
|
|
10331
|
-
const metadataUser =
|
|
10332
|
-
const metadataPasswordSecret =
|
|
10635
|
+
const metadataUser = metadataString3(metadata, ["screenUser", "screen_user", "user", "username"]);
|
|
10636
|
+
const metadataPasswordSecret = metadataString3(metadata, [
|
|
10333
10637
|
"screenPasswordSecret",
|
|
10334
10638
|
"screen_password_secret",
|
|
10335
10639
|
"screenVncPasswordSecret",
|
|
@@ -10386,7 +10690,7 @@ function buildScreenEnableCommand(machineId, options = {}) {
|
|
|
10386
10690
|
}
|
|
10387
10691
|
|
|
10388
10692
|
// src/commands/sync.ts
|
|
10389
|
-
import { existsSync as
|
|
10693
|
+
import { existsSync as existsSync9, lstatSync, readFileSync as readFileSync7, symlinkSync, copyFileSync } from "fs";
|
|
10390
10694
|
import { homedir as homedir5 } from "os";
|
|
10391
10695
|
init_paths();
|
|
10392
10696
|
init_db();
|
|
@@ -10441,15 +10745,15 @@ function detectFileActions(machine) {
|
|
|
10441
10745
|
throw new Error(`Remote file sync planning is not supported for ${machine.id}; refusing to inspect or apply local paths as remote state.`);
|
|
10442
10746
|
}
|
|
10443
10747
|
return (machine.files || []).map((file, index) => {
|
|
10444
|
-
const sourceExists =
|
|
10445
|
-
const targetExists =
|
|
10748
|
+
const sourceExists = existsSync9(file.source);
|
|
10749
|
+
const targetExists = existsSync9(file.target);
|
|
10446
10750
|
let status = "missing";
|
|
10447
10751
|
if (sourceExists && targetExists) {
|
|
10448
10752
|
if (file.mode === "symlink") {
|
|
10449
10753
|
status = lstatSync(file.target).isSymbolicLink() ? "ok" : "drifted";
|
|
10450
10754
|
} else {
|
|
10451
|
-
const source =
|
|
10452
|
-
const target =
|
|
10755
|
+
const source = readFileSync7(file.source, "utf8");
|
|
10756
|
+
const target = readFileSync7(file.target, "utf8");
|
|
10453
10757
|
status = source === target ? "ok" : "drifted";
|
|
10454
10758
|
}
|
|
10455
10759
|
}
|
|
@@ -10802,7 +11106,7 @@ function parseKeyValue(stdout) {
|
|
|
10802
11106
|
}
|
|
10803
11107
|
return result;
|
|
10804
11108
|
}
|
|
10805
|
-
function
|
|
11109
|
+
function defaultRunner3(machineId, command) {
|
|
10806
11110
|
return runMachineCommand(machineId, command);
|
|
10807
11111
|
}
|
|
10808
11112
|
function inspectCommand(machineId, spec, runner) {
|
|
@@ -10956,7 +11260,7 @@ function workspaceCheck(machineId, spec, runner) {
|
|
|
10956
11260
|
}
|
|
10957
11261
|
function checkMachineCompatibility(options = {}) {
|
|
10958
11262
|
const machineId = options.machineId ?? getLocalMachineId();
|
|
10959
|
-
const runner = options.runner ??
|
|
11263
|
+
const runner = options.runner ?? defaultRunner3;
|
|
10960
11264
|
const commands = options.commands ?? DEFAULT_COMMANDS;
|
|
10961
11265
|
const packages = options.packages ?? defaultPackages();
|
|
10962
11266
|
const workspaces = options.workspaces ?? [];
|
|
@@ -11176,9 +11480,9 @@ function runDoctor(machineId, options = {}) {
|
|
|
11176
11480
|
|
|
11177
11481
|
// src/commands/daemon.ts
|
|
11178
11482
|
import { execFileSync } from "child_process";
|
|
11179
|
-
import { chmodSync, existsSync as
|
|
11483
|
+
import { chmodSync, existsSync as existsSync10, readFileSync as readFileSync8, statSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync5 } from "fs";
|
|
11180
11484
|
import { delimiter as delimiter2, dirname as dirname4 } from "path";
|
|
11181
|
-
import { platform as
|
|
11485
|
+
import { platform as osPlatform2 } from "os";
|
|
11182
11486
|
var DEFAULT_SERVICE_NAME = "machines-agent";
|
|
11183
11487
|
var DEFAULT_EXECUTABLE = "/usr/local/bin/machines-agent";
|
|
11184
11488
|
var DEFAULT_INTERVAL_MS = 30000;
|
|
@@ -11235,7 +11539,7 @@ function runDaemonServicePlan(plan, options = {}) {
|
|
|
11235
11539
|
};
|
|
11236
11540
|
}
|
|
11237
11541
|
mkdirSync2(dirname4(path), { recursive: true });
|
|
11238
|
-
|
|
11542
|
+
writeFileSync5(path, content, "utf8");
|
|
11239
11543
|
chmodSync(path, Number.parseInt(file.mode, 8));
|
|
11240
11544
|
filesWritten.push(path);
|
|
11241
11545
|
}
|
|
@@ -11330,7 +11634,7 @@ function normalizeServiceName(value, warnings) {
|
|
|
11330
11634
|
return DEFAULT_SERVICE_NAME;
|
|
11331
11635
|
}
|
|
11332
11636
|
function normalizePlatform3(value, warnings) {
|
|
11333
|
-
const raw = value ??
|
|
11637
|
+
const raw = value ?? osPlatform2();
|
|
11334
11638
|
if (raw === "darwin" || raw === "macos")
|
|
11335
11639
|
return "macos";
|
|
11336
11640
|
if (raw === "linux")
|
|
@@ -11591,7 +11895,7 @@ function bunRuntimeCandidates(executable) {
|
|
|
11591
11895
|
}
|
|
11592
11896
|
function isBunShebangScript(executable) {
|
|
11593
11897
|
try {
|
|
11594
|
-
const content =
|
|
11898
|
+
const content = readFileSync8(executable, "utf8").slice(0, 256);
|
|
11595
11899
|
const firstLine2 = content.split(/\r?\n/, 1)[0] ?? "";
|
|
11596
11900
|
return /^#!.*\bbun\b/.test(firstLine2);
|
|
11597
11901
|
} catch {
|
|
@@ -11599,7 +11903,7 @@ function isBunShebangScript(executable) {
|
|
|
11599
11903
|
}
|
|
11600
11904
|
}
|
|
11601
11905
|
function isExecutableFile(path) {
|
|
11602
|
-
if (!
|
|
11906
|
+
if (!existsSync10(path))
|
|
11603
11907
|
return false;
|
|
11604
11908
|
try {
|
|
11605
11909
|
const stats = statSync(path);
|
|
@@ -12222,7 +12526,7 @@ function runSelfTest() {
|
|
|
12222
12526
|
// src/commands/clipboard.ts
|
|
12223
12527
|
init_paths();
|
|
12224
12528
|
import { createHash as createHash2 } from "crypto";
|
|
12225
|
-
import { existsSync as
|
|
12529
|
+
import { existsSync as existsSync11, readFileSync as readFileSync9, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
|
|
12226
12530
|
import { join as join8 } from "path";
|
|
12227
12531
|
var DEFAULT_CONFIG = {
|
|
12228
12532
|
version: 1,
|
|
@@ -12253,25 +12557,25 @@ function getDefaultConfig() {
|
|
|
12253
12557
|
}
|
|
12254
12558
|
function readConfig(configPath) {
|
|
12255
12559
|
const path = resolveConfigPath(configPath);
|
|
12256
|
-
if (!
|
|
12560
|
+
if (!existsSync11(path)) {
|
|
12257
12561
|
return getDefaultConfig();
|
|
12258
12562
|
}
|
|
12259
|
-
const parsed = JSON.parse(
|
|
12563
|
+
const parsed = JSON.parse(readFileSync9(path, "utf8"));
|
|
12260
12564
|
return { ...getDefaultConfig(), ...parsed };
|
|
12261
12565
|
}
|
|
12262
12566
|
function writeConfig(config, configPath) {
|
|
12263
12567
|
const path = resolveConfigPath(configPath);
|
|
12264
12568
|
ensureParentDir(path);
|
|
12265
|
-
|
|
12569
|
+
writeFileSync6(path, `${JSON.stringify(config, null, 2)}
|
|
12266
12570
|
`, "utf8");
|
|
12267
12571
|
}
|
|
12268
12572
|
function readHistory(historyPath) {
|
|
12269
12573
|
const path = resolveHistoryPath(historyPath);
|
|
12270
|
-
if (!
|
|
12574
|
+
if (!existsSync11(path)) {
|
|
12271
12575
|
return [];
|
|
12272
12576
|
}
|
|
12273
12577
|
try {
|
|
12274
|
-
return JSON.parse(
|
|
12578
|
+
return JSON.parse(readFileSync9(path, "utf8"));
|
|
12275
12579
|
} catch {
|
|
12276
12580
|
return [];
|
|
12277
12581
|
}
|
|
@@ -12279,7 +12583,7 @@ function readHistory(historyPath) {
|
|
|
12279
12583
|
function writeHistory(entries, historyPath) {
|
|
12280
12584
|
const path = resolveHistoryPath(historyPath);
|
|
12281
12585
|
ensureParentDir(path);
|
|
12282
|
-
|
|
12586
|
+
writeFileSync6(path, `${JSON.stringify(entries, null, 2)}
|
|
12283
12587
|
`, "utf8");
|
|
12284
12588
|
}
|
|
12285
12589
|
function computeHash(content) {
|
|
@@ -12300,12 +12604,12 @@ function sanitizeClipboardForRead(content, maxSizeBytes, skipPatterns) {
|
|
|
12300
12604
|
}
|
|
12301
12605
|
function getOrCreateClipboardKey() {
|
|
12302
12606
|
const keyPath = getClipboardKeyPath();
|
|
12303
|
-
if (
|
|
12304
|
-
return
|
|
12607
|
+
if (existsSync11(keyPath)) {
|
|
12608
|
+
return readFileSync9(keyPath, "utf8").trim();
|
|
12305
12609
|
}
|
|
12306
12610
|
const key = createHash2("sha256").update(crypto.randomUUID()).digest("hex").slice(0, 32);
|
|
12307
12611
|
ensureParentDir(keyPath);
|
|
12308
|
-
|
|
12612
|
+
writeFileSync6(keyPath, `${key}
|
|
12309
12613
|
`, "utf8");
|
|
12310
12614
|
return key;
|
|
12311
12615
|
}
|
|
@@ -12340,7 +12644,7 @@ function addClipboardEntry(entry, historyPath) {
|
|
|
12340
12644
|
}
|
|
12341
12645
|
function clearClipboardHistory(historyPath) {
|
|
12342
12646
|
const path = resolveHistoryPath(historyPath);
|
|
12343
|
-
if (
|
|
12647
|
+
if (existsSync11(path)) {
|
|
12344
12648
|
rmSync2(path);
|
|
12345
12649
|
}
|
|
12346
12650
|
}
|
|
@@ -12356,7 +12660,7 @@ function getClipboardStatus(historyPath) {
|
|
|
12356
12660
|
|
|
12357
12661
|
// src/commands/clipboard-daemon.ts
|
|
12358
12662
|
init_paths();
|
|
12359
|
-
import { readFileSync as
|
|
12663
|
+
import { readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "fs";
|
|
12360
12664
|
import { join as join9 } from "path";
|
|
12361
12665
|
import { createHash as createHash4 } from "crypto";
|
|
12362
12666
|
|
|
@@ -12364,7 +12668,7 @@ import { createHash as createHash4 } from "crypto";
|
|
|
12364
12668
|
init_paths();
|
|
12365
12669
|
import { createServer } from "http";
|
|
12366
12670
|
import { createHash as createHash3 } from "crypto";
|
|
12367
|
-
import { readFileSync as
|
|
12671
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
12368
12672
|
function readLocalClipboardSync() {
|
|
12369
12673
|
const platform5 = process.platform;
|
|
12370
12674
|
if (platform5 === "darwin") {
|
|
@@ -12410,7 +12714,7 @@ function hasCommand3(binary) {
|
|
|
12410
12714
|
function loadSharedSecret() {
|
|
12411
12715
|
const keyPath = getClipboardKeyPath();
|
|
12412
12716
|
try {
|
|
12413
|
-
return
|
|
12717
|
+
return readFileSync10(keyPath, "utf8").trim();
|
|
12414
12718
|
} catch {
|
|
12415
12719
|
return "";
|
|
12416
12720
|
}
|
|
@@ -12577,18 +12881,18 @@ function computeHash2(content) {
|
|
|
12577
12881
|
}
|
|
12578
12882
|
function loadSharedSecret2() {
|
|
12579
12883
|
try {
|
|
12580
|
-
return
|
|
12884
|
+
return readFileSync11(getClipboardKeyPath(), "utf8").trim();
|
|
12581
12885
|
} catch {
|
|
12582
12886
|
return "";
|
|
12583
12887
|
}
|
|
12584
12888
|
}
|
|
12585
12889
|
function writePid(pid) {
|
|
12586
|
-
|
|
12890
|
+
writeFileSync7(DAEMON_PID_PATH, `${pid}
|
|
12587
12891
|
`);
|
|
12588
12892
|
}
|
|
12589
12893
|
function readPid() {
|
|
12590
12894
|
try {
|
|
12591
|
-
const pid = Number.parseInt(
|
|
12895
|
+
const pid = Number.parseInt(readFileSync11(DAEMON_PID_PATH, "utf8").trim());
|
|
12592
12896
|
return Number.isFinite(pid) ? pid : null;
|
|
12593
12897
|
} catch {
|
|
12594
12898
|
return null;
|
|
@@ -12689,7 +12993,7 @@ async function discoverPeers() {
|
|
|
12689
12993
|
|
|
12690
12994
|
// src/commands/heal.ts
|
|
12691
12995
|
init_paths();
|
|
12692
|
-
import { existsSync as
|
|
12996
|
+
import { existsSync as existsSync12, readFileSync as readFileSync12, writeFileSync as writeFileSync8 } from "fs";
|
|
12693
12997
|
import { join as join10 } from "path";
|
|
12694
12998
|
var DEFAULT_THRESHOLDS = {
|
|
12695
12999
|
reconnect: 3,
|
|
@@ -12741,9 +13045,9 @@ function getHealStatePath() {
|
|
|
12741
13045
|
}
|
|
12742
13046
|
function readHealConfig(path) {
|
|
12743
13047
|
const p = path || getHealConfigPath();
|
|
12744
|
-
if (!
|
|
13048
|
+
if (!existsSync12(p))
|
|
12745
13049
|
return { ...DEFAULT_HEAL_CONFIG, thresholds: { ...DEFAULT_THRESHOLDS } };
|
|
12746
|
-
const parsed = JSON.parse(
|
|
13050
|
+
const parsed = JSON.parse(readFileSync12(p, "utf8"));
|
|
12747
13051
|
return {
|
|
12748
13052
|
...DEFAULT_HEAL_CONFIG,
|
|
12749
13053
|
...parsed,
|
|
@@ -12754,15 +13058,15 @@ function readHealConfig(path) {
|
|
|
12754
13058
|
function writeHealConfig(config, path) {
|
|
12755
13059
|
const p = path || getHealConfigPath();
|
|
12756
13060
|
ensureParentDir(p);
|
|
12757
|
-
|
|
13061
|
+
writeFileSync8(p, `${JSON.stringify(config, null, 2)}
|
|
12758
13062
|
`, "utf8");
|
|
12759
13063
|
}
|
|
12760
13064
|
function readHealState(path) {
|
|
12761
13065
|
const p = path || getHealStatePath();
|
|
12762
|
-
if (!
|
|
13066
|
+
if (!existsSync12(p))
|
|
12763
13067
|
return defaultHealState();
|
|
12764
13068
|
try {
|
|
12765
|
-
return { ...defaultHealState(), ...JSON.parse(
|
|
13069
|
+
return { ...defaultHealState(), ...JSON.parse(readFileSync12(p, "utf8")) };
|
|
12766
13070
|
} catch {
|
|
12767
13071
|
return defaultHealState();
|
|
12768
13072
|
}
|
|
@@ -12770,7 +13074,7 @@ function readHealState(path) {
|
|
|
12770
13074
|
function writeHealState(state, path) {
|
|
12771
13075
|
const p = path || getHealStatePath();
|
|
12772
13076
|
ensureParentDir(p);
|
|
12773
|
-
|
|
13077
|
+
writeFileSync8(p, `${JSON.stringify(state, null, 2)}
|
|
12774
13078
|
`, "utf8");
|
|
12775
13079
|
}
|
|
12776
13080
|
function evaluateHealth(probe, config, state) {
|
|
@@ -12889,7 +13193,7 @@ function sh(cmd, timeoutMs = 8000) {
|
|
|
12889
13193
|
}
|
|
12890
13194
|
function getCurrentBootId() {
|
|
12891
13195
|
try {
|
|
12892
|
-
return
|
|
13196
|
+
return readFileSync12("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
12893
13197
|
} catch {
|
|
12894
13198
|
return "";
|
|
12895
13199
|
}
|
|
@@ -12975,7 +13279,7 @@ function executeAction(action, config) {
|
|
|
12975
13279
|
|
|
12976
13280
|
// src/commands/heal-daemon.ts
|
|
12977
13281
|
init_paths();
|
|
12978
|
-
import { existsSync as
|
|
13282
|
+
import { existsSync as existsSync13, readFileSync as readFileSync13, writeFileSync as writeFileSync9 } from "fs";
|
|
12979
13283
|
import { join as join11 } from "path";
|
|
12980
13284
|
var DAEMON_PID_PATH2 = join11(getDataDir(), "heal-daemon.pid");
|
|
12981
13285
|
var SERVICE_PATH = "/etc/systemd/system/machines-heal.service";
|
|
@@ -13018,12 +13322,12 @@ function runHealOnce(config, opts = {}) {
|
|
|
13018
13322
|
return result;
|
|
13019
13323
|
}
|
|
13020
13324
|
function writePid2(pid) {
|
|
13021
|
-
|
|
13325
|
+
writeFileSync9(DAEMON_PID_PATH2, `${pid}
|
|
13022
13326
|
`);
|
|
13023
13327
|
}
|
|
13024
13328
|
function readPid2() {
|
|
13025
13329
|
try {
|
|
13026
|
-
const pid = Number.parseInt(
|
|
13330
|
+
const pid = Number.parseInt(readFileSync13(DAEMON_PID_PATH2, "utf8").trim());
|
|
13027
13331
|
return Number.isFinite(pid) ? pid : null;
|
|
13028
13332
|
} catch {
|
|
13029
13333
|
return null;
|
|
@@ -13095,9 +13399,9 @@ function applyDeterminism(config) {
|
|
|
13095
13399
|
}
|
|
13096
13400
|
function enableHardwareWatchdog() {
|
|
13097
13401
|
const log2 = [];
|
|
13098
|
-
if (!
|
|
13402
|
+
if (!existsSync13(SYSTEM_CONF))
|
|
13099
13403
|
return ["/etc/systemd/system.conf not found; skipping hardware watchdog"];
|
|
13100
|
-
let conf =
|
|
13404
|
+
let conf = readFileSync13(SYSTEM_CONF, "utf8");
|
|
13101
13405
|
const set = (key, value) => {
|
|
13102
13406
|
const re = new RegExp(`^#?\\s*${key}=.*$`, "m");
|
|
13103
13407
|
if (re.test(conf))
|
|
@@ -13109,7 +13413,7 @@ ${key}=${value}
|
|
|
13109
13413
|
};
|
|
13110
13414
|
set("RuntimeWatchdogSec", "20s");
|
|
13111
13415
|
set("RebootWatchdogSec", "2min");
|
|
13112
|
-
|
|
13416
|
+
writeFileSync9(SYSTEM_CONF, conf);
|
|
13113
13417
|
sh2("systemctl daemon-reexec");
|
|
13114
13418
|
log2.push("hardware watchdog: RuntimeWatchdogSec=20s RebootWatchdogSec=2min");
|
|
13115
13419
|
return log2;
|
|
@@ -13127,7 +13431,7 @@ function binPath() {
|
|
|
13127
13431
|
candidates.push(`${home}/.bun/bin/machines`);
|
|
13128
13432
|
candidates.push("/root/.bun/bin/machines", "/usr/local/bin/machines");
|
|
13129
13433
|
for (const c of candidates) {
|
|
13130
|
-
if (c &&
|
|
13434
|
+
if (c && existsSync13(c))
|
|
13131
13435
|
return c;
|
|
13132
13436
|
}
|
|
13133
13437
|
return "machines";
|
|
@@ -13154,7 +13458,7 @@ Environment=PATH=${binDir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sb
|
|
|
13154
13458
|
[Install]
|
|
13155
13459
|
WantedBy=multi-user.target
|
|
13156
13460
|
`;
|
|
13157
|
-
|
|
13461
|
+
writeFileSync9(SERVICE_PATH, unit);
|
|
13158
13462
|
sh2("systemctl daemon-reload");
|
|
13159
13463
|
sh2("systemctl enable --now machines-heal.service");
|
|
13160
13464
|
log2.push(`installed + enabled ${SERVICE_PATH} (ExecStart=${exec} heal daemon)`);
|
|
@@ -13163,7 +13467,7 @@ WantedBy=multi-user.target
|
|
|
13163
13467
|
function uninstallHealService() {
|
|
13164
13468
|
const log2 = [];
|
|
13165
13469
|
sh2("systemctl disable --now machines-heal.service 2>/dev/null || true");
|
|
13166
|
-
if (
|
|
13470
|
+
if (existsSync13(SERVICE_PATH)) {
|
|
13167
13471
|
sh2(`rm -f ${SERVICE_PATH}`);
|
|
13168
13472
|
sh2("systemctl daemon-reload");
|
|
13169
13473
|
log2.push(`removed ${SERVICE_PATH}`);
|
|
@@ -13174,7 +13478,7 @@ function uninstallHealService() {
|
|
|
13174
13478
|
}
|
|
13175
13479
|
function healServiceStatus() {
|
|
13176
13480
|
return {
|
|
13177
|
-
installed:
|
|
13481
|
+
installed: existsSync13(SERVICE_PATH),
|
|
13178
13482
|
active: sh2("systemctl is-active machines-heal.service").out === "active",
|
|
13179
13483
|
enabled: sh2("systemctl is-enabled machines-heal.service 2>/dev/null").out === "enabled"
|
|
13180
13484
|
};
|
|
@@ -13538,9 +13842,9 @@ function withEventStoreScope2(args) {
|
|
|
13538
13842
|
return { event_store_dir: eventStoreDir2(), ...args };
|
|
13539
13843
|
}
|
|
13540
13844
|
function readJsonArrayFile(path) {
|
|
13541
|
-
if (!
|
|
13845
|
+
if (!existsSync14(path))
|
|
13542
13846
|
return [];
|
|
13543
|
-
const raw =
|
|
13847
|
+
const raw = readFileSync14(path, "utf8").trim();
|
|
13544
13848
|
if (!raw)
|
|
13545
13849
|
return [];
|
|
13546
13850
|
const parsed = JSON.parse(raw);
|
|
@@ -13831,7 +14135,7 @@ manifestCommand.command("add").description("Add or replace a machine in the flee
|
|
|
13831
14135
|
console.error("error: --from-stdin requires piped input");
|
|
13832
14136
|
process.exit(1);
|
|
13833
14137
|
}
|
|
13834
|
-
const input =
|
|
14138
|
+
const input = readFileSync14(0, "utf8");
|
|
13835
14139
|
const machine2 = JSON.parse(input);
|
|
13836
14140
|
requireCliMutation("manifest_add", typeof options["approvalToken"] === "string" ? options["approvalToken"] : undefined, { machineId: machine2.id, args: machine2 });
|
|
13837
14141
|
console.log(JSON.stringify(manifestAdd(machine2), null, 2));
|
|
@@ -14058,6 +14362,37 @@ dnsCommand.command("list").description("List saved local domain mappings").optio
|
|
|
14058
14362
|
dnsCommand.command("render").description("Render hosts/proxy configuration for a domain").argument("<domain>", "Domain name").option("-j, --json", "Print JSON output", false).action((domain) => {
|
|
14059
14363
|
console.log(JSON.stringify(renderDomainMapping(domain), null, 2));
|
|
14060
14364
|
});
|
|
14365
|
+
var hostsCommand = program2.command("hosts").description("Sync fleet machine names into /etc/hosts so machine<NN>:port resolves on the LAN and tailnet");
|
|
14366
|
+
function printHostsResult(plan, applied, viaSudo = false) {
|
|
14367
|
+
console.log(`hosts file: ${plan.hostsPath}`);
|
|
14368
|
+
console.log(`local subnets: ${plan.localSubnets.join(", ") || "none"}`);
|
|
14369
|
+
for (const entry of plan.entries) {
|
|
14370
|
+
console.log(` ${entry.ip} ${entry.names.join(" ")} (${entry.source})`);
|
|
14371
|
+
}
|
|
14372
|
+
if (plan.unresolved.length > 0) {
|
|
14373
|
+
console.log(`unresolved: ${plan.unresolved.join(", ")}`);
|
|
14374
|
+
}
|
|
14375
|
+
if (plan.warnings.length > 0) {
|
|
14376
|
+
console.log(`warnings: ${plan.warnings.join(", ")}`);
|
|
14377
|
+
}
|
|
14378
|
+
console.log(applied ? `applied ${plan.entries.length} entries${viaSudo ? " (via sudo)" : ""}` : "dry run \u2014 re-run `machines hosts apply` to write");
|
|
14379
|
+
}
|
|
14380
|
+
hostsCommand.command("plan", { isDefault: true }).description("Preview the managed /etc/hosts block for the fleet (dry run)").option("-j, --json", "Print JSON output", false).option("--no-warm", "Skip establishing direct Tailscale paths to discover LAN endpoints").action((options) => {
|
|
14381
|
+
const plan = planFleetHosts({ warm: options.warm });
|
|
14382
|
+
if (options.json) {
|
|
14383
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
14384
|
+
return;
|
|
14385
|
+
}
|
|
14386
|
+
printHostsResult(plan, false);
|
|
14387
|
+
});
|
|
14388
|
+
hostsCommand.command("apply").description("Write the managed fleet block into /etc/hosts (uses sudo when required)").option("-j, --json", "Print JSON output", false).option("--no-warm", "Skip establishing direct Tailscale paths to discover LAN endpoints").action((options) => {
|
|
14389
|
+
const result = applyFleetHosts({ warm: options.warm });
|
|
14390
|
+
if (options.json) {
|
|
14391
|
+
console.log(JSON.stringify(result, null, 2));
|
|
14392
|
+
return;
|
|
14393
|
+
}
|
|
14394
|
+
printHostsResult(result, true, result.viaSudo);
|
|
14395
|
+
});
|
|
14061
14396
|
notificationsCommand.command("add").description("Add or replace a notification channel").requiredOption("--id <id>", "Channel identifier").requiredOption("--type <type>", "email | webhook | command").requiredOption("--target <target>", "Email, webhook URL, or command executable").option("--arg <arg...>", "Command argument for command transports", collectOptionValues, []).option("--event <event...>", "Events routed to this channel", ["setup_failed", "sync_failed"]).option("--disabled", "Create the channel in disabled state", false).option("--approval-token <token>", "Operator mutation approval token for command transports").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
14062
14397
|
const enabled = !options.disabled;
|
|
14063
14398
|
const events = [...new Set(options.event)];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { FleetManifest } from "../types.js";
|
|
2
|
+
export declare const HOSTS_BLOCK_BEGIN = "# >>> hasna machines fleet >>>";
|
|
3
|
+
export declare const HOSTS_BLOCK_END = "# <<< hasna machines fleet <<<";
|
|
4
|
+
/**
|
|
5
|
+
* Where each fleet host entry's IP came from, in descending preference:
|
|
6
|
+
* - manifest_lan : a `metadata.lanAddress` on the same /24 as this machine
|
|
7
|
+
* - tailscale_lan : the peer's live direct LAN endpoint (CurAddr) on this /24
|
|
8
|
+
* - tailscale : the peer's tailnet (100.64.0.0/10) IP — always routable
|
|
9
|
+
* - manifest_ip : an explicit `metadata.ipAddress` override
|
|
10
|
+
*/
|
|
11
|
+
export type HostEntrySource = "manifest_lan" | "tailscale_lan" | "tailscale" | "manifest_ip";
|
|
12
|
+
export interface FleetHostEntry {
|
|
13
|
+
id: string;
|
|
14
|
+
ip: string;
|
|
15
|
+
names: string[];
|
|
16
|
+
source: HostEntrySource;
|
|
17
|
+
}
|
|
18
|
+
export interface RawTailscalePeer {
|
|
19
|
+
HostName?: string;
|
|
20
|
+
DNSName?: string;
|
|
21
|
+
TailscaleIPs?: string[];
|
|
22
|
+
CurAddr?: string;
|
|
23
|
+
Online?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface RawTailscaleStatus {
|
|
26
|
+
Self?: RawTailscalePeer;
|
|
27
|
+
Peer?: Record<string, RawTailscalePeer>;
|
|
28
|
+
}
|
|
29
|
+
export interface HostsCommandResult {
|
|
30
|
+
stdout: string;
|
|
31
|
+
exitCode: number;
|
|
32
|
+
}
|
|
33
|
+
export type HostsCommandRunner = (command: string) => HostsCommandResult;
|
|
34
|
+
export interface BuildFleetHostEntriesInput {
|
|
35
|
+
manifest: FleetManifest;
|
|
36
|
+
tailscale: RawTailscaleStatus | null;
|
|
37
|
+
localSubnets: string[];
|
|
38
|
+
localMachineId?: string | null;
|
|
39
|
+
}
|
|
40
|
+
export interface BuildFleetHostEntriesResult {
|
|
41
|
+
entries: FleetHostEntry[];
|
|
42
|
+
unresolved: string[];
|
|
43
|
+
warnings: string[];
|
|
44
|
+
}
|
|
45
|
+
export interface FleetHostsPlan extends BuildFleetHostEntriesResult {
|
|
46
|
+
hostsPath: string;
|
|
47
|
+
block: string;
|
|
48
|
+
localSubnets: string[];
|
|
49
|
+
}
|
|
50
|
+
export interface FleetHostsOptions {
|
|
51
|
+
runner?: HostsCommandRunner;
|
|
52
|
+
localSubnets?: string[];
|
|
53
|
+
localMachineId?: string | null;
|
|
54
|
+
/**
|
|
55
|
+
* Establish direct Tailscale paths to online peers first so their LAN
|
|
56
|
+
* endpoints (CurAddr) become visible and can be preferred over tailnet IPs.
|
|
57
|
+
* Defaults to true.
|
|
58
|
+
*/
|
|
59
|
+
warm?: boolean;
|
|
60
|
+
warmTimeoutSeconds?: number;
|
|
61
|
+
}
|
|
62
|
+
export interface ApplyFleetHostsResult extends FleetHostsPlan {
|
|
63
|
+
written: boolean;
|
|
64
|
+
viaSudo: boolean;
|
|
65
|
+
}
|
|
66
|
+
export declare function getHostsPath(): string;
|
|
67
|
+
export declare function isPrivateIpv4(value: string): boolean;
|
|
68
|
+
export declare function subnet24(value: string): string | null;
|
|
69
|
+
export declare function localPrivateSubnets(): string[];
|
|
70
|
+
export declare function buildFleetHostEntries(input: BuildFleetHostEntriesInput): BuildFleetHostEntriesResult;
|
|
71
|
+
export declare function renderHostsBlock(entries: FleetHostEntry[]): string;
|
|
72
|
+
export declare function mergeHostsContent(existing: string, block: string): string;
|
|
73
|
+
/**
|
|
74
|
+
* Online peers that do not yet expose a same-subnet LAN endpoint but do have a
|
|
75
|
+
* tailnet IP — pinging these establishes a direct path so their LAN address
|
|
76
|
+
* becomes resolvable.
|
|
77
|
+
*/
|
|
78
|
+
export declare function collectPingTargets(tailscale: RawTailscaleStatus | null, localSubnets: string[]): string[];
|
|
79
|
+
export declare function warmDirectPaths(runner: HostsCommandRunner, targets: string[], timeoutSeconds?: number): void;
|
|
80
|
+
export declare function planFleetHosts(options?: FleetHostsOptions): FleetHostsPlan;
|
|
81
|
+
export declare function applyFleetHosts(options?: FleetHostsOptions): ApplyFleetHostsResult;
|
package/dist/index.js
CHANGED
|
@@ -19926,7 +19926,7 @@ class JSONSchemaGenerator {
|
|
|
19926
19926
|
if (val === undefined) {
|
|
19927
19927
|
if (this.unrepresentable === "throw") {
|
|
19928
19928
|
throw new Error("Literal `undefined` cannot be represented in JSON Schema");
|
|
19929
|
-
}
|
|
19929
|
+
} else {}
|
|
19930
19930
|
} else if (typeof val === "bigint") {
|
|
19931
19931
|
if (this.unrepresentable === "throw") {
|
|
19932
19932
|
throw new Error("BigInt literals cannot be represented in JSON Schema");
|