conductor-remote 0.0.0-development → 1.1.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/README.md CHANGED
@@ -35,6 +35,13 @@ on your machine, so `npm i -g` finishes in about a second. `service install`
35
35
  prints a phone URL with an embedded token; open it and **Add to Home Screen**.
36
36
  Manage the service with `conductor-remote service status|restart|uninstall`.
37
37
 
38
+ **Reachability (`EXPOSE`).** By default the URL is exposed publicly via
39
+ [Tailscale Funnel](https://tailscale.com/kb/1223/funnel) — reachable from any
40
+ browser, gated by the embedded 128-bit token (so the phone needs **no** Tailscale
41
+ app). Funnel must be enabled once for your tailnet (Admin console). To keep it
42
+ tailnet-only instead (devices logged into your tailnet, via `tailscale serve`),
43
+ install with `EXPOSE=tailnet`. The choice is remembered across re-deploys.
44
+
38
45
  ## Architecture
39
46
 
40
47
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "0.0.0-development",
3
+ "version": "1.1.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.",
@@ -166,43 +166,131 @@ function magicDnsName(bin: string): string | null {
166
166
  }
167
167
  }
168
168
 
169
- /** Is `tailscale serve` already proxying https://<dns>/ to the loopback relay? Keeps the wiring idempotent. */
170
- function serveConfigured(bin: string, dns: string): boolean {
169
+ /**
170
+ * How the stable HTTPS URL is fronted:
171
+ * 'public' → `tailscale funnel` — reachable from ANY browser on the internet (token-gated).
172
+ * 'tailnet' → `tailscale serve` — reachable only by devices logged into this tailnet.
173
+ */
174
+ type ExposeMode = 'public' | 'tailnet'
175
+
176
+ /** Where the chosen expose mode is persisted so a later bare `yarn deploy` keeps the same posture. */
177
+ function exposeStorePath(): string {
178
+ return path.join(os.homedir(), 'Library', 'Application Support', 'conductor-remote', 'expose')
179
+ }
180
+
181
+ function normalizeMode(raw: string | undefined): ExposeMode | null {
182
+ const v = raw?.trim().toLowerCase()
183
+ if (v === 'public' || v === 'funnel') return 'public'
184
+ if (v === 'tailnet' || v === 'serve' || v === 'private') return 'tailnet'
185
+ return null
186
+ }
187
+
188
+ /**
189
+ * Resolve the expose mode. Precedence: `EXPOSE` env (public|funnel / tailnet|serve|private) > persisted
190
+ * choice > 'public' default. An explicit env value is persisted so re-deploys don't silently flip posture.
191
+ */
192
+ function resolveExposeMode(): ExposeMode {
193
+ const fromEnv = normalizeMode(process.env.EXPOSE)
194
+ if (fromEnv) {
195
+ try {
196
+ const file = exposeStorePath()
197
+ fs.mkdirSync(path.dirname(file), { recursive: true })
198
+ fs.writeFileSync(file, fromEnv)
199
+ } catch {
200
+ // persistence is a convenience; ignore failures
201
+ }
202
+ return fromEnv
203
+ }
204
+ if (process.env.EXPOSE) console.info(` ⚠ unrecognized EXPOSE=${process.env.EXPOSE} — expected public|tailnet.`)
205
+ try {
206
+ const saved = normalizeMode(fs.readFileSync(exposeStorePath(), 'utf8'))
207
+ if (saved) return saved
208
+ } catch {
209
+ // no saved choice yet
210
+ }
211
+ return 'public'
212
+ }
213
+
214
+ /** Live serve/funnel state for this node: is the loopback proxy wired, and is Funnel (public) on? */
215
+ function tailscaleState(bin: string, dns: string | null): { proxyOk: boolean; funnelOn: boolean } {
216
+ if (!dns) return { proxyOk: false, funnelOn: false }
171
217
  try {
172
218
  const out = execFileSync(bin, ['serve', 'status', '--json'], { encoding: 'utf8', stdio: 'pipe' })
173
- const proxy = JSON.parse(out)?.Web?.[`${dns}:443`]?.Handlers?.['/']?.Proxy
174
- return proxy === `http://127.0.0.1:${RELAY_PORT}`
219
+ const cfg = JSON.parse(out)
220
+ const key = `${dns}:443`
221
+ const proxyOk = cfg?.Web?.[key]?.Handlers?.['/']?.Proxy === `http://127.0.0.1:${RELAY_PORT}`
222
+ return { proxyOk, funnelOn: Boolean(cfg?.AllowFunnel?.[key]) }
175
223
  } catch {
176
- return false
224
+ return { proxyOk: false, funnelOn: false }
225
+ }
226
+ }
227
+
228
+ /** Assert the tailnet-only `serve` proxy — used for tailnet mode and as the Funnel fallback. */
229
+ function ensureServeOnly(bin: string, url: string, state: { proxyOk: boolean; funnelOn: boolean }): void {
230
+ if (state.proxyOk && !state.funnelOn) {
231
+ console.info(`✓ tailscale serve fronts ${url} → 127.0.0.1:${RELAY_PORT} (tailnet-only)`)
232
+ return
233
+ }
234
+ try {
235
+ execFileSync(bin, ['serve', '--bg', RELAY_PORT], { stdio: 'pipe' })
236
+ console.info(`✓ tailscale serve → ${url} proxies 127.0.0.1:${RELAY_PORT} (tailnet-only)`)
237
+ } catch (err) {
238
+ console.info(
239
+ `\n ⚠ could not configure tailscale serve (${err instanceof Error ? err.message : err}). Run by hand:`
240
+ )
241
+ console.info(` tailscale serve --bg ${RELAY_PORT}`)
177
242
  }
178
243
  }
179
244
 
180
245
  /**
181
- * Wire the relay to a stable HTTPS tailnet URL via `tailscale serve` (tailnet-only NOT Funnel).
182
- * Idempotent and non-fatal: the relay binds loopback regardless, so a failure here just means the phone
183
- * URL isn't set up yet, and we print how to do it by hand. Real TLS also satisfies the PWA's secure-context
184
- * requirement (a service worker won't register over plain http on a 100.x IP).
246
+ * Front the loopback relay with a stable HTTPS URL, either publicly (`tailscale funnel`, the default) or
247
+ * tailnet-only (`tailscale serve`), per resolveExposeMode(). Idempotent flips Funnel off when switching
248
+ * back to tailnet and non-fatal: the relay binds loopback regardless, so a failure here just means the
249
+ * phone URL isn't wired yet and we print how to do it by hand. Real TLS also satisfies the PWA's
250
+ * secure-context requirement (a service worker won't register over plain http on a 100.x IP).
251
+ *
252
+ * PUBLIC IS INTERNET-FACING: the 128-bit token on every /api/* request is the only gate. Funnel must be
253
+ * enabled for the tailnet (Admin console) or the funnel command fails — we then fall back to tailnet-only.
185
254
  */
186
- function ensureServe(): void {
255
+ function ensureTailscale(): void {
187
256
  const bin = tailscaleBin()
188
257
  if (!bin) {
189
- console.info('\n ⚠ tailscale CLI not found — skipped serve setup. Once Tailscale is installed, run:')
190
- console.info(` tailscale serve --bg ${RELAY_PORT}`)
258
+ console.info('\n ⚠ tailscale CLI not found — skipped URL setup. Once Tailscale is installed, run:')
259
+ console.info(` tailscale funnel --bg ${RELAY_PORT} # public, or \`serve\` for tailnet-only`)
191
260
  return
192
261
  }
193
262
  const dns = magicDnsName(bin)
194
- if (dns && serveConfigured(bin, dns)) {
195
- console.info(`✓ tailscale serve already fronts https://${dns}/ → 127.0.0.1:${RELAY_PORT}`)
263
+ const url = `https://${dns ?? '<node>'}/`
264
+ const mode = resolveExposeMode()
265
+ const state = tailscaleState(bin, dns)
266
+
267
+ if (mode === 'tailnet') {
268
+ if (state.funnelOn) {
269
+ try {
270
+ execFileSync(bin, ['funnel', 'reset'], { stdio: 'pipe' })
271
+ } catch {
272
+ // best-effort; ensureServeOnly re-asserts the proxy below
273
+ }
274
+ ensureServeOnly(bin, url, { proxyOk: false, funnelOn: false })
275
+ } else {
276
+ ensureServeOnly(bin, url, state)
277
+ }
278
+ return
279
+ }
280
+
281
+ // public (Funnel)
282
+ if (state.proxyOk && state.funnelOn) {
283
+ console.info(`✓ tailscale funnel already exposes ${url} → 127.0.0.1:${RELAY_PORT} (public, token-gated)`)
196
284
  return
197
285
  }
198
286
  try {
199
- execFileSync(bin, ['serve', '--bg', RELAY_PORT], { stdio: 'pipe' })
200
- console.info(`✓ tailscale servehttps://${dns ?? '<node>'}/ now proxies 127.0.0.1:${RELAY_PORT} (tailnet-only)`)
287
+ execFileSync(bin, ['funnel', '--bg', '--yes', RELAY_PORT], { stdio: 'pipe' })
288
+ console.info(`✓ tailscale funnel${url} now public over the internet (token-gated) → 127.0.0.1:${RELAY_PORT}`)
201
289
  } catch (err) {
202
- console.info(
203
- `\n ⚠ could not configure tailscale serve (${err instanceof Error ? err.message : err}). Run by hand:`
204
- )
205
- console.info(` tailscale serve --bg ${RELAY_PORT}`)
290
+ console.info(`\n ⚠ could not enable Funnel (${err instanceof Error ? err.message.trim() : err}).`)
291
+ console.info(' Funnel must be enabled for this tailnet: open the URL Tailscale printed above, or add the')
292
+ console.info(' "funnel" nodeAttr in Admin console ▸ Access controls. Falling back to tailnet-only for now.')
293
+ ensureServeOnly(bin, url, state)
206
294
  }
207
295
  }
208
296
 
@@ -210,14 +298,16 @@ function printUrl(): void {
210
298
  const frag = `#token=${currentToken() ?? '<starts on first run>'}`
211
299
  const bin = tailscaleBin()
212
300
  const dns = bin ? magicDnsName(bin) : null
213
- if (bin && dns && serveConfigured(bin, dns)) {
214
- console.info(`\n Phone URL (same Tailnet, HTTPS):\n https://${dns}/${frag}`)
301
+ const state = bin ? tailscaleState(bin, dns) : { proxyOk: false, funnelOn: false }
302
+ if (dns && state.proxyOk) {
303
+ const scope = state.funnelOn ? 'public — any browser, token-gated' : 'same Tailnet only'
304
+ console.info(`\n Phone URL (HTTPS, ${scope}):\n https://${dns}/${frag}`)
215
305
  return
216
306
  }
217
- // Serve not wired yet — the relay is only on loopback.
307
+ // Nothing fronting yet — the relay is only on loopback.
218
308
  console.info(`\n Local URL:\n http://127.0.0.1:${RELAY_PORT}/${frag}`)
219
309
  console.info(
220
- `\n ⚠ Not reachable from your phone yet. Run \`tailscale serve --bg ${RELAY_PORT}\`${dns ? ` → https://${dns}/` : ''}, then \`yarn service status\`.`
310
+ `\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\`.`
221
311
  )
222
312
  }
223
313
 
@@ -247,7 +337,7 @@ function install(): void {
247
337
  console.info(` plist: ${plistPath}`)
248
338
  console.info(` logs: ${logDir}/relay.log`)
249
339
  console.info(` node: ${process.execPath}`)
250
- ensureServe()
340
+ ensureTailscale()
251
341
  printUrl()
252
342
  console.info(
253
343
  '\n Note: a node version change (nvm) invalidates the baked path — re-run `yarn deploy` after upgrading node.'
package/src/server.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto'
1
2
  import fs from 'node:fs'
2
3
  import http from 'node:http'
3
4
  import path from 'node:path'
@@ -28,11 +29,19 @@ function json(res: http.ServerResponse, status: number, body: unknown): void {
28
29
  res.end(payload)
29
30
  }
30
31
 
32
+ /** Constant-time string compare — the token is the sole internet-facing gate when exposed via Funnel. */
33
+ function tokenEq(candidate: string | null): boolean {
34
+ if (candidate == null) return false
35
+ const a = Buffer.from(candidate)
36
+ const b = Buffer.from(cfg.token)
37
+ return a.length === b.length && crypto.timingSafeEqual(a, b)
38
+ }
39
+
31
40
  function authed(req: http.IncomingMessage): boolean {
32
41
  const auth = req.headers.authorization
33
- if (auth === `Bearer ${cfg.token}`) return true
42
+ if (auth?.startsWith('Bearer ')) return tokenEq(auth.slice('Bearer '.length))
34
43
  const url = new URL(req.url ?? '/', 'http://x')
35
- return url.searchParams.get('token') === cfg.token
44
+ return tokenEq(url.searchParams.get('token'))
36
45
  }
37
46
 
38
47
  async function readBody(req: http.IncomingMessage): Promise<string> {
@@ -143,7 +152,7 @@ server.listen(cfg.port, cfg.host, () => {
143
152
  ` bound: ${cfg.host}:${cfg.port}`,
144
153
  '',
145
154
  ` Local: http://${cfg.host}:${cfg.port}/#token=${cfg.token}`,
146
- ' Phone: fronted over the tailnet by `tailscale serve` — run `yarn service status` for the HTTPS URL'
155
+ ' Phone: fronted by `tailscale funnel`/`serve` — run `yarn service status` for the HTTPS URL'
147
156
  ].join('\n')
148
157
  )
149
158
  })