@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.
@@ -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
- /** The tailnet this machine is on, or null. Read-only; a few seconds at most. */
67
- export async function detectTailscale(): Promise<Tailnet | null> {
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
- try { const t = parseTailscaleStatus(JSON.parse(out)); if (t) return t; } catch { /* not JSON — try the next */ }
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
- /** `http://laptop.tail1234.ts.net:4177` — the name when there is one, the address otherwise. */
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
  }