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 +7 -0
- package/package.json +1 -1
- package/scripts/qr.ts +388 -0
- package/scripts/service.ts +132 -26
- package/src/server.ts +12 -3
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
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
|
+
}
|
package/scripts/service.ts
CHANGED
|
@@ -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
|
-
/**
|
|
170
|
-
|
|
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
|
|
174
|
-
|
|
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
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
|
256
|
+
function ensureTailscale(): void {
|
|
187
257
|
const bin = tailscaleBin()
|
|
188
258
|
if (!bin) {
|
|
189
|
-
console.info('\n ⚠ tailscale CLI not found — skipped
|
|
190
|
-
console.info(` tailscale
|
|
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
|
-
|
|
195
|
-
|
|
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, ['
|
|
200
|
-
console.info(`✓ tailscale
|
|
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
|
-
|
|
204
|
-
)
|
|
205
|
-
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
//
|
|
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}
|
|
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
|
-
|
|
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
|
|
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')
|
|
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
|
|
155
|
+
' Phone: fronted by `tailscale funnel`/`serve` — run `yarn service status` for the HTTPS URL'
|
|
147
156
|
].join('\n')
|
|
148
157
|
)
|
|
149
158
|
})
|