@amenophis1er/foreman 0.1.4 → 0.1.6
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 +4 -0
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/fleet-planner.test.ts +54 -0
- package/src/fleet-planner.ts +379 -0
- package/src/notify/commands.test.ts +6 -0
- package/src/notify/commands.ts +7 -4
- package/src/notify/telegram.ts +11 -0
- package/src/notify.ts +11 -0
- package/src/planner.ts +1 -1
- package/src/preflight.ts +4 -2
- package/src/server.ts +427 -48
- package/src/store.ts +6 -2
- package/src/tailscale.test.ts +27 -1
- package/src/tailscale.ts +58 -4
- package/ui/dist/assets/index-btSPOnoZ.js +67 -0
- package/ui/dist/assets/index-tY7na0em.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-D_tuMLCb.js +0 -66
- package/ui/dist/assets/index-LAj0Dy9p.css +0 -1
package/src/tailscale.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { isTailscaleIp, parseTailscaleStatus, tailnetFromInterfaces, tailnetUrl } from './tailscale.js';
|
|
3
|
+
import { isTailscaleIp, parseTailscaleStatus, tailnetFromInterfaces, tailnetUrl, parseServeStatus, serveHint } from './tailscale.js';
|
|
4
4
|
|
|
5
5
|
test('isTailscaleIp: only 100.64.0.0/10', () => {
|
|
6
6
|
assert.equal(isTailscaleIp('100.94.221.98'), true);
|
|
@@ -30,3 +30,29 @@ test('tailnetFromInterfaces: finds the CGNAT address, ignores loopback and LAN',
|
|
|
30
30
|
assert.deepEqual(t, { ip: '100.94.221.98' });
|
|
31
31
|
assert.equal(tailnetFromInterfaces({ en0: [{ address: '192.168.1.5', family: 'IPv4', internal: false } as never] }), null);
|
|
32
32
|
});
|
|
33
|
+
|
|
34
|
+
test('parseServeStatus finds the HTTPS port that proxies to Foreman, and the ports already taken', () => {
|
|
35
|
+
const json = {
|
|
36
|
+
TCP: { 443: { HTTPS: true }, 8443: { HTTPS: true } },
|
|
37
|
+
Web: {
|
|
38
|
+
'laptop.tail1234.ts.net:443': { Handlers: { '/': { Proxy: 'http://127.0.0.1:7717' } } },
|
|
39
|
+
'laptop.tail1234.ts.net:8443': { Handlers: { '/': { Proxy: 'http://127.0.0.1:4177' } } },
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
assert.deepEqual(parseServeStatus(json, 4177), { httpsPort: 8443, httpsInUse: [443, 8443] });
|
|
43
|
+
assert.deepEqual(parseServeStatus(json, 4178), { httpsPort: undefined, httpsInUse: [443, 8443] });
|
|
44
|
+
assert.deepEqual(parseServeStatus({}, 4177), { httpsPort: undefined, httpsInUse: [] });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('tailnetUrl prefers the served HTTPS origin, port only when not 443', () => {
|
|
48
|
+
const t = { ip: '100.64.0.1', dnsName: 'laptop.tail1234.ts.net' };
|
|
49
|
+
assert.equal(tailnetUrl({ ...t, httpsPort: 443 }, 4177), 'https://laptop.tail1234.ts.net');
|
|
50
|
+
assert.equal(tailnetUrl({ ...t, httpsPort: 8443 }, 4177), 'https://laptop.tail1234.ts.net:8443');
|
|
51
|
+
assert.equal(tailnetUrl(t, 4177), 'http://laptop.tail1234.ts.net:4177');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('serveHint picks 443 when free, else the next conventional port', () => {
|
|
55
|
+
assert.equal(serveHint(4177), 'tailscale serve --bg 4177');
|
|
56
|
+
assert.equal(serveHint(4177, [443]), 'tailscale serve --bg --https=8443 4177');
|
|
57
|
+
assert.equal(serveHint(4177, [443, 8443]), 'tailscale serve --bg --https=10000 4177');
|
|
58
|
+
});
|
package/src/tailscale.ts
CHANGED
|
@@ -21,6 +21,14 @@ export interface Tailnet {
|
|
|
21
21
|
ip: string;
|
|
22
22
|
/** MagicDNS name without the trailing dot, e.g. `laptop.tail1234.ts.net`. */
|
|
23
23
|
dnsName?: string;
|
|
24
|
+
/**
|
|
25
|
+
* An HTTPS port `tailscale serve` maps onto Foreman's own port, when one
|
|
26
|
+
* is configured. Tailscale terminates TLS with a certificate for the node
|
|
27
|
+
* name; Foreman never holds a key. Links prefer this origin when present.
|
|
28
|
+
*/
|
|
29
|
+
httpsPort?: number;
|
|
30
|
+
/** HTTPS ports `serve` already uses for something else — so a hint can pick a free one. */
|
|
31
|
+
httpsInUse?: number[];
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
/** True for addresses in 100.64.0.0/10, the CGNAT range Tailscale hands out. */
|
|
@@ -51,6 +59,32 @@ export function tailnetFromInterfaces(ifaces: NodeJS.Dict<os.NetworkInterfaceInf
|
|
|
51
59
|
return null;
|
|
52
60
|
}
|
|
53
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The parts of `tailscale serve status --json` Foreman reads: which HTTPS
|
|
64
|
+
* port, if any, proxies "/" to this port on loopback. `--bg` config only;
|
|
65
|
+
* a foreground `serve` is the same on the wire and shows up the same way.
|
|
66
|
+
*/
|
|
67
|
+
export function parseServeStatus(json: unknown, port: number): { httpsPort?: number; httpsInUse: number[] } {
|
|
68
|
+
const d = json as { Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> } | null;
|
|
69
|
+
const inUse: number[] = [];
|
|
70
|
+
let httpsPort: number | undefined;
|
|
71
|
+
for (const [hostPort, site] of Object.entries(d?.Web ?? {})) {
|
|
72
|
+
const p = Number(hostPort.split(':').pop());
|
|
73
|
+
if (!Number.isFinite(p)) continue;
|
|
74
|
+
inUse.push(p);
|
|
75
|
+
const proxy = site.Handlers?.['/']?.Proxy ?? '';
|
|
76
|
+
const m = /^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):(\d+)\/?$/.exec(proxy);
|
|
77
|
+
if (m && Number(m[1]) === port && httpsPort === undefined) httpsPort = p;
|
|
78
|
+
}
|
|
79
|
+
return { httpsPort, httpsInUse: inUse };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The one command that gives Foreman a certificate: 443 when free, else the next conventional port. */
|
|
83
|
+
export function serveHint(port: number, inUse: number[] = []): string {
|
|
84
|
+
const https = [443, 8443, 10000].find((p) => !inUse.includes(p)) ?? 8443;
|
|
85
|
+
return `tailscale serve --bg${https === 443 ? '' : ` --https=${https}`} ${port}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
54
88
|
const CLI_CANDIDATES = [
|
|
55
89
|
'tailscale',
|
|
56
90
|
'/Applications/Tailscale.app/Contents/MacOS/Tailscale',
|
|
@@ -63,17 +97,37 @@ function run(cmd: string, args: string[], timeoutMs: number): Promise<string | n
|
|
|
63
97
|
});
|
|
64
98
|
}
|
|
65
99
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
100
|
+
/**
|
|
101
|
+
* The tailnet this machine is on, or null. Read-only; a few seconds at most.
|
|
102
|
+
* With a port, also asks whether `tailscale serve` fronts it with HTTPS.
|
|
103
|
+
*/
|
|
104
|
+
export async function detectTailscale(port?: number): Promise<Tailnet | null> {
|
|
68
105
|
for (const cmd of CLI_CANDIDATES) {
|
|
69
106
|
const out = await run(cmd, ['status', '--json'], 3_000);
|
|
70
107
|
if (!out) continue;
|
|
71
|
-
|
|
108
|
+
let t: Tailnet | null = null;
|
|
109
|
+
try { t = parseTailscaleStatus(JSON.parse(out)); } catch { /* not JSON — try the next */ }
|
|
110
|
+
if (!t) continue;
|
|
111
|
+
if (port && t.dnsName) {
|
|
112
|
+
const serve = await run(cmd, ['serve', 'status', '--json'], 3_000);
|
|
113
|
+
if (serve) {
|
|
114
|
+
try {
|
|
115
|
+
const { httpsPort, httpsInUse } = parseServeStatus(JSON.parse(serve), port);
|
|
116
|
+
t = { ...t, ...(httpsPort ? { httpsPort } : {}), httpsInUse };
|
|
117
|
+
} catch { /* no serve config, or an older CLI */ }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return t;
|
|
72
121
|
}
|
|
73
122
|
return tailnetFromInterfaces();
|
|
74
123
|
}
|
|
75
124
|
|
|
76
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* Where the tailnet reaches Foreman: `https://laptop.tail1234.ts.net` when
|
|
127
|
+
* `tailscale serve` fronts it (the port only when it is not 443), else
|
|
128
|
+
* `http://name:port`, else the address.
|
|
129
|
+
*/
|
|
77
130
|
export function tailnetUrl(t: Tailnet, port: number): string {
|
|
131
|
+
if (t.httpsPort && t.dnsName) return `https://${t.dnsName}${t.httpsPort === 443 ? '' : `:${t.httpsPort}`}`;
|
|
78
132
|
return `http://${t.dnsName ?? t.ip}:${port}`;
|
|
79
133
|
}
|