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 +7 -0
- package/package.json +1 -1
- package/scripts/service.ts +115 -25
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "
|
|
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.",
|
package/scripts/service.ts
CHANGED
|
@@ -166,43 +166,131 @@ function magicDnsName(bin: string): string | null {
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
/**
|
|
170
|
-
|
|
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
|
|
174
|
-
|
|
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
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
|
255
|
+
function ensureTailscale(): void {
|
|
187
256
|
const bin = tailscaleBin()
|
|
188
257
|
if (!bin) {
|
|
189
|
-
console.info('\n ⚠ tailscale CLI not found — skipped
|
|
190
|
-
console.info(` tailscale
|
|
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
|
-
|
|
195
|
-
|
|
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, ['
|
|
200
|
-
console.info(`✓ tailscale
|
|
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
|
-
|
|
204
|
-
)
|
|
205
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
//
|
|
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}
|
|
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
|
-
|
|
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
|
|
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
|
})
|