pinokiod 8.2.29 → 8.2.31
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/forward.js +162 -0
- package/kernel/anywhere/index.js +60 -11
- package/kernel/anywhere/ports.js +1 -1
- package/kernel/anywhere/serve.js +21 -5
- package/kernel/bin/conda.js +1 -1
- package/kernel/bin/setup.js +1 -1
- package/kernel/bin/tailscaled.js +2 -2
- package/package.json +1 -1
- package/server/index.js +138 -2
- package/server/views/anywhere.ejs +149 -47
- package/test/anywhere-clipboard.test.js +18 -17
- package/test/anywhere-forward.test.js +135 -0
- package/test/anywhere-onboarding.test.js +95 -0
- package/test/anywhere-others.test.js +96 -0
- package/test/anywhere-peers.test.js +199 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const assert = require('node:assert/strict')
|
|
2
|
+
const fs = require('node:fs')
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
const test = require('node:test')
|
|
5
|
+
const ejs = require('ejs')
|
|
6
|
+
const { JSDOM, VirtualConsole } = require('jsdom')
|
|
7
|
+
|
|
8
|
+
// The Remote page's onboarding states (spec/requirements/pinokio-anywhere.md,
|
|
9
|
+
// Interface > Onboarding): what the ready state shows before and after a
|
|
10
|
+
// device joins, the key-expiry warning, and the approval that opens on its own.
|
|
11
|
+
const filename = path.resolve(__dirname, '../server/views/anywhere.ejs')
|
|
12
|
+
const template = fs.readFileSync(filename, 'utf8')
|
|
13
|
+
const address = 'https://pinokio.tail123.ts.net'
|
|
14
|
+
const connectUrl = 'https://pinokio.co/anywhere/connect#u=' + encodeURIComponent(address) + '&a=me%40example.com'
|
|
15
|
+
const days = (n) => new Date(Date.now() + n * 86400000).toISOString()
|
|
16
|
+
|
|
17
|
+
function render(t, status, agent = 'electron') {
|
|
18
|
+
const state = { status: { enabled: true, address, connectUrl, dnsName: 'pinokio.tail123.ts.net', account: { email: 'me@example.com' }, peers: [], ...status }, timers: [], opened: [] }
|
|
19
|
+
const html = ejs.render(template, { theme: 'light', agent, status: state.status }, { filename, includer: () => ({ template: ' ' }) })
|
|
20
|
+
const virtualConsole = new VirtualConsole()
|
|
21
|
+
virtualConsole.on('jsdomError', (error) => { throw error })
|
|
22
|
+
const dom = new JSDOM(html, {
|
|
23
|
+
url: 'http://127.0.0.1:42000/', runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole,
|
|
24
|
+
beforeParse(window) {
|
|
25
|
+
window.setTimeout = (callback, delay) => state.timers.push({ callback, delay })
|
|
26
|
+
window.fetch = async () => ({ ok: true, json: async () => ({ ...state.status }) })
|
|
27
|
+
window.open = (...args) => state.opened.push(args)
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
t.after(() => dom.window.close())
|
|
31
|
+
const document = dom.window.document
|
|
32
|
+
return { state, document, poll: () => state.timers.shift().callback(), text: (id) => document.getElementById(id).textContent }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('before any device joins, the ready state is the connect card alone, with sign out', (t) => {
|
|
36
|
+
const h = render(t, { phase: 'ready', peers: [] })
|
|
37
|
+
const card = h.document.querySelector('[data-state=ready] .anywhere-card')
|
|
38
|
+
assert.equal(h.document.getElementById('anywhere-details').hidden, true, 'apps, devices and details wait for a device')
|
|
39
|
+
assert.ok(card.querySelector('.anywhere-signout[data-action=logout]'), 'a wrong-account sign-in is fixable during onboarding')
|
|
40
|
+
assert.ok(!h.document.querySelector('#anywhere-details [data-action=logout]'), 'sign out lives with the account, not in details')
|
|
41
|
+
assert.equal(card.querySelectorAll('ol, h3').length, 0, 'no step lists on the desk: the phone page explains Tailscale after the scan')
|
|
42
|
+
const connect = h.document.getElementById('anywhere-connect')
|
|
43
|
+
assert.equal(connect.dataset.device, 'phone', 'the QR is the default')
|
|
44
|
+
assert.match(connect.querySelector('[data-panel=phone]').textContent, /Scan with your phone\./)
|
|
45
|
+
const computer = connect.querySelector('[data-panel=computer]')
|
|
46
|
+
assert.match(computer.textContent, /signing in as me@example.com/)
|
|
47
|
+
const choose = connect.querySelector('input[value=computer]')
|
|
48
|
+
choose.checked = true
|
|
49
|
+
choose.dispatchEvent(new h.document.defaultView.Event('change'))
|
|
50
|
+
assert.equal(connect.dataset.device, 'computer', 'the segmented control switches the panel')
|
|
51
|
+
const link = computer.querySelector('#anywhere-address')
|
|
52
|
+
assert.equal(link.getAttribute('href'), connectUrl, 'the computer path is the same link the QR carries')
|
|
53
|
+
assert.equal(link.textContent, 'pinokio.co/anywhere/connect#…', 'the fragment is not spelled out')
|
|
54
|
+
assert.equal(h.text('anywhere-account'), 'me@example.com')
|
|
55
|
+
assert.match(h.text('anywhere-waiting'), /Waiting for another device/)
|
|
56
|
+
assert.equal(h.document.getElementById('anywhere-expiry-warning').hidden, true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('once a device has joined, the rest of the page appears, online or not', async (t) => {
|
|
60
|
+
const h = render(t, { phase: 'ready', peers: [{ name: 'phone', os: 'iOS', online: false }] })
|
|
61
|
+
assert.equal(h.document.getElementById('anywhere-details').hidden, false)
|
|
62
|
+
assert.match(h.text('anywhere-waiting'), /No other devices are online/)
|
|
63
|
+
h.state.status.peers = []
|
|
64
|
+
h.poll(); await new Promise(setImmediate)
|
|
65
|
+
assert.equal(h.document.getElementById('anywhere-details').hidden, true, 'the page follows the payload')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('a key expiring within two weeks is warned about on the desk, with the admin console link', (t) => {
|
|
69
|
+
const soon = render(t, { phase: 'ready', keyExpiry: days(10) })
|
|
70
|
+
const warning = soon.document.getElementById('anywhere-expiry-warning')
|
|
71
|
+
assert.equal(warning.hidden, false)
|
|
72
|
+
assert.match(warning.textContent, /expires .*\(in 10 days\)/)
|
|
73
|
+
assert.match(warning.textContent, /Disable key expiry for pinokio/)
|
|
74
|
+
assert.equal(warning.querySelector('a').getAttribute('href'), 'https://login.tailscale.com/admin/machines')
|
|
75
|
+
assert.equal(render(t, { phase: 'ready', keyExpiry: days(60) }).document.getElementById('anywhere-expiry-warning').hidden, true)
|
|
76
|
+
assert.equal(render(t, { phase: 'ready' }).document.getElementById('anywhere-expiry-warning').hidden, true, 'no expiry reported, no warning')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('the approval opens in the external browser once per URL, without a click, and the button stays', async (t) => {
|
|
80
|
+
const approval = { url: 'https://login.tailscale.com/f/serve?node=1', text: 'Enable HTTPS certificates for your tailnet to continue.' }
|
|
81
|
+
const h = render(t, { phase: 'https', approval })
|
|
82
|
+
assert.deepEqual(h.state.opened, [[approval.url, '_blank', 'browser']])
|
|
83
|
+
assert.equal(h.document.getElementById('anywhere-approval-link').getAttribute('href'), approval.url)
|
|
84
|
+
assert.equal(h.text('anywhere-approval-text'), approval.text, "Tailscale's own text is the detail line")
|
|
85
|
+
h.poll(); await new Promise(setImmediate)
|
|
86
|
+
assert.equal(h.state.opened.length, 1, 'a poll with the same URL does not open it again')
|
|
87
|
+
assert.equal(h.document.defaultView.sessionStorage.getItem('anywhere-approval-opened'), approval.url, 'remembered for the window, so a reload does not open it again')
|
|
88
|
+
h.state.status.approval = { url: 'https://login.tailscale.com/f/serve?node=2', text: '' }
|
|
89
|
+
h.poll(); await new Promise(setImmediate)
|
|
90
|
+
assert.equal(h.state.opened.length, 2, 'a fresh approval URL opens once more')
|
|
91
|
+
assert.equal(h.text('anywhere-approval-text'), '')
|
|
92
|
+
const login = render(t, { phase: 'login', loginUrl: 'https://login.tailscale.com/a/1' })
|
|
93
|
+
assert.match(login.document.querySelector('[data-state=login]').textContent, /may be asked to approve one thing/)
|
|
94
|
+
assert.deepEqual(login.state.opened, [], 'sign-in itself still waits for the click')
|
|
95
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const assert = require('node:assert/strict')
|
|
2
|
+
const fs = require('node:fs')
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
const test = require('node:test')
|
|
5
|
+
const ejs = require('ejs')
|
|
6
|
+
const { JSDOM, VirtualConsole } = require('jsdom')
|
|
7
|
+
|
|
8
|
+
// The Remote page's "Your other Pinokios" section (spec/requirements/
|
|
9
|
+
// pinokio-anywhere.md, Other Pinokios): on a loopback host only, filled from
|
|
10
|
+
// /anywhere/peers at each status poll while ready, stop relayed per app.
|
|
11
|
+
const filename = path.resolve(__dirname, '../server/views/anywhere.ejs')
|
|
12
|
+
const template = fs.readFileSync(filename, 'utf8')
|
|
13
|
+
const address = 'https://pinokio.tail123.ts.net'
|
|
14
|
+
const ready = { phase: 'ready', enabled: true, address, connectUrl: 'https://pinokio.co/anywhere/connect#u=x', dnsName: 'pinokio.tail123.ts.net', account: { email: 'me@example.com' }, peers: [{ name: 'STUDIO', os: 'windows', online: true }], apps: [], serve: [] }
|
|
15
|
+
|
|
16
|
+
function render(t, { loopback, peers }) {
|
|
17
|
+
const state = { calls: [], timers: [], peers }
|
|
18
|
+
const html = ejs.render(template, { theme: 'light', agent: 'electron', status: ready, loopback }, { filename, includer: () => ({ template: ' ' }) })
|
|
19
|
+
const virtualConsole = new VirtualConsole()
|
|
20
|
+
virtualConsole.on('jsdomError', (error) => { throw error })
|
|
21
|
+
const dom = new JSDOM(html, {
|
|
22
|
+
url: 'http://127.0.0.1:42000/anywhere', runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole,
|
|
23
|
+
beforeParse(window) {
|
|
24
|
+
window.setTimeout = (callback, delay) => state.timers.push({ callback, delay })
|
|
25
|
+
window.fetch = async (url, options = {}) => {
|
|
26
|
+
state.calls.push({ url, method: options.method || 'GET', body: options.body })
|
|
27
|
+
if (url === '/anywhere/status') return { ok: true, json: async () => ({ ...ready }) }
|
|
28
|
+
if (url === '/anywhere/peers') return state.peersError ? { ok: false, status: 502, json: async () => ({ error: state.peersError }) } : { ok: true, json: async () => ({ peers: state.peers }) }
|
|
29
|
+
if (state.stopFails) return { ok: false, status: 502, json: async () => ({ error: 'no answer in 30 s' }) }
|
|
30
|
+
return { ok: true, json: async () => ({ action: 'stopped' }) }
|
|
31
|
+
}
|
|
32
|
+
window.open = () => {}
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
t.after(() => dom.window.close())
|
|
36
|
+
const document = dom.window.document
|
|
37
|
+
// the poll: status, then the peers refresh it starts; let both settle
|
|
38
|
+
const poll = async () => { state.timers.shift().callback(); await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)) }
|
|
39
|
+
return { state, document, poll, section: () => document.getElementById('anywhere-others') }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('on a loopback host the section lists each other Pinokio with Open Pinokio and its apps through the open route', async (t) => {
|
|
43
|
+
const h = render(t, { loopback: true, peers: [
|
|
44
|
+
{ name: 'pinokio-1', apps: [
|
|
45
|
+
{ id: 'comfy', name: 'ComfyUI', state: 'ready', exposed: true, open: '/anywhere/peers/pinokio-1/open?port=43000&to=%2F' },
|
|
46
|
+
{ id: 'forge', name: 'Forge', state: 'starting', exposed: false, open: null },
|
|
47
|
+
] },
|
|
48
|
+
{ name: 'other', apps: [] },
|
|
49
|
+
] })
|
|
50
|
+
assert.ok(h.section(), 'the card exists before the first answer')
|
|
51
|
+
await h.poll()
|
|
52
|
+
assert.deepEqual(h.state.calls.map((call) => call.url), ['/anywhere/peers', '/anywhere/status', '/anywhere/peers'], 'once on load, then with each status poll')
|
|
53
|
+
const rows = [...h.section().querySelectorAll('[data-peer]')]
|
|
54
|
+
assert.deepEqual(rows.map((row) => row.dataset.peer), ['pinokio-1', 'other'])
|
|
55
|
+
const links = [...rows[0].querySelectorAll('a')].map((a) => [a.textContent, a.getAttribute('href')])
|
|
56
|
+
assert.deepEqual(links, [['Open Pinokio', '/anywhere/peers/pinokio-1/open?port=443&to=%2F'], ['Open', '/anywhere/peers/pinokio-1/open?port=43000&to=%2F']])
|
|
57
|
+
assert.match(rows[0].textContent, /Forge/)
|
|
58
|
+
assert.ok(rows[0].querySelector('.anywhere-badge'), 'an app without an address shows its state instead of a link')
|
|
59
|
+
assert.match(rows[1].textContent, /no app running/)
|
|
60
|
+
assert.doesNotMatch(h.section().textContent, /Looking…/)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('with none found the section says so, and a stop is relayed to the peer then refreshed', async (t) => {
|
|
64
|
+
const h = render(t, { loopback: true, peers: [] })
|
|
65
|
+
await h.poll()
|
|
66
|
+
assert.match(h.section().textContent, /No other Pinokio with Remote on is online/)
|
|
67
|
+
h.state.peers = [{ name: 'pinokio-1', apps: [{ id: 'comfy', name: 'ComfyUI', state: 'ready', exposed: true, open: '/anywhere/peers/pinokio-1/open?port=43000&to=%2F' }] }]
|
|
68
|
+
await h.poll()
|
|
69
|
+
const stop = h.section().querySelector('button[data-stop="comfy"]')
|
|
70
|
+
assert.ok(stop)
|
|
71
|
+
stop.click()
|
|
72
|
+
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve))
|
|
73
|
+
const posted = h.state.calls.find((call) => call.method === 'POST')
|
|
74
|
+
assert.deepEqual([posted.url, posted.body], ['/anywhere/peers/pinokio-1/apps/stop', '{"id":"comfy"}'])
|
|
75
|
+
assert.equal(h.state.calls.filter((call) => call.url === '/anywhere/peers').length, 4, 'load, two polls, and once more after the stop')
|
|
76
|
+
assert.equal(h.section().querySelector('.anywhere-error'), null)
|
|
77
|
+
h.state.stopFails = true
|
|
78
|
+
h.section().querySelector('button[data-stop="comfy"]').click()
|
|
79
|
+
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve))
|
|
80
|
+
assert.match(h.section().querySelector('.anywhere-error').textContent, /Could not stop it on pinokio-1: no answer in 30 s/)
|
|
81
|
+
assert.ok(h.section().querySelector('[data-peer="pinokio-1"]'), 'the list is still there under the note')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('while the daemon runs without the proxy, the section shows the reason the server gives', async (t) => {
|
|
85
|
+
const h = render(t, { loopback: true, peers: [] })
|
|
86
|
+
h.state.peersError = 'Turn Remote off and on to reach your other Pinokios'
|
|
87
|
+
await h.poll()
|
|
88
|
+
assert.match(h.section().textContent, /Could not look for other Pinokios: Turn Remote off and on to reach your other Pinokios/)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('through the node\'s own Tailscale name there is no section and no peers request', async (t) => {
|
|
92
|
+
const h = render(t, { loopback: false, peers: [{ name: 'pinokio-1', apps: [] }] })
|
|
93
|
+
assert.equal(h.section(), null)
|
|
94
|
+
await h.poll()
|
|
95
|
+
assert.deepEqual(h.state.calls.map((call) => call.url), ['/anywhere/status'], 'no peers request at load or at the poll')
|
|
96
|
+
})
|
|
@@ -0,0 +1,199 @@
|
|
|
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', 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('launch and stop relay the peer\'s status and body, turning an open URL into the open route', async () => {
|
|
87
|
+
const sent = []
|
|
88
|
+
const anywhere = anywhereWith({
|
|
89
|
+
'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/' }) } },
|
|
90
|
+
'https://pinokio-1.tail1.ts.net/anywhere/apps/stop': json({ error: 'Unknown app: x' }, 400),
|
|
91
|
+
})
|
|
92
|
+
const server = fake(anywhere)
|
|
93
|
+
const launch = await Server.prototype.relayAnywherePeerAction.call(server, 'pinokio-1', 'launch', { id: 'comfy' })
|
|
94
|
+
assert.deepEqual(launch, { status: 200, body: { action: 'open', url: '/anywhere/peers/pinokio-1/open?port=43000&to=%2F' } })
|
|
95
|
+
assert.equal(sent[0].method, 'POST')
|
|
96
|
+
assert.equal(sent[0].body, '{"id":"comfy"}')
|
|
97
|
+
assert.equal(sent[0].headers['Content-Type'], 'application/json')
|
|
98
|
+
assert.deepEqual(await Server.prototype.relayAnywherePeerAction.call(server, 'pinokio-1', 'stop', { id: 'x' }), { status: 400, body: { error: 'Unknown app: x' } })
|
|
99
|
+
assert.equal((await Server.prototype.relayAnywherePeerAction.call(server, 'nobody', 'stop', {})).status, 400)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('a launch whose default is an outside address passes through unchanged', async () => {
|
|
103
|
+
const anywhere = anywhereWith({
|
|
104
|
+
'https://pinokio-1.tail1.ts.net/anywhere/apps/launch': json({ action: 'open', url: 'https://github.com/x/y' }),
|
|
105
|
+
})
|
|
106
|
+
const launch = await Server.prototype.relayAnywherePeerAction.call(fake(anywhere), 'pinokio-1', 'launch', { id: 'docs' })
|
|
107
|
+
assert.deepEqual(launch.body, { action: 'open', url: 'https://github.com/x/y' })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('a peer that cannot be reached or answers without JSON relays as 502', async () => {
|
|
111
|
+
const anywhere = anywhereWith({
|
|
112
|
+
'https://pinokio-1.tail1.ts.net/anywhere/apps/stop': async () => { throw new Error('no answer in 30 s') },
|
|
113
|
+
'https://other.tail1.ts.net/anywhere/apps/stop': async () => ({ status: 404, headers: {}, body: '<html>' }),
|
|
114
|
+
})
|
|
115
|
+
const server = fake(anywhere)
|
|
116
|
+
const down = await Server.prototype.relayAnywherePeerAction.call(server, 'pinokio-1', 'stop', { id: 'x' })
|
|
117
|
+
assert.equal(down.status, 502)
|
|
118
|
+
assert.match(down.body.error, /Could not reach STUDIO: no answer/)
|
|
119
|
+
const old = await Server.prototype.relayAnywherePeerAction.call(server, 'other', 'stop', { id: 'x' })
|
|
120
|
+
assert.deepEqual(old, { status: 502, body: { error: 'other did not answer with JSON (HTTP 404)' } })
|
|
121
|
+
const stopped = await Server.prototype.relayAnywherePeerAction.call(fake(anywhereWith({}, { proxy: false })), 'other', 'stop', {})
|
|
122
|
+
assert.equal(stopped.status, 502)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('the peer routes answer a browser on this computer only, never a tailnet client through Serve, whatever Host it sends', () => {
|
|
126
|
+
const server = { kernel: { anywhere: anywhereWith() }, isLoopbackHost: Server.prototype.isLoopbackHost, isLocalRequest: Server.prototype.isLocalRequest }
|
|
127
|
+
const request = ({ remote = '127.0.0.1', host = 'localhost:42000', origin, headers = {} } = {}) => ({
|
|
128
|
+
socket: { remoteAddress: remote },
|
|
129
|
+
get: (name) => ({ host, origin, ...Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])) })[name.toLowerCase()],
|
|
130
|
+
})
|
|
131
|
+
const allowed = (req) => Server.prototype.anywherePeerAllowed.call(server, req)
|
|
132
|
+
assert.equal(allowed(request()), true, 'a top-level navigation on this computer')
|
|
133
|
+
assert.equal(allowed(request({ remote: '::1', host: '[::1]:42000', origin: 'http://[::1]:42000' })), true)
|
|
134
|
+
assert.equal(allowed(request({ remote: '::ffff:127.0.0.1', host: '127.0.0.1:42000', origin: 'http://127.0.0.1:42000' })), true)
|
|
135
|
+
assert.equal(allowed(request({ origin: 'http://localhost:3000' })), false, 'another local site is not this page')
|
|
136
|
+
assert.equal(allowed(request({ origin: 'https://pinokio.co' })), false)
|
|
137
|
+
assert.equal(allowed(request({ origin: 'not a url' })), false)
|
|
138
|
+
assert.equal(allowed(request({ host: 'pinokio.tail1.ts.net' })), false, 'the node\'s own name')
|
|
139
|
+
assert.equal(allowed(request({ headers: { 'X-Forwarded-For': '100.1.1.1' } })), false, 'through Serve with a forged loopback Host')
|
|
140
|
+
assert.equal(allowed(request({ headers: { 'X-Pinokio-Peer': '1', 'X-Pinokio-Source-Host': 'localhost' } })), false, 'through another Pinokio')
|
|
141
|
+
assert.equal(allowed(request({ remote: '192.168.1.5' })), false, 'Home Server on the LAN')
|
|
142
|
+
assert.equal(allowed(request({ remote: null })), false, 'no socket address at all')
|
|
143
|
+
assert.equal(Server.prototype.isLoopbackHost('LOCALHOST'), true)
|
|
144
|
+
assert.equal(Server.prototype.isLoopbackHost('localhost.evil'), false)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('a live daemon is still ours across a pinokiod restart, its proxy port read from its command line', () => {
|
|
148
|
+
const anywhere = new Anywhere({ platform: 'linux', path: (...parts) => ['/home/x', ...parts].join('/'), bin: { mod: { tailscaled: { binPath: () => '/opt/tailscaled' } } } })
|
|
149
|
+
const line = (port) => ['/opt/tailscaled', ...anywhere.daemonArgs(port)].join(' ')
|
|
150
|
+
assert.equal(anywhere.proxyPort, null, 'a fresh pinokiod process knows no port yet')
|
|
151
|
+
assert.equal(anywhere.ownedPid({ pid: 7 }, line(1055)), 7)
|
|
152
|
+
assert.equal(anywhere.ownedProxyPort, 1055, 'the port the daemon was given, for adoption')
|
|
153
|
+
assert.equal(anywhere.ownedPid({ pid: 7 }, line(null)), 7, 'a daemon started before the proxy existed')
|
|
154
|
+
assert.equal(anywhere.ownedProxyPort, null, 'and it has no proxy to use')
|
|
155
|
+
assert.equal(anywhere.ownedPid({ pid: 7 }, line(1055) + ' --extra'), null, 'anything else is not ours')
|
|
156
|
+
assert.equal(anywhere.ownedPid({ pid: 7 }, '/usr/bin/tailscaled --tun=userspace-networking'), null)
|
|
157
|
+
anywhere.platform = 'win32'
|
|
158
|
+
assert.equal(anywhere.ownedPid({ pid: 9 }, `"${line(1055).toUpperCase()}"`), 9, 'Windows compares without quotes, case-insensitively')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('peerFor finds a peer by the first label of its DNS name, which may differ from its displayed name', () => {
|
|
162
|
+
const anywhere = anywhereWith()
|
|
163
|
+
assert.equal(anywhere.peerFor('pinokio-1').name, 'STUDIO')
|
|
164
|
+
assert.equal(anywhere.peerFor('iphone-14-pro').mobile, true)
|
|
165
|
+
assert.equal(anywhere.peerFor('STUDIO'), null, 'the hostname is not the key')
|
|
166
|
+
assert.equal(anywhere.peerFor(''), null)
|
|
167
|
+
assert.equal(anywhere.peerFor(null), null)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('a forwarded visitor gets menu links on the forwarder\'s open path; every other visitor gets today\'s links', async () => {
|
|
171
|
+
const anywhere = new Anywhere({})
|
|
172
|
+
anywhere.config = { enabled: true }
|
|
173
|
+
anywhere.lastStatus = status
|
|
174
|
+
anywhere.lastServe = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', targets: [{ listen: 43000, target: 7860 }] })
|
|
175
|
+
const href = 'http://127.0.0.1:7860/ui/?a=1#v'
|
|
176
|
+
assert.equal(anywhere.rewriteHref('pinokio.tail1.ts.net', href, true), '/.pinokio/open?port=43000&to=%2Fui%2F%3Fa%3D1%23v')
|
|
177
|
+
assert.equal(anywhere.rewriteHref('pinokio.tail1.ts.net', href), 'https://pinokio.tail1.ts.net:43000/ui/?a=1#v')
|
|
178
|
+
assert.equal(anywhere.rewriteHref('pinokio.tail1.ts.net', href, false), 'https://pinokio.tail1.ts.net:43000/ui/?a=1#v')
|
|
179
|
+
assert.equal(anywhere.rewriteHref('localhost:42000', href, true), href, 'the Host gate still applies with the header')
|
|
180
|
+
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')
|
|
181
|
+
const menu = [{ href, src: href, menu: [{ href: 'https://example.com/' }] }]
|
|
182
|
+
await anywhere.rewriteMenu('pinokio.tail1.ts.net', menu, true)
|
|
183
|
+
assert.equal(menu[0].href, '/.pinokio/open?port=43000&to=%2Fui%2F%3Fa%3D1%23v')
|
|
184
|
+
assert.equal(menu[0].src, menu[0].href)
|
|
185
|
+
assert.equal(menu[0].menu[0].href, 'https://example.com/')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('the daemon is started with the proxy on the port pinokiod picked, and peers carry their DNS name', () => {
|
|
189
|
+
const anywhere = new Anywhere({ platform: 'linux', path: (...parts) => ['/home/x', ...parts].join('/') })
|
|
190
|
+
anywhere.proxyPort = 1055
|
|
191
|
+
assert.ok(anywhere.daemonArgs().includes('--outbound-http-proxy-listen=127.0.0.1:1055'))
|
|
192
|
+
assert.equal(anywhere.proxyUrl(), 'http://127.0.0.1:1055')
|
|
193
|
+
anywhere.proxyPort = null
|
|
194
|
+
assert.equal(anywhere.proxyUrl(), null)
|
|
195
|
+
assert.ok(!anywhere.daemonArgs().some((arg) => arg.includes('proxy')), 'no port, no flag')
|
|
196
|
+
assert.deepEqual(anywhere.peersFrom(status).map((p) => [p.name, p.dnsName]), [
|
|
197
|
+
['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'],
|
|
198
|
+
])
|
|
199
|
+
})
|