conductor-remote 1.22.0 → 1.23.0
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/assets/{index-mi7qGnEU.js → index-BpxkVzXq.js} +2 -2
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/dist-node/scripts/service.js +11 -43
- package/dist-node/src/funnel-watchdog.js +201 -0
- package/dist-node/src/server.js +4 -0
- package/dist-node/src/tailscale.js +74 -0
- package/package.json +1 -1
package/dist/index.html
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<title>Conductor Remote</title>
|
|
14
14
|
<!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
|
|
15
15
|
<script src="/self-heal.js"></script>
|
|
16
|
-
<script type="module" crossorigin src="/assets/index-
|
|
16
|
+
<script type="module" crossorigin src="/assets/index-BpxkVzXq.js"></script>
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/index-BL-O33Ca.css">
|
|
18
18
|
<link rel="manifest" href="/manifest.webmanifest"></head>
|
|
19
19
|
<body>
|
package/dist/sw.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
if(!self.define){let e,i={};const
|
|
1
|
+
if(!self.define){let e,i={};const s=(s,n)=>(s=new URL(s+".js",n).href,i[s]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=s,e.onload=i,document.head.appendChild(e)}else e=s,importScripts(s),i()}).then(()=>{let e=i[s];if(!e)throw new Error(`Module ${s} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let l={};const t=e=>s(e,o),c={module:{uri:o},exports:l,require:t};i[o]=Promise.all(n.map(e=>c[e]||t(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"index.html",revision:"f8e519f4a8c664e338315a2129d6f59e"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-BpxkVzXq.js",revision:null},{url:"assets/index-BL-O33Ca.css",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
|
|
@@ -13,6 +13,7 @@ import fs from 'node:fs';
|
|
|
13
13
|
import os from 'node:os';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { packageRoot } from "../src/pkg-root.js";
|
|
16
|
+
import { exposeStorePath, magicDnsName, normalizeExposeMode, relayPort, tailscaleBin } from "../src/tailscale.js";
|
|
16
17
|
import { qrLines } from "./qr.js";
|
|
17
18
|
const LABEL = 'no.adluna.conductor-remote';
|
|
18
19
|
const projectDir = packageRoot(import.meta.dirname);
|
|
@@ -33,6 +34,8 @@ const FLAG_ENV = {
|
|
|
33
34
|
'--write-strategy': 'WRITE_STRATEGY',
|
|
34
35
|
'--auto-update': 'AUTO_UPDATE',
|
|
35
36
|
'--auto-update-interval': 'AUTO_UPDATE_INTERVAL_MINUTES',
|
|
37
|
+
'--funnel-watchdog': 'FUNNEL_WATCHDOG',
|
|
38
|
+
'--funnel-watchdog-interval': 'FUNNEL_WATCHDOG_INTERVAL_SECONDS',
|
|
36
39
|
'--db': 'CONDUCTOR_DB',
|
|
37
40
|
'--workspaces': 'CONDUCTOR_WORKSPACES'
|
|
38
41
|
};
|
|
@@ -137,6 +140,10 @@ function buildPlist() {
|
|
|
137
140
|
envEntries.push(['AUTO_UPDATE', process.env.AUTO_UPDATE]);
|
|
138
141
|
if (process.env.AUTO_UPDATE_INTERVAL_MINUTES)
|
|
139
142
|
envEntries.push(['AUTO_UPDATE_INTERVAL_MINUTES', process.env.AUTO_UPDATE_INTERVAL_MINUTES]);
|
|
143
|
+
if (process.env.FUNNEL_WATCHDOG)
|
|
144
|
+
envEntries.push(['FUNNEL_WATCHDOG', process.env.FUNNEL_WATCHDOG]);
|
|
145
|
+
if (process.env.FUNNEL_WATCHDOG_INTERVAL_SECONDS)
|
|
146
|
+
envEntries.push(['FUNNEL_WATCHDOG_INTERVAL_SECONDS', process.env.FUNNEL_WATCHDOG_INTERVAL_SECONDS]);
|
|
140
147
|
if (process.env.CONDUCTOR_DB)
|
|
141
148
|
envEntries.push(['CONDUCTOR_DB', process.env.CONDUCTOR_DB]);
|
|
142
149
|
if (process.env.CONDUCTOR_WORKSPACES)
|
|
@@ -207,53 +214,14 @@ function persistPinnedToken() {
|
|
|
207
214
|
console.info(` ⚠ could not persist --token (${err instanceof Error ? err.message : err})`);
|
|
208
215
|
}
|
|
209
216
|
}
|
|
210
|
-
const RELAY_PORT =
|
|
211
|
-
/** Locate the tailscale CLI: PATH first, then the common macOS install locations. Null if absent. */
|
|
212
|
-
function tailscaleBin() {
|
|
213
|
-
for (const bin of [
|
|
214
|
-
'tailscale',
|
|
215
|
-
'/opt/homebrew/bin/tailscale',
|
|
216
|
-
'/usr/local/bin/tailscale',
|
|
217
|
-
'/Applications/Tailscale.app/Contents/MacOS/Tailscale'
|
|
218
|
-
]) {
|
|
219
|
-
try {
|
|
220
|
-
execFileSync(bin, ['version'], { stdio: 'pipe' });
|
|
221
|
-
return bin;
|
|
222
|
-
}
|
|
223
|
-
catch {
|
|
224
|
-
// try the next candidate
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
return null;
|
|
228
|
-
}
|
|
229
|
-
/** This node's MagicDNS name without the trailing dot, e.g. `mac.taila6dcd6.ts.net`. */
|
|
230
|
-
function magicDnsName(bin) {
|
|
231
|
-
try {
|
|
232
|
-
const out = execFileSync(bin, ['status', '--json'], { encoding: 'utf8', stdio: 'pipe' });
|
|
233
|
-
return (JSON.parse(out)?.Self?.DNSName ?? '').replace(/\.$/, '') || null;
|
|
234
|
-
}
|
|
235
|
-
catch {
|
|
236
|
-
return null;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
/** Where the chosen expose mode is persisted so a later bare `yarn deploy` keeps the same posture. */
|
|
240
|
-
function exposeStorePath() {
|
|
241
|
-
return path.join(os.homedir(), 'Library', 'Application Support', 'conductor-remote', 'expose');
|
|
242
|
-
}
|
|
243
|
-
function normalizeMode(raw) {
|
|
244
|
-
const v = raw?.trim().toLowerCase();
|
|
245
|
-
if (v === 'public' || v === 'funnel')
|
|
246
|
-
return 'public';
|
|
247
|
-
if (v === 'tailnet' || v === 'serve' || v === 'private')
|
|
248
|
-
return 'tailnet';
|
|
249
|
-
return null;
|
|
250
|
-
}
|
|
217
|
+
const RELAY_PORT = relayPort();
|
|
251
218
|
/**
|
|
252
219
|
* Resolve the expose mode. Precedence: `EXPOSE` env (public|funnel / tailnet|serve|private) > persisted
|
|
253
220
|
* choice > 'public' default. An explicit env value is persisted so re-deploys don't silently flip posture.
|
|
221
|
+
* (The read-only counterpart used by the runtime watchdog is readExposeMode() in src/tailscale.ts.)
|
|
254
222
|
*/
|
|
255
223
|
function resolveExposeMode() {
|
|
256
|
-
const fromEnv =
|
|
224
|
+
const fromEnv = normalizeExposeMode(process.env.EXPOSE);
|
|
257
225
|
if (fromEnv) {
|
|
258
226
|
try {
|
|
259
227
|
const file = exposeStorePath();
|
|
@@ -268,7 +236,7 @@ function resolveExposeMode() {
|
|
|
268
236
|
if (process.env.EXPOSE)
|
|
269
237
|
console.info(` ⚠ unrecognized EXPOSE=${process.env.EXPOSE} — expected public|tailnet.`);
|
|
270
238
|
try {
|
|
271
|
-
const saved =
|
|
239
|
+
const saved = normalizeExposeMode(fs.readFileSync(exposeStorePath(), 'utf8'));
|
|
272
240
|
if (saved)
|
|
273
241
|
return saved;
|
|
274
242
|
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps the phone's public URL actually reachable.
|
|
3
|
+
*
|
|
4
|
+
* Tailscale Funnel exposes the loopback relay to the internet by having tailscaled hold a registration
|
|
5
|
+
* with Tailscale's Funnel *ingress* relays; public clients hit those relays, which forward the TLS stream
|
|
6
|
+
* to this node. That registration goes STALE after a network transition (Wi-Fi ⇄ iPhone hotspot, a new
|
|
7
|
+
* DHCP lease, sleep/wake) — especially behind the symmetric NAT common at offices, where the node's
|
|
8
|
+
* public endpoint keeps moving. When it does, `tailscale funnel status` still cheerfully reports "Funnel
|
|
9
|
+
* on" and tailscaled keeps its local :443 listener up, but the ingress can no longer reach the node: the
|
|
10
|
+
* phone (and any desktop tab) sits on "Offline — retrying" until someone manually re-runs `funnel reset`.
|
|
11
|
+
* Nothing self-heals it. (See FINDINGS / the funnel-ingress-stale note.)
|
|
12
|
+
*
|
|
13
|
+
* So this watchdog does NOT trust any local funnel status. It probes the REAL public path end-to-end —
|
|
14
|
+
* resolving the node's MagicDNS name against a *public* resolver to get the ingress IPs (never the tailnet
|
|
15
|
+
* 100.x address, which would mask the break exactly like a `--resolve` to the node does), then an HTTPS
|
|
16
|
+
* GET /health pinned to an ingress IP with the node's SNI. A healthy funnel answers; a stale one fails the
|
|
17
|
+
* TLS/HTTP exchange entirely. After a few consecutive failures — and only when the ingress is TCP-reachable,
|
|
18
|
+
* so a plain internet outage doesn't trigger a pointless reset — it re-registers with
|
|
19
|
+
* `tailscale funnel reset && tailscale funnel --bg --yes <port>`, then waits for propagation. The periodic
|
|
20
|
+
* probe doubles as keepalive traffic through the ingress, which also helps hold the mapping open.
|
|
21
|
+
*
|
|
22
|
+
* Gated like the self-updater: only the launchd-managed daemon (`CONDUCTOR_REMOTE_MANAGED=1`) in public
|
|
23
|
+
* (Funnel) posture runs it. `FUNNEL_WATCHDOG=off` disables; `on` forces it where a tailscale CLI + Funnel
|
|
24
|
+
* posture exist. Stdlib + global fetch/dns/https only — no runtime deps, strip-clean.
|
|
25
|
+
*/
|
|
26
|
+
import { execFile } from 'node:child_process';
|
|
27
|
+
import { Resolver } from 'node:dns/promises';
|
|
28
|
+
import https from 'node:https';
|
|
29
|
+
import net from 'node:net';
|
|
30
|
+
import { promisify } from 'node:util';
|
|
31
|
+
import { magicDnsName, readExposeMode, relayPort, tailscaleBin } from "./tailscale.js";
|
|
32
|
+
const execFileP = promisify(execFile);
|
|
33
|
+
const PROBE_PATH = '/health'; // unauthenticated 200 on the relay — no token needed to prove reachability
|
|
34
|
+
const PROBE_TIMEOUT_MS = 8000;
|
|
35
|
+
const TCP_TIMEOUT_MS = 4000;
|
|
36
|
+
const FIRST_DELAY_MS = 20 * 1000; // let the relay + funnel settle after a (re)start before the first probe
|
|
37
|
+
// Consecutive failed probes before we re-register. A single miss is often a transient blip; a stale ingress
|
|
38
|
+
// stays broken until reset, so waiting for N in a row costs a little detection latency to avoid a needless
|
|
39
|
+
// funnel reset (which itself briefly drops clients). ~N×interval of confirmed-down before acting.
|
|
40
|
+
const FAIL_THRESHOLD = 3;
|
|
41
|
+
const HEAL_COOLDOWN_MS = 120 * 1000; // min gap between re-registrations, so a persistent fault can't hammer funnel
|
|
42
|
+
const POST_HEAL_MS = 60 * 1000; // after a reset, give the control plane time to propagate before re-probing
|
|
43
|
+
/** Probe cadence. Frequent enough to detect a stale ingress within a couple minutes and to keep the mapping warm. */
|
|
44
|
+
function resolveIntervalMs() {
|
|
45
|
+
const raw = Number(process.env.FUNNEL_WATCHDOG_INTERVAL_SECONDS);
|
|
46
|
+
const seconds = Number.isFinite(raw) && raw > 0 ? raw : 60;
|
|
47
|
+
return Math.max(seconds, 15) * 1000;
|
|
48
|
+
}
|
|
49
|
+
function log(msg) {
|
|
50
|
+
console.info(`[funnel-watchdog] ${msg}`);
|
|
51
|
+
}
|
|
52
|
+
function wantEnabled() {
|
|
53
|
+
const raw = process.env.FUNNEL_WATCHDOG?.trim().toLowerCase();
|
|
54
|
+
if (raw === 'off' || raw === 'false' || raw === '0')
|
|
55
|
+
return false;
|
|
56
|
+
if (raw === 'on' || raw === 'true' || raw === '1')
|
|
57
|
+
return true;
|
|
58
|
+
return process.env.CONDUCTOR_REMOTE_MANAGED === '1'; // default: only the managed daemon
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Public A records for `host` — the Funnel ingress IPs. Queried against public resolvers so MagicDNS on
|
|
62
|
+
* this node can't answer with the tailnet 100.x address (which would make every probe hit the node directly
|
|
63
|
+
* and never see an ingress break). Falls back to the system resolver only if the public query fails.
|
|
64
|
+
*/
|
|
65
|
+
async function ingressIps(host) {
|
|
66
|
+
try {
|
|
67
|
+
const r = new Resolver({ timeout: 4000, tries: 2 });
|
|
68
|
+
r.setServers(['1.1.1.1', '8.8.8.8']);
|
|
69
|
+
const ips = await r.resolve4(host);
|
|
70
|
+
if (ips.length)
|
|
71
|
+
return ips;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// public resolver blocked/unreachable — fall through to the system resolver
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const { lookup } = await import('node:dns/promises');
|
|
78
|
+
const all = await lookup(host, { all: true, family: 4 });
|
|
79
|
+
return all.map(a => a.address);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** GET https://host<PROBE_PATH> but pinned to `ip` (an ingress relay) with SNI=host. Resolves the HTTP status
|
|
86
|
+
* code; rejects if the TLS/HTTP exchange never completes — the signature of a stale ingress. */
|
|
87
|
+
function probeVia(ip, host, timeoutMs) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const req = https.request({
|
|
90
|
+
host: ip,
|
|
91
|
+
port: 443,
|
|
92
|
+
servername: host, // SNI so the ingress routes to this node and the node's cert validates
|
|
93
|
+
path: PROBE_PATH,
|
|
94
|
+
method: 'GET',
|
|
95
|
+
headers: { host, 'user-agent': 'conductor-remote-funnel-watchdog' },
|
|
96
|
+
timeout: timeoutMs
|
|
97
|
+
}, res => {
|
|
98
|
+
res.resume(); // drain so the socket can close
|
|
99
|
+
resolve(res.statusCode ?? 0);
|
|
100
|
+
});
|
|
101
|
+
req.on('timeout', () => req.destroy(new Error('probe timeout')));
|
|
102
|
+
req.on('error', reject);
|
|
103
|
+
req.end();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** True if a bare TCP connection to `ip:port` opens — i.e. the internet + ingress are reachable, isolating
|
|
107
|
+
* "stale funnel stream" (heal-worthy) from "no connectivity" (nothing to fix, don't churn). */
|
|
108
|
+
function tcpOpen(ip, port, timeoutMs) {
|
|
109
|
+
return new Promise(resolve => {
|
|
110
|
+
const socket = net.connect({ host: ip, port });
|
|
111
|
+
const finish = (ok) => {
|
|
112
|
+
socket.destroy();
|
|
113
|
+
resolve(ok);
|
|
114
|
+
};
|
|
115
|
+
socket.setTimeout(timeoutMs);
|
|
116
|
+
socket.once('connect', () => finish(true));
|
|
117
|
+
socket.once('timeout', () => finish(false));
|
|
118
|
+
socket.once('error', () => finish(false));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
async function reRegisterFunnel(bin, port) {
|
|
122
|
+
await execFileP(bin, ['funnel', 'reset'], { timeout: 15_000 });
|
|
123
|
+
await execFileP(bin, ['funnel', '--bg', '--yes', port], { timeout: 20_000 });
|
|
124
|
+
}
|
|
125
|
+
let fails = 0;
|
|
126
|
+
let lastHealAt = 0;
|
|
127
|
+
function schedule(fn, delayMs) {
|
|
128
|
+
setTimeout(fn, delayMs).unref();
|
|
129
|
+
}
|
|
130
|
+
async function tick(host, bin, port, intervalMs) {
|
|
131
|
+
const again = (delay) => schedule(() => void tick(host, bin, port, intervalMs), delay);
|
|
132
|
+
const ips = await ingressIps(host);
|
|
133
|
+
if (ips.length === 0) {
|
|
134
|
+
// Can't resolve the ingress at all (DNS down / offline) — can't confirm a fault, so never heal.
|
|
135
|
+
return again(intervalMs);
|
|
136
|
+
}
|
|
137
|
+
const ip = ips[0];
|
|
138
|
+
let healthy = false;
|
|
139
|
+
try {
|
|
140
|
+
const status = await probeVia(ip, host, PROBE_TIMEOUT_MS);
|
|
141
|
+
healthy = status > 0 && status < 500; // any real HTTP answer means the ingress reached the node
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
healthy = false;
|
|
145
|
+
}
|
|
146
|
+
if (healthy) {
|
|
147
|
+
if (fails > 0)
|
|
148
|
+
log(`ingress healthy again after ${fails} failed probe(s)`);
|
|
149
|
+
fails = 0;
|
|
150
|
+
return again(intervalMs);
|
|
151
|
+
}
|
|
152
|
+
fails++;
|
|
153
|
+
if (fails < FAIL_THRESHOLD)
|
|
154
|
+
return again(intervalMs);
|
|
155
|
+
// Confirmed down. Only re-register if the ingress is actually reachable (internet up) — otherwise it's a
|
|
156
|
+
// connectivity outage that will clear on its own, and a funnel reset would just churn.
|
|
157
|
+
const reachable = await tcpOpen(ip, 443, TCP_TIMEOUT_MS);
|
|
158
|
+
if (!reachable) {
|
|
159
|
+
log(`ingress ${ip} unreachable after ${fails} probes — looks offline, not re-registering`);
|
|
160
|
+
return again(intervalMs);
|
|
161
|
+
}
|
|
162
|
+
if (Date.now() - lastHealAt < HEAL_COOLDOWN_MS)
|
|
163
|
+
return again(intervalMs);
|
|
164
|
+
log(`funnel ingress stale (${fails} failed probes, ingress TCP-reachable) — re-registering: funnel reset && funnel --bg ${port}`);
|
|
165
|
+
try {
|
|
166
|
+
await reRegisterFunnel(bin, port);
|
|
167
|
+
lastHealAt = Date.now();
|
|
168
|
+
fails = 0;
|
|
169
|
+
log(`re-registered funnel; waiting ${POST_HEAL_MS / 1000}s for the ingress to propagate`);
|
|
170
|
+
return again(POST_HEAL_MS);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
log(`funnel re-register failed: ${err instanceof Error ? err.message.trim() : String(err)} — will retry`);
|
|
174
|
+
return again(intervalMs);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Start the funnel watchdog. Safe to call unconditionally — no-ops unless the gates pass. */
|
|
178
|
+
export function startFunnelWatchdog() {
|
|
179
|
+
if (!wantEnabled())
|
|
180
|
+
return;
|
|
181
|
+
if (readExposeMode() !== 'public') {
|
|
182
|
+
// Funnel-specific: in tailnet (serve) posture there's no public ingress to keep alive.
|
|
183
|
+
if (process.env.FUNNEL_WATCHDOG)
|
|
184
|
+
log('skipped: expose posture is tailnet (serve), not public (funnel)');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const bin = tailscaleBin();
|
|
188
|
+
if (!bin) {
|
|
189
|
+
log('skipped: tailscale CLI not found');
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const host = magicDnsName(bin);
|
|
193
|
+
if (!host) {
|
|
194
|
+
log('skipped: no MagicDNS name for this node');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const port = relayPort();
|
|
198
|
+
const intervalMs = resolveIntervalMs();
|
|
199
|
+
log(`enabled; probing https://${host}${PROBE_PATH} via the public ingress every ${intervalMs / 1000}s (heal after ${FAIL_THRESHOLD} fails)`);
|
|
200
|
+
schedule(() => void tick(host, bin, port, intervalMs), FIRST_DELAY_MS);
|
|
201
|
+
}
|
package/dist-node/src/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import zlib from 'node:zlib';
|
|
|
6
6
|
import { startAutoUpdate, updateStatus } from "./autoupdate.js";
|
|
7
7
|
import { loadConfig } from "./config.js";
|
|
8
8
|
import { ConductorDb } from "./db.js";
|
|
9
|
+
import { startFunnelWatchdog } from "./funnel-watchdog.js";
|
|
9
10
|
import { workspaceDiff } from "./git.js";
|
|
10
11
|
import { mergePr } from "./merge.js";
|
|
11
12
|
import { attachPrStatus } from "./pr.js";
|
|
@@ -257,4 +258,7 @@ server.listen(cfg.port, cfg.host, () => {
|
|
|
257
258
|
].join('\n'));
|
|
258
259
|
// Keep the managed global daemon current — no-ops for dev checkouts / unmanaged runs (see autoupdate.ts).
|
|
259
260
|
startAutoUpdate();
|
|
261
|
+
// Keep the phone's public URL reachable — re-registers Funnel when its ingress goes stale after a
|
|
262
|
+
// network change. No-ops unless managed + public (Funnel) posture (see funnel-watchdog.ts).
|
|
263
|
+
startFunnelWatchdog();
|
|
260
264
|
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Tailscale helpers used by BOTH the deploy script (scripts/service.ts) and the runtime funnel
|
|
3
|
+
* watchdog (src/funnel-watchdog.ts). They must agree: the watchdog re-establishes Funnel with the same
|
|
4
|
+
* port and posture the deploy configured, so "how do we find tailscale / this node's public name / the
|
|
5
|
+
* expose mode" lives here once rather than drifting between deploy-time and runtime copies.
|
|
6
|
+
*
|
|
7
|
+
* Stdlib only, strip-clean (no transform-requiring syntax — see CLAUDE.md ▸ dev path).
|
|
8
|
+
*/
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
/** The relay's loopback port — what Funnel/Serve proxy to. Mirrors config.ts (`RELAY_PORT ?? 8787`). */
|
|
14
|
+
export function relayPort() {
|
|
15
|
+
return String(process.env.RELAY_PORT ?? 8787);
|
|
16
|
+
}
|
|
17
|
+
/** Locate the tailscale CLI: PATH first, then the common macOS install locations. Null if absent. */
|
|
18
|
+
export function tailscaleBin() {
|
|
19
|
+
for (const bin of [
|
|
20
|
+
'tailscale',
|
|
21
|
+
'/opt/homebrew/bin/tailscale',
|
|
22
|
+
'/usr/local/bin/tailscale',
|
|
23
|
+
'/Applications/Tailscale.app/Contents/MacOS/Tailscale'
|
|
24
|
+
]) {
|
|
25
|
+
try {
|
|
26
|
+
execFileSync(bin, ['version'], { stdio: 'pipe' });
|
|
27
|
+
return bin;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// try the next candidate
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
/** This node's MagicDNS name without the trailing dot, e.g. `mac.taila6dcd6.ts.net`. Null if unknown. */
|
|
36
|
+
export function magicDnsName(bin) {
|
|
37
|
+
try {
|
|
38
|
+
const out = execFileSync(bin, ['status', '--json'], { encoding: 'utf8', stdio: 'pipe' });
|
|
39
|
+
return (JSON.parse(out)?.Self?.DNSName ?? '').replace(/\.$/, '') || null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Where the chosen expose mode is persisted (written by the deploy script; read-only here). */
|
|
46
|
+
export function exposeStorePath() {
|
|
47
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'conductor-remote', 'expose');
|
|
48
|
+
}
|
|
49
|
+
export function normalizeExposeMode(raw) {
|
|
50
|
+
const v = raw?.trim().toLowerCase();
|
|
51
|
+
if (v === 'public' || v === 'funnel')
|
|
52
|
+
return 'public';
|
|
53
|
+
if (v === 'tailnet' || v === 'serve' || v === 'private')
|
|
54
|
+
return 'tailnet';
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Read-only resolve of the expose posture: `EXPOSE` env > persisted choice > 'public' default. Unlike the
|
|
59
|
+
* deploy script's resolveExposeMode(), this never writes the persisted file — the runtime only observes.
|
|
60
|
+
*/
|
|
61
|
+
export function readExposeMode() {
|
|
62
|
+
const fromEnv = normalizeExposeMode(process.env.EXPOSE);
|
|
63
|
+
if (fromEnv)
|
|
64
|
+
return fromEnv;
|
|
65
|
+
try {
|
|
66
|
+
const saved = normalizeExposeMode(fs.readFileSync(exposeStorePath(), 'utf8'));
|
|
67
|
+
if (saved)
|
|
68
|
+
return saved;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// no saved choice yet
|
|
72
|
+
}
|
|
73
|
+
return 'public';
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "yarn@4.15.0",
|
|
6
6
|
"description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",
|