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.
- package/kernel/anywhere/index.js +698 -0
- package/kernel/anywhere/localapi.js +147 -0
- package/kernel/anywhere/ports.js +51 -0
- package/kernel/anywhere/serve.js +95 -0
- package/kernel/bin/setup.js +29 -0
- package/kernel/bin/tailscaled.js +71 -0
- package/kernel/index.js +15 -0
- package/package.json +1 -1
- package/server/index.js +123 -0
- package/server/public/anywhere.webmanifest +1 -0
- package/server/views/anywhere.ejs +436 -0
- package/server/views/app.ejs +3 -1
- package/server/views/index.ejs +6 -0
- package/server/views/partials/main_sidebar.ejs +39 -0
- package/test/anywhere.test.js +443 -0
|
@@ -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
|