pinokiod 8.2.9 → 8.2.11

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.
@@ -1,6 +1,6 @@
1
1
  /*
2
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
3
+ conda-forge by kernel/bin/tailscaled.js) and controls it with the bundled CLI so
4
4
  the user can reach this Pinokio and every app it launches from their phone
5
5
  or another computer on their private tailnet, over HTTPS.
6
6
 
@@ -19,14 +19,14 @@ const os = require('os')
19
19
  const fs = require('fs')
20
20
  const path = require('path')
21
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')
22
+ const { spawn, execFile, execFileSync } = require('child_process')
23
+ const TailscaleCLI = require('./cli')
24
+ const writeJson = require('./state')
25
+ const { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref, LOOPBACK_HOSTS } = require('./serve')
25
26
  const { loadPortMap, savePortMap, assignPorts } = require('./ports')
26
27
  const Util = require('../util')
27
28
 
28
29
  const DEFAULT_HOSTNAME = 'pinokio'
29
- const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '[::1]'])
30
30
  const DEFAULT_CONNECT_URL_TEMPLATE = 'https://pinokio.co/anywhere/connect#u={address}&a={email}'
31
31
  const MAX_UNIX_SOCKET_PATH = 100 // sun_path is 104 bytes on macOS, 108 on Linux
32
32
  const DAEMON_READY_TIMEOUT_MS = 20000
@@ -41,7 +41,6 @@ class Anywhere {
41
41
  this.kernel = kernel
42
42
  this.platform = kernel && kernel.platform ? kernel.platform : os.platform()
43
43
  this.child = null
44
- this.pid = null
45
44
  this.api = null
46
45
  this.config = null
47
46
  this.starting = null
@@ -54,7 +53,8 @@ class Anywhere {
54
53
  this.feature = null // cached query-feature result
55
54
  this.featureAt = 0
56
55
  this.error = null
57
- this.loginRequestedAt = 0
56
+ this.login = null
57
+ this.stopping = null
58
58
  }
59
59
 
60
60
  // ---------- paths ----------
@@ -120,6 +120,7 @@ class Anywhere {
120
120
  config = JSON.parse(raw) || {}
121
121
  this.configExists = true
122
122
  } catch (e) {
123
+ if (e.code !== 'ENOENT') throw e
123
124
  config = {}
124
125
  }
125
126
  this.config = Object.assign({
@@ -129,10 +130,8 @@ class Anywhere {
129
130
  return this.config
130
131
  }
131
132
  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))
133
+ const config = Object.assign({}, await this.loadConfig(), patch || {})
134
+ await writeJson(this.configPath(), config)
136
135
  this.config = config
137
136
  this.configExists = true
138
137
  return config
@@ -176,7 +175,9 @@ class Anywhere {
176
175
  // ---------- daemon lifecycle ----------
177
176
  client() {
178
177
  if (!this.api) {
179
- this.api = new LocalAPI({ socketPath: this.socketPath() })
178
+ this.api = new TailscaleCLI({
179
+ binPath: this.kernel.bin.mod.tailscaled.cliPath(), socketPath: this.socketPath(),
180
+ })
180
181
  }
181
182
  return this.api
182
183
  }
@@ -185,32 +186,53 @@ class Anywhere {
185
186
  }
186
187
  // Called from kernel/bin/tailscaled.js start() during bin init.
187
188
  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
189
+ return this.start(true)
194
190
  }
195
- async start() {
191
+ async start(automatic = false) {
192
+ if (this.stopping) await this.stopping
196
193
  if (this.starting) return this.starting
197
- this.starting = this._start().finally(() => { this.starting = null })
194
+ this.starting = this._start(automatic).catch((e) => {
195
+ if (!this.stopping) this.error = e.message
196
+ throw e
197
+ }).finally(() => { this.starting = null })
198
198
  return this.starting
199
199
  }
200
- async _start() {
201
- this.error = null
200
+ async assertOwnership() {
201
+ const api = this.api
202
+ const config = this.config
203
+ const owned = (this.child && this.child.pid && this.child.exitCode === null) || await this.readPid()
204
+ if (this.stopping || this.api !== api || this.config !== config) return false
205
+ if (!owned) {
206
+ throw new Error('Cannot verify ownership of tailscaled; its process and PID record were preserved.')
207
+ }
208
+ return true
209
+ }
210
+ async _start(automatic) {
202
211
  await this.loadConfig()
212
+ if (!this.config.enabled) return false
213
+ if (!(await this.installed())) {
214
+ if (automatic) return false
215
+ throw new Error('Install the matching Tailscale daemon and CLI through Anywhere setup.')
216
+ }
217
+ if (this.stopping) return false
218
+ this.error = null
219
+ if (this.api) this.api.cancel()
220
+ if (this.syncing) await this.syncing.catch(() => {})
221
+ const forceLogin = !!(this.login && this.login.error)
222
+ this.login = null
203
223
  await fs.promises.mkdir(this.stateDir(), { recursive: true })
204
224
  const bin = this.binPath()
205
225
  if (!bin) {
206
226
  throw new Error('tailscaled module is not available')
207
227
  }
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')
228
+ const alive = await this.daemonAlive()
229
+ if (this.stopping) return false
230
+ if (alive && (!(await this.assertOwnership()) || this.stopping)) return false
231
+ if (!alive) {
232
+ const pid = await this.readPid()
233
+ if (this.stopping) return false
234
+ if (pid) throw new Error('tailscaled is still running but its private endpoint is unavailable; no second daemon was started.')
235
+ const logFd = fs.openSync(this.logPath(), 'a')
214
236
  const env = Object.assign({}, process.env, {
215
237
  TS_LOGS_DIR: this.stateDir(),
216
238
  })
@@ -222,22 +244,26 @@ class Anywhere {
222
244
  })
223
245
  fs.closeSync(logFd)
224
246
  this.child = child
225
- this.pid = child.pid
226
- if (child.pid) fs.writeFileSync(this.pidPath(), String(child.pid))
227
247
  child.on('exit', (code, signal) => {
228
248
  console.log(`[anywhere] tailscaled exited (code=${code} signal=${signal})`)
229
249
  if (this.child === child) {
230
250
  // not a stop() of ours: surface it, the page offers "Try again"
231
251
  this.child = null
232
- this.pid = null
233
- this.error = `tailscaled exited (code=${code} signal=${signal})`
234
- fs.rmSync(this.pidPath(), { force: true })
252
+ if (!this.stopping && !this.error) this.error = `tailscaled exited (code=${code} signal=${signal})`
253
+ try { fs.rmSync(this.pidPath(), { force: true }) }
254
+ catch (error) { this.error = `Could not remove tailscaled PID record: ${error.message}` }
235
255
  }
236
256
  })
237
257
  child.on('error', (err) => {
238
258
  console.log('[anywhere] tailscaled spawn error', err && err.message ? err.message : err)
239
259
  this.error = `tailscaled failed to start: ${err && err.message ? err.message : err}`
240
260
  })
261
+ try {
262
+ if (child.pid) fs.writeFileSync(this.pidPath(), String(child.pid))
263
+ } catch (error) {
264
+ child.kill()
265
+ throw error
266
+ }
241
267
  const ready = await this.waitForDaemon(DAEMON_READY_TIMEOUT_MS)
242
268
  if (!ready) {
243
269
  let tail = ''
@@ -249,20 +275,21 @@ class Anywhere {
249
275
  }
250
276
  throw new Error(this.error)
251
277
  }
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
278
  }
256
- await this.applyPrefs()
279
+ if (this.stopping) return false
280
+ await this.client().setPrefs(await this.hostname())
257
281
  this.lastServe = undefined
282
+ this.lastServeError = null
258
283
  this.lastStatusAt = 0
259
- await this.refreshStatus(true)
284
+ const status = await this.refreshStatus(true)
285
+ if (!status) throw new Error(this.error)
286
+ if (status.BackendState !== 'Running') this.requestLogin(forceLogin)
260
287
  return true
261
288
  }
262
289
  async waitForDaemon(timeout) {
263
290
  const started = Date.now()
264
291
  while (Date.now() - started < timeout) {
265
- if (this.error || (this.child && this.child.exitCode !== null)) {
292
+ if (this.stopping || this.error || (this.child && this.child.exitCode !== null)) {
266
293
  return false
267
294
  }
268
295
  if (await this.daemonAlive()) {
@@ -272,41 +299,70 @@ class Anywhere {
272
299
  }
273
300
  return false
274
301
  }
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,
302
+ async stop() {
303
+ if (this.stopping) return this.stopping
304
+ this.stopping = this._stop().catch((e) => { this.error = e.message; throw e }).finally(() => { this.stopping = null })
305
+ return this.stopping
306
+ }
307
+ pidInspection() {
308
+ let pid
309
+ try { pid = Number(fs.readFileSync(this.pidPath(), 'utf8').trim()) }
310
+ catch (e) { if (e.code === 'ENOENT') return null; throw e }
311
+ if (!Number.isInteger(pid) || pid <= 0) return null
312
+ try { process.kill(pid, 0) }
313
+ catch (e) { if (e.code === 'ESRCH') return null; throw e }
314
+ return { pid, file: this.platform === 'win32' ? 'powershell.exe' : '/bin/ps',
315
+ args: this.platform === 'win32' ? ['-NoProfile', '-NonInteractive', '-Command',
316
+ `[Console]::OutputEncoding=[Text.Encoding]::UTF8; (Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine`]
317
+ : ['-ww', '-p', String(pid), '-o', 'args='],
318
+ options: { encoding: 'utf8', shell: false, windowsHide: true, timeout: 2000 } }
319
+ }
320
+ ownedPid(inspection, command) {
321
+ command = command.trim()
322
+ let expected = [this.binPath(), ...this.daemonArgs()].join(' ')
323
+ if (this.platform === 'win32') {
324
+ command = command.replace(/"/g, '').toLowerCase()
325
+ expected = expected.toLowerCase()
326
+ }
327
+ return command === expected ? inspection.pid : null
328
+ }
329
+ async readPid() {
330
+ const inspection = this.pidInspection()
331
+ if (!inspection) return null
332
+ const command = await new Promise((resolve, reject) => {
333
+ execFile(inspection.file, inspection.args, inspection.options, (error, stdout) => error ? reject(error) : resolve(stdout))
334
+ }).catch(error => {
335
+ // The daemon can exit between the liveness probe and process inspection.
336
+ try { process.kill(inspection.pid, 0) }
337
+ catch (cause) { if (cause.code === 'ESRCH') return null }
338
+ throw error
291
339
  })
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
340
+ if (command === null) return null
341
+ return this.ownedPid(inspection, command)
342
+ }
343
+ readPidSync() {
344
+ const inspection = this.pidInspection()
345
+ return inspection ? this.ownedPid(inspection, execFileSync(inspection.file, inspection.args, inspection.options)) : null
346
+ }
347
+ async _stop() {
348
+ if (this.api) this.api.cancel()
349
+ if (this.starting) await this.starting.catch(() => {})
350
+ if (this.syncing) await this.syncing.catch(() => {})
351
+ if (this.login) await this.login.promise
352
+ if (this.featureQuery) await this.featureQuery.catch(() => {})
353
+ const pid = this.child && this.child.exitCode === null ? this.child.pid : await this.readPid()
354
+ if (pid) process.kill(pid, 'SIGTERM')
355
+ else if (await this.daemonAlive()) throw Object.assign(new Error('Cannot verify ownership of tailscaled; its process and PID record were preserved.'), { code: 'ERR_ANYWHERE_UNOWNED' })
356
+ const deadline = Date.now() + 5000
357
+ while ((this.child && this.child.exitCode === null) || (pid && await this.readPid()) || await this.daemonAlive()) {
358
+ if (Date.now() >= deadline) throw new Error('tailscaled did not stop; its state and PID record were preserved.')
359
+ await sleep(100)
301
360
  }
302
- }
303
- async stop() {
304
- const pid = this.pid || this.readPid()
361
+ this.error = null
362
+ this.lastServeError = null
305
363
  this.child = null
306
- this.pid = null
307
- if (pid) {
308
- try { process.kill(pid, 'SIGTERM') } catch (e) { }
309
- }
364
+ this.login = null
365
+ this.api = null
310
366
  fs.rmSync(this.pidPath(), { force: true })
311
367
  this.lastStatus = null
312
368
  this.lastStatusAt = 0
@@ -315,11 +371,12 @@ class Anywhere {
315
371
  }
316
372
  // synchronous: used from the process shutdown path
317
373
  killSync() {
318
- const pid = this.pid || this.readPid()
374
+ if (this.api) this.api.cancel()
375
+ const pid = this.child && this.child.exitCode === null ? this.child.pid : this.readPidSync()
319
376
  if (pid) {
320
- try { process.kill(pid, 'SIGKILL') } catch (e) { }
377
+ process.kill(pid, 'SIGKILL')
378
+ fs.rmSync(this.pidPath(), { force: true })
321
379
  }
322
- fs.rmSync(this.pidPath(), { force: true })
323
380
  }
324
381
  async enable() {
325
382
  await this.saveConfig({ enabled: true })
@@ -331,20 +388,42 @@ class Anywhere {
331
388
  await this.stop()
332
389
  return true
333
390
  }
334
- async requestLogin() {
335
- this.loginRequestedAt = Date.now()
336
- await this.client().loginInteractive()
337
- this.lastStatusAt = 0
338
- return true
391
+ requestLogin(force = false) {
392
+ if (!this.config || !this.config.enabled || this.login || this.stopping || this.error) return
393
+ const login = { url: null, error: null, done: false }
394
+ this.login = login
395
+ login.promise = this.assertOwnership().then((owned) => {
396
+ if (!owned || this.stopping || this.login !== login || !this.config.enabled) return
397
+ return this.client().up((update) => {
398
+ if (this.login !== login) return
399
+ if (update.AuthURL) login.url = update.AuthURL
400
+ if (update.Error) { login.url = null; login.error = update.Error; this.error = update.Error }
401
+ this.lastStatusAt = 0
402
+ }, force, this.config.hostname)
403
+ }).then(() => {
404
+ login.done = true
405
+ if (this.login === login) this.login = null
406
+ this.lastStatusAt = 0
407
+ }).catch((e) => {
408
+ login.done = true
409
+ if (this.login === login && !e.cancelled) {
410
+ login.url = null
411
+ login.error = this.error = e.message
412
+ }
413
+ })
339
414
  }
340
415
  async logout() {
416
+ if (!(await this.assertOwnership()) || this.stopping) return false
417
+ if (this.api) this.api.cancel()
418
+ this.login = null
341
419
  await this.client().logout()
342
- await this.applyPrefs()
343
- await this.client().start()
420
+ this.error = null
344
421
  this.lastStatus = null
345
422
  this.lastStatusAt = 0
346
423
  this.lastServe = undefined
347
424
  this.feature = null
425
+ this.featureAt = 0
426
+ this.requestLogin()
348
427
  return true
349
428
  }
350
429
 
@@ -354,12 +433,16 @@ class Anywhere {
354
433
  if (!force && this.lastStatus && now - this.lastStatusAt < STATUS_CACHE_MS) {
355
434
  return this.lastStatus
356
435
  }
436
+ const api = this.client()
357
437
  try {
358
- const status = await this.client().status()
438
+ const status = await api.status()
439
+ if (this.api !== api) return null
359
440
  this.lastStatus = status
360
441
  this.lastStatusAt = Date.now()
361
442
  return status
362
443
  } catch (e) {
444
+ if (this.api !== api) return null
445
+ if (!e.cancelled) this.error = e.message
363
446
  this.lastStatus = null
364
447
  this.lastStatusAt = Date.now()
365
448
  return null
@@ -369,30 +452,8 @@ class Anywhere {
369
452
  if (!status || !status.Self || !status.Self.DNSName) return null
370
453
  return String(status.Self.DNSName).replace(/\.$/, '')
371
454
  }
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
455
  async httpsFeature(status) {
456
+ if (!this.config || !this.config.enabled || this.stopping) return null
396
457
  if (!status || status.BackendState !== 'Running') return null
397
458
  if (Array.isArray(status.CertDomains) && status.CertDomains.length > 0) {
398
459
  this.feature = { Complete: true }
@@ -402,12 +463,20 @@ class Anywhere {
402
463
  return this.feature
403
464
  }
404
465
  try {
405
- this.feature = await this.client().queryFeature('serve')
466
+ if (!this.featureQuery) {
467
+ this.featureQuery = this.assertOwnership().then((owned) => {
468
+ if (!owned || this.stopping || !this.config.enabled) return null
469
+ return this.client().queryFeature()
470
+ })
471
+ }
472
+ this.feature = await this.featureQuery
406
473
  this.featureAt = Date.now()
407
474
  } catch (e) {
408
- // e.g. 503 "no netmap" right after login: not an answer yet, ask again next time
475
+ if (!e.cancelled) this.error = e.message
409
476
  this.feature = null
410
477
  this.featureAt = 0
478
+ } finally {
479
+ this.featureQuery = null
411
480
  }
412
481
  return this.feature
413
482
  }
@@ -486,25 +555,22 @@ class Anywhere {
486
555
  const port = Number(url.port)
487
556
  if (seen.has(port)) continue
488
557
  seen.add(port)
489
- targets.push({ key: domain === '@' ? name : `${domain}.${name}`, name, local: port })
558
+ targets.push({ key: domain === '@' ? name : `${domain}.${name}`, name, local: port, origin: url.origin.replace('://0.0.0.0:', '://127.0.0.1:') })
490
559
  }
491
560
  }
492
561
  return targets
493
562
  }
494
563
  // 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.
564
+ // port (kept in ports.json).
496
565
  async refreshTargets() {
497
566
  if (!this.portMap) this.portMap = await loadPortMap(this.portMapPath())
498
567
  const apps = this.appTargets()
499
568
  const { map, changed } = assignPorts(this.portMap, apps.map((app) => app.key))
500
569
  if (changed) {
570
+ await savePortMap(this.portMapPath(), map)
501
571
  this.portMap = map
502
- await savePortMap(this.portMapPath(), map).catch((e) => console.log('[anywhere] could not save ports.json', e.message))
503
572
  }
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
573
+ this.targets = apps.map((app) => Object.assign({}, app, { listen: map[app.key] }))
508
574
  }
509
575
  async desiredServeConfig(status) {
510
576
  const dnsName = this.dnsNameFrom(status)
@@ -513,14 +579,9 @@ class Anywhere {
513
579
  return buildServeConfig({
514
580
  dnsName,
515
581
  serverPort: this.serverPort(),
516
- targets: this.targets.map((t) => ({ listen: t.listen, target: t.local })),
582
+ targets: this.targets.map((t) => ({ listen: t.listen, target: t.local, origin: t.origin })),
517
583
  })
518
584
  }
519
- targetNames() {
520
- const names = {}
521
- for (const t of this.targets) names[t.listen] = t.name
522
- return names
523
- }
524
585
  // true when `host` (a Host header) is this node's own Tailscale name
525
586
  isNodeHost(host) {
526
587
  const dnsName = this.lastStatus ? this.dnsNameFrom(this.lastStatus) : null
@@ -533,28 +594,16 @@ class Anywhere {
533
594
  // href back unchanged.
534
595
  rewriteHref(host, href) {
535
596
  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
597
+ return rewriteHref(href, describeServeConfig(this.lastServe, this.dnsNameFrom(this.lastStatus)))
551
598
  }
552
599
  async rewriteMenu(host, menu) {
553
600
  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(() => {})
601
+ // Confirm new or changed routes on first render; refresh checks unchanged ones.
602
+ const apps = this.appTargets()
603
+ if (this.syncing || this.lastServe === undefined || this.lastServeError || apps.length !== this.targets.length ||
604
+ apps.some((app, i) => app.key !== this.targets[i].key || app.local !== this.targets[i].local || app.origin !== this.targets[i].origin)) {
605
+ await this.sync()
606
+ }
558
607
  const rewrite = (items) => {
559
608
  for (const item of items) {
560
609
  if (!item || typeof item !== 'object') continue
@@ -575,29 +624,42 @@ class Anywhere {
575
624
  // Regenerates the Serve config from the apps' published URLs. Called from
576
625
  // kernel.refresh() (same trigger as router.update()) and after start.
577
626
  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 })
627
+ // Concurrent callers share one operation, with a further pass for new work.
628
+ if (this.syncing) {
629
+ this.syncAgain = true
630
+ return this.syncing
631
+ }
632
+ this.syncing = (async () => {
633
+ let changed = false
634
+ do {
635
+ this.syncAgain = false
636
+ changed = await this.pushServeConfig() || changed
637
+ } while (this.syncAgain)
638
+ return changed
639
+ })().finally(() => { this.syncing = null })
581
640
  return this.syncing
582
641
  }
583
642
  async pushServeConfig() {
584
- if (!this.config || !this.config.enabled) return false
585
- if (!this.pid && !this.api) return false
643
+ if (this.stopping || !this.config || !this.config.enabled) return false
644
+ if (!this.api || this.starting) return false
645
+ if (!(await this.assertOwnership()) || this.stopping) return false
586
646
  const status = await this.refreshStatus(false)
587
647
  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
- }
648
+ const feature = await this.httpsFeature(status)
649
+ if (!feature || !feature.Complete) return false
593
650
  try {
651
+ const desired = await this.desiredServeConfig(status)
652
+ if (this.stopping || !this.config.enabled) return false
653
+ if (!desired) throw new Error('Tailscale did not report this node\'s DNS name.')
594
654
  const current = await this.client().serveConfig()
595
- await this.client().setServeConfig(desired, current.etag)
596
- this.lastServe = desired
655
+ const changed = !sameServeConfig(desired, current)
656
+ const applied = changed ? await this.client().setServeConfig(desired, current) : current
657
+ if (!sameServeConfig(desired, applied)) throw new Error('Tailscale Serve configuration does not match the required routes. Try again.')
658
+ this.lastServe = applied
597
659
  this.lastServeError = null
598
- console.log(`[anywhere] serve config updated: ${Object.keys(desired.TCP).join(', ')}`)
599
- return true
660
+ return changed
600
661
  } catch (e) {
662
+ this.lastServe = undefined
601
663
  this.lastServeError = e && e.message ? e.message : String(e)
602
664
  console.log('[anywhere] serve config update failed', this.lastServeError)
603
665
  return false
@@ -615,19 +677,26 @@ class Anywhere {
615
677
  error: this.error,
616
678
  serveError: this.lastServeError,
617
679
  }
680
+ const inactive = () => Object.assign(base, { enabled: !!this.config.enabled, running: false,
681
+ phase: this.error ? 'error' : this.config.enabled ? 'starting' : 'off', error: this.error })
618
682
  if (!installed) {
619
683
  return Object.assign(base, { phase: 'setup', running: false })
620
684
  }
685
+ if (this.error) return Object.assign(base, { phase: 'error', running: false })
621
686
  if (!enabled) {
622
687
  return Object.assign(base, { phase: 'off', running: false })
623
688
  }
624
689
  if (this.starting) {
625
690
  return Object.assign(base, { phase: 'starting', running: false })
626
691
  }
692
+ const owned = await this.assertOwnership()
693
+ if (!owned || this.stopping || !this.config.enabled) return inactive()
627
694
  const status = await this.refreshStatus(false)
695
+ if (this.stopping || !this.config.enabled) return inactive()
628
696
  if (!status) {
629
697
  return Object.assign(base, {
630
698
  phase: this.error ? 'error' : 'starting',
699
+ error: this.error,
631
700
  running: false,
632
701
  })
633
702
  }
@@ -655,7 +724,8 @@ class Anywhere {
655
724
  })
656
725
  if (backendState === 'NeedsLogin') {
657
726
  out.phase = 'login'
658
- out.loginUrl = await this.ensureLoginUrl(status)
727
+ this.requestLogin()
728
+ out.loginUrl = this.login ? this.login.url : null
659
729
  if (!out.loginUrl) {
660
730
  out.phase = 'starting'
661
731
  }
@@ -666,17 +736,20 @@ class Anywhere {
666
736
  return out
667
737
  }
668
738
  if (backendState === 'Stopped') {
669
- await this.ensureRunningPrefs(status)
739
+ this.requestLogin()
670
740
  out.phase = 'starting'
671
741
  return out
672
742
  }
673
743
  if (backendState !== 'Running') {
674
- out.phase = 'starting'
744
+ out.phase = this.login && !this.login.done ? 'starting' : 'error'
745
+ if (out.phase === 'error') out.error = `Unexpected Tailscale state: ${backendState}`
675
746
  return out
676
747
  }
677
748
  const feature = await this.httpsFeature(status)
749
+ if (this.stopping || !this.config.enabled) return inactive()
678
750
  if (!feature) {
679
- out.phase = 'starting'
751
+ out.phase = 'error'
752
+ out.error = this.error || 'Could not query HTTPS approval.'
680
753
  return out
681
754
  }
682
755
  if (!feature.Complete) {
@@ -687,9 +760,12 @@ class Anywhere {
687
760
  }
688
761
  return out
689
762
  }
690
- out.phase = 'ready'
691
- const serve = this.lastServe !== undefined ? this.lastServe : await this.desiredServeConfig(status)
692
- out.serve = describeServeConfig(serve, dnsName, this.targetNames())
763
+ await this.sync()
764
+ if (this.stopping || !this.config.enabled) return inactive()
765
+ out.phase = this.error || this.lastServeError || this.lastServe === undefined || !this.lastStatus || this.lastStatus.BackendState !== 'Running' ? 'error' : 'ready'
766
+ if (out.phase === 'error') out.error = this.error || this.lastServeError || 'Routes are not confirmed'
767
+ out.serveError = this.lastServeError
768
+ out.serve = describeServeConfig(this.lastServe, dnsName, Object.fromEntries(this.targets.map(target => [target.listen, target.name])))
693
769
  return out
694
770
  }
695
771
  }