pinokiod 8.2.10 → 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.
- package/kernel/anywhere/cli.js +100 -0
- package/kernel/anywhere/index.js +236 -160
- package/kernel/anywhere/ports.js +6 -9
- package/kernel/anywhere/serve.js +17 -7
- package/kernel/anywhere/state.js +14 -0
- package/kernel/bin/conda-meta.js +19 -1
- package/kernel/bin/conda-pins.js +7 -3
- package/kernel/bin/conda.js +67 -15
- package/kernel/bin/index.js +5 -2
- package/kernel/bin/setup.js +3 -17
- package/kernel/bin/tailscaled.js +23 -13
- package/kernel/index.js +11 -9
- package/kernel/shell.js +14 -6
- package/kernel/util.js +1 -0
- package/package.json +1 -1
- package/server/index.js +15 -19
- package/server/public/universal-launcher.js +5 -0
- package/server/views/anywhere.ejs +13 -13
- package/server/views/partials/main_sidebar.ejs +0 -1
- package/server/views/remote.ejs +12 -4
- package/system/pip_seed.json +6 -0
- package/system/repair_pip_seed.py +100 -0
- package/test/anywhere-remote.test.js +182 -8
- package/test/anywhere.test.js +1051 -131
- package/test/conda-bin.test.js +254 -4
- package/test/pip_seed_test.py +183 -0
- package/test/shell-run-template.test.js +147 -140
- package/test/tailscale-cli.test.js +239 -0
- package/kernel/anywhere/localapi.js +0 -147
package/kernel/anywhere/index.js
CHANGED
|
@@ -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
|
|
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
|
|
24
|
-
const
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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().
|
|
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
|
|
201
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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.
|
|
233
|
-
this.
|
|
234
|
-
|
|
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
|
-
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
304
|
-
const pid = this.pid || this.readPid()
|
|
361
|
+
this.error = null
|
|
362
|
+
this.lastServeError = null
|
|
305
363
|
this.child = null
|
|
306
|
-
this.
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
this.
|
|
336
|
-
|
|
337
|
-
this.
|
|
338
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
//
|
|
579
|
-
if (this.syncing)
|
|
580
|
-
|
|
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.
|
|
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
|
|
589
|
-
if (!
|
|
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
|
-
|
|
596
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = '
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
out.
|
|
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
|
}
|