conductor-remote 1.0.0 → 1.2.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": "1.0.0",
3
+ "version": "1.2.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.",
package/scripts/qr.ts ADDED
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Dependency-free QR encoder — just enough to render the phone URL in the terminal.
3
+ *
4
+ * Scope is deliberately narrow: byte mode, error-correction level M, versions 1–10 (≈120 bytes max,
5
+ * and the phone URL is ~70). That keeps the version/EC tables small and hand-verifiable while covering
6
+ * every URL this relay can produce. Kept strip-clean like the rest of the repo (no enums/namespaces/
7
+ * param-property constructors) so it runs under plain `node` type-stripping with zero deps.
8
+ *
9
+ * References the ISO/IEC 18004 QR Code spec; placement/format math mirrors Nayuki's reference encoder.
10
+ */
11
+
12
+ // ── Galois field GF(256), primitive polynomial 0x11d ────────────────────────────────────────────────
13
+ const EXP = new Uint8Array(512)
14
+ const LOG = new Uint8Array(256)
15
+ let gfx = 1
16
+ for (let i = 0; i < 255; i++) {
17
+ EXP[i] = gfx
18
+ LOG[gfx] = i
19
+ gfx <<= 1
20
+ if (gfx & 0x100) gfx ^= 0x11d
21
+ }
22
+ for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]
23
+
24
+ function gfMul(a: number, b: number): number {
25
+ return a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]]
26
+ }
27
+
28
+ /** Reed–Solomon generator polynomial of the given degree; coeff[0] is the leading (highest) term. */
29
+ function rsGenerator(degree: number): number[] {
30
+ let poly = [1]
31
+ for (let i = 0; i < degree; i++) {
32
+ const next = new Array(poly.length + 1).fill(0)
33
+ for (let j = 0; j < poly.length; j++) {
34
+ next[j] ^= poly[j]
35
+ next[j + 1] ^= gfMul(poly[j], EXP[i])
36
+ }
37
+ poly = next
38
+ }
39
+ return poly
40
+ }
41
+
42
+ /** The `ecLen` error-correction codewords for one data block (polynomial remainder). */
43
+ function rsRemainder(data: number[], ecLen: number): number[] {
44
+ const gen = rsGenerator(ecLen)
45
+ const res = new Array(ecLen).fill(0)
46
+ for (const d of data) {
47
+ const factor = d ^ res[0]
48
+ res.shift()
49
+ res.push(0)
50
+ if (factor !== 0) for (let i = 0; i < ecLen; i++) res[i] ^= gfMul(gen[i + 1], factor)
51
+ }
52
+ return res
53
+ }
54
+
55
+ // ── Version tables (level M only) ───────────────────────────────────────────────────────────────────
56
+ type Group = [number, number] // [block count, data codewords per block]
57
+ type VersionInfo = { align: number[]; ec: number; groups: Group[] }
58
+
59
+ const VERSIONS: Record<number, VersionInfo> = {
60
+ 1: { align: [], ec: 10, groups: [[1, 16]] },
61
+ 2: { align: [6, 18], ec: 16, groups: [[1, 28]] },
62
+ 3: { align: [6, 22], ec: 26, groups: [[1, 44]] },
63
+ 4: { align: [6, 26], ec: 18, groups: [[2, 32]] },
64
+ 5: { align: [6, 30], ec: 24, groups: [[2, 43]] },
65
+ 6: { align: [6, 34], ec: 16, groups: [[4, 27]] },
66
+ 7: { align: [6, 22, 38], ec: 18, groups: [[4, 31]] },
67
+ 8: {
68
+ align: [6, 24, 42],
69
+ ec: 22,
70
+ groups: [
71
+ [2, 38],
72
+ [2, 39]
73
+ ]
74
+ },
75
+ 9: {
76
+ align: [6, 26, 46],
77
+ ec: 22,
78
+ groups: [
79
+ [3, 36],
80
+ [2, 37]
81
+ ]
82
+ },
83
+ 10: {
84
+ align: [6, 28, 50],
85
+ ec: 26,
86
+ groups: [
87
+ [4, 43],
88
+ [1, 44]
89
+ ]
90
+ }
91
+ }
92
+
93
+ function dataCodewords(v: number): number {
94
+ return VERSIONS[v].groups.reduce((sum, [count, per]) => sum + count * per, 0)
95
+ }
96
+
97
+ function countBits(v: number): number {
98
+ return v <= 9 ? 8 : 16
99
+ }
100
+
101
+ // ── Bit helpers ─────────────────────────────────────────────────────────────────────────────────────
102
+ function pushBits(bits: number[], value: number, len: number): void {
103
+ for (let i = len - 1; i >= 0; i--) bits.push((value >> i) & 1)
104
+ }
105
+
106
+ // ── Data encoding: text → interleaved data+EC bitstream for the smallest fitting version ─────────────
107
+ function encodeText(text: string): { version: number; bits: number[] } {
108
+ const bytes = Array.from(new TextEncoder().encode(text))
109
+ let version = 0
110
+ for (let v = 1; v <= 10; v++) {
111
+ if (4 + countBits(v) + 8 * bytes.length <= dataCodewords(v) * 8) {
112
+ version = v
113
+ break
114
+ }
115
+ }
116
+ if (version === 0) throw new Error(`data too long for a v1–10 QR (${bytes.length} bytes)`)
117
+
118
+ const capacityBits = dataCodewords(version) * 8
119
+ const bits: number[] = []
120
+ pushBits(bits, 0b0100, 4) // byte mode
121
+ pushBits(bits, bytes.length, countBits(version))
122
+ for (const b of bytes) pushBits(bits, b, 8)
123
+ for (let i = 0, n = Math.min(4, capacityBits - bits.length); i < n; i++) bits.push(0) // terminator
124
+ while (bits.length % 8 !== 0) bits.push(0)
125
+ const pad = [0xec, 0x11]
126
+ for (let i = 0; bits.length < capacityBits; i++) pushBits(bits, pad[i % 2], 8)
127
+
128
+ const dataCw: number[] = []
129
+ for (let i = 0; i < bits.length; i += 8) {
130
+ let cw = 0
131
+ for (let b = 0; b < 8; b++) cw = (cw << 1) | bits[i + b]
132
+ dataCw.push(cw)
133
+ }
134
+
135
+ const { ec, groups } = VERSIONS[version]
136
+ const blocks: { data: number[]; ec: number[] }[] = []
137
+ let idx = 0
138
+ for (const [count, per] of groups) {
139
+ for (let b = 0; b < count; b++) {
140
+ const data = dataCw.slice(idx, idx + per)
141
+ idx += per
142
+ blocks.push({ data, ec: rsRemainder(data, ec) })
143
+ }
144
+ }
145
+
146
+ const out: number[] = []
147
+ const maxData = Math.max(...blocks.map(bl => bl.data.length))
148
+ for (let i = 0; i < maxData; i++) for (const bl of blocks) if (i < bl.data.length) out.push(bl.data[i])
149
+ for (let i = 0; i < ec; i++) for (const bl of blocks) out.push(bl.ec[i])
150
+
151
+ const dataBits: number[] = []
152
+ for (const cw of out) pushBits(dataBits, cw, 8)
153
+ return { version, bits: dataBits }
154
+ }
155
+
156
+ // ── Matrix construction ─────────────────────────────────────────────────────────────────────────────
157
+ function buildMatrix(version: number, dataBits: number[]): boolean[][] {
158
+ const size = version * 4 + 17
159
+ const m: boolean[][] = Array.from({ length: size }, () => new Array(size).fill(false))
160
+ const reserved: boolean[][] = Array.from({ length: size }, () => new Array(size).fill(false))
161
+
162
+ const finder = (r0: number, c0: number) => {
163
+ for (let r = -1; r <= 7; r++)
164
+ for (let c = -1; c <= 7; c++) {
165
+ const rr = r0 + r
166
+ const cc = c0 + c
167
+ if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue
168
+ const border = (r >= 0 && r <= 6 && (c === 0 || c === 6)) || (c >= 0 && c <= 6 && (r === 0 || r === 6))
169
+ const center = r >= 2 && r <= 4 && c >= 2 && c <= 4
170
+ m[rr][cc] = border || center
171
+ reserved[rr][cc] = true
172
+ }
173
+ }
174
+ finder(0, 0)
175
+ finder(0, size - 7)
176
+ finder(size - 7, 0)
177
+
178
+ // Timing patterns
179
+ for (let i = 8; i < size - 8; i++) {
180
+ const v = i % 2 === 0
181
+ if (!reserved[6][i]) {
182
+ m[6][i] = v
183
+ reserved[6][i] = true
184
+ }
185
+ if (!reserved[i][6]) {
186
+ m[i][6] = v
187
+ reserved[i][6] = true
188
+ }
189
+ }
190
+
191
+ // Alignment patterns (skip the three that collide with finders)
192
+ const pos = VERSIONS[version].align
193
+ for (let i = 0; i < pos.length; i++)
194
+ for (let j = 0; j < pos.length; j++) {
195
+ const corner = (i === 0 && j === 0) || (i === 0 && j === pos.length - 1) || (i === pos.length - 1 && j === 0)
196
+ if (corner) continue
197
+ const cr = pos[i]
198
+ const cc = pos[j]
199
+ for (let r = -2; r <= 2; r++)
200
+ for (let c = -2; c <= 2; c++) {
201
+ m[cr + r][cc + c] = Math.max(Math.abs(r), Math.abs(c)) !== 1
202
+ reserved[cr + r][cc + c] = true
203
+ }
204
+ }
205
+
206
+ // Reserve format-info strips and (v≥7) version-info blocks — filled after masking.
207
+ for (let i = 0; i <= 8; i++) {
208
+ if (i !== 6) {
209
+ reserved[8][i] = true
210
+ reserved[i][8] = true
211
+ }
212
+ }
213
+ for (let i = 0; i < 8; i++) {
214
+ reserved[8][size - 1 - i] = true
215
+ reserved[size - 1 - i][8] = true
216
+ }
217
+ reserved[size - 8][8] = true // dark module
218
+ if (version >= 7)
219
+ for (let i = 0; i < 18; i++) {
220
+ const a = size - 11 + (i % 3)
221
+ const b = Math.floor(i / 3)
222
+ reserved[b][a] = true
223
+ reserved[a][b] = true
224
+ }
225
+
226
+ // Data placement: two-column zigzag from bottom-right, skipping the timing column.
227
+ let bit = 0
228
+ let upward = true
229
+ for (let col = size - 1; col > 0; col -= 2) {
230
+ if (col === 6) col--
231
+ for (let i = 0; i < size; i++) {
232
+ const row = upward ? size - 1 - i : i
233
+ for (let k = 0; k < 2; k++) {
234
+ const c = col - k
235
+ if (reserved[row][c]) continue
236
+ m[row][c] = bit < dataBits.length ? dataBits[bit] === 1 : false
237
+ bit++
238
+ }
239
+ }
240
+ upward = !upward
241
+ }
242
+
243
+ if (version >= 7) drawVersion(m, version, size)
244
+
245
+ // Try every mask, keep the lowest-penalty one.
246
+ let best: boolean[][] | null = null
247
+ let bestScore = Number.POSITIVE_INFINITY
248
+ for (let mask = 0; mask < 8; mask++) {
249
+ const cand = m.map(row => row.slice())
250
+ for (let r = 0; r < size; r++)
251
+ for (let c = 0; c < size; c++) if (!reserved[r][c] && maskBit(mask, r, c)) cand[r][c] = !cand[r][c]
252
+ drawFormat(cand, mask, size)
253
+ const score = penalty(cand)
254
+ if (score < bestScore) {
255
+ bestScore = score
256
+ best = cand
257
+ }
258
+ }
259
+ return best as boolean[][]
260
+ }
261
+
262
+ function maskBit(mask: number, r: number, c: number): boolean {
263
+ switch (mask) {
264
+ case 0:
265
+ return (r + c) % 2 === 0
266
+ case 1:
267
+ return r % 2 === 0
268
+ case 2:
269
+ return c % 3 === 0
270
+ case 3:
271
+ return (r + c) % 3 === 0
272
+ case 4:
273
+ return (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0
274
+ case 5:
275
+ return ((r * c) % 2) + ((r * c) % 3) === 0
276
+ case 6:
277
+ return (((r * c) % 2) + ((r * c) % 3)) % 2 === 0
278
+ default:
279
+ return (((r + c) % 2) + ((r * c) % 3)) % 2 === 0
280
+ }
281
+ }
282
+
283
+ /** 15-bit format info (level M = 00) with BCH(15,5) and the 0x5412 mask. */
284
+ function drawFormat(m: boolean[][], mask: number, size: number): void {
285
+ const data = (0b00 << 3) | mask
286
+ let rem = data
287
+ for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >> 9) & 1 ? 0x537 : 0)
288
+ const bits = (((data << 10) | (rem & 0x3ff)) ^ 0x5412) & 0x7fff
289
+ const get = (i: number) => ((bits >> i) & 1) === 1
290
+ for (let i = 0; i <= 5; i++) m[i][8] = get(i)
291
+ m[7][8] = get(6)
292
+ m[8][8] = get(7)
293
+ m[8][7] = get(8)
294
+ for (let i = 9; i < 15; i++) m[8][14 - i] = get(i)
295
+ for (let i = 0; i < 8; i++) m[8][size - 1 - i] = get(i)
296
+ for (let i = 8; i < 15; i++) m[size - 15 + i][8] = get(i)
297
+ m[size - 8][8] = true // dark module
298
+ }
299
+
300
+ /** 18-bit version info (v≥7), BCH(18,6) with 0x1f25. */
301
+ function drawVersion(m: boolean[][], version: number, size: number): void {
302
+ let rem = version
303
+ for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >> 11) & 1 ? 0x1f25 : 0)
304
+ const bits = (version << 12) | (rem & 0xfff)
305
+ for (let i = 0; i < 18; i++) {
306
+ const b = ((bits >> i) & 1) === 1
307
+ const a = size - 11 + (i % 3)
308
+ const d = Math.floor(i / 3)
309
+ m[d][a] = b
310
+ m[a][d] = b
311
+ }
312
+ }
313
+
314
+ // ── Mask penalty (ISO/IEC 18004 §8.8.2) — only used to pick the mask, so exactness isn't critical ────
315
+ function penalty(m: boolean[][]): number {
316
+ const n = m.length
317
+ let score = 0
318
+ const runScore = (get: (i: number) => boolean) => {
319
+ let color = get(0)
320
+ let len = 1
321
+ for (let i = 1; i < n; i++) {
322
+ if (get(i) === color) len++
323
+ else {
324
+ if (len >= 5) score += len - 2
325
+ color = get(i)
326
+ len = 1
327
+ }
328
+ }
329
+ if (len >= 5) score += len - 2
330
+ }
331
+ for (let r = 0; r < n; r++) runScore(i => m[r][i])
332
+ for (let c = 0; c < n; c++) runScore(i => m[i][c])
333
+
334
+ for (let r = 0; r < n - 1; r++)
335
+ for (let c = 0; c < n - 1; c++) {
336
+ const v = m[r][c]
337
+ if (v === m[r][c + 1] && v === m[r + 1][c] && v === m[r + 1][c + 1]) score += 3
338
+ }
339
+
340
+ const a = [true, false, true, true, true, false, true, false, false, false, false]
341
+ const b = [false, false, false, false, true, false, true, true, true, false, true]
342
+ const match = (get: (i: number) => boolean, start: number, pat: boolean[]) => {
343
+ for (let i = 0; i < 11; i++) if (get(start + i) !== pat[i]) return false
344
+ return true
345
+ }
346
+ for (let r = 0; r < n; r++)
347
+ for (let c = 0; c <= n - 11; c++) {
348
+ if (match(i => m[r][i], c, a) || match(i => m[r][i], c, b)) score += 40
349
+ }
350
+ for (let c = 0; c < n; c++)
351
+ for (let r = 0; r <= n - 11; r++) {
352
+ if (match(i => m[i][c], r, a) || match(i => m[i][c], r, b)) score += 40
353
+ }
354
+
355
+ let dark = 0
356
+ for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (m[r][c]) dark++
357
+ score += Math.floor(Math.abs((dark / (n * n)) * 100 - 50) / 5) * 10
358
+ return score
359
+ }
360
+
361
+ // ── Terminal rendering ──────────────────────────────────────────────────────────────────────────────
362
+ /**
363
+ * Render the matrix as half-block lines (two modules per character row, ~square modules) with a forced
364
+ * black-on-white style so it scans correctly regardless of the terminal theme, plus the 4-module quiet
365
+ * zone the spec requires. `indent` is prepended (outside the styled region) to align with other output.
366
+ */
367
+ function render(m: boolean[][], indent: string): string[] {
368
+ const size = m.length
369
+ const quiet = 4
370
+ const dark = (r: number, c: number) => (r >= 0 && r < size && c >= 0 && c < size ? m[r][c] : false)
371
+ const lines: string[] = []
372
+ for (let r = -quiet; r < size + quiet; r += 2) {
373
+ let line = `${indent}\x1b[30;47m`
374
+ for (let c = -quiet; c < size + quiet; c++) {
375
+ const top = dark(r, c)
376
+ const bottom = dark(r + 1, c)
377
+ line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' '
378
+ }
379
+ lines.push(`${line}\x1b[0m`)
380
+ }
381
+ return lines
382
+ }
383
+
384
+ /** Encode `text` and return the terminal lines that draw its QR code (each already indented). */
385
+ export function qrLines(text: string, indent = ' '): string[] {
386
+ const { version, bits } = encodeText(text)
387
+ return render(buildMatrix(version, bits), indent)
388
+ }
@@ -12,6 +12,7 @@ import { execFileSync } from 'node:child_process'
12
12
  import fs from 'node:fs'
