pinokiod 8.2.2 → 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,443 @@
|
|
|
1
|
+
const assert = require('node:assert/strict')
|
|
2
|
+
const test = require('node:test')
|
|
3
|
+
const http = require('node:http')
|
|
4
|
+
const os = require('node:os')
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
|
|
8
|
+
const { buildServeConfig, describeServeConfig, sameServeConfig } = require('../kernel/anywhere/serve')
|
|
9
|
+
const { assignPorts } = require('../kernel/anywhere/ports')
|
|
10
|
+
const { LocalAPI } = require('../kernel/anywhere/localapi')
|
|
11
|
+
const Anywhere = require('../kernel/anywhere')
|
|
12
|
+
const { spawn } = require('node:child_process')
|
|
13
|
+
const Tailscaled = require('../kernel/bin/tailscaled')
|
|
14
|
+
const Setup = require('../kernel/bin/setup')
|
|
15
|
+
|
|
16
|
+
test('serve config maps pinokiod to 443 and each app to its stable remote port', () => {
|
|
17
|
+
const config = buildServeConfig({
|
|
18
|
+
dnsName: 'pinokio.tail1234.ts.net.',
|
|
19
|
+
serverPort: 42000,
|
|
20
|
+
targets: [{ listen: 43001, target: 8188 }, { listen: 43000, target: 7860 }, { listen: 43000, target: 9999 }, { listen: 443, target: 1 }, { listen: 'nope', target: 1 }, { listen: 43002, target: 70000 }],
|
|
21
|
+
})
|
|
22
|
+
assert.deepEqual(Object.keys(config.TCP), ['443', '43000', '43001'])
|
|
23
|
+
assert.equal(config.TCP['43000'].HTTPS, true)
|
|
24
|
+
assert.equal(config.Web['pinokio.tail1234.ts.net:443'].Handlers['/'].Proxy, 'http://127.0.0.1:42000')
|
|
25
|
+
assert.equal(config.Web['pinokio.tail1234.ts.net:43000'].Handlers['/'].Proxy, 'http://127.0.0.1:7860')
|
|
26
|
+
assert.equal(config.Web['pinokio.tail1234.ts.net:43001'].Handlers['/'].Proxy, 'http://127.0.0.1:8188')
|
|
27
|
+
assert.equal(config.AllowFunnel, undefined, 'serve-only: never emits funnel entries')
|
|
28
|
+
|
|
29
|
+
const described = describeServeConfig(config, 'pinokio.tail1234.ts.net', { 43000: 'comfy' })
|
|
30
|
+
assert.deepEqual(described.map((d) => [d.url, d.local, d.name]), [
|
|
31
|
+
['https://pinokio.tail1234.ts.net', 42000, null],
|
|
32
|
+
['https://pinokio.tail1234.ts.net:43000', 7860, 'comfy'],
|
|
33
|
+
['https://pinokio.tail1234.ts.net:43001', 8188, null],
|
|
34
|
+
])
|
|
35
|
+
const same = buildServeConfig({ dnsName: 'pinokio.tail1234.ts.net', serverPort: 42000, targets: [{ listen: 43000, target: 7860 }, { listen: 43001, target: 8188 }] })
|
|
36
|
+
assert.equal(sameServeConfig(config, same), true, 'order of targets does not matter')
|
|
37
|
+
const relaunched = buildServeConfig({ dnsName: 'pinokio.tail1234.ts.net', serverPort: 42000, targets: [{ listen: 43000, target: 7861 }, { listen: 43001, target: 8188 }] })
|
|
38
|
+
assert.equal(sameServeConfig(config, relaunched), false, 'a new local port changes the mapping but not the address')
|
|
39
|
+
assert.equal(describeServeConfig(relaunched, 'pinokio.tail1234.ts.net')[1].url, 'https://pinokio.tail1234.ts.net:43000')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('stable ports are allocated once, lowest free first, and never move', () => {
|
|
43
|
+
let r = assignPorts({}, ['comfy', 'forge'])
|
|
44
|
+
assert.deepEqual(r.map, { comfy: 43000, forge: 43001 })
|
|
45
|
+
assert.equal(r.changed, true)
|
|
46
|
+
r = assignPorts(r.map, ['forge', 'webui'])
|
|
47
|
+
assert.deepEqual(r.map, { comfy: 43000, forge: 43001, webui: 43002 })
|
|
48
|
+
r = assignPorts(r.map, ['forge'])
|
|
49
|
+
assert.equal(r.changed, false, 'known apps need no write')
|
|
50
|
+
assert.equal(assignPorts({ a: 43000, b: 43002 }, ['c']).map.c, 43001, 'gaps are reused')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('anywhere preset requires tailscaled but not caddy', () => {
|
|
54
|
+
const preset = Setup.anywhere({})
|
|
55
|
+
const names = preset.requirements.map((r) => r.name)
|
|
56
|
+
assert.ok(names.includes('tailscaled'))
|
|
57
|
+
assert.ok(!names.includes('caddy'))
|
|
58
|
+
assert.ok(preset.conda_requirements.includes('tailscaled'))
|
|
59
|
+
assert.ok(!preset.conda_requirements.includes('caddy'))
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('tailscaled bin module pins 1.104.1 and checks the installed version', async () => {
|
|
63
|
+
const mod = new Tailscaled()
|
|
64
|
+
assert.equal(mod.cmd(), 'tailscaled=1.104.1')
|
|
65
|
+
mod.kernel = {
|
|
66
|
+
platform: 'darwin',
|
|
67
|
+
bin: {
|
|
68
|
+
installed: { conda: new Set(['tailscaled']), conda_versions: { tailscaled: '1.104.1' } },
|
|
69
|
+
path: (...segments) => segments.join('/'),
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
assert.equal(await mod.installed(), true)
|
|
73
|
+
assert.equal(mod.binPath(), 'miniforge/bin/tailscaled')
|
|
74
|
+
mod.kernel.bin.installed.conda_versions.tailscaled = '1.98.5'
|
|
75
|
+
assert.equal(await mod.installed(), false, 'older than 1.104 (no --windows-mode=dev) is not accepted')
|
|
76
|
+
mod.kernel.bin.installed.conda = new Set()
|
|
77
|
+
assert.equal(await mod.installed(), false)
|
|
78
|
+
mod.kernel.platform = 'win32'
|
|
79
|
+
assert.equal(mod.binPath(), 'miniforge/Library/bin/tailscaled.exe')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
function withMockDaemon(handler, fn) {
|
|
83
|
+
const socketPath = path.join(os.tmpdir(), `pk-ts-test-${process.pid}-${Date.now()}.sock`)
|
|
84
|
+
const server = http.createServer(handler)
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
server.listen(socketPath, async () => {
|
|
87
|
+
try {
|
|
88
|
+
resolve(await fn(socketPath))
|
|
89
|
+
} catch (e) {
|
|
90
|
+
reject(e)
|
|
91
|
+
} finally {
|
|
92
|
+
server.close()
|
|
93
|
+
fs.rmSync(socketPath, { force: true })
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test('LocalAPI client talks plain HTTP over the socket with the Sec-Tailscale header', { skip: process.platform === 'win32' }, async () => {
|
|
100
|
+
const seen = []
|
|
101
|
+
await withMockDaemon((req, res) => {
|
|
102
|
+
let body = ''
|
|
103
|
+
req.on('data', (c) => body += c)
|
|
104
|
+
req.on('end', () => {
|
|
105
|
+
seen.push({ method: req.method, url: req.url, sec: req.headers['sec-tailscale'], ifMatch: req.headers['if-match'], body })
|
|
106
|
+
if (req.url === '/localapi/v0/status') {
|
|
107
|
+
res.setHeader('Content-Type', 'application/json')
|
|
108
|
+
res.end(JSON.stringify({ BackendState: 'Running', Self: { DNSName: 'pinokio.tail1.ts.net.' } }))
|
|
109
|
+
} else if (req.url === '/localapi/v0/serve-config' && req.method === 'GET') {
|
|
110
|
+
res.setHeader('Etag', 'abc')
|
|
111
|
+
res.end('null')
|
|
112
|
+
} else if (req.url === '/localapi/v0/serve-config' && req.method === 'POST') {
|
|
113
|
+
res.statusCode = 200
|
|
114
|
+
res.end()
|
|
115
|
+
} else if (req.url === '/localapi/v0/login-interactive') {
|
|
116
|
+
res.statusCode = 204
|
|
117
|
+
res.end()
|
|
118
|
+
} else {
|
|
119
|
+
res.statusCode = 404
|
|
120
|
+
res.end('nope')
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
}, async (socketPath) => {
|
|
124
|
+
const api = new LocalAPI({ socketPath })
|
|
125
|
+
const status = await api.status()
|
|
126
|
+
assert.equal(status.BackendState, 'Running')
|
|
127
|
+
const { etag } = await api.serveConfig()
|
|
128
|
+
assert.equal(etag, 'abc')
|
|
129
|
+
await api.setServeConfig({ TCP: {} }, etag)
|
|
130
|
+
await api.loginInteractive()
|
|
131
|
+
await assert.rejects(() => api.expect('GET', '/missing'), /404/)
|
|
132
|
+
})
|
|
133
|
+
const post = seen.find((s) => s.method === 'POST' && s.url === '/localapi/v0/serve-config')
|
|
134
|
+
assert.equal(post.sec, 'localapi')
|
|
135
|
+
assert.equal(post.ifMatch, 'abc')
|
|
136
|
+
assert.equal(post.body, JSON.stringify({ TCP: {} }))
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
function fakeKernel(tmp, { installed = true } = {}) {
|
|
140
|
+
return {
|
|
141
|
+
platform: 'darwin',
|
|
142
|
+
homedir: tmp,
|
|
143
|
+
server_port: 42000,
|
|
144
|
+
path: (...segments) => path.join(tmp, ...segments),
|
|
145
|
+
processes: {},
|
|
146
|
+
memory: { local: {} },
|
|
147
|
+
bin: {
|
|
148
|
+
path: (...segments) => path.join(tmp, 'bin', ...segments),
|
|
149
|
+
mod: { tailscaled: { installed: async () => installed, binPath: () => path.join(tmp, 'bin', 'tailscaled') } },
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
test('anywhere snapshot walks setup -> off -> login -> https -> ready', async () => {
|
|
155
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
156
|
+
try {
|
|
157
|
+
const kernel = fakeKernel(tmp, { installed: false })
|
|
158
|
+
const anywhere = new Anywhere(kernel)
|
|
159
|
+
assert.equal((await anywhere.snapshot()).phase, 'setup')
|
|
160
|
+
|
|
161
|
+
kernel.bin.mod.tailscaled.installed = async () => true
|
|
162
|
+
assert.equal((await anywhere.snapshot()).phase, 'off')
|
|
163
|
+
|
|
164
|
+
await anywhere.saveConfig({ enabled: true })
|
|
165
|
+
assert.equal(await anywhere.configured(), true)
|
|
166
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(tmp, 'anywhere', 'config.json'), 'utf8')).enabled, true)
|
|
167
|
+
|
|
168
|
+
// stub the daemon
|
|
169
|
+
let status = { BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/abc', Self: {}, Peer: null, User: null }
|
|
170
|
+
let loginRequests = 0
|
|
171
|
+
let pushed = null
|
|
172
|
+
anywhere.pid = 123
|
|
173
|
+
anywhere.api = {
|
|
174
|
+
status: async () => status,
|
|
175
|
+
loginInteractive: async () => { loginRequests += 1 },
|
|
176
|
+
queryFeature: async () => ({ Complete: false, Text: 'approve', URL: 'https://login.tailscale.com/f/serve?node=1' }),
|
|
177
|
+
serveConfig: async () => ({ config: null, etag: 'e1' }),
|
|
178
|
+
setServeConfig: async (config) => { pushed = config },
|
|
179
|
+
}
|
|
180
|
+
let snap = await anywhere.snapshot()
|
|
181
|
+
assert.equal(snap.phase, 'login')
|
|
182
|
+
assert.equal(snap.loginUrl, 'https://login.tailscale.com/a/abc')
|
|
183
|
+
assert.equal(loginRequests, 0, 'uses the AuthURL the daemon already has')
|
|
184
|
+
|
|
185
|
+
anywhere.lastStatusAt = 0
|
|
186
|
+
status = { BackendState: 'NeedsLogin', AuthURL: '', Self: {}, Peer: null, User: null }
|
|
187
|
+
snap = await anywhere.snapshot()
|
|
188
|
+
assert.equal(loginRequests, 1, 'asks the daemon for a login URL when it has none')
|
|
189
|
+
|
|
190
|
+
anywhere.lastStatusAt = 0
|
|
191
|
+
status = {
|
|
192
|
+
BackendState: 'Running',
|
|
193
|
+
Version: '1.104.1',
|
|
194
|
+
TailscaleIPs: ['100.1.2.3'],
|
|
195
|
+
CertDomains: null,
|
|
196
|
+
Self: { DNSName: 'pinokio.tail1.ts.net.', UserID: 7, KeyExpiry: '2027-04-20T04:33:03Z' },
|
|
197
|
+
User: { '7': { LoginName: 'me@example.com', DisplayName: 'Me' } },
|
|
198
|
+
Peer: { a: { HostName: 'iphone', OS: 'iOS', Online: true, DNSName: 'iphone.tail1.ts.net.', TailscaleIPs: ['100.1.2.4'], LastSeen: '0001-01-01T00:00:00Z' } },
|
|
199
|
+
CurrentTailnet: { Name: 'me@example.com', MagicDNSSuffix: 'tail1.ts.net', MagicDNSEnabled: true },
|
|
200
|
+
}
|
|
201
|
+
snap = await anywhere.snapshot()
|
|
202
|
+
assert.equal(snap.phase, 'https')
|
|
203
|
+
assert.equal(snap.approval.url, 'https://login.tailscale.com/f/serve?node=1')
|
|
204
|
+
assert.equal(snap.address, 'https://pinokio.tail1.ts.net')
|
|
205
|
+
|
|
206
|
+
anywhere.lastStatusAt = 0
|
|
207
|
+
anywhere.featureAt = 0
|
|
208
|
+
status.CertDomains = ['pinokio.tail1.ts.net']
|
|
209
|
+
kernel.memory.local = { [path.join(tmp, 'api', 'comfy', 'start.json')]: { url: 'http://localhost:7860', other: 'not a url' } }
|
|
210
|
+
snap = await anywhere.snapshot()
|
|
211
|
+
assert.equal(snap.phase, 'ready')
|
|
212
|
+
assert.equal(snap.account.email, 'me@example.com')
|
|
213
|
+
assert.equal(snap.keyExpiry, '2027-04-20T04:33:03Z')
|
|
214
|
+
assert.equal(snap.mobilePeerPresent, true)
|
|
215
|
+
assert.equal(snap.mobilePeerOnline, true)
|
|
216
|
+
assert.equal(snap.connectUrl, 'https://pinokio.co/anywhere/connect#u=https%3A%2F%2Fpinokio.tail1.ts.net&a=me%40example.com')
|
|
217
|
+
assert.deepEqual(snap.serve.map((s) => [s.url, s.local, s.name]), [['https://pinokio.tail1.ts.net', 42000, null], ['https://pinokio.tail1.ts.net:43000', 7860, 'comfy']])
|
|
218
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(tmp, 'anywhere', 'ports.json'), 'utf8')), { comfy: 43000 }, 'stable port persisted')
|
|
219
|
+
|
|
220
|
+
// menu links are rewritten only for requests addressed to the node itself
|
|
221
|
+
const menu = () => [
|
|
222
|
+
{ href: 'http://localhost:7860/?x=1#t', src: 'http://localhost:7860/?x=1#t', target_full: 'http://localhost:7860/?x=1#t', menu: [{ href: 'http://127.0.0.1:42000/home' }] },
|
|
223
|
+
{ href: 'http://localhost:9999/' },
|
|
224
|
+
{ href: '/files/api/comfy' },
|
|
225
|
+
{ href: 'https://example.com:7860/' },
|
|
226
|
+
]
|
|
227
|
+
for (const host of ['localhost:42000', '192.168.1.5:42000', 'pinokio.p5.localhost', undefined]) {
|
|
228
|
+
assert.deepEqual(await anywhere.rewriteMenu(host, menu()), menu(), `untouched for host ${host}`)
|
|
229
|
+
}
|
|
230
|
+
for (const host of ['pinokio.tail1.ts.net', 'PINOKIO.TAIL1.TS.NET:443']) {
|
|
231
|
+
const rewritten = await anywhere.rewriteMenu(host, menu())
|
|
232
|
+
assert.equal(rewritten[0].href, 'https://pinokio.tail1.ts.net:43000/?x=1#t')
|
|
233
|
+
assert.equal(rewritten[0].src, 'https://pinokio.tail1.ts.net:43000/?x=1#t')
|
|
234
|
+
assert.equal(rewritten[0].target_full, 'https://pinokio.tail1.ts.net:43000/?x=1#t', 'the popout target follows the href')
|
|
235
|
+
assert.equal(rewritten[0].menu[0].href, 'https://pinokio.tail1.ts.net/home', 'pinokiod itself maps to the bare address')
|
|
236
|
+
assert.equal(rewritten[1].href, 'http://localhost:9999/', 'unpublished ports are left alone')
|
|
237
|
+
assert.equal(rewritten[2].href, '/files/api/comfy')
|
|
238
|
+
assert.equal(rewritten[3].href, 'https://example.com:7860/')
|
|
239
|
+
}
|
|
240
|
+
// sync pushes once, then only on changes
|
|
241
|
+
assert.equal(await anywhere.sync(), true)
|
|
242
|
+
assert.deepEqual(Object.keys(pushed.TCP), ['443', '43000'])
|
|
243
|
+
pushed = null
|
|
244
|
+
assert.equal(await anywhere.sync(), false)
|
|
245
|
+
assert.equal(pushed, null)
|
|
246
|
+
|
|
247
|
+
// an app that just published its URL gets its stable port at render time
|
|
248
|
+
// and its route is pushed right away, without waiting for the next sync tick
|
|
249
|
+
kernel.memory.local[path.join(tmp, 'api', 'forge', 'start.json')] = { url: 'http://127.0.0.1:8188' }
|
|
250
|
+
const fresh = await anywhere.rewriteMenu('pinokio.tail1.ts.net', [{ href: 'http://127.0.0.1:8188/' }])
|
|
251
|
+
assert.equal(fresh[0].href, 'https://pinokio.tail1.ts.net:43001/')
|
|
252
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(tmp, 'anywhere', 'ports.json'), 'utf8')), { comfy: 43000, forge: 43001 })
|
|
253
|
+
await anywhere.syncing
|
|
254
|
+
assert.deepEqual(Object.keys(pushed.TCP), ['443', '43000', '43001'], 'route pushed at render time')
|
|
255
|
+
pushed = null
|
|
256
|
+
assert.equal(await anywhere.sync(), false)
|
|
257
|
+
kernel.memory.local = {}
|
|
258
|
+
assert.equal(await anywhere.sync(), true, 'stopped app removes its entry')
|
|
259
|
+
assert.deepEqual(Object.keys(pushed.TCP), ['443'])
|
|
260
|
+
} finally {
|
|
261
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('anywhere uses dev mode flags on windows and a short socket path on long homes', () => {
|
|
266
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
267
|
+
try {
|
|
268
|
+
const kernel = fakeKernel(tmp)
|
|
269
|
+
kernel.platform = 'win32'
|
|
270
|
+
const win = new Anywhere(kernel)
|
|
271
|
+
assert.ok(win.daemonArgs().includes('--windows-mode=dev'))
|
|
272
|
+
assert.ok(win.daemonArgs().includes('--tun=userspace-networking'))
|
|
273
|
+
assert.equal(win.socketPath(), '\\\\.\\pipe\\pinokio-tailscaled')
|
|
274
|
+
|
|
275
|
+
const longHome = path.join(tmp, 'x'.repeat(120))
|
|
276
|
+
const deep = new Anywhere(fakeKernel(longHome))
|
|
277
|
+
assert.ok(Buffer.byteLength(deep.socketPath()) <= 100, deep.socketPath())
|
|
278
|
+
assert.ok(deep.socketPath().startsWith(os.tmpdir()))
|
|
279
|
+
const shallow = new Anywhere(fakeKernel('/Users/me/pinokio'))
|
|
280
|
+
assert.equal(shallow.socketPath(), '/Users/me/pinokio/anywhere/tailscaled/tailscaled.sock')
|
|
281
|
+
} finally {
|
|
282
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('while Anywhere is off, sync and the menu rewrite are no-ops and never touch the daemon', async () => {
|
|
287
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
288
|
+
try {
|
|
289
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
290
|
+
anywhere.api = new Proxy({}, { get() { throw new Error('LocalAPI must not be called while Anywhere is off') } })
|
|
291
|
+
assert.equal(await anywhere.sync(), false)
|
|
292
|
+
await anywhere.loadConfig()
|
|
293
|
+
assert.equal(await anywhere.sync(), false)
|
|
294
|
+
const menu = [{ href: 'http://localhost:7860/' }, { href: 'http://127.0.0.1:42000/home' }]
|
|
295
|
+
assert.deepEqual(await anywhere.rewriteMenu('pinokio.tail1.ts.net', JSON.parse(JSON.stringify(menu))), menu)
|
|
296
|
+
} finally {
|
|
297
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('serve targets follow the Home Server router rules for an app\'s URL variables', () => {
|
|
302
|
+
const tmp = path.join(os.tmpdir(), 'pk-home')
|
|
303
|
+
const kernel = fakeKernel(tmp)
|
|
304
|
+
kernel.router = {
|
|
305
|
+
localhost_variable_router: {
|
|
306
|
+
domain(name, key) {
|
|
307
|
+
if (name === 'comfy') return key === 'url' ? '@' : (key === 'api_url' ? 'api' : undefined)
|
|
308
|
+
throw new Error('no pinokio config for ' + name) // what Processor.domain does for an unknown app
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
kernel.memory.local = {
|
|
313
|
+
[path.join(tmp, 'api', 'comfy', 'start.json')]: {
|
|
314
|
+
url: 'http://localhost:7860',
|
|
315
|
+
api_url: 'http://127.0.0.1:7861',
|
|
316
|
+
other: 'http://localhost:7862', // not dns-mapped
|
|
317
|
+
remote: 'http://192.168.1.9:7863', // not loopback: cannot be proxied
|
|
318
|
+
noport: 'http://localhost',
|
|
319
|
+
count: 3,
|
|
320
|
+
},
|
|
321
|
+
[path.join(tmp, 'api', 'nocfg', 'start.json')]: { url: 'http://localhost:8000', other: 'http://localhost:8001' },
|
|
322
|
+
['/elsewhere/start.json']: { url: 'http://localhost:9000' }, // outside ~/pinokio/api
|
|
323
|
+
}
|
|
324
|
+
const anywhere = new Anywhere(kernel)
|
|
325
|
+
assert.deepEqual(anywhere.appTargets(), [ // `url` first, then the other keys alphabetically
|
|
326
|
+
{ key: 'comfy', name: 'comfy', local: 7860 },
|
|
327
|
+
{ key: 'api.comfy', name: 'comfy', local: 7861 },
|
|
328
|
+
{ key: 'nocfg', name: 'nocfg', local: 8000 },
|
|
329
|
+
])
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test('a failed https feature query keeps the page in starting instead of showing an approval', async () => {
|
|
333
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
334
|
+
try {
|
|
335
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
336
|
+
await anywhere.saveConfig({ enabled: true })
|
|
337
|
+
anywhere.pid = 123
|
|
338
|
+
let feature = () => { throw new Error('503 no netmap') }
|
|
339
|
+
anywhere.api = {
|
|
340
|
+
status: async () => ({ BackendState: 'Running', CertDomains: null, Self: { DNSName: 'pinokio.tail1.ts.net.', UserID: 7 }, User: {}, Peer: null }),
|
|
341
|
+
queryFeature: async () => feature(),
|
|
342
|
+
}
|
|
343
|
+
let snap = await anywhere.snapshot()
|
|
344
|
+
assert.equal(snap.phase, 'starting')
|
|
345
|
+
assert.equal(snap.approval, null)
|
|
346
|
+
feature = () => ({ Complete: false, Text: 'approve', URL: 'https://login.tailscale.com/f/serve?node=1' })
|
|
347
|
+
anywhere.lastStatusAt = 0
|
|
348
|
+
snap = await anywhere.snapshot()
|
|
349
|
+
assert.equal(snap.phase, 'https')
|
|
350
|
+
assert.equal(snap.approval.url, 'https://login.tailscale.com/f/serve?node=1')
|
|
351
|
+
} finally {
|
|
352
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
// a stand-in daemon process we can safely signal
|
|
357
|
+
function spawnIdle() {
|
|
358
|
+
const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { stdio: 'ignore' }) // self-exits if a run is cut short
|
|
359
|
+
const exited = new Promise((resolve) => child.on('exit', () => resolve(true)))
|
|
360
|
+
return { child, exited: (ms = 3000) => Promise.race([exited, new Promise((resolve) => setTimeout(() => resolve(false), ms))]) }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
test('adopting a daemon left by a previous run recovers its pid from the pid file', async () => {
|
|
364
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
365
|
+
const idle = spawnIdle()
|
|
366
|
+
try {
|
|
367
|
+
const kernel = fakeKernel(tmp)
|
|
368
|
+
const anywhere = new Anywhere(kernel)
|
|
369
|
+
fs.mkdirSync(anywhere.stateDir(), { recursive: true })
|
|
370
|
+
const dead = spawnIdle(); dead.child.kill('SIGKILL'); await dead.exited()
|
|
371
|
+
fs.writeFileSync(anywhere.pidPath(), String(dead.child.pid))
|
|
372
|
+
assert.equal(anywhere.readPid(), null, 'a pid that no longer exists is ignored')
|
|
373
|
+
fs.writeFileSync(anywhere.pidPath(), String(idle.child.pid))
|
|
374
|
+
await anywhere.saveConfig({ enabled: true })
|
|
375
|
+
anywhere.api = {
|
|
376
|
+
ping: async () => true,
|
|
377
|
+
patchPrefs: async () => ({}),
|
|
378
|
+
status: async () => ({ BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/abc', Self: {}, User: null, Peer: null }),
|
|
379
|
+
}
|
|
380
|
+
await anywhere.start()
|
|
381
|
+
assert.equal(anywhere.pid, idle.child.pid)
|
|
382
|
+
} finally {
|
|
383
|
+
idle.child.kill('SIGKILL')
|
|
384
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
385
|
+
}
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
test('a daemon that cannot be spawned reports the cause at once instead of waiting 20 s', async () => {
|
|
390
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
391
|
+
try {
|
|
392
|
+
const kernel = fakeKernel(tmp)
|
|
393
|
+
kernel.bin.mod.tailscaled.binPath = () => path.join(tmp, 'no-such-tailscaled')
|
|
394
|
+
const anywhere = new Anywhere(kernel)
|
|
395
|
+
await anywhere.saveConfig({ enabled: true })
|
|
396
|
+
const started = Date.now()
|
|
397
|
+
await assert.rejects(() => anywhere.start(), /failed to start/)
|
|
398
|
+
assert.ok(Date.now() - started < 5000, 'did not sit out the 20 s daemon timeout')
|
|
399
|
+
assert.equal((await anywhere.snapshot()).phase, 'error')
|
|
400
|
+
assert.equal(fs.existsSync(anywhere.pidPath()), false)
|
|
401
|
+
} finally {
|
|
402
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
403
|
+
}
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
test('sign-out resets the login state, and a link the server rejected is replaced instead of shown', async () => {
|
|
407
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
408
|
+
try {
|
|
409
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
410
|
+
await anywhere.saveConfig({ enabled: true })
|
|
411
|
+
anywhere.pid = 123
|
|
412
|
+
const calls = []
|
|
413
|
+
let status = { BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/dead', Self: {}, User: null, Peer: null,
|
|
414
|
+
Health: ['You are logged out. The last login error was: register request: http 410: auth path not found'] }
|
|
415
|
+
anywhere.api = {
|
|
416
|
+
status: async () => status,
|
|
417
|
+
logout: async () => { calls.push('logout') },
|
|
418
|
+
patchPrefs: async (p) => { calls.push('prefs:' + (p.LoggedOut === false ? 'login' : 'other')); return {} },
|
|
419
|
+
start: async () => { calls.push('start') },
|
|
420
|
+
loginInteractive: async () => { calls.push('login-interactive') },
|
|
421
|
+
}
|
|
422
|
+
await anywhere.logout()
|
|
423
|
+
assert.deepEqual(calls, ['logout', 'prefs:login', 'start'], 'sign-out: logout, then back to logged-in prefs, then reset')
|
|
424
|
+
|
|
425
|
+
calls.length = 0
|
|
426
|
+
let snap = await anywhere.snapshot()
|
|
427
|
+
assert.equal(snap.loginUrl, null, 'the rejected link is not offered')
|
|
428
|
+
assert.equal(snap.phase, 'starting')
|
|
429
|
+
assert.deepEqual(calls, ['start', 'login-interactive'], 'a rejected link triggers a reset and a fresh request')
|
|
430
|
+
|
|
431
|
+
// after the reset the daemon keeps a stale "last login error: ... context canceled" line; that is not a rejection
|
|
432
|
+
anywhere.lastStatusAt = 0
|
|
433
|
+
calls.length = 0
|
|
434
|
+
status = { BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/fresh', Self: {}, User: null, Peer: null,
|
|
435
|
+
Health: ['You are logged out. The last login error was: fetch control key: Get "https://controlplane.tailscale.com/key?v=148": context canceled'] }
|
|
436
|
+
snap = await anywhere.snapshot()
|
|
437
|
+
assert.equal(snap.loginUrl, 'https://login.tailscale.com/a/fresh', 'a fresh link is offered as is')
|
|
438
|
+
assert.equal(snap.phase, 'login')
|
|
439
|
+
assert.deepEqual(calls, [], 'no reset loop on a stale error line')
|
|
440
|
+
} finally {
|
|
441
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
442
|
+
}
|
|
443
|
+
})
|