pinokiod 8.2.2 → 8.2.4

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.
@@ -0,0 +1,706 @@
1
+ /*
2
+ Pinokio Anywhere: owns the bundled `tailscaled` daemon (installed from
3
+ conda-forge by kernel/bin/tailscaled.js) and drives it over its LocalAPI so
4
+ the user can reach this Pinokio and every app it launches from their phone
5
+ or another computer on their private tailnet, over HTTPS.
6
+
7
+ Serve-only: nothing is ever exposed publicly (no Funnel).
8
+
9
+ Layout under the Pinokio home:
10
+ anywhere/config.json { enabled, hostname }
11
+ anywhere/tailscaled/tailscaled.state node key + prefs (tailscaled --state)
12
+ anywhere/tailscaled/ certs, logs (tailscaled --statedir, TS_LOGS_DIR)
13
+ anywhere/tailscaled/tailscaled.sock LocalAPI socket (mac/linux; falls back to
14
+ os.tmpdir() when the path is too long for
15
+ a unix socket)
16
+ \\.\pipe\pinokio-tailscaled LocalAPI pipe (Windows, --windows-mode=dev)
17
+ */
18
+ const os = require('os')
19
+ const fs = require('fs')
20
+ const path = require('path')
21
+ const crypto = require('crypto')
22
+ const { spawn } = require('child_process')
23
+ const { LocalAPI } = require('./localapi')
24
+ const { buildServeConfig, describeServeConfig, sameServeConfig, DEFAULT_HTTPS_PORT } = require('./serve')
25
+ const { loadPortMap, savePortMap, assignPorts } = require('./ports')
26
+ const Util = require('../util')
27
+
28
+ const DEFAULT_HOSTNAME = 'pinokio'
29
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '[::1]'])
30
+ const DEFAULT_CONNECT_URL_TEMPLATE = 'https://pinokio.co/anywhere/connect#u={address}&a={email}'
31
+ const MAX_UNIX_SOCKET_PATH = 100 // sun_path is 104 bytes on macOS, 108 on Linux
32
+ const DAEMON_READY_TIMEOUT_MS = 20000
33
+ const STATUS_CACHE_MS = 1500
34
+ const FEATURE_QUERY_CACHE_MS = 30000
35
+ const MOBILE_OS = new Set(['ios', 'ipados', 'android'])
36
+
37
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
38
+
39
+ class Anywhere {
40
+ constructor(kernel) {
41
+ this.kernel = kernel
42
+ this.platform = kernel && kernel.platform ? kernel.platform : os.platform()
43
+ this.child = null
44
+ this.pid = null
45
+ this.api = null
46
+ this.config = null
47
+ this.starting = null
48
+ this.lastStatus = null
49
+ this.lastStatusAt = 0
50
+ this.lastServe = undefined // last serve config we pushed (undefined = unknown)
51
+ this.lastServeError = null
52
+ this.portMap = null // app key -> stable remote port (ports.json)
53
+ this.targets = [] // [{ key, name, local, listen }] behind the current serve config
54
+ this.feature = null // cached query-feature result
55
+ this.featureAt = 0
56
+ this.error = null
57
+ this.loginRequestedAt = 0
58
+ }
59
+
60
+ // ---------- paths ----------
61
+ dir(...args) {
62
+ return this.kernel.path('anywhere', ...args)
63
+ }
64
+ stateDir(...args) {
65
+ return this.dir('tailscaled', ...args)
66
+ }
67
+ configPath() {
68
+ return this.dir('config.json')
69
+ }
70
+ logPath() {
71
+ return this.stateDir('tailscaled.log')
72
+ }
73
+ pidPath() {
74
+ return this.stateDir('tailscaled.pid')
75
+ }
76
+ socketPath() {
77
+ if (this._socketPath) return this._socketPath
78
+ if (this.platform === 'win32') {
79
+ this._socketPath = '\\\\.\\pipe\\pinokio-tailscaled'
80
+ } else {
81
+ const candidate = this.stateDir('tailscaled.sock')
82
+ if (Buffer.byteLength(candidate) <= MAX_UNIX_SOCKET_PATH) {
83
+ this._socketPath = candidate
84
+ } else {
85
+ const hash = crypto.createHash('sha1').update(this.kernel.homedir || '').digest('hex').slice(0, 8)
86
+ this._socketPath = path.join(os.tmpdir(), `pinokio-ts-${hash}.sock`)
87
+ }
88
+ }
89
+ return this._socketPath
90
+ }
91
+ binPath() {
92
+ const mod = this.kernel.bin && this.kernel.bin.mod ? this.kernel.bin.mod.tailscaled : null
93
+ if (mod && typeof mod.binPath === 'function') {
94
+ return mod.binPath()
95
+ }
96
+ return null
97
+ }
98
+ daemonArgs() {
99
+ const args = [
100
+ '--tun=userspace-networking',
101
+ '--state', this.stateDir('tailscaled.state'),
102
+ '--statedir', this.stateDir(),
103
+ '--socket', this.socketPath(),
104
+ '--port', '0',
105
+ ]
106
+ if (this.platform === 'win32') {
107
+ // 1.104+: run as a per-user console process, no service / admin needed
108
+ args.push('--windows-mode=dev')
109
+ }
110
+ return args
111
+ }
112
+
113
+ // ---------- config ----------
114
+ async loadConfig() {
115
+ if (this.config) return this.config
116
+ let config = {}
117
+ this.configExists = false
118
+ try {
119
+ const raw = await fs.promises.readFile(this.configPath(), 'utf8')
120
+ config = JSON.parse(raw) || {}
121
+ this.configExists = true
122
+ } catch (e) {
123
+ config = {}
124
+ }
125
+ this.config = Object.assign({
126
+ enabled: false,
127
+ hostname: DEFAULT_HOSTNAME,
128
+ }, config)
129
+ return this.config
130
+ }
131
+ async saveConfig(patch) {
132
+ const config = await this.loadConfig()
133
+ Object.assign(config, patch || {})
134
+ await fs.promises.mkdir(this.dir(), { recursive: true })
135
+ await fs.promises.writeFile(this.configPath(), JSON.stringify(config, null, 2))
136
+ this.config = config
137
+ this.configExists = true
138
+ return config
139
+ }
140
+ // true once config.json exists (the first page visit writes it)
141
+ async configured() {
142
+ await this.loadConfig()
143
+ return !!this.configExists
144
+ }
145
+ async enabled() {
146
+ const config = await this.loadConfig()
147
+ return !!config.enabled
148
+ }
149
+ async hostname() {
150
+ const config = await this.loadConfig()
151
+ return String(config.hostname || DEFAULT_HOSTNAME)
152
+ }
153
+ connectUrlTemplate() {
154
+ const fromEnv = (process.env.PINOKIO_ANYWHERE_CONNECT_URL || '').trim()
155
+ if (fromEnv) return fromEnv
156
+ return DEFAULT_CONNECT_URL_TEMPLATE
157
+ }
158
+ connectUrl(address, email) {
159
+ if (!address) return null
160
+ return this.connectUrlTemplate()
161
+ .replace('{address}', encodeURIComponent(address))
162
+ .replace('{email}', encodeURIComponent(email || ''))
163
+ }
164
+
165
+ // ---------- install state ----------
166
+ async installed() {
167
+ const mod = this.kernel.bin && this.kernel.bin.mod ? this.kernel.bin.mod.tailscaled : null
168
+ if (!mod || typeof mod.installed !== 'function') return false
169
+ try {
170
+ return !!(await mod.installed())
171
+ } catch (e) {
172
+ return false
173
+ }
174
+ }
175
+
176
+ // ---------- daemon lifecycle ----------
177
+ client() {
178
+ if (!this.api) {
179
+ this.api = new LocalAPI({ socketPath: this.socketPath() })
180
+ }
181
+ return this.api
182
+ }
183
+ async daemonAlive() {
184
+ return this.client().ping()
185
+ }
186
+ // Called from kernel/bin/tailscaled.js start() during bin init.
187
+ async autostart() {
188
+ // a pid file left behind by a crash or reboot must never point at an unrelated process
189
+ if (!(await this.daemonAlive())) fs.rmSync(this.pidPath(), { force: true })
190
+ if (!(await this.installed())) {
191
+ await this.stop()
192
+ return false
193
+ }
194
+ if (!(await this.enabled())) return false
195
+ await this.start()
196
+ return true
197
+ }
198
+ async start() {
199
+ if (this.starting) return this.starting
200
+ this.starting = this._start().finally(() => { this.starting = null })
201
+ return this.starting
202
+ }
203
+ async _start() {
204
+ this.error = null
205
+ if (!(await this.installed())) {
206
+ await this.stop()
207
+ this.error = "Update the tailscaled tool before starting Pinokio Anywhere."
208
+ throw new Error(this.error)
209
+ }
210
+ await this.loadConfig()
211
+ await fs.promises.mkdir(this.stateDir(), { recursive: true })
212
+ const bin = this.binPath()
213
+ if (!bin) {
214
+ throw new Error('tailscaled module is not available')
215
+ }
216
+ if (!(await this.daemonAlive())) {
217
+ if (this.platform !== 'win32') {
218
+ // a stale socket file makes bind() fail
219
+ await fs.promises.rm(this.socketPath(), { force: true }).catch(() => {})
220
+ }
221
+ const logFd = fs.openSync(this.logPath(), 'w')
222
+ const env = Object.assign({}, process.env, {
223
+ TS_LOGS_DIR: this.stateDir(),
224
+ })
225
+ const child = spawn(bin, this.daemonArgs(), {
226
+ cwd: this.stateDir(),
227
+ env,
228
+ stdio: ['ignore', logFd, logFd],
229
+ windowsHide: true,
230
+ })
231
+ fs.closeSync(logFd)
232
+ this.child = child
233
+ this.pid = child.pid
234
+ if (child.pid) fs.writeFileSync(this.pidPath(), String(child.pid))
235
+ child.on('exit', (code, signal) => {
236
+ console.log(`[anywhere] tailscaled exited (code=${code} signal=${signal})`)
237
+ if (this.child === child) {
238
+ // not a stop() of ours: surface it, the page offers "Try again"
239
+ this.child = null
240
+ this.pid = null
241
+ this.error = `tailscaled exited (code=${code} signal=${signal})`
242
+ fs.rmSync(this.pidPath(), { force: true })
243
+ }
244
+ })
245
+ child.on('error', (err) => {
246
+ console.log('[anywhere] tailscaled spawn error', err && err.message ? err.message : err)
247
+ this.error = `tailscaled failed to start: ${err && err.message ? err.message : err}`
248
+ })
249
+ const ready = await this.waitForDaemon(DAEMON_READY_TIMEOUT_MS)
250
+ if (!ready) {
251
+ let tail = ''
252
+ try {
253
+ tail = (await fs.promises.readFile(this.logPath(), 'utf8')).split(/\r?\n/).slice(-10).join('\n')
254
+ } catch (e) { }
255
+ if (!this.error) {
256
+ this.error = `tailscaled did not answer on ${this.socketPath()} within ${DAEMON_READY_TIMEOUT_MS / 1000}s\n${tail}`
257
+ }
258
+ throw new Error(this.error)
259
+ }
260
+ } else if (!this.pid) {
261
+ // adopted from a previous pinokiod run: recover its pid so shutdown can still kill it
262
+ this.pid = this.readPid()
263
+ }
264
+ await this.applyPrefs()
265
+ this.lastServe = undefined
266
+ this.lastStatusAt = 0
267
+ await this.refreshStatus(true)
268
+ return true
269
+ }
270
+ async waitForDaemon(timeout) {
271
+ const started = Date.now()
272
+ while (Date.now() - started < timeout) {
273
+ if (this.error || (this.child && this.child.exitCode !== null)) {
274
+ return false
275
+ }
276
+ if (await this.daemonAlive()) {
277
+ return true
278
+ }
279
+ await sleep(250)
280
+ }
281
+ return false
282
+ }
283
+ async applyPrefs() {
284
+ const hostname = await this.hostname()
285
+ await this.client().patchPrefs({
286
+ HostnameSet: true,
287
+ Hostname: hostname,
288
+ WantRunningSet: true,
289
+ WantRunning: true,
290
+ LoggedOutSet: true,
291
+ LoggedOut: false,
292
+ // this node is a server; never route the machine's traffic elsewhere
293
+ RouteAllSet: true,
294
+ RouteAll: false,
295
+ ExitNodeIDSet: true,
296
+ ExitNodeID: '',
297
+ CorpDNSSet: true,
298
+ CorpDNS: false,
299
+ })
300
+ }
301
+ readPid() {
302
+ try {
303
+ const pid = Number(fs.readFileSync(this.pidPath(), 'utf8').trim())
304
+ if (!Number.isInteger(pid) || pid <= 0) return null
305
+ process.kill(pid, 0) // throws when there is no such process
306
+ return pid
307
+ } catch (e) {
308
+ return null
309
+ }
310
+ }
311
+ async stop() {
312
+ const pid = this.pid || this.readPid()
313
+ this.child = null
314
+ this.pid = null
315
+ if (pid) {
316
+ try { process.kill(pid, 'SIGTERM') } catch (e) { }
317
+ }
318
+ fs.rmSync(this.pidPath(), { force: true })
319
+ this.lastStatus = null
320
+ this.lastStatusAt = 0
321
+ this.lastServe = undefined
322
+ return true
323
+ }
324
+ // synchronous: used from the process shutdown path
325
+ killSync() {
326
+ const pid = this.pid || this.readPid()
327
+ if (pid) {
328
+ try { process.kill(pid, 'SIGKILL') } catch (e) { }
329
+ }
330
+ fs.rmSync(this.pidPath(), { force: true })
331
+ }
332
+ async enable() {
333
+ await this.saveConfig({ enabled: true })
334
+ await this.start()
335
+ return true
336
+ }
337
+ async disable() {
338
+ await this.saveConfig({ enabled: false })
339
+ await this.stop()
340
+ return true
341
+ }
342
+ async requestLogin() {
343
+ this.loginRequestedAt = Date.now()
344
+ await this.client().loginInteractive()
345
+ this.lastStatusAt = 0
346
+ return true
347
+ }
348
+ async logout() {
349
+ await this.client().logout()
350
+ await this.applyPrefs()
351
+ await this.client().start()
352
+ this.lastStatus = null
353
+ this.lastStatusAt = 0
354
+ this.lastServe = undefined
355
+ this.feature = null
356
+ return true
357
+ }
358
+
359
+ // ---------- status ----------
360
+ async refreshStatus(force) {
361
+ const now = Date.now()
362
+ if (!force && this.lastStatus && now - this.lastStatusAt < STATUS_CACHE_MS) {
363
+ return this.lastStatus
364
+ }
365
+ try {
366
+ const status = await this.client().status()
367
+ this.lastStatus = status
368
+ this.lastStatusAt = Date.now()
369
+ return status
370
+ } catch (e) {
371
+ this.lastStatus = null
372
+ this.lastStatusAt = Date.now()
373
+ return null
374
+ }
375
+ }
376
+ dnsNameFrom(status) {
377
+ if (!status || !status.Self || !status.Self.DNSName) return null
378
+ return String(status.Self.DNSName).replace(/\.$/, '')
379
+ }
380
+ async ensureRunningPrefs(status) {
381
+ // the daemon persisted WantRunning=false (e.g. after `logout`): nudge it
382
+ if (status && status.BackendState === 'Stopped') {
383
+ try {
384
+ await this.client().patchPrefs({ WantRunningSet: true, WantRunning: true })
385
+ this.lastStatusAt = 0
386
+ } catch (e) { }
387
+ }
388
+ }
389
+ async ensureLoginUrl(status) {
390
+ if (!status || status.BackendState !== 'NeedsLogin') return null
391
+ // the daemon keeps offering a link even after the control server rejected it
392
+ // ("register request: http 410: auth path not found"); reset and ask again
393
+ const dead = Array.isArray(status.Health) && status.Health.some((h) => /auth path not found/i.test(String(h)))
394
+ if (status.AuthURL && !dead) return status.AuthURL
395
+ if (Date.now() - this.loginRequestedAt > 5000) {
396
+ try {
397
+ if (dead) await this.client().start()
398
+ await this.requestLogin()
399
+ } catch (e) { }
400
+ }
401
+ return null
402
+ }
403
+ async httpsFeature(status) {
404
+ if (!status || status.BackendState !== 'Running') return null
405
+ if (Array.isArray(status.CertDomains) && status.CertDomains.length > 0) {
406
+ this.feature = { Complete: true }
407
+ return this.feature
408
+ }
409
+ if (this.feature && Date.now() - this.featureAt < FEATURE_QUERY_CACHE_MS) {
410
+ return this.feature
411
+ }
412
+ try {
413
+ this.feature = await this.client().queryFeature('serve')
414
+ this.featureAt = Date.now()
415
+ } catch (e) {
416
+ // e.g. 503 "no netmap" right after login: not an answer yet, ask again next time
417
+ this.feature = null
418
+ this.featureAt = 0
419
+ }
420
+ return this.feature
421
+ }
422
+ peersFrom(status) {
423
+ const peers = []
424
+ if (!status || !status.Peer) return peers
425
+ for (const key of Object.keys(status.Peer)) {
426
+ const p = status.Peer[key]
427
+ if (!p) continue
428
+ const osName = String(p.OS || '')
429
+ const dnsLabel = p.DNSName ? String(p.DNSName).split('.')[0] : ''
430
+ const hostName = String(p.HostName || '').trim()
431
+ // phones often report a generic HostName ("localhost"); MagicDNS has the nicer one
432
+ const generic = !hostName || /^(localhost|iphone|ipad|android)$/i.test(hostName)
433
+ peers.push({
434
+ name: (generic && dnsLabel) ? dnsLabel : (hostName || dnsLabel || key),
435
+ os: osName,
436
+ mobile: MOBILE_OS.has(osName.toLowerCase()),
437
+ online: !!p.Online,
438
+ lastSeen: p.LastSeen && !String(p.LastSeen).startsWith('0001-') ? p.LastSeen : null,
439
+ ips: Array.isArray(p.TailscaleIPs) ? p.TailscaleIPs : [],
440
+ })
441
+ }
442
+ peers.sort((a, b) => {
443
+ if (a.online !== b.online) return a.online ? -1 : 1
444
+ if (a.mobile !== b.mobile) return a.mobile ? -1 : 1
445
+ return a.name.localeCompare(b.name)
446
+ })
447
+ return peers
448
+ }
449
+ accountFrom(status) {
450
+ if (!status || !status.Self || !status.User) return null
451
+ const user = status.User[String(status.Self.UserID)]
452
+ if (!user) return null
453
+ return {
454
+ email: user.LoginName || '',
455
+ name: user.DisplayName || '',
456
+ }
457
+ }
458
+ keyExpiryFrom(status) {
459
+ if (!status || !status.Self) return null
460
+ const value = status.Self.KeyExpiry
461
+ if (!value || String(value).startsWith('0001-')) return null
462
+ return value
463
+ }
464
+
465
+ // ---------- serve config ----------
466
+ serverPort() {
467
+ return Number(this.kernel.server_port || 42000)
468
+ }
469
+ portMapPath() {
470
+ return this.dir('ports.json')
471
+ }
472
+ // Running Pinokio apps and their web UI ports, taken from the same URL
473
+ // variables the Home Server router publishes (an app's `url`, plus any key
474
+ // its pinokio.json dns config maps). Only loopback URLs can be proxied.
475
+ appTargets() {
476
+ const local = this.kernel.memory && this.kernel.memory.local ? this.kernel.memory.local : {}
477
+ const router = this.kernel.router && this.kernel.router.localhost_variable_router
478
+ const targets = []
479
+ const seen = new Set()
480
+ for (const scriptPath of Object.keys(local).sort()) {
481
+ const vars = local[scriptPath]
482
+ if (!vars || typeof vars !== 'object') continue
483
+ const name = Util.api_name(scriptPath, this.kernel)
484
+ if (!name || name.startsWith('..')) continue
485
+ // `url` first so an app's main UI gets its first (and therefore lowest) stable port
486
+ for (const key of Object.keys(vars).sort((a, b) => a === 'url' ? -1 : b === 'url' ? 1 : a.localeCompare(b))) {
487
+ if (typeof vars[key] !== 'string') continue
488
+ let url
489
+ try { url = new URL(vars[key]) } catch (_) { continue }
490
+ if (!/^https?:$/.test(url.protocol) || !LOOPBACK_HOSTS.has(url.hostname) || !url.port) continue
491
+ let domain
492
+ try { domain = router ? router.domain(name, key) : (key === 'url' ? '@' : null) } catch (_) { domain = key === 'url' ? '@' : null }
493
+ if (!domain) continue
494
+ const port = Number(url.port)
495
+ if (seen.has(port)) continue
496
+ seen.add(port)
497
+ targets.push({ key: domain === '@' ? name : `${domain}.${name}`, name, local: port })
498
+ }
499
+ }
500
+ return targets
501
+ }
502
+ // Recomputes this.targets from the running apps, giving a new app its stable
503
+ // port (kept in ports.json). Returns true when the targets changed.
504
+ async refreshTargets() {
505
+ if (!this.portMap) this.portMap = await loadPortMap(this.portMapPath())
506
+ const apps = this.appTargets()
507
+ const { map, changed } = assignPorts(this.portMap, apps.map((app) => app.key))
508
+ if (changed) {
509
+ this.portMap = map
510
+ await savePortMap(this.portMapPath(), map).catch((e) => console.log('[anywhere] could not save ports.json', e.message))
511
+ }
512
+ const targets = apps.map((app) => Object.assign({}, app, { listen: map[app.key] }))
513
+ const differs = JSON.stringify(targets) !== JSON.stringify(this.targets)
514
+ this.targets = targets
515
+ return differs
516
+ }
517
+ async desiredServeConfig(status) {
518
+ const dnsName = this.dnsNameFrom(status)
519
+ if (!dnsName) return null
520
+ await this.refreshTargets()
521
+ return buildServeConfig({
522
+ dnsName,
523
+ serverPort: this.serverPort(),
524
+ targets: this.targets.map((t) => ({ listen: t.listen, target: t.local })),
525
+ })
526
+ }
527
+ targetNames() {
528
+ const names = {}
529
+ for (const t of this.targets) names[t.listen] = t.name
530
+ return names
531
+ }
532
+ // true when `host` (a Host header) is this node's own Tailscale name
533
+ isNodeHost(host) {
534
+ const dnsName = this.lastStatus ? this.dnsNameFrom(this.lastStatus) : null
535
+ if (!dnsName || !this.config || !this.config.enabled) return false
536
+ return String(host || '').toLowerCase().replace(/:443$/, '') === dnsName.toLowerCase()
537
+ }
538
+ // Rewrites one menu link for a visitor arriving through the Tailscale
539
+ // address. Only a loopback link with a port is touched, and only when the
540
+ // request's Host header is this node's name; any other request gets the
541
+ // href back unchanged.
542
+ rewriteHref(host, href) {
543
+ if (!this.isNodeHost(host)) return href
544
+ const dnsName = this.dnsNameFrom(this.lastStatus)
545
+ let url
546
+ try { url = new URL(String(href)) } catch (_) { return href }
547
+ if (!/^https?:$/.test(url.protocol) || !LOOPBACK_HOSTS.has(url.hostname) || !url.port) return href
548
+ const local = Number(url.port)
549
+ let listen
550
+ if (local === this.serverPort()) {
551
+ listen = DEFAULT_HTTPS_PORT
552
+ } else {
553
+ const target = this.targets.find((t) => t.local === local)
554
+ if (!target) return href
555
+ listen = target.listen
556
+ }
557
+ const origin = listen === DEFAULT_HTTPS_PORT ? `https://${dnsName}` : `https://${dnsName}:${listen}`
558
+ return origin + url.pathname + url.search + url.hash
559
+ }
560
+ async rewriteMenu(host, menu) {
561
+ if (!Array.isArray(menu) || !this.isNodeHost(host)) return menu
562
+ // An app that just published its URL is not in the serve config until the
563
+ // next kernel.refresh() tick. Give it its stable port now, so the menu is
564
+ // right on first render, and push the route without waiting for the tick.
565
+ if (await this.refreshTargets()) this.sync().catch(() => {})
566
+ const rewrite = (items) => {
567
+ for (const item of items) {
568
+ if (!item || typeof item !== 'object') continue
569
+ if (typeof item.href === 'string') {
570
+ const next = this.rewriteHref(host, item.href)
571
+ if (next !== item.href) {
572
+ if (item.src === item.href) item.src = next
573
+ if (item.target_full === item.href) item.target_full = next
574
+ item.href = next
575
+ }
576
+ }
577
+ if (Array.isArray(item.menu)) rewrite(item.menu)
578
+ }
579
+ }
580
+ rewrite(menu)
581
+ return menu
582
+ }
583
+ // Regenerates the Serve config from the apps' published URLs. Called from
584
+ // kernel.refresh() (same trigger as router.update()) and after start.
585
+ async sync() {
586
+ // one push at a time; a call made during a push runs its own afterwards
587
+ if (this.syncing) return this.syncing.then(() => this.sync())
588
+ this.syncing = this.pushServeConfig().finally(() => { this.syncing = null })
589
+ return this.syncing
590
+ }
591
+ async pushServeConfig() {
592
+ if (!this.config || !this.config.enabled) return false
593
+ if (!this.pid && !this.api) return false
594
+ const status = await this.refreshStatus(false)
595
+ if (!status || status.BackendState !== 'Running') return false
596
+ const desired = await this.desiredServeConfig(status)
597
+ if (!desired) return false
598
+ if (this.lastServe !== undefined && sameServeConfig(desired, this.lastServe)) {
599
+ return false
600
+ }
601
+ try {
602
+ const current = await this.client().serveConfig()
603
+ await this.client().setServeConfig(desired, current.etag)
604
+ this.lastServe = desired
605
+ this.lastServeError = null
606
+ console.log(`[anywhere] serve config updated: ${Object.keys(desired.TCP).join(', ')}`)
607
+ return true
608
+ } catch (e) {
609
+ this.lastServeError = e && e.message ? e.message : String(e)
610
+ console.log('[anywhere] serve config update failed', this.lastServeError)
611
+ return false
612
+ }
613
+ }
614
+
615
+ // ---------- snapshot for the UI ----------
616
+ async snapshot() {
617
+ await this.loadConfig()
618
+ const installed = await this.installed()
619
+ const enabled = !!this.config.enabled
620
+ const base = {
621
+ installed,
622
+ enabled,
623
+ error: this.error,
624
+ serveError: this.lastServeError,
625
+ }
626
+ if (!installed) {
627
+ return Object.assign(base, { phase: 'setup', running: false })
628
+ }
629
+ if (!enabled) {
630
+ return Object.assign(base, { phase: 'off', running: false })
631
+ }
632
+ if (this.starting) {
633
+ return Object.assign(base, { phase: 'starting', running: false })
634
+ }
635
+ const status = await this.refreshStatus(false)
636
+ if (!status) {
637
+ return Object.assign(base, {
638
+ phase: this.error ? 'error' : 'starting',
639
+ running: false,
640
+ })
641
+ }
642
+ const backendState = status.BackendState
643
+ const dnsName = this.dnsNameFrom(status)
644
+ const address = dnsName ? `https://${dnsName}` : null
645
+ const account = this.accountFrom(status)
646
+ const peers = this.peersFrom(status)
647
+ const out = Object.assign(base, {
648
+ running: true,
649
+ version: status.Version,
650
+ dnsName,
651
+ address,
652
+ ips: Array.isArray(status.TailscaleIPs) ? status.TailscaleIPs : [],
653
+ account,
654
+ keyExpiry: this.keyExpiryFrom(status),
655
+ keyExpired: !!(status.Self && status.Self.Expired),
656
+ peers,
657
+ mobilePeerPresent: peers.some((p) => p.mobile),
658
+ mobilePeerOnline: peers.some((p) => p.mobile && p.online),
659
+ connectUrl: this.connectUrl(address, account ? account.email : ''),
660
+ loginUrl: null,
661
+ approval: null,
662
+ serve: [],
663
+ })
664
+ if (backendState === 'NeedsLogin') {
665
+ out.phase = 'login'
666
+ out.loginUrl = await this.ensureLoginUrl(status)
667
+ if (!out.loginUrl) {
668
+ out.phase = 'starting'
669
+ }
670
+ return out
671
+ }
672
+ if (backendState === 'NeedsMachineAuth') {
673
+ out.phase = 'machine_auth'
674
+ return out
675
+ }
676
+ if (backendState === 'Stopped') {
677
+ await this.ensureRunningPrefs(status)
678
+ out.phase = 'starting'
679
+ return out
680
+ }
681
+ if (backendState !== 'Running') {
682
+ out.phase = 'starting'
683
+ return out
684
+ }
685
+ const feature = await this.httpsFeature(status)
686
+ if (!feature) {
687
+ out.phase = 'starting'
688
+ return out
689
+ }
690
+ if (!feature.Complete) {
691
+ out.phase = 'https'
692
+ out.approval = {
693
+ url: feature.URL || null,
694
+ text: feature.Text || 'Enable HTTPS certificates for your tailnet to continue.',
695
+ }
696
+ return out
697
+ }
698
+ out.phase = 'ready'
699
+ const serve = this.lastServe !== undefined ? this.lastServe : await this.desiredServeConfig(status)
700
+ out.serve = describeServeConfig(serve, dnsName, this.targetNames())
701
+ return out
702
+ }
703
+ }
704
+
705
+
706
+ module.exports = Anywhere