13
13
  import os from 'node:os'
14
14
  import path from 'node:path'
15
+ import { qrLines } from './qr.ts'
15
16
 
16
17
  const LABEL = 'no.adluna.conductor-remote'
17
18
  const projectDir = path.resolve(import.meta.dirname, '..')
@@ -166,58 +167,163 @@ function magicDnsName(bin: string): string | null {
166
167
  }
167
168
  }
168
169
 
169
- /** Is `tailscale serve` already proxying https://<dns>/ to the loopback relay? Keeps the wiring idempotent. */
170
- function serveConfigured(bin: string, dns: string): boolean {
170
+ /**
171
+ * How the stable HTTPS URL is fronted:
172
+ * 'public' → `tailscale funnel` — reachable from ANY browser on the internet (token-gated).
173
+ * 'tailnet' → `tailscale serve` — reachable only by devices logged into this tailnet.
174
+ */
175
+ type ExposeMode = 'public' | 'tailnet'
176
+
177
+ /** Where the chosen expose mode is persisted so a later bare `yarn deploy` keeps the same posture. */
178
+ function exposeStorePath(): string {
179
+ return path.join(os.homedir(), 'Library', 'Application Support', 'conductor-remote', 'expose')
180
+ }
181
+
182
+ function normalizeMode(raw: string | undefined): ExposeMode | null {
183
+ const v = raw?.trim().toLowerCase()
184
+ if (v === 'public' || v === 'funnel') return 'public'
185
+ if (v === 'tailnet' || v === 'serve' || v === 'private') return 'tailnet'
186
+ return null
187
+ }
188
+
189
+ /**
190
+ * Resolve the expose mode. Precedence: `EXPOSE` env (public|funnel / tailnet|serve|private) > persisted
191
+ * choice > 'public' default. An explicit env value is persisted so re-deploys don't silently flip posture.
192
+ */
193
+ function resolveExposeMode(): ExposeMode {
194
+ const fromEnv = normalizeMode(process.env.EXPOSE)
195
+ if (fromEnv) {
196
+ try {
197
+ const file = exposeStorePath()
198
+ fs.mkdirSync(path.dirname(file), { recursive: true })
199
+ fs.writeFileSync(file, fromEnv)
200
+ } catch {
201
+ // persistence is a convenience; ignore failures
202
+ }
203
+ return fromEnv
204
+ }
205
+ if (process.env.EXPOSE) console.info(` ⚠ unrecognized EXPOSE=${process.env.EXPOSE} — expected public|tailnet.`)
206
+ try {
207
+ const saved = normalizeMode(fs.readFileSync(exposeStorePath(), 'utf8'))
208
+ if (saved) return saved
209
+ } catch {
210
+ // no saved choice yet
211
+ }
212
+ return 'public'
213
+ }
214
+
215
+ /** Live serve/funnel state for this node: is the loopback proxy wired, and is Funnel (public) on? */
216
+ function tailscaleState(bin: string, dns: string | null): { proxyOk: boolean; funnelOn: boolean } {
217
+ if (!dns) return { proxyOk: false, funnelOn: false }
171
218
  try {
172
219
  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}`
220
+ const cfg = JSON.parse(out)
221
+ const key = `${dns}:443`
222
+ const proxyOk = cfg?.Web?.[key]?.Handlers?.['/']?.Proxy === `http://127.0.0.1:${RELAY_PORT}`
223
+ return { proxyOk, funnelOn: Boolean(cfg?.AllowFunnel?.[key]) }
175
224
  } catch {
176
- return false
225
+ return { proxyOk: false, funnelOn: false }
226
+ }
227
+ }
228
+
229
+ /** Assert the tailnet-only `serve` proxy — used for tailnet mode and as the Funnel fallback. */
230
+ function ensureServeOnly(bin: string, url: string, state: { proxyOk: boolean; funnelOn: boolean }): void {
231
+ if (state.proxyOk && !state.funnelOn) {
232
+ console.info(`✓ tailscale serve fronts ${url} → 127.0.0.1:${RELAY_PORT} (tailnet-only)`)
233
+ return
234
+ }
235
+ try {
236
+ execFileSync(bin, ['serve', '--bg', RELAY_PORT], { stdio: 'pipe' })
237
+ console.info(`✓ tailscale serve → ${url} proxies 127.0.0.1:${RELAY_PORT} (tailnet-only)`)
238
+ } catch (err) {
239
+ console.info(
240
+ `\n ⚠ could not configure tailscale serve (${err instanceof Error ? err.message : err}). Run by hand:`
241
+ )
242
+ console.info(` tailscale serve --bg ${RELAY_PORT}`)
177
243
  }
178
244
  }
179
245
 
180
246
  /**
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).
247
+ * Front the loopback relay with a stable HTTPS URL, either publicly (`tailscale funnel`, the default) or
248
+ * tailnet-only (`tailscale serve`), per resolveExposeMode(). Idempotent flips Funnel off when switching
249
+ * back to tailnet and non-fatal: the relay binds loopback regardless, so a failure here just means the
250
+ * phone URL isn't wired yet and we print how to do it by hand. Real TLS also satisfies the PWA's
251
+ * secure-context requirement (a service worker won't register over plain http on a 100.x IP).
252
+ *
253
+ * PUBLIC IS INTERNET-FACING: the 128-bit token on every /api/* request is the only gate. Funnel must be
254
+ * enabled for the tailnet (Admin console) or the funnel command fails — we then fall back to tailnet-only.
185
255
  */
186
- function ensureServe(): void {
256
+ function ensureTailscale(): void {
187
257
  const bin = tailscaleBin()
188
258
  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}`)
259
+ console.info('\n ⚠ tailscale CLI not found — skipped URL setup. Once Tailscale is installed, run:')
260
+ console.info(` tailscale funnel --bg ${RELAY_PORT} # public, or \`serve\` for tailnet-only`)
191
261
  return
192
262
  }
193
263
  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}`)
264
+ const url = `https://${dns ?? '<node>'}/`
265
+ const mode = resolveExposeMode()
266
+ const state = tailscaleState(bin, dns)
267
+
268
+ if (mode === 'tailnet') {
269
+ if (state.funnelOn) {
270
+ try {
271
+ execFileSync(bin, ['funnel', 'reset'], { stdio: 'pipe' })
272
+ } catch {
273
+ // best-effort; ensureServeOnly re-asserts the proxy below
274
+ }
275
+ ensureServeOnly(bin, url, { proxyOk: false, funnelOn: false })
276
+ } else {
277
+ ensureServeOnly(bin, url, state)
278
+ }
279
+ return
280
+ }
281
+
282
+ // public (Funnel)
283
+ if (state.proxyOk && state.funnelOn) {
284
+ console.info(`✓ tailscale funnel already exposes ${url} → 127.0.0.1:${RELAY_PORT} (public, token-gated)`)
196
285
  return
197
286
  }
198
287
  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)`)
288
+ execFileSync(bin, ['funnel', '--bg', '--yes', RELAY_PORT], { stdio: 'pipe' })
289
+ console.info(`✓ tailscale funnel${url} now public over the internet (token-gated) → 127.0.0.1:${RELAY_PORT}`)
201
290
  } 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}`)
291
+ console.info(`\n ⚠ could not enable Funnel (${err instanceof Error ? err.message.trim() : err}).`)
292
+ console.info(' Funnel must be enabled for this tailnet: open the URL Tailscale printed above, or add the')
293
+ console.info(' "funnel" nodeAttr in Admin console ▸ Access controls. Falling back to tailnet-only for now.')
294
+ ensureServeOnly(bin, url, state)
295
+ }
296
+ }
297
+
298
+ /** Print a scannable QR of `url` (theme-independent black-on-white). Never fatal — QR is a convenience. */
299
+ function printQr(url: string): void {
300
+ try {
301
+ console.info(`\n${qrLines(url).join('\n')}`)
302
+ } catch (err) {
303
+ console.info(` (QR skipped: ${err instanceof Error ? err.message : err})`)
206
304
  }
207
305
  }
208
306
 
209
307
  function printUrl(): void {
210
- const frag = `#token=${currentToken() ?? '<starts on first run>'}`
308
+ const token = currentToken()
309
+ const frag = `#token=${token ?? '<starts on first run>'}`
211
310
  const bin = tailscaleBin()
212
311
  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}`)
312
+ const state = bin ? tailscaleState(bin, dns) : { proxyOk: false, funnelOn: false }
313
+ if (dns && state.proxyOk) {
314
+ const scope = state.funnelOn ? 'public — any browser, token-gated' : 'same Tailnet only'
315
+ const url = `https://${dns}/${frag}`
316
+ console.info(`\n Phone URL (HTTPS, ${scope}):\n ${url}`)
317
+ if (token) {
318
+ console.info('\n Scan to open on your phone:')
319
+ printQr(url)
320
+ }
215
321
  return
216
322
  }
217
- // Serve not wired yet — the relay is only on loopback.
323
+ // Nothing fronting yet — the relay is only on loopback.
218
324
  console.info(`\n Local URL:\n http://127.0.0.1:${RELAY_PORT}/${frag}`)
219
325
  console.info(
220
- `\n ⚠ Not reachable from your phone yet. Run \`tailscale serve --bg ${RELAY_PORT}\`${dns ? ` → https://${dns}/` : ''}, then \`yarn service status\`.`
326
+ `\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
327
  )
222
328
  }
223
329
 
@@ -247,7 +353,7 @@ function install(): void {
247
353
  console.info(` plist: ${plistPath}`)
248
354
  console.info(` logs: ${logDir}/relay.log`)
249
355
  console.info(` node: ${process.execPath}`)
250
- ensureServe()
356
+ ensureTailscale()
251
357
  printUrl()
252
358
  console.info(
253
359
  '\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
  })