conductor-remote 1.76.0 → 1.76.1

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/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-C97AjnMr.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-B7l0pOim.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-BnacVtpu.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,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let c={};const l=e=>i(e,o),t={module:{uri:o},exports:c,require:l};s[o]=Promise.all(n.map(e=>t[e]||l(e))).then(e=>(r(...e),c))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e7ef44deca46c0539e6ff7bba5eb815e"},{url:"index.html",revision:"db08ac43ea0b836457637a13fc35eb16"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-C97AjnMr.js",revision:null},{url:"assets/index-BnacVtpu.css",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
1
+ if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const c=e=>i(e,o),t={module:{uri:o},exports:l,require:c};s[o]=Promise.all(n.map(e=>t[e]||c(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e7ef44deca46c0539e6ff7bba5eb815e"},{url:"index.html",revision:"b2d7edff3447d77e3f268d0e40abfcc1"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-BnacVtpu.css",revision:null},{url:"assets/index-B7l0pOim.js",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
@@ -14,7 +14,7 @@ import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import { installedServiceEnvironment, serviceEnvironmentWithSetting } from "../src/config.js";
16
16
  import { packageRoot } from "../src/pkg-root.js";
17
- import { driftWarningLines, exposeStorePath, magicDnsName, normalizeExposeMode, relayPort, tailscaleBin, writeUrlHost } from "../src/tailscale.js";
17
+ import { driftWarningLines, exposeStorePath, FUNNEL_PORTS, freeServePort, magicDnsName, normalizeExposeMode, parseServeStatus, relayPort, relayServeState, serveUrl, tailscaleBin, writeUrlHost } from "../src/tailscale.js";
18
18
  import { qrLines } from "./qr.js";
19
19
  const LABEL = 'no.adluna.conductor-remote';
20
20
  const projectDir = packageRoot(import.meta.dirname);
@@ -264,35 +264,95 @@ function resolveExposeMode() {
264
264
  }
265
265
  return 'public';
266
266
  }
267
- /** Live serve/funnel state for this node: is the loopback proxy wired, and is Funnel (public) on? */
268
- function tailscaleState(bin, dns) {
269
- if (!dns)
270
- return { proxyOk: false, funnelOn: false };
267
+ /**
268
+ * Live serve/funnel state for this node as it concerns the relay: the HTTPS port fronting it (any port, not
269
+ * only :443), whether that port is public, and which ports carry mounts that are not the relay's. A status
270
+ * that cannot be read counts as nothing fronting — the same answer the old reader gave, and one that only
271
+ * ever leads to a mount on a port nobody holds.
272
+ */
273
+ function tailscaleState(bin, port = RELAY_PORT) {
271
274
  try {
272
275
  const out = execFileSync(bin, ['serve', 'status', '--json'], { encoding: 'utf8', stdio: 'pipe' });
273
- const cfg = JSON.parse(out);
274
- const key = `${dns}:443`;
275
- const proxyOk = cfg?.Web?.[key]?.Handlers?.['/']?.Proxy === `http://127.0.0.1:${RELAY_PORT}`;
276
- return { proxyOk, funnelOn: Boolean(cfg?.AllowFunnel?.[key]) };
276
+ return relayServeState(parseServeStatus(out), port);
277
277
  }
278
278
  catch {
279
- return { proxyOk: false, funnelOn: false };
279
+ return relayServeState({}, port);
280
280
  }
281
281
  }
282
- /** Assert the tailnet-only `serve` proxy — used for tailnet mode and as the Funnel fallback. */
283
- function ensureServeOnly(bin, url, state) {
284
- if (state.proxyOk && !state.funnelOn) {
285
- console.info(`✓ tailscale serve fronts ${url} 127.0.0.1:${RELAY_PORT} (tailnet-only)`);
286
- return;
287
- }
282
+ /**
283
+ * The one Tailscale write here: `tailscale <serve|funnel> --bg --https=<port> <relay port>`. Always scoped
284
+ * to a port the relay owns outright or nobody holds — the callers check, this does not. Serving a port
285
+ * Funnel currently exposes drops that port's Funnel flag and touches no other port, which is what made
286
+ * the old `funnel reset` (every mount on the node, gone) unnecessary. Returns the CLI's error, or null.
287
+ */
288
+ function mountRelay(bin, kind, port) {
288
289
  try {
289
- execFileSync(bin, ['serve', '--bg', RELAY_PORT], { stdio: 'pipe' });
290
- console.info(`✓ tailscale serve → ${url} proxies 127.0.0.1:${RELAY_PORT} (tailnet-only)`);
290
+ execFileSync(bin, [kind, '--bg', '--yes', `--https=${port}`, RELAY_PORT], { stdio: 'pipe' });
291
+ return null;
291
292
  }
292
293
  catch (err) {
293
- console.info(`\n ⚠ could not configure tailscale serve (${err instanceof Error ? err.message : err}). Run by hand:`);
294
- console.info(` tailscale serve --bg ${RELAY_PORT}`);
294
+ return err instanceof Error ? err.message.trim() : String(err);
295
+ }
296
+ }
297
+ function byHand(kind, port) {
298
+ return `tailscale ${kind} --bg --yes --https=${port} ${RELAY_PORT}`;
299
+ }
300
+ function describeTaken(state, port) {
301
+ return (state.taken.get(port) ?? []).join(', ');
302
+ }
303
+ /** A port the relay shares is left exactly as found: Funnel is per port, so a change would drag the rest along. */
304
+ function leaveShared(url, state, want) {
305
+ console.info(`\n ⚠ ${url} fronts the relay, but :${state.port} also carries ${state.shared.join(', ')}.`);
306
+ console.info(` Funnel is per port, so making the relay ${want} would take those mounts along. Left as is.`);
307
+ console.info(` To change it, move the relay to a port of its own: ${byHand(want === 'public' ? 'funnel' : 'serve', '<port>')}`);
308
+ }
309
+ function refuseNoPort(state, candidates) {
310
+ const held = candidates.map(p => `:${p} → ${describeTaken(state, p)}`).join('; ');
311
+ console.info(`\n ⚠ every port the relay could take already carries another service (${held}). Not touching them.`);
312
+ console.info(` Free one, then run \`yarn deploy\` again — or mount by hand: ${byHand('serve', '<port>')}`);
313
+ }
314
+ /**
315
+ * Assert a tailnet-only `serve` proxy in front of the relay — tailnet mode, and the fallback when Funnel is
316
+ * refused. Non-destructive by construction: a mapping is kept wherever it already lives (:443 or not), a
317
+ * shared port is reported rather than rewritten, and a fresh mount steps around any port someone else holds.
318
+ * The order of candidates is 443 (the default, unchanged), then the relay's own port number — the memorable
319
+ * choice, and it leaves 8443/10000 for services that need Funnel — then the remaining Funnel ports.
320
+ */
321
+ function ensureServeOnly(bin, dns, state) {
322
+ const host = dns ?? '<node>';
323
+ if (state.port !== null) {
324
+ const url = serveUrl(host, state.port);
325
+ if (!state.funnelOn) {
326
+ console.info(`✓ tailscale serve fronts ${url} → 127.0.0.1:${RELAY_PORT} (tailnet-only)`);
327
+ return;
328
+ }
329
+ if (state.shared.length) {
330
+ leaveShared(url, state, 'tailnet-only');
331
+ return;
332
+ }
333
+ const failed = mountRelay(bin, 'serve', state.port);
334
+ if (failed) {
335
+ console.info(`\n ⚠ could not take Funnel off ${url} (${failed}). Run by hand:\n ${byHand('serve', state.port)}`);
336
+ return;
337
+ }
338
+ console.info(`✓ tailscale serve → ${url} is tailnet-only again → 127.0.0.1:${RELAY_PORT}`);
339
+ return;
340
+ }
341
+ const candidates = [443, Number(RELAY_PORT), ...FUNNEL_PORTS.filter(p => p !== 443)];
342
+ const port = freeServePort(state, candidates);
343
+ if (port === null) {
344
+ refuseNoPort(state, candidates);
345
+ return;
295
346
  }
347
+ if (port !== 443)
348
+ console.info(` :443 carries ${describeTaken(state, 443)}, so the relay goes on :${port} instead.`);
349
+ const url = serveUrl(host, port);
350
+ const failed = mountRelay(bin, 'serve', port);
351
+ if (failed) {
352
+ console.info(`\n ⚠ could not configure tailscale serve (${failed}). Run by hand:\n ${byHand('serve', port)}`);
353
+ return;
354
+ }
355
+ console.info(`✓ tailscale serve → ${url} proxies 127.0.0.1:${RELAY_PORT} (tailnet-only)`);
296
356
  }
297
357
  /**
298
358
  * Persist a stable Tailscale device name so this node's MagicDNS URL — the origin the installed PWA is
@@ -351,39 +411,58 @@ function ensureTailscale() {
351
411
  const dns = magicDnsName(bin);
352
412
  if (dns)
353
413
  writeUrlHost(dns); // baseline for drift detection (server startup + `service status` compare against this)
354
- const url = `https://${dns ?? '<node>'}/`;
414
+ const host = dns ?? '<node>';
355
415
  const mode = resolveExposeMode();
356
- const state = tailscaleState(bin, dns);
416
+ const state = tailscaleState(bin);
357
417
  if (mode === 'tailnet') {
418
+ ensureServeOnly(bin, dns, state);
419
+ return;
420
+ }
421
+ // public (Funnel)
422
+ if (state.port !== null) {
423
+ const url = serveUrl(host, state.port);
358
424
  if (state.funnelOn) {
359
- try {
360
- execFileSync(bin, ['funnel', 'reset'], { stdio: 'pipe' });
361
- }
362
- catch {
363
- // best-effort; ensureServeOnly re-asserts the proxy below
364
- }
365
- ensureServeOnly(bin, url, { proxyOk: false, funnelOn: false });
425
+ console.info(`✓ tailscale funnel already exposes ${url} → 127.0.0.1:${RELAY_PORT} (public, token-gated)`);
426
+ return;
366
427
  }
367
- else {
368
- ensureServeOnly(bin, url, state);
428
+ if (state.shared.length) {
429
+ leaveShared(url, state, 'public');
430
+ return;
431
+ }
432
+ if (!FUNNEL_PORTS.includes(state.port)) {
433
+ console.info(`\n ⚠ the relay is fronted tailnet-only at ${url}, and Funnel cannot listen on :${state.port}`);
434
+ console.info(` (only ${FUNNEL_PORTS.join(', ')}). Left as is — \`conductor-remote config\` will show the mismatch.`);
435
+ console.info(` To go public, move it to a Funnel port by hand: ${byHand('funnel', '<443|8443|10000>')}`);
436
+ return;
437
+ }
438
+ const failed = mountRelay(bin, 'funnel', state.port);
439
+ if (failed) {
440
+ funnelRefused(failed);
441
+ ensureServeOnly(bin, dns, state);
442
+ return;
369
443
  }
444
+ console.info(`✓ tailscale funnel → ${url} now public over the internet (token-gated) → 127.0.0.1:${RELAY_PORT}`);
370
445
  return;
371
446
  }
372
- // public (Funnel)
373
- if (state.proxyOk && state.funnelOn) {
374
- console.info(`✓ tailscale funnel already exposes ${url} → 127.0.0.1:${RELAY_PORT} (public, token-gated)`);
447
+ const port = freeServePort(state, FUNNEL_PORTS);
448
+ if (port === null) {
449
+ refuseNoPort(state, FUNNEL_PORTS);
375
450
  return;
376
451
  }
377
- try {
378
- execFileSync(bin, ['funnel', '--bg', '--yes', RELAY_PORT], { stdio: 'pipe' });
379
- console.info(`✓ tailscale funnel → ${url} now public over the internet (token-gated) 127.0.0.1:${RELAY_PORT}`);
380
- }
381
- catch (err) {
382
- console.info(`\n ⚠ could not enable Funnel (${err instanceof Error ? err.message.trim() : err}).`);
383
- console.info(' Funnel must be enabled for this tailnet: open the URL Tailscale printed above, or add the');
384
- console.info(' "funnel" nodeAttr in Admin console ▸ Access controls. Falling back to tailnet-only for now.');
385
- ensureServeOnly(bin, url, state);
452
+ if (port !== 443)
453
+ console.info(` :443 carries ${describeTaken(state, 443)}, so the relay goes on :${port} instead.`);
454
+ const failed = mountRelay(bin, 'funnel', port);
455
+ if (failed) {
456
+ funnelRefused(failed);
457
+ ensureServeOnly(bin, dns, state);
458
+ return;
386
459
  }
460
+ console.info(`✓ tailscale funnel → ${serveUrl(host, port)} now public over the internet (token-gated) → 127.0.0.1:${RELAY_PORT}`);
461
+ }
462
+ function funnelRefused(error) {
463
+ console.info(`\n ⚠ could not enable Funnel (${error}).`);
464
+ console.info(' Funnel must be enabled for this tailnet: open the URL Tailscale printed above, or add the');
465
+ console.info(' "funnel" nodeAttr in Admin console ▸ Access controls. Falling back to tailnet-only for now.');
387
466
  }
388
467
  /** Print a scannable QR of `url` (theme-independent black-on-white). Never fatal — QR is a convenience. */
389
468
  function printQr(url) {
@@ -404,10 +483,10 @@ function printUrl() {
404
483
  console.info(`\n${drift.join('\n')}`);
405
484
  }
406
485
  const dns = bin ? magicDnsName(bin) : null;
407
- const state = bin ? tailscaleState(bin, dns) : { proxyOk: false, funnelOn: false };
408
- if (dns && state.proxyOk) {
486
+ const state = bin ? tailscaleState(bin) : relayServeState({}, RELAY_PORT);
487
+ if (dns && state.port !== null) {
409
488
  const scope = state.funnelOn ? 'public — any browser, token-gated' : 'same Tailnet only';
410
- const url = `https://${dns}/${frag}`;
489
+ const url = `${serveUrl(dns, state.port)}${frag}`;
411
490
  console.info(`\n Phone URL (HTTPS, ${scope}):\n ${url}`);
412
491
  if (token) {
413
492
  console.info('\n Scan to open on your phone:');
@@ -417,7 +496,7 @@ function printUrl() {
417
496
  }
418
497
  // Nothing fronting yet — the relay is only on loopback.
419
498
  console.info(`\n Local URL:\n http://127.0.0.1:${RELAY_PORT}/${frag}`);
420
- console.info(`\n ⚠ Not reachable from your phone yet. Run \`tailscale funnel --bg ${RELAY_PORT}\` (public) or \`tailscale serve --bg ${RELAY_PORT}\` (tailnet)${dns ? ` → https://${dns}/` : ''}, then \`yarn service status\`.`);
499
+ console.info(`\n ⚠ Not reachable from your phone yet. Run \`yarn deploy\` (it picks a free port), or by hand \`tailscale funnel --bg ${RELAY_PORT}\` (public) / \`tailscale serve --bg ${RELAY_PORT}\` (tailnet)${dns ? ` → https://${dns}/` : ''}, then \`yarn service status\`.`);
421
500
  }
422
501
  /** npx unpacks into a throwaway cache that gets purged; a LaunchAgent baked against it would rot. */
423
502
  function isEphemeralInstall(dir) {
@@ -607,6 +686,17 @@ function config() {
607
686
  // no token file yet
608
687
  }
609
688
  rows.push({ name: 'token', value: token, source: 'token file' });
689
+ // The HTTPS port is a live Tailscale fact, not a plist knob: :443 by default, elsewhere once another
690
+ // service holds :443 (see ensureServeOnly), and the phone URL carries whichever it is. Read against the
691
+ // daemon's own port, since this shell's RELAY_PORT is not the one the relay listens on.
692
+ const bin = tailscaleBin();
693
+ const livePort = env.RELAY_PORT ?? '8787';
694
+ const live = bin ? tailscaleState(bin, livePort) : relayServeState({}, livePort);
695
+ rows.push({
696
+ name: 'https-port',
697
+ value: live.port === null ? '(not fronted)' : String(live.port),
698
+ source: 'tailscale serve'
699
+ });
610
700
  const width = Math.max(...rows.map(r => r.name.length));
611
701
  const valueWidth = Math.max(...rows.map(r => r.value.length));
612
702
  console.info('');
@@ -618,25 +708,31 @@ function config() {
618
708
  // The cross-check worth having. A posture the relay believes and a Tailscale that is doing something
619
709
  // else is invisible in every other command, and it is self-healing in the wrong direction: the funnel
620
710
  // watchdog only runs for `public`, so a relay that thinks it is public will re-register Funnel.
621
- const bin = tailscaleBin();
622
711
  const configured = rows.find(r => r.name === 'expose')?.value;
623
712
  if (!bin || !configured)
624
713
  return;
625
- const dns = magicDnsName(bin);
626
- const live = tailscaleState(bin, dns);
627
- const liveMode = live.funnelOn ? 'public' : live.proxyOk ? 'tailnet' : null;
714
+ const liveMode = live.funnelOn ? 'public' : live.port !== null ? 'tailnet' : null;
715
+ const where = live.port !== null && live.port !== 443 ? ` on :${live.port}` : '';
716
+ const funnelable = live.port === null || FUNNEL_PORTS.includes(live.port);
628
717
  console.info('');
629
718
  if (liveMode === null) {
630
- console.info(` \u26a0 tailscale is not fronting 127.0.0.1:${RELAY_PORT} at all — the phone URL is not wired.`);
719
+ console.info(` \u26a0 tailscale is not fronting 127.0.0.1:${livePort} at all — the phone URL is not wired.`);
631
720
  console.info(' Fix with: conductor-remote service install');
632
721
  }
633
722
  else if (liveMode === configured) {
634
- console.info(` \u2713 tailscale agrees: ${liveMode === 'public' ? 'Funnel on (internet-facing)' : 'serve only (tailnet)'}`);
723
+ console.info(` \u2713 tailscale agrees: ${liveMode === 'public' ? 'Funnel on (internet-facing)' : 'serve only (tailnet)'}${where}`);
635
724
  }
636
725
  else {
637
- console.info(` \u26a0 tailscale says ${liveMode}, the daemon is configured ${configured}. They must match:`);
726
+ console.info(` \u26a0 tailscale says ${liveMode}${where}, the daemon is configured ${configured}. They must match:`);
638
727
  console.info(` the funnel watchdog only runs for \`public\`, so the two can heal each other apart.`);
639
- console.info(` Fix with: conductor-remote service install --expose ${configured}`);
728
+ if (configured === 'public' && !funnelable) {
729
+ console.info(` Funnel cannot listen on :${live.port} (only ${FUNNEL_PORTS.join(', ')}), so either move the relay by hand`);
730
+ console.info(` (tailscale funnel --bg --yes --https=<443|8443|10000> ${livePort}) or match the daemon to it:`);
731
+ console.info(' conductor-remote service install --expose tailnet');
732
+ }
733
+ else {
734
+ console.info(` Fix with: conductor-remote service install --expose ${configured}`);
735
+ }
640
736
  }
641
737
  }
642
738
  function status() {
@@ -29,7 +29,7 @@ import https from 'node:https';
29
29
  import net from 'node:net';
30
30
  import { promisify } from 'node:util';
31
31
  import { readSettings } from "./settings.js";
32
- import { magicDnsName, readExposeMode, relayPort, tailscaleBin } from "./tailscale.js";
32
+ import { magicDnsName, parseServeStatus, readExposeMode, relayPort, relayServeState, tailscaleBin } from "./tailscale.js";
33
33
  import { hasDefaultRoute, joinNetwork, preferredNetworks } from "./wifi.js";
34
34
  import { joinInstantHotspot } from "./writes.js";
35
35
  const execFileP = promisify(execFile);
@@ -121,7 +121,33 @@ function tcpOpen(ip, port, timeoutMs) {
121
121
  socket.once('error', () => finish(false));
122
122
  });
123
123
  }
124
+ /**
125
+ * The heal is `funnel reset` + `funnel --bg <port>`, which mounts the relay on :443. That is refused when
126
+ * :443 carries a mount that is not the relay's, or the relay is served on another port — the arrangement
127
+ * once another service needs :443 (OpenAI's cloud dials nothing else, measured 2026-09-02, so the voice
128
+ * listener holds :443 and the relay sits tailnet-only on :8787). The probe above was never reaching the
129
+ * relay in that case, and the heal would delete the mount it did reach. Fails closed: a status that cannot
130
+ * be read blocks the heal, never allows it.
131
+ */
132
+ async function reRegisterBlocked(bin, port) {
133
+ try {
134
+ const { stdout } = await execFileP(bin, ['serve', 'status', '--json'], { encoding: 'utf8', timeout: 10_000 });
135
+ const state = relayServeState(parseServeStatus(stdout), port);
136
+ const foreign = state.taken.get(443);
137
+ if (foreign?.length)
138
+ return `:443 carries ${foreign.join(', ')}`;
139
+ if (state.port !== null && state.port !== 443)
140
+ return `the relay is served on :${state.port}, not :443`;
141
+ return null;
142
+ }
143
+ catch (err) {
144
+ return `could not read tailscale serve status (${err instanceof Error ? err.message.trim() : String(err)})`;
145
+ }
146
+ }
124
147
  async function reRegisterFunnel(bin, port) {
148
+ const blocked = await reRegisterBlocked(bin, port);
149
+ if (blocked)
150
+ throw new Error(`not re-registering, ${blocked}`);
125
151
  await execFileP(bin, ['funnel', 'reset'], { timeout: 15_000 });
126
152
  await execFileP(bin, ['funnel', '--bg', '--yes', port], { timeout: 20_000 });
127
153
  }
@@ -156,6 +182,7 @@ let fails = 0;
156
182
  let dnsFails = 0;
157
183
  let lastHealAt = 0;
158
184
  let lastRejoinAt = 0;
185
+ let blockedLogged = null;
159
186
  const REJOIN_COOLDOWN_MS = 5 * 60 * 1000; // a network switch is disruptive; never churn on one
160
187
  const REJOIN_SETTLE_MS = 12 * 1000; // DHCP + tailscaled noticing the new endpoint
161
188
  // An Instant Hotspot press has the phone's Bluetooth wake + hotspot spin-up in front of the
@@ -289,6 +316,15 @@ async function tick(host, bin, port, intervalMs) {
289
316
  }
290
317
  if (Date.now() - lastHealAt < HEAL_COOLDOWN_MS)
291
318
  return again(intervalMs);
319
+ // A block is a standing fact, so it is logged when it changes and not once a minute for as long as it holds.
320
+ const blocked = await reRegisterBlocked(bin, port);
321
+ if (blocked) {
322
+ if (blocked !== blockedLogged)
323
+ log(`ingress probe failing (${fails}), but not re-registering: ${blocked}`);
324
+ blockedLogged = blocked;
325
+ return again(intervalMs);
326
+ }
327
+ blockedLogged = null;
292
328
  log(`funnel ingress stale (${fails} failed probes, ingress TCP-reachable) — re-registering: funnel reset && funnel --bg ${port}`);
293
329
  try {
294
330
  await reRegisterFunnel(bin, port);
@@ -122,3 +122,72 @@ export function driftWarningLines(bin) {
122
122
  ` …or re-add the PWA at the new URL: https://${drift.current}/`
123
123
  ];
124
124
  }
125
+ /** Tailscale's Funnel listens on these ports only; a tailnet-only `serve` may use any port. */
126
+ export const FUNNEL_PORTS = [443, 8443, 10000];
127
+ /** `tailscale serve status --json` prints `null` for an empty config, and anything else is not a status. */
128
+ export function parseServeStatus(out) {
129
+ try {
130
+ const parsed = JSON.parse(out);
131
+ return parsed && typeof parsed === 'object' ? parsed : {};
132
+ }
133
+ catch {
134
+ return {};
135
+ }
136
+ }
137
+ function portOf(hostPort) {
138
+ const port = Number(hostPort.slice(hostPort.lastIndexOf(':') + 1));
139
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
140
+ }
141
+ function describeHandler(handler) {
142
+ return handler.Proxy ?? handler.Path ?? (handler.Text !== undefined ? 'text' : 'unknown');
143
+ }
144
+ /** Pure: reads one parsed status, so a saved JSON can drive it in a test. `relayPort` is the loopback port. */
145
+ export function relayServeState(status, relayPort) {
146
+ const relayTargets = new Set([`http://127.0.0.1:${relayPort}`, `http://localhost:${relayPort}`]);
147
+ const owned = new Map(); // port → the host:port key, for the AllowFunnel lookup
148
+ const taken = new Map();
149
+ const claim = (port, what) => {
150
+ const list = taken.get(port) ?? [];
151
+ list.push(what);
152
+ taken.set(port, list);
153
+ };
154
+ for (const [key, web] of Object.entries(status.Web ?? {})) {
155
+ const port = portOf(key);
156
+ if (port === null)
157
+ continue;
158
+ for (const [mount, handler] of Object.entries(web.Handlers ?? {})) {
159
+ const relay = handler.Proxy !== undefined && relayTargets.has(handler.Proxy);
160
+ if (relay && mount === '/') {
161
+ if (!owned.has(port) || port === 443)
162
+ owned.set(port, key);
163
+ continue;
164
+ }
165
+ // A relay mount off the root is not a phone URL, and not a stranger either: neither owned nor taken.
166
+ if (!relay)
167
+ claim(port, `${mount} → ${describeHandler(handler)}`);
168
+ }
169
+ }
170
+ for (const [key, tcp] of Object.entries(status.TCP ?? {})) {
171
+ const port = portOf(key);
172
+ if (port === null)
173
+ continue;
174
+ if (tcp.TCPForward)
175
+ claim(port, `tcp → ${tcp.TCPForward}`);
176
+ }
177
+ const port = owned.has(443) ? 443 : ([...owned.keys()].sort((a, b) => a - b)[0] ?? null);
178
+ const key = port === null ? undefined : owned.get(port);
179
+ return {
180
+ port,
181
+ funnelOn: key !== undefined && status.AllowFunnel?.[key] === true,
182
+ shared: port === null ? [] : (taken.get(port) ?? []),
183
+ taken
184
+ };
185
+ }
186
+ /** The first candidate port carrying nothing the relay does not own, or null when every one is spoken for. */
187
+ export function freeServePort(state, candidates) {
188
+ return candidates.find(port => !state.taken.has(port)) ?? null;
189
+ }
190
+ /** The phone URL for a node name and the HTTPS port fronting the relay — no port suffix on 443. */
191
+ export function serveUrl(host, port) {
192
+ return `https://${host}${port === 443 ? '' : `:${port}`}/`;
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.76.0",
3
+ "version": "1.76.1",
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.",