pinokiod 8.2.33 → 8.2.35
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 +10 -59
- package/kernel/anywhere/serve.js +5 -21
- package/package.json +1 -1
- package/server/index.js +1 -154
- package/server/views/anywhere.ejs +30 -111
- package/test/anywhere-onboarding.test.js +15 -13
- package/kernel/anywhere/forward.js +0 -171
- package/test/anywhere-forward.test.js +0 -168
- package/test/anywhere-others.test.js +0 -96
- package/test/anywhere-peers.test.js +0 -223
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
const assert = require('node:assert/strict')
|
|
2
|
-
const test = require('node:test')
|
|
3
|
-
const Server = require('../server/index.js')
|
|
4
|
-
const Anywhere = require('../kernel/anywhere')
|
|
5
|
-
const { buildServeConfig } = require('../kernel/anywhere/serve')
|
|
6
|
-
|
|
7
|
-
// The peer routes (spec/requirements/pinokio-anywhere-api.md, Peers) and the
|
|
8
|
-
// forwarded link rewrite, with the proxy request and the forwarders stubbed.
|
|
9
|
-
|
|
10
|
-
const status = {
|
|
11
|
-
Self: { DNSName: 'pinokio.tail1.ts.net.' },
|
|
12
|
-
Peer: {
|
|
13
|
-
a: { HostName: 'STUDIO', DNSName: 'pinokio-1.tail1.ts.net.', OS: 'windows', Online: true, TailscaleIPs: ['100.1.1.1'] },
|
|
14
|
-
b: { HostName: 'iphone', DNSName: 'iphone-14-pro.tail1.ts.net.', OS: 'iOS', Online: true },
|
|
15
|
-
c: { HostName: 'asleep', DNSName: 'asleep.tail1.ts.net.', OS: 'macOS', Online: false },
|
|
16
|
-
d: { HostName: 'other', DNSName: 'other.tail1.ts.net.', OS: 'linux', Online: true },
|
|
17
|
-
},
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function anywhereWith(requests = {}, { proxy = true } = {}) {
|
|
21
|
-
const anywhere = new Anywhere({})
|
|
22
|
-
anywhere.config = { enabled: true }
|
|
23
|
-
anywhere.lastStatus = status
|
|
24
|
-
anywhere.proxyPort = proxy ? 1055 : null
|
|
25
|
-
anywhere.forwarders.request = async (url, options) => {
|
|
26
|
-
const handler = requests[url]
|
|
27
|
-
if (!handler) throw new Error(`no route to ${url}`)
|
|
28
|
-
return handler(options)
|
|
29
|
-
}
|
|
30
|
-
anywhere.forwarders.open = async (dnsName, port) => 51000 + (port % 1000)
|
|
31
|
-
return anywhere
|
|
32
|
-
}
|
|
33
|
-
const json = (body, code = 200) => async () => ({ status: code, headers: {}, body: JSON.stringify(body) })
|
|
34
|
-
const fake = (anywhere) => ({ kernel: { anywhere }, anywherePeerOpenPath: Server.prototype.anywherePeerOpenPath, anywherePeerUnavailable: Server.prototype.anywherePeerUnavailable })
|
|
35
|
-
|
|
36
|
-
test('peers lists the online computers that answered a status, with apps linked through the open route', async () => {
|
|
37
|
-
const anywhere = anywhereWith({
|
|
38
|
-
'https://pinokio-1.tail1.ts.net/anywhere/status': json({ phase: 'ready', apps: [
|
|
39
|
-
{ id: 'comfy', name: 'ComfyUI', state: 'ready', exposed: true, remote: 'https://pinokio-1.tail1.ts.net:43000/ui/?a=1#v' },
|
|
40
|
-
{ id: 'forge', name: 'Forge', state: 'starting', exposed: false, remote: null },
|
|
41
|
-
] }),
|
|
42
|
-
'https://other.tail1.ts.net/anywhere/status': async () => ({ status: 404, headers: {}, body: 'not pinokio' }),
|
|
43
|
-
})
|
|
44
|
-
const out = await Server.prototype.composeAnywherePeers.call(fake(anywhere))
|
|
45
|
-
assert.equal(out.status, 200)
|
|
46
|
-
assert.deepEqual(out.body, { peers: [{ name: 'pinokio-1', address: 'https://pinokio-1.tail1.ts.net', apps: [
|
|
47
|
-
{ id: 'comfy', name: 'ComfyUI', state: 'ready', exposed: true, open: '/anywhere/peers/pinokio-1/open?port=43000&to=%2Fui%2F%3Fa%3D1%23v' },
|
|
48
|
-
{ id: 'forge', name: 'Forge', state: 'starting', exposed: false, open: null },
|
|
49
|
-
] }] })
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
test('peers skips phones and offline peers, tolerates a peer that fails, and says why while the proxy is missing', async () => {
|
|
53
|
-
const asked = []
|
|
54
|
-
const anywhere = anywhereWith({
|
|
55
|
-
'https://pinokio-1.tail1.ts.net/anywhere/status': async () => { asked.push('pinokio-1'); throw new Error('no answer in 4 s') },
|
|
56
|
-
'https://other.tail1.ts.net/anywhere/status': async () => { asked.push('other'); return { status: 200, headers: {}, body: 'not json' } },
|
|
57
|
-
})
|
|
58
|
-
assert.deepEqual((await Server.prototype.composeAnywherePeers.call(fake(anywhere))).body, { peers: [] })
|
|
59
|
-
assert.deepEqual(asked.sort(), ['other', 'pinokio-1'], 'the phone and the offline computer are never asked')
|
|
60
|
-
const adopted = anywhereWith({}, { proxy: false })
|
|
61
|
-
assert.deepEqual(await Server.prototype.composeAnywherePeers.call(fake(adopted)), { status: 502, body: { error: 'Turn Remote off and on to reach your other Pinokios' } }, 'a daemon from before the proxy existed')
|
|
62
|
-
adopted.error = 'tailscaled exited (code=1 signal=null)'
|
|
63
|
-
assert.deepEqual((await Server.prototype.composeAnywherePeers.call(fake(adopted))).body, { error: 'Remote is not running on this computer' }, 'a daemon that died')
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
test('open validates the peer, the port and the path, then redirects to the forwarder', async () => {
|
|
67
|
-
const server = fake(anywhereWith())
|
|
68
|
-
assert.equal((await Server.prototype.anywherePeerOpen.call(server, 'nobody', { port: '443' })).status, 400)
|
|
69
|
-
assert.equal((await Server.prototype.anywherePeerOpen.call(server, 'pinokio-1', { port: '8080' })).status, 400)
|
|
70
|
-
assert.equal((await Server.prototype.anywherePeerOpen.call(server, 'pinokio-1', { port: '443.5' })).status, 400)
|
|
71
|
-
const root = await Server.prototype.anywherePeerOpen.call(server, 'pinokio-1', { port: '443' })
|
|
72
|
-
assert.equal(root.status, 302)
|
|
73
|
-
assert.equal(root.headers.Location, 'http://127.0.0.1:51443/')
|
|
74
|
-
const deep = await Server.prototype.anywherePeerOpen.call(server, 'PINOKIO-1', { port: '43000', to: '/ui/?a=1#v' })
|
|
75
|
-
assert.equal(deep.headers.Location, 'http://127.0.0.1:51000/ui/?a=1#v', 'the path, query and fragment survive; the name is case-insensitive')
|
|
76
|
-
for (const bad of ['//evil.example', 'https://evil.example/', '\\\\evil.example', 'http://127.0.0.1:51443@evil.example/']) {
|
|
77
|
-
const answer = await Server.prototype.anywherePeerOpen.call(server, 'pinokio-1', { port: '443', to: bad })
|
|
78
|
-
assert.equal(new URL(answer.headers.Location).origin, 'http://127.0.0.1:51443', `a path that could leave the forwarder (${bad}) stays on it`)
|
|
79
|
-
}
|
|
80
|
-
const odd = await Server.prototype.anywherePeerOpen.call(server, 'pinokio-1', { port: '443', to: '/ui/?q=caf\u00e9 x#\u00fc' })
|
|
81
|
-
assert.equal(odd.headers.Location, 'http://127.0.0.1:51443/ui/?q=caf%C3%A9%20x#%C3%BC', 'a path with characters a header cannot carry is encoded, fragment kept')
|
|
82
|
-
const unavailable = await Server.prototype.anywherePeerOpen.call(fake(anywhereWith({}, { proxy: false })), 'pinokio-1', { port: '443' })
|
|
83
|
-
assert.deepEqual(unavailable, { status: 502, body: { error: 'Turn Remote off and on to reach your other Pinokios' } }, 'a daemon from before the proxy existed')
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
test('the relays carry the peer\'s API under its prefix, with its own addresses made absolute open routes', async () => {
|
|
87
|
-
const anywhere = anywhereWith({
|
|
88
|
-
'https://pinokio-1.tail1.ts.net/anywhere/status': json({ phase: 'ready', apps: [
|
|
89
|
-
{ id: 'comfy', name: 'ComfyUI', remote: 'https://pinokio-1.tail1.ts.net:43000/ui/?a=1#v' },
|
|
90
|
-
{ id: 'ext', name: 'Ext', remote: 'https://example.com/' },
|
|
91
|
-
{ id: 'forge', name: 'Forge', remote: null },
|
|
92
|
-
] }),
|
|
93
|
-
'https://pinokio-1.tail1.ts.net/anywhere/apps': json({ apps: [{ id: 'comfy', name: 'ComfyUI', icon: '/asset/api/comfy/icon.png' }] }),
|
|
94
|
-
'https://pinokio-1.tail1.ts.net/asset/api/comfy/icon.png': async () => ({ status: 200, headers: { 'content-type': 'image/png' }, raw: Buffer.from([0x89, 0x50]), body: '' }),
|
|
95
|
-
})
|
|
96
|
-
const server = fake(anywhere)
|
|
97
|
-
const status = await Server.prototype.relayAnywherePeer.call(server, 'pinokio-1', 'GET', 'anywhere/status')
|
|
98
|
-
assert.equal(status.status, 200)
|
|
99
|
-
assert.deepEqual(status.body.apps.map((app) => app.remote), [
|
|
100
|
-
'http://127.0.0.1:42000/anywhere/peers/pinokio-1/open?port=43000&to=%2Fui%2F%3Fa%3D1%23v', 'https://example.com/', null,
|
|
101
|
-
], 'the peer\'s own address becomes an absolute open route; anything else passes through')
|
|
102
|
-
const catalog = await Server.prototype.relayAnywherePeer.call(server, 'pinokio-1', 'GET', 'anywhere/apps')
|
|
103
|
-
assert.deepEqual(catalog.body, { apps: [{ id: 'comfy', name: 'ComfyUI', icon: '/asset/api/comfy/icon.png' }] })
|
|
104
|
-
const icon = await Server.prototype.relayAnywherePeer.call(server, 'pinokio-1', 'GET', 'asset/api/comfy/icon.png')
|
|
105
|
-
assert.deepEqual([icon.status, icon.contentType, [...icon.raw]], [200, 'image/png', [0x89, 0x50]], 'an asset is passed through as bytes')
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
test('launch and stop relay the peer\'s status and body, turning an open URL into the open route', async () => {
|
|
109
|
-
const sent = []
|
|
110
|
-
const anywhere = anywhereWith({
|
|
111
|
-
'https://pinokio-1.tail1.ts.net/anywhere/apps/launch': async (options) => { sent.push(options); return { status: 200, headers: {}, body: JSON.stringify({ action: 'open', url: 'https://pinokio-1.tail1.ts.net:43000/' }) } },
|
|
112
|
-
'https://pinokio-1.tail1.ts.net/anywhere/apps/stop': json({ error: 'Unknown app: x' }, 400),
|
|
113
|
-
})
|
|
114
|
-
const server = fake(anywhere)
|
|
115
|
-
const launch = await Server.prototype.relayAnywherePeer.call(server, 'pinokio-1', 'POST', 'anywhere/apps/launch', { id: 'comfy' })
|
|
116
|
-
assert.deepEqual(launch, { status: 200, body: { action: 'open', url: 'http://127.0.0.1:42000/anywhere/peers/pinokio-1/open?port=43000&to=%2F' } })
|
|
117
|
-
assert.equal(sent[0].method, 'POST')
|
|
118
|
-
assert.equal(sent[0].body, '{"id":"comfy"}')
|
|
119
|
-
assert.equal(sent[0].headers['Content-Type'], 'application/json')
|
|
120
|
-
assert.deepEqual(await Server.prototype.relayAnywherePeer.call(server, 'pinokio-1', 'POST', 'anywhere/apps/stop', { id: 'x' }), { status: 400, body: { error: 'Unknown app: x' } })
|
|
121
|
-
assert.equal((await Server.prototype.relayAnywherePeer.call(server, 'nobody', 'POST', 'anywhere/apps/stop', {})).status, 400)
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
test('a launch whose default is an outside address passes through unchanged', async () => {
|
|
125
|
-
const anywhere = anywhereWith({
|
|
126
|
-
'https://pinokio-1.tail1.ts.net/anywhere/apps/launch': json({ action: 'open', url: 'https://github.com/x/y' }),
|
|
127
|
-
})
|
|
128
|
-
const launch = await Server.prototype.relayAnywherePeer.call(fake(anywhere), 'pinokio-1', 'POST', 'anywhere/apps/launch', { id: 'docs' })
|
|
129
|
-
assert.deepEqual(launch.body, { action: 'open', url: 'https://github.com/x/y' })
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
test('a peer that cannot be reached or answers without JSON relays as 502', async () => {
|
|
133
|
-
const anywhere = anywhereWith({
|
|
134
|
-
'https://pinokio-1.tail1.ts.net/anywhere/apps/stop': async () => { throw new Error('no answer in 30 s') },
|
|
135
|
-
'https://other.tail1.ts.net/anywhere/apps/stop': async () => ({ status: 404, headers: {}, body: '<html>' }),
|
|
136
|
-
})
|
|
137
|
-
const server = fake(anywhere)
|
|
138
|
-
const down = await Server.prototype.relayAnywherePeer.call(server, 'pinokio-1', 'POST', 'anywhere/apps/stop', { id: 'x' })
|
|
139
|
-
assert.equal(down.status, 502)
|
|
140
|
-
assert.match(down.body.error, /Could not reach STUDIO: no answer/)
|
|
141
|
-
const old = await Server.prototype.relayAnywherePeer.call(server, 'other', 'POST', 'anywhere/apps/stop', { id: 'x' })
|
|
142
|
-
assert.deepEqual(old, { status: 502, body: { error: 'other did not answer with JSON (HTTP 404)' } })
|
|
143
|
-
const stopped = await Server.prototype.relayAnywherePeer.call(fake(anywhereWith({}, { proxy: false })), 'other', 'POST', 'anywhere/apps/stop', {})
|
|
144
|
-
assert.equal(stopped.status, 502)
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
test('the peer routes answer a browser on this computer only, never a tailnet client through Serve, whatever Host it sends', () => {
|
|
148
|
-
const server = { kernel: { anywhere: anywhereWith() }, isLoopbackHost: Server.prototype.isLoopbackHost, isLocalRequest: Server.prototype.isLocalRequest }
|
|
149
|
-
const request = ({ remote = '127.0.0.1', host = 'localhost:42000', origin, headers = {} } = {}) => ({
|
|
150
|
-
socket: { remoteAddress: remote },
|
|
151
|
-
get: (name) => ({ host, origin, ...Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])) })[name.toLowerCase()],
|
|
152
|
-
})
|
|
153
|
-
const allowed = (req) => Server.prototype.anywherePeerAllowed.call(server, req)
|
|
154
|
-
assert.equal(allowed(request()), true, 'a top-level navigation on this computer')
|
|
155
|
-
assert.equal(allowed(request({ remote: '::1', host: '[::1]:42000', origin: 'http://[::1]:42000' })), true)
|
|
156
|
-
assert.equal(allowed(request({ remote: '::ffff:127.0.0.1', host: '127.0.0.1:42000', origin: 'http://127.0.0.1:42000' })), true)
|
|
157
|
-
assert.equal(allowed(request({ origin: 'http://localhost:3000' })), false, 'another local site is not this page')
|
|
158
|
-
assert.equal(allowed(request({ origin: 'https://pinokio.co' })), true, 'the Remote tab in a browser on this computer')
|
|
159
|
-
assert.equal(allowed(request({ origin: 'https://pinokio.co', headers: { 'X-Forwarded-For': '100.1.1.1' } })), false, 'but not through Serve')
|
|
160
|
-
assert.equal(allowed(request({ origin: 'https://pinokio.co.evil' })), false)
|
|
161
|
-
assert.equal(allowed(request({ origin: 'not a url' })), false)
|
|
162
|
-
assert.equal(allowed(request({ host: 'pinokio.tail1.ts.net' })), false, 'the node\'s own name')
|
|
163
|
-
assert.equal(allowed(request({ headers: { 'X-Forwarded-For': '100.1.1.1' } })), false, 'through Serve with a forged loopback Host')
|
|
164
|
-
assert.equal(allowed(request({ headers: { 'X-Pinokio-Peer': '1', 'X-Pinokio-Source-Host': 'localhost' } })), false, 'through another Pinokio')
|
|
165
|
-
assert.equal(allowed(request({ remote: '192.168.1.5' })), false, 'Home Server on the LAN')
|
|
166
|
-
assert.equal(allowed(request({ remote: null })), false, 'no socket address at all')
|
|
167
|
-
assert.equal(Server.prototype.isLoopbackHost('LOCALHOST'), true)
|
|
168
|
-
assert.equal(Server.prototype.isLoopbackHost('localhost.evil'), false)
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
test('a live daemon is still ours across a pinokiod restart, its proxy port read from its command line', () => {
|
|
172
|
-
const anywhere = new Anywhere({ platform: 'linux', path: (...parts) => ['/home/x', ...parts].join('/'), bin: { mod: { tailscaled: { binPath: () => '/opt/tailscaled' } } } })
|
|
173
|
-
const line = (port) => ['/opt/tailscaled', ...anywhere.daemonArgs(port)].join(' ')
|
|
174
|
-
assert.equal(anywhere.proxyPort, null, 'a fresh pinokiod process knows no port yet')
|
|
175
|
-
assert.equal(anywhere.ownedPid({ pid: 7 }, line(1055)), 7)
|
|
176
|
-
assert.equal(anywhere.ownedProxyPort, 1055, 'the port the daemon was given, for adoption')
|
|
177
|
-
assert.equal(anywhere.ownedPid({ pid: 7 }, line(null)), 7, 'a daemon started before the proxy existed')
|
|
178
|
-
assert.equal(anywhere.ownedProxyPort, null, 'and it has no proxy to use')
|
|
179
|
-
assert.equal(anywhere.ownedPid({ pid: 7 }, line(1055) + ' --extra'), null, 'anything else is not ours')
|
|
180
|
-
assert.equal(anywhere.ownedPid({ pid: 7 }, '/usr/bin/tailscaled --tun=userspace-networking'), null)
|
|
181
|
-
anywhere.platform = 'win32'
|
|
182
|
-
assert.equal(anywhere.ownedPid({ pid: 9 }, `"${line(1055).toUpperCase()}"`), 9, 'Windows compares without quotes, case-insensitively')
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
test('peerFor finds a peer by the first label of its DNS name, which may differ from its displayed name', () => {
|
|
186
|
-
const anywhere = anywhereWith()
|
|
187
|
-
assert.equal(anywhere.peerFor('pinokio-1').name, 'STUDIO')
|
|
188
|
-
assert.equal(anywhere.peerFor('iphone-14-pro').mobile, true)
|
|
189
|
-
assert.equal(anywhere.peerFor('STUDIO'), null, 'the hostname is not the key')
|
|
190
|
-
assert.equal(anywhere.peerFor(''), null)
|
|
191
|
-
assert.equal(anywhere.peerFor(null), null)
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
test('a forwarded visitor gets menu links on the forwarder\'s open path; every other visitor gets today\'s links', async () => {
|
|
195
|
-
const anywhere = new Anywhere({})
|
|
196
|
-
anywhere.config = { enabled: true }
|
|
197
|
-
anywhere.lastStatus = status
|
|
198
|
-
anywhere.lastServe = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', targets: [{ listen: 43000, target: 7860 }] })
|
|
199
|
-
const href = 'http://127.0.0.1:7860/ui/?a=1#v'
|
|
200
|
-
assert.equal(anywhere.rewriteHref('pinokio.tail1.ts.net', href, true), '/.pinokio/open?port=43000&to=%2Fui%2F%3Fa%3D1%23v')
|
|
201
|
-
assert.equal(anywhere.rewriteHref('pinokio.tail1.ts.net', href), 'https://pinokio.tail1.ts.net:43000/ui/?a=1#v')
|
|
202
|
-
assert.equal(anywhere.rewriteHref('pinokio.tail1.ts.net', href, false), 'https://pinokio.tail1.ts.net:43000/ui/?a=1#v')
|
|
203
|
-
assert.equal(anywhere.rewriteHref('localhost:42000', href, true), href, 'the Host gate still applies with the header')
|
|
204
|
-
assert.equal(anywhere.rewriteHref('pinokio.tail1.ts.net', 'http://127.0.0.1:9999/', true), 'http://127.0.0.1:9999/', 'a port without a route is left alone, as today')
|
|
205
|
-
const menu = [{ href, src: href, menu: [{ href: 'https://example.com/' }] }]
|
|
206
|
-
await anywhere.rewriteMenu('pinokio.tail1.ts.net', menu, true)
|
|
207
|
-
assert.equal(menu[0].href, '/.pinokio/open?port=43000&to=%2Fui%2F%3Fa%3D1%23v')
|
|
208
|
-
assert.equal(menu[0].src, menu[0].href)
|
|
209
|
-
assert.equal(menu[0].menu[0].href, 'https://example.com/')
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
test('the daemon is started with the proxy on the port pinokiod picked, and peers carry their DNS name', () => {
|
|
213
|
-
const anywhere = new Anywhere({ platform: 'linux', path: (...parts) => ['/home/x', ...parts].join('/') })
|
|
214
|
-
anywhere.proxyPort = 1055
|
|
215
|
-
assert.ok(anywhere.daemonArgs().includes('--outbound-http-proxy-listen=127.0.0.1:1055'))
|
|
216
|
-
assert.equal(anywhere.proxyUrl(), 'http://127.0.0.1:1055')
|
|
217
|
-
anywhere.proxyPort = null
|
|
218
|
-
assert.equal(anywhere.proxyUrl(), null)
|
|
219
|
-
assert.ok(!anywhere.daemonArgs().some((arg) => arg.includes('proxy')), 'no port, no flag')
|
|
220
|
-
assert.deepEqual(anywhere.peersFrom(status).map((p) => [p.name, p.dnsName]), [
|
|
221
|
-
['iphone-14-pro', 'iphone-14-pro.tail1.ts.net'], ['other', 'other.tail1.ts.net'], ['STUDIO', 'pinokio-1.tail1.ts.net'], ['asleep', 'asleep.tail1.ts.net'],
|
|
222
|
-
])
|
|
223
|
-
})
|