pinokiod 8.2.1 → 8.2.3

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,698 @@
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.enabled())) return false
191
+ if (!(await this.installed())) return false
192
+ await this.start()
193
+ return true
194
+ }
195
+ async start() {
196
+ if (this.starting) return this.starting
197
+ this.starting = this._start().finally(() => { this.starting = null })
198
+ return this.starting
199
+ }
200
+ async _start() {
201
+ this.error = null
202
+ await this.loadConfig()
203
+ await fs.promises.mkdir(this.stateDir(), { recursive: true })
204
+ const bin = this.binPath()
205
+ if (!bin) {
206
+ throw new Error('tailscaled module is not available')
207
+ }
208
+ if (!(await this.daemonAlive())) {
209
+ if (this.platform !== 'win32') {
210
+ // a stale socket file makes bind() fail
211
+ await fs.promises.rm(this.socketPath(), { force: true }).catch(() => {})
212
+ }
213
+ const logFd = fs.openSync(this.logPath(), 'w')
214
+ const env = Object.assign({}, process.env, {
215
+ TS_LOGS_DIR: this.stateDir(),
216
+ })
217
+ const child = spawn(bin, this.daemonArgs(), {
218
+ cwd: this.stateDir(),
219
+ env,
220
+ stdio: ['ignore', logFd, logFd],
221
+ windowsHide: true,
222
+ })
223
+ fs.closeSync(logFd)
224
+ this.child = child
225
+ this.pid = child.pid
226
+ if (child.pid) fs.writeFileSync(this.pidPath(), String(child.pid))
227
+ child.on('exit', (code, signal) => {
228
+ console.log(`[anywhere] tailscaled exited (code=${code} signal=${signal})`)
229
+ if (this.child === child) {
230
+ // not a stop() of ours: surface it, the page offers "Try again"
231
+ this.child = null
232
+ this.pid = null
233
+ this.error = `tailscaled exited (code=${code} signal=${signal})`
234
+ fs.rmSync(this.pidPath(), { force: true })
235
+ }
236
+ })
237
+ child.on('error', (err) => {
238
+ console.log('[anywhere] tailscaled spawn error', err && err.message ? err.message : err)
239
+ this.error = `tailscaled failed to start: ${err && err.message ? err.message : err}`
240
+ })
241
+ const ready = await this.waitForDaemon(DAEMON_READY_TIMEOUT_MS)
242
+ if (!ready) {
243
+ let tail = ''
244
+ try {
245
+ tail = (await fs.promises.readFile(this.logPath(), 'utf8')).split(/\r?\n/).slice(-10).join('\n')
246
+ } catch (e) { }
247
+ if (!this.error) {
248
+ this.error = `tailscaled did not answer on ${this.socketPath()} within ${DAEMON_READY_TIMEOUT_MS / 1000}s\n${tail}`
249
+ }
250
+ throw new Error(this.error)
251
+ }
252
+ } else if (!this.pid) {
253
+ // adopted from a previous pinokiod run: recover its pid so shutdown can still kill it
254
+ this.pid = this.readPid()
255
+ }
256
+ await this.applyPrefs()
257
+ this.lastServe = undefined
258
+ this.lastStatusAt = 0
259
+ await this.refreshStatus(true)
260
+ return true
261
+ }
262
+ async waitForDaemon(timeout) {
263
+ const started = Date.now()
264
+ while (Date.now() - started < timeout) {
265
+ if (this.error || (this.child && this.child.exitCode !== null)) {
266
+ return false
267
+ }
268
+ if (await this.daemonAlive()) {
269
+ return true
270
+ }
271
+ await sleep(250)
272
+ }
273
+ return false
274
+ }
275
+ async applyPrefs() {
276
+ const hostname = await this.hostname()
277
+ await this.client().patchPrefs({
278
+ HostnameSet: true,
279
+ Hostname: hostname,
280
+ WantRunningSet: true,
281
+ WantRunning: true,
282
+ LoggedOutSet: true,
283
+ LoggedOut: false,
284
+ // this node is a server; never route the machine's traffic elsewhere
285
+ RouteAllSet: true,
286
+ RouteAll: false,
287
+ ExitNodeIDSet: true,
288
+ ExitNodeID: '',
289
+ CorpDNSSet: true,
290
+ CorpDNS: false,
291
+ })
292
+ }
293
+ readPid() {
294
+ try {
295
+ const pid = Number(fs.readFileSync(this.pidPath(), 'utf8').trim())
296
+ if (!Number.isInteger(pid) || pid <= 0) return null
297
+ process.kill(pid, 0) // throws when there is no such process
298
+ return pid
299
+ } catch (e) {
300
+ return null
301
+ }
302
+ }
303
+ async stop() {
304
+ const pid = this.pid || this.readPid()
305
+ this.child = null
306
+ this.pid = null
307
+ if (pid) {
308
+ try { process.kill(pid, 'SIGTERM') } catch (e) { }
309
+ }
310
+ fs.rmSync(this.pidPath(), { force: true })
311
+ this.lastStatus = null
312
+ this.lastStatusAt = 0
313
+ this.lastServe = undefined
314
+ return true
315
+ }
316
+ // synchronous: used from the process shutdown path
317
+ killSync() {
318
+ const pid = this.pid || this.readPid()
319
+ if (pid) {
320
+ try { process.kill(pid, 'SIGKILL') } catch (e) { }
321
+ }
322
+ fs.rmSync(this.pidPath(), { force: true })
323
+ }
324
+ async enable() {
325
+ await this.saveConfig({ enabled: true })
326
+ await this.start()
327
+ return true
328
+ }
329
+ async disable() {
330
+ await this.saveConfig({ enabled: false })
331
+ await this.stop()
332
+ return true
333
+ }
334
+ async requestLogin() {
335
+ this.loginRequestedAt = Date.now()
336
+ await this.client().loginInteractive()
337
+ this.lastStatusAt = 0
338
+ return true
339
+ }
340
+ async logout() {
341
+ await this.client().logout()
342
+ await this.applyPrefs()
343
+ await this.client().start()
344
+ this.lastStatus = null
345
+ this.lastStatusAt = 0
346
+ this.lastServe = undefined
347
+ this.feature = null
348
+ return true
349
+ }
350
+
351
+ // ---------- status ----------
352
+ async refreshStatus(force) {
353
+ const now = Date.now()
354
+ if (!force && this.lastStatus && now - this.lastStatusAt < STATUS_CACHE_MS) {
355
+ return this.lastStatus
356
+ }
357
+ try {
358
+ const status = await this.client().status()
359
+ this.lastStatus = status
360
+ this.lastStatusAt = Date.now()
361
+ return status
362
+ } catch (e) {
363
+ this.lastStatus = null
364
+ this.lastStatusAt = Date.now()
365
+ return null
366
+ }
367
+ }
368
+ dnsNameFrom(status) {
369
+ if (!status || !status.Self || !status.Self.DNSName) return null
370
+ return String(status.Self.DNSName).replace(/\.$/, '')
371
+ }
372
+ async ensureRunningPrefs(status) {
373
+ // the daemon persisted WantRunning=false (e.g. after `logout`): nudge it
374
+ if (status && status.BackendState === 'Stopped') {
375
+ try {
376
+ await this.client().patchPrefs({ WantRunningSet: true, WantRunning: true })
377
+ this.lastStatusAt = 0
378
+ } catch (e) { }
379
+ }
380
+ }
381
+ async ensureLoginUrl(status) {
382
+ if (!status || status.BackendState !== 'NeedsLogin') return null
383
+ // the daemon keeps offering a link even after the control server rejected it
384
+ // ("register request: http 410: auth path not found"); reset and ask again
385
+ const dead = Array.isArray(status.Health) && status.Health.some((h) => /auth path not found/i.test(String(h)))
386
+ if (status.AuthURL && !dead) return status.AuthURL
387
+ if (Date.now() - this.loginRequestedAt > 5000) {
388
+ try {
389
+ if (dead) await this.client().start()
390
+ await this.requestLogin()
391
+ } catch (e) { }
392
+ }
393
+ return null
394
+ }
395
+ async httpsFeature(status) {
396
+ if (!status || status.BackendState !== 'Running') return null
397
+ if (Array.isArray(status.CertDomains) && status.CertDomains.length > 0) {
398
+ this.feature = { Complete: true }
399
+ return this.feature
400
+ }
401
+ if (this.feature && Date.now() - this.featureAt < FEATURE_QUERY_CACHE_MS) {
402
+ return this.feature
403
+ }
404
+ try {
405
+ this.feature = await this.client().queryFeature('serve')
406
+ this.featureAt = Date.now()
407
+ } catch (e) {
408
+ // e.g. 503 "no netmap" right after login: not an answer yet, ask again next time
409
+ this.feature = null
410
+ this.featureAt = 0
411
+ }
412
+ return this.feature
413
+ }
414
+ peersFrom(status) {
415
+ const peers = []
416
+ if (!status || !status.Peer) return peers
417
+ for (const key of Object.keys(status.Peer)) {
418
+ const p = status.Peer[key]
419
+ if (!p) continue
420
+ const osName = String(p.OS || '')
421
+ const dnsLabel = p.DNSName ? String(p.DNSName).split('.')[0] : ''
422
+ const hostName = String(p.HostName || '').trim()
423
+ // phones often report a generic HostName ("localhost"); MagicDNS has the nicer one
424
+ const generic = !hostName || /^(localhost|iphone|ipad|android)$/i.test(hostName)
425
+ peers.push({
426
+ name: (generic && dnsLabel) ? dnsLabel : (hostName || dnsLabel || key),
427
+ os: osName,
428
+ mobile: MOBILE_OS.has(osName.toLowerCase()),
429
+ online: !!p.Online,
430
+ lastSeen: p.LastSeen && !String(p.LastSeen).startsWith('0001-') ? p.LastSeen : null,
431
+ ips: Array.isArray(p.TailscaleIPs) ? p.TailscaleIPs : [],
432
+ })
433
+ }
434
+ peers.sort((a, b) => {
435
+ if (a.online !== b.online) return a.online ? -1 : 1
436
+ if (a.mobile !== b.mobile) return a.mobile ? -1 : 1
437
+ return a.name.localeCompare(b.name)
438
+ })
439
+ return peers
440
+ }
441
+ accountFrom(status) {
442
+ if (!status || !status.Self || !status.User) return null
443
+ const user = status.User[String(status.Self.UserID)]
444
+ if (!user) return null
445
+ return {
446
+ email: user.LoginName || '',
447
+ name: user.DisplayName || '',
448
+ }
449
+ }
450
+ keyExpiryFrom(status) {
451
+ if (!status || !status.Self) return null
452
+ const value = status.Self.KeyExpiry
453
+ if (!value || String(value).startsWith('0001-')) return null
454
+ return value
455
+ }
456
+
457
+ // ---------- serve config ----------
458
+ serverPort() {
459
+ return Number(this.kernel.server_port || 42000)
460
+ }
461
+ portMapPath() {
462
+ return this.dir('ports.json')
463
+ }
464
+ // Running Pinokio apps and their web UI ports, taken from the same URL
465
+ // variables the Home Server router publishes (an app's `url`, plus any key
466
+ // its pinokio.json dns config maps). Only loopback URLs can be proxied.
467
+ appTargets() {
468
+ const local = this.kernel.memory && this.kernel.memory.local ? this.kernel.memory.local : {}
469
+ const router = this.kernel.router && this.kernel.router.localhost_variable_router
470
+ const targets = []
471
+ const seen = new Set()
472
+ for (const scriptPath of Object.keys(local).sort()) {
473
+ const vars = local[scriptPath]
474
+ if (!vars || typeof vars !== 'object') continue
475
+ const name = Util.api_name(scriptPath, this.kernel)
476
+ if (!name || name.startsWith('..')) continue
477
+ // `url` first so an app's main UI gets its first (and therefore lowest) stable port
478
+ for (const key of Object.keys(vars).sort((a, b) => a === 'url' ? -1 : b === 'url' ? 1 : a.localeCompare(b))) {
479
+ if (typeof vars[key] !== 'string') continue
480
+ let url
481
+ try { url = new URL(vars[key]) } catch (_) { continue }
482
+ if (!/^https?:$/.test(url.protocol) || !LOOPBACK_HOSTS.has(url.hostname) || !url.port) continue
483
+ let domain
484
+ try { domain = router ? router.domain(name, key) : (key === 'url' ? '@' : null) } catch (_) { domain = key === 'url' ? '@' : null }
485
+ if (!domain) continue
486
+ const port = Number(url.port)
487
+ if (seen.has(port)) continue
488
+ seen.add(port)
489
+ targets.push({ key: domain === '@' ? name : `${domain}.${name}`, name, local: port })
490
+ }
491
+ }
492
+ return targets
493
+ }
494
+ // Recomputes this.targets from the running apps, giving a new app its stable
495
+ // port (kept in ports.json). Returns true when the targets changed.
496
+ async refreshTargets() {
497
+ if (!this.portMap) this.portMap = await loadPortMap(this.portMapPath())
498
+ const apps = this.appTargets()
499
+ const { map, changed } = assignPorts(this.portMap, apps.map((app) => app.key))
500
+ if (changed) {
501
+ this.portMap = map
502
+ await savePortMap(this.portMapPath(), map).catch((e) => console.log('[anywhere] could not save ports.json', e.message))
503
+ }
504
+ const targets = apps.map((app) => Object.assign({}, app, { listen: map[app.key] }))
505
+ const differs = JSON.stringify(targets) !== JSON.stringify(this.targets)
506
+ this.targets = targets
507
+ return differs
508
+ }
509
+ async desiredServeConfig(status) {
510
+ const dnsName = this.dnsNameFrom(status)
511
+ if (!dnsName) return null
512
+ await this.refreshTargets()
513
+ return buildServeConfig({
514
+ dnsName,
515
+ serverPort: this.serverPort(),
516
+ targets: this.targets.map((t) => ({ listen: t.listen, target: t.local })),
517
+ })
518
+ }
519
+ targetNames() {
520
+ const names = {}
521
+ for (const t of this.targets) names[t.listen] = t.name
522
+ return names
523
+ }
524
+ // true when `host` (a Host header) is this node's own Tailscale name
525
+ isNodeHost(host) {
526
+ const dnsName = this.lastStatus ? this.dnsNameFrom(this.lastStatus) : null
527
+ if (!dnsName || !this.config || !this.config.enabled) return false
528
+ return String(host || '').toLowerCase().replace(/:443$/, '') === dnsName.toLowerCase()
529
+ }
530
+ // Rewrites one menu link for a visitor arriving through the Tailscale
531
+ // address. Only a loopback link with a port is touched, and only when the
532
+ // request's Host header is this node's name; any other request gets the
533
+ // href back unchanged.
534
+ rewriteHref(host, href) {
535
+ if (!this.isNodeHost(host)) return href
536
+ const dnsName = this.dnsNameFrom(this.lastStatus)
537
+ let url
538
+ try { url = new URL(String(href)) } catch (_) { return href }
539
+ if (!/^https?:$/.test(url.protocol) || !LOOPBACK_HOSTS.has(url.hostname) || !url.port) return href
540
+ const local = Number(url.port)
541
+ let listen
542
+ if (local === this.serverPort()) {
543
+ listen = DEFAULT_HTTPS_PORT
544
+ } else {
545
+ const target = this.targets.find((t) => t.local === local)
546
+ if (!target) return href
547
+ listen = target.listen
548
+ }
549
+ const origin = listen === DEFAULT_HTTPS_PORT ? `https://${dnsName}` : `https://${dnsName}:${listen}`
550
+ return origin + url.pathname + url.search + url.hash
551
+ }
552
+ async rewriteMenu(host, menu) {
553
+ if (!Array.isArray(menu) || !this.isNodeHost(host)) return menu
554
+ // An app that just published its URL is not in the serve config until the
555
+ // next kernel.refresh() tick. Give it its stable port now, so the menu is
556
+ // right on first render, and push the route without waiting for the tick.
557
+ if (await this.refreshTargets()) this.sync().catch(() => {})
558
+ const rewrite = (items) => {
559
+ for (const item of items) {
560
+ if (!item || typeof item !== 'object') continue
561
+ if (typeof item.href === 'string') {
562
+ const next = this.rewriteHref(host, item.href)
563
+ if (next !== item.href) {
564
+ if (item.src === item.href) item.src = next
565
+ if (item.target_full === item.href) item.target_full = next
566
+ item.href = next
567
+ }
568
+ }
569
+ if (Array.isArray(item.menu)) rewrite(item.menu)
570
+ }
571
+ }
572
+ rewrite(menu)
573
+ return menu
574
+ }
575
+ // Regenerates the Serve config from the apps' published URLs. Called from
576
+ // kernel.refresh() (same trigger as router.update()) and after start.
577
+ async sync() {
578
+ // one push at a time; a call made during a push runs its own afterwards
579
+ if (this.syncing) return this.syncing.then(() => this.sync())
580
+ this.syncing = this.pushServeConfig().finally(() => { this.syncing = null })
581
+ return this.syncing
582
+ }
583
+ async pushServeConfig() {
584
+ if (!this.config || !this.config.enabled) return false
585
+ if (!this.pid && !this.api) return false
586
+ const status = await this.refreshStatus(false)
587
+ if (!status || status.BackendState !== 'Running') return false
588
+ const desired = await this.desiredServeConfig(status)
589
+ if (!desired) return false
590
+ if (this.lastServe !== undefined && sameServeConfig(desired, this.lastServe)) {
591
+ return false
592
+ }
593
+ try {
594
+ const current = await this.client().serveConfig()
595
+ await this.client().setServeConfig(desired, current.etag)
596
+ this.lastServe = desired
597
+ this.lastServeError = null
598
+ console.log(`[anywhere] serve config updated: ${Object.keys(desired.TCP).join(', ')}`)
599
+ return true
600
+ } catch (e) {
601
+ this.lastServeError = e && e.message ? e.message : String(e)
602
+ console.log('[anywhere] serve config update failed', this.lastServeError)
603
+ return false
604
+ }
605
+ }
606
+
607
+ // ---------- snapshot for the UI ----------
608
+ async snapshot() {
609
+ await this.loadConfig()
610
+ const installed = await this.installed()
611
+ const enabled = !!this.config.enabled
612
+ const base = {
613
+ installed,
614
+ enabled,
615
+ error: this.error,
616
+ serveError: this.lastServeError,
617
+ }
618
+ if (!installed) {
619
+ return Object.assign(base, { phase: 'setup', running: false })
620
+ }
621
+ if (!enabled) {
622
+ return Object.assign(base, { phase: 'off', running: false })
623
+ }
624
+ if (this.starting) {
625
+ return Object.assign(base, { phase: 'starting', running: false })
626
+ }
627
+ const status = await this.refreshStatus(false)
628
+ if (!status) {
629
+ return Object.assign(base, {
630
+ phase: this.error ? 'error' : 'starting',
631
+ running: false,
632
+ })
633
+ }
634
+ const backendState = status.BackendState
635
+ const dnsName = this.dnsNameFrom(status)
636
+ const address = dnsName ? `https://${dnsName}` : null
637
+ const account = this.accountFrom(status)
638
+ const peers = this.peersFrom(status)
639
+ const out = Object.assign(base, {
640
+ running: true,
641
+ version: status.Version,
642
+ dnsName,
643
+ address,
644
+ ips: Array.isArray(status.TailscaleIPs) ? status.TailscaleIPs : [],
645
+ account,
646
+ keyExpiry: this.keyExpiryFrom(status),
647
+ keyExpired: !!(status.Self && status.Self.Expired),
648
+ peers,
649
+ mobilePeerPresent: peers.some((p) => p.mobile),
650
+ mobilePeerOnline: peers.some((p) => p.mobile && p.online),
651
+ connectUrl: this.connectUrl(address, account ? account.email : ''),
652
+ loginUrl: null,
653
+ approval: null,
654
+ serve: [],
655
+ })
656
+ if (backendState === 'NeedsLogin') {
657
+ out.phase = 'login'
658
+ out.loginUrl = await this.ensureLoginUrl(status)
659
+ if (!out.loginUrl) {
660
+ out.phase = 'starting'
661
+ }
662
+ return out
663
+ }
664
+ if (backendState === 'NeedsMachineAuth') {
665
+ out.phase = 'machine_auth'
666
+ return out
667
+ }
668
+ if (backendState === 'Stopped') {
669
+ await this.ensureRunningPrefs(status)
670
+ out.phase = 'starting'
671
+ return out
672
+ }
673
+ if (backendState !== 'Running') {
674
+ out.phase = 'starting'
675
+ return out
676
+ }
677
+ const feature = await this.httpsFeature(status)
678
+ if (!feature) {
679
+ out.phase = 'starting'
680
+ return out
681
+ }
682
+ if (!feature.Complete) {
683
+ out.phase = 'https'
684
+ out.approval = {
685
+ url: feature.URL || null,
686
+ text: feature.Text || 'Enable HTTPS certificates for your tailnet to continue.',
687
+ }
688
+ return out
689
+ }
690
+ out.phase = 'ready'
691
+ const serve = this.lastServe !== undefined ? this.lastServe : await this.desiredServeConfig(status)
692
+ out.serve = describeServeConfig(serve, dnsName, this.targetNames())
693
+ return out
694
+ }
695
+ }
696
+
697
+
698
+ module.exports = Anywhere