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,162 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Forwarders for Remote from computer to computer
|
|
3
|
+
(spec/requirements/pinokio-anywhere.md, Other Pinokios; the routes in
|
|
4
|
+
spec/requirements/pinokio-anywhere-api.md, Peers).
|
|
5
|
+
|
|
6
|
+
The bundled tailscaled runs in userspace networking mode, so the browser on
|
|
7
|
+
this computer has no route to tailnet addresses. The daemon does, and
|
|
8
|
+
exposes it as a plain HTTP proxy on loopback (--outbound-http-proxy-listen).
|
|
9
|
+
A forwarder is one loopback port that relays HTTP requests and upgrades
|
|
10
|
+
through that proxy, with TLS to one peer's .ts.net name on one remote port.
|
|
11
|
+
The peer sees an ordinary tailnet client; the browser talks plain HTTP to
|
|
12
|
+
127.0.0.1, as it does to Pinokio itself.
|
|
13
|
+
|
|
14
|
+
Every forwarder answers /.pinokio/open itself, never relaying it: a peer on
|
|
15
|
+
this revision rewrites the links inside its interface to that path, and the
|
|
16
|
+
redirect lands on this node's `open` route, which maps the port on demand.
|
|
17
|
+
*/
|
|
18
|
+
const http = require('http')
|
|
19
|
+
const https = require('https')
|
|
20
|
+
const httpProxy = require('http-proxy')
|
|
21
|
+
const { ProxyAgent } = require('proxy-agent')
|
|
22
|
+
const { OPEN_PATH } = require('./serve')
|
|
23
|
+
|
|
24
|
+
const FORWARDED_HEADER = 'x-pinokio-forwarded'
|
|
25
|
+
// honoured by the peer middleware's trust; a forwarder never sends them
|
|
26
|
+
const STRIPPED_HEADERS = ['x-pinokio-peer', 'x-pinokio-source-host', 'x-pinokio-source-proto']
|
|
27
|
+
|
|
28
|
+
// This node's `open` route for a peer and port, relative for the page's links.
|
|
29
|
+
function openPath(label, port, to) {
|
|
30
|
+
return `/anywhere/peers/${encodeURIComponent(label)}/open?port=${Number(port)}&to=${encodeURIComponent(to || '/')}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class Forwarders {
|
|
34
|
+
// proxyUrl: () => 'http://127.0.0.1:<port>' or null while the daemon is down
|
|
35
|
+
// serverPort: () => pinokiod's own port
|
|
36
|
+
// scheme exists for the tests, which forward to a plain local server instead of
|
|
37
|
+
// TLS through the proxy; the product never passes it.
|
|
38
|
+
constructor({ proxyUrl, serverPort, scheme = 'https' } = {}) {
|
|
39
|
+
this.proxyUrl = proxyUrl
|
|
40
|
+
this.serverPort = serverPort
|
|
41
|
+
this.scheme = scheme
|
|
42
|
+
this.agents = new Map() // proxy URL -> agent, one per daemon start
|
|
43
|
+
this.servers = new Map() // "<dnsName>:<port>" -> { port, server, proxy, sockets } or { pending }
|
|
44
|
+
}
|
|
45
|
+
agent() {
|
|
46
|
+
const url = this.proxyUrl()
|
|
47
|
+
if (this.scheme === 'https' && !url) throw new Error('Remote is not running on this computer')
|
|
48
|
+
if (!url) return undefined
|
|
49
|
+
// proxy-agent picks CONNECT-then-TLS for an http:// proxy and an https target
|
|
50
|
+
if (!this.agents.has(url)) this.agents.set(url, new ProxyAgent({ keepAlive: true, getProxyForUrl: () => url }))
|
|
51
|
+
return this.agents.get(url)
|
|
52
|
+
}
|
|
53
|
+
// One request to a peer through the proxy, for the status probe and the launch/stop relay.
|
|
54
|
+
async request(url, options) {
|
|
55
|
+
return requestThroughProxy(this.agent(), url, options)
|
|
56
|
+
}
|
|
57
|
+
// Finds or starts the forwarder for (dnsName, port); resolves to its loopback port.
|
|
58
|
+
async open(dnsName, port) {
|
|
59
|
+
const key = `${String(dnsName).toLowerCase()}:${Number(port)}`
|
|
60
|
+
const existing = this.servers.get(key)
|
|
61
|
+
if (existing) return existing.port || (await existing.pending).port
|
|
62
|
+
const pending = this.start(dnsName, Number(port))
|
|
63
|
+
this.servers.set(key, { pending })
|
|
64
|
+
try {
|
|
65
|
+
const entry = await pending
|
|
66
|
+
this.servers.set(key, entry)
|
|
67
|
+
return entry.port
|
|
68
|
+
} catch (error) {
|
|
69
|
+
this.servers.delete(key)
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async start(dnsName, port) {
|
|
74
|
+
const label = String(dnsName).split('.')[0]
|
|
75
|
+
const target = `${this.scheme}://${dnsName}:${port}`
|
|
76
|
+
const proxy = httpProxy.createProxyServer({ target, agent: this.agent(), changeOrigin: true, secure: true, ws: true })
|
|
77
|
+
proxy.on('proxyReq', (proxyReq) => { proxyReq.setHeader(FORWARDED_HEADER, '1') })
|
|
78
|
+
proxy.on('proxyReqWs', (proxyReq) => { proxyReq.setHeader(FORWARDED_HEADER, '1') })
|
|
79
|
+
proxy.on('error', (error, req, res) => {
|
|
80
|
+
const message = `Could not reach ${dnsName}: ${error && error.message ? error.message : error}`
|
|
81
|
+
if (res && typeof res.writeHead === 'function' && !res.headersSent) {
|
|
82
|
+
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
83
|
+
res.end(message)
|
|
84
|
+
} else if (res && typeof res.destroy === 'function') {
|
|
85
|
+
res.destroy()
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
const strip = (req) => { for (const name of STRIPPED_HEADERS) delete req.headers[name] }
|
|
89
|
+
const server = http.createServer((req, res) => {
|
|
90
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
91
|
+
if (url.pathname === OPEN_PATH) {
|
|
92
|
+
const port = Number(url.searchParams.get('port'))
|
|
93
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
94
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
95
|
+
res.end('Not a Remote port')
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
const route = openPath(label, port, url.searchParams.get('to') || '/')
|
|
99
|
+
res.writeHead(302, { Location: `http://127.0.0.1:${this.serverPort()}${route}`, 'Cache-Control': 'no-store' })
|
|
100
|
+
res.end()
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
strip(req)
|
|
104
|
+
proxy.web(req, res)
|
|
105
|
+
})
|
|
106
|
+
server.on('upgrade', (req, socket, head) => {
|
|
107
|
+
strip(req)
|
|
108
|
+
proxy.ws(req, socket, head)
|
|
109
|
+
})
|
|
110
|
+
// every connection on both sides, upgraded ones included, so stopAll can end a stream a
|
|
111
|
+
// tab still holds; destroying only the browser's side would leave the peer's side piped open
|
|
112
|
+
const sockets = new Set()
|
|
113
|
+
const track = (socket) => {
|
|
114
|
+
sockets.add(socket)
|
|
115
|
+
socket.once('close', () => sockets.delete(socket))
|
|
116
|
+
}
|
|
117
|
+
server.on('connection', track)
|
|
118
|
+
proxy.on('open', track)
|
|
119
|
+
await new Promise((resolve, reject) => {
|
|
120
|
+
server.once('error', reject)
|
|
121
|
+
server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve() })
|
|
122
|
+
})
|
|
123
|
+
return { port: server.address().port, server, proxy, sockets }
|
|
124
|
+
}
|
|
125
|
+
// Ends every forwarder, open streams included: they belong to the daemon that is going away.
|
|
126
|
+
async stopAll() {
|
|
127
|
+
const entries = [...this.servers.values()]
|
|
128
|
+
this.servers.clear()
|
|
129
|
+
for (const agent of this.agents.values()) if (typeof agent.destroy === 'function') agent.destroy()
|
|
130
|
+
this.agents.clear()
|
|
131
|
+
await Promise.all(entries.map(async (entry) => {
|
|
132
|
+
const live = entry.server ? entry : await entry.pending.catch(() => null)
|
|
133
|
+
if (!live) return
|
|
134
|
+
live.proxy.close()
|
|
135
|
+
for (const socket of live.sockets) socket.destroy()
|
|
136
|
+
await new Promise((resolve) => live.server.close(resolve))
|
|
137
|
+
}))
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Resolves { status, body }; rejects on a transport failure or when the whole
|
|
142
|
+
// request, the proxy's CONNECT included, exceeds the deadline.
|
|
143
|
+
function requestThroughProxy(agent, url, { method = 'GET', headers = {}, body = null, timeoutMs = 4000 } = {}) {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const target = new URL(url)
|
|
146
|
+
const lib = target.protocol === 'https:' ? https : http
|
|
147
|
+
// the deadline is kept here, not as the request's signal: while the agent's CONNECT is still
|
|
148
|
+
// pending the request has no socket, and Node reports an abort only once it gets one
|
|
149
|
+
const deadline = setTimeout(() => { reject(new Error(`no answer in ${Math.round(timeoutMs / 1000)} s`)); req.destroy() }, timeoutMs)
|
|
150
|
+
const req = lib.request(target, { method, headers, agent }, (res) => {
|
|
151
|
+
const chunks = []
|
|
152
|
+
res.on('data', (chunk) => chunks.push(chunk))
|
|
153
|
+
res.on('end', () => { clearTimeout(deadline); resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }) })
|
|
154
|
+
res.on('error', (error) => { clearTimeout(deadline); reject(error) })
|
|
155
|
+
})
|
|
156
|
+
req.on('error', (error) => { clearTimeout(deadline); reject(error) })
|
|
157
|
+
if (body != null) req.write(body)
|
|
158
|
+
req.end()
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { Forwarders, openPath, FORWARDED_HEADER }
|
package/kernel/anywhere/index.js
CHANGED
|
@@ -22,8 +22,10 @@ const crypto = require('crypto')
|
|
|
22
22
|
const { spawn, execFile, execFileSync } = require('child_process')
|
|
23
23
|
const TailscaleCLI = require('./cli')
|
|
24
24
|
const writeJson = require('./state')
|
|
25
|
-
const { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref, LOOPBACK_HOSTS } = require('./serve')
|
|
25
|
+
const { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref, rewriteHrefForwarded, LOOPBACK_HOSTS } = require('./serve')
|
|
26
26
|
const { loadPortMap, savePortMap, assignPorts } = require('./ports')
|
|
27
|
+
const { Forwarders } = require('./forward')
|
|
28
|
+
const net = require('net')
|
|
27
29
|
const Util = require('../util')
|
|
28
30
|
|
|
29
31
|
const DEFAULT_HOSTNAME = 'pinokio'
|
|
@@ -39,6 +41,10 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
39
41
|
class Anywhere {
|
|
40
42
|
constructor(kernel) {
|
|
41
43
|
this.kernel = kernel
|
|
44
|
+
// the daemon's outbound HTTP proxy, picked by pinokiod at each daemon start; null while down
|
|
45
|
+
this.proxyPort = null
|
|
46
|
+
this.ownedProxyPort = null // the port on an adopted daemon's command line, set by ownedPid
|
|
47
|
+
this.forwarders = new Forwarders({ proxyUrl: () => this.proxyUrl(), serverPort: () => this.serverPort() })
|
|
42
48
|
this.platform = kernel && kernel.platform ? kernel.platform : os.platform()
|
|
43
49
|
this.child = null
|
|
44
50
|
this.api = null
|
|
@@ -76,6 +82,9 @@ class Anywhere {
|
|
|
76
82
|
pidPath() {
|
|
77
83
|
return this.stateDir('tailscaled.pid')
|
|
78
84
|
}
|
|
85
|
+
proxyUrl() {
|
|
86
|
+
return this.proxyPort ? `http://127.0.0.1:${this.proxyPort}` : null
|
|
87
|
+
}
|
|
79
88
|
socketPath() {
|
|
80
89
|
if (this._socketPath) return this._socketPath
|
|
81
90
|
if (this.platform === 'win32') {
|
|
@@ -98,7 +107,8 @@ class Anywhere {
|
|
|
98
107
|
}
|
|
99
108
|
return null
|
|
100
109
|
}
|
|
101
|
-
|
|
110
|
+
// proxyPort: the outbound proxy's loopback port; null describes a daemon from before the proxy existed
|
|
111
|
+
daemonArgs(proxyPort = this.proxyPort) {
|
|
102
112
|
const args = [
|
|
103
113
|
'--tun=userspace-networking',
|
|
104
114
|
'--state', this.stateDir('tailscaled.state'),
|
|
@@ -106,6 +116,7 @@ class Anywhere {
|
|
|
106
116
|
'--socket', this.socketPath(),
|
|
107
117
|
'--port', '0',
|
|
108
118
|
]
|
|
119
|
+
if (proxyPort) args.push(`--outbound-http-proxy-listen=127.0.0.1:${proxyPort}`)
|
|
109
120
|
if (this.platform === 'win32') {
|
|
110
121
|
// 1.104+: run as a per-user console process, no service / admin needed
|
|
111
122
|
args.push('--windows-mode=dev')
|
|
@@ -215,7 +226,7 @@ class Anywhere {
|
|
|
215
226
|
if (!this.config.enabled) return false
|
|
216
227
|
if (!(await this.installed())) {
|
|
217
228
|
if (automatic) return false
|
|
218
|
-
throw new Error('Install the matching Tailscale daemon and CLI through
|
|
229
|
+
throw new Error('Install the matching Tailscale daemon and CLI through Remote setup.')
|
|
219
230
|
}
|
|
220
231
|
if (this.stopping) return false
|
|
221
232
|
this.error = null
|
|
@@ -234,10 +245,17 @@ class Anywhere {
|
|
|
234
245
|
const alive = await this.daemonAlive()
|
|
235
246
|
if (this.stopping) return false
|
|
236
247
|
if (alive && (!(await this.assertOwnership()) || this.stopping)) return false
|
|
248
|
+
// a daemon this process started keeps its port; an adopted one gives the port on its command
|
|
249
|
+
// line, none for a daemon from before the proxy existed, so forwarding waits for its next start
|
|
250
|
+
if (alive) this.proxyPort = this.child ? this.proxyPort : this.ownedProxyPort
|
|
237
251
|
if (!alive) {
|
|
238
252
|
const pid = await this.readPid()
|
|
239
253
|
if (this.stopping) return false
|
|
240
254
|
if (pid) throw new Error('tailscaled is still running but its private endpoint is unavailable; no second daemon was started.')
|
|
255
|
+
// forwarders of a daemon that died relay to its proxy port; a new daemon gets new ones
|
|
256
|
+
await this.forwarders.stopAll()
|
|
257
|
+
// a free port now; the daemon fails to start if something takes it first, and Try again picks another
|
|
258
|
+
this.proxyPort = await freeLoopbackPort()
|
|
241
259
|
const logFd = fs.openSync(this.logPath(), 'a')
|
|
242
260
|
const env = Object.assign({}, process.env, {
|
|
243
261
|
TS_LOGS_DIR: this.stateDir(),
|
|
@@ -255,6 +273,7 @@ class Anywhere {
|
|
|
255
273
|
if (this.child === child) {
|
|
256
274
|
// not a stop() of ours: surface it, the page offers "Try again"
|
|
257
275
|
this.child = null
|
|
276
|
+
this.proxyPort = null // its proxy went with it; the next start replaces the forwarders
|
|
258
277
|
if (!this.stopping && !this.error) this.error = `tailscaled exited (code=${code} signal=${signal})`
|
|
259
278
|
try { fs.rmSync(this.pidPath(), { force: true }) }
|
|
260
279
|
catch (error) { this.error = `Could not remove tailscaled PID record: ${error.message}` }
|
|
@@ -325,12 +344,18 @@ class Anywhere {
|
|
|
325
344
|
}
|
|
326
345
|
ownedPid(inspection, command) {
|
|
327
346
|
command = command.trim()
|
|
328
|
-
|
|
347
|
+
// the proxy port is whatever the running daemon was given; a daemon from before the
|
|
348
|
+
// proxy existed has none and is still ours. A match records the port for adoption.
|
|
349
|
+
const match = command.match(/--outbound-http-proxy-listen=127\.0\.0\.1:(\d+)/i)
|
|
350
|
+
const port = match ? Number(match[1]) : null
|
|
351
|
+
let expected = [this.binPath(), ...this.daemonArgs(port)].join(' ')
|
|
329
352
|
if (this.platform === 'win32') {
|
|
330
353
|
command = command.replace(/"/g, '').toLowerCase()
|
|
331
354
|
expected = expected.toLowerCase()
|
|
332
355
|
}
|
|
333
|
-
|
|
356
|
+
if (command !== expected) return null
|
|
357
|
+
this.ownedProxyPort = port
|
|
358
|
+
return inspection.pid
|
|
334
359
|
}
|
|
335
360
|
async readPid() {
|
|
336
361
|
const inspection = this.pidInspection()
|
|
@@ -372,6 +397,8 @@ class Anywhere {
|
|
|
372
397
|
this.child = null
|
|
373
398
|
this.login = null
|
|
374
399
|
this.api = null
|
|
400
|
+
this.proxyPort = null
|
|
401
|
+
await this.forwarders.stopAll()
|
|
375
402
|
fs.rmSync(this.pidPath(), { force: true })
|
|
376
403
|
this.lastStatus = null
|
|
377
404
|
this.lastStatusAt = 0
|
|
@@ -518,6 +545,7 @@ class Anywhere {
|
|
|
518
545
|
const generic = !hostName || /^(localhost|iphone|ipad|android)$/i.test(hostName)
|
|
519
546
|
peers.push({
|
|
520
547
|
name: (generic && dnsLabel) ? dnsLabel : (hostName || dnsLabel || key),
|
|
548
|
+
dnsName: p.DNSName ? String(p.DNSName).replace(/\.$/, '') : null,
|
|
521
549
|
os: osName,
|
|
522
550
|
mobile: MOBILE_OS.has(osName.toLowerCase()),
|
|
523
551
|
online: !!p.Online,
|
|
@@ -613,15 +641,24 @@ class Anywhere {
|
|
|
613
641
|
if (!dnsName || !this.config || !this.config.enabled) return false
|
|
614
642
|
return String(host || '').toLowerCase().replace(/:443$/, '') === dnsName.toLowerCase()
|
|
615
643
|
}
|
|
644
|
+
// A peer from `status --json` by the first label of its DNS name, the key
|
|
645
|
+
// of the peer routes; null for a name that is not a peer.
|
|
646
|
+
peerFor(label) {
|
|
647
|
+
if (!this.lastStatus || typeof label !== 'string') return null
|
|
648
|
+
const wanted = label.toLowerCase()
|
|
649
|
+
return this.peersFrom(this.lastStatus).find((p) => p.dnsName && p.dnsName.split('.')[0].toLowerCase() === wanted) || null
|
|
650
|
+
}
|
|
616
651
|
// Rewrites one menu link for a visitor arriving through the Tailscale
|
|
617
652
|
// address. Only a loopback link with a port is touched, and only when the
|
|
618
653
|
// request's Host header is this node's name; any other request gets the
|
|
619
|
-
// href back unchanged.
|
|
620
|
-
|
|
654
|
+
// href back unchanged. A visitor forwarded by another Pinokio gets the
|
|
655
|
+
// relative path its forwarder answers instead of this node's address.
|
|
656
|
+
rewriteHref(host, href, forwarded = false) {
|
|
621
657
|
if (!this.isNodeHost(host)) return href
|
|
622
|
-
|
|
658
|
+
const routes = describeServeConfig(this.lastServe, this.dnsNameFrom(this.lastStatus))
|
|
659
|
+
return forwarded ? rewriteHrefForwarded(href, routes) : rewriteHref(href, routes)
|
|
623
660
|
}
|
|
624
|
-
async rewriteMenu(host, menu) {
|
|
661
|
+
async rewriteMenu(host, menu, forwarded = false) {
|
|
625
662
|
if (!Array.isArray(menu) || !this.isNodeHost(host)) return menu
|
|
626
663
|
// Confirm new or changed routes on first render; refresh checks unchanged ones.
|
|
627
664
|
const apps = this.appTargets()
|
|
@@ -634,7 +671,7 @@ class Anywhere {
|
|
|
634
671
|
for (const item of items) {
|
|
635
672
|
if (!item || typeof item !== 'object') continue
|
|
636
673
|
if (typeof item.href === 'string') {
|
|
637
|
-
const next = this.rewriteHref(host, item.href)
|
|
674
|
+
const next = this.rewriteHref(host, item.href, forwarded)
|
|
638
675
|
if (next !== item.href) {
|
|
639
676
|
if (item.src === item.href) item.src = next
|
|
640
677
|
if (item.target_full === item.href) item.target_full = next
|
|
@@ -742,7 +779,7 @@ class Anywhere {
|
|
|
742
779
|
const dnsName = this.dnsNameFrom(status)
|
|
743
780
|
const address = dnsName ? `https://${dnsName}` : null
|
|
744
781
|
const account = this.accountFrom(status)
|
|
745
|
-
const peers = this.peersFrom(status)
|
|
782
|
+
const peers = this.peersFrom(status).map(({ dnsName, ...peer }) => peer) // DNS names serve the peer routes, not the payload
|
|
746
783
|
const out = Object.assign(base, {
|
|
747
784
|
running: true,
|
|
748
785
|
version: status.Version,
|
|
@@ -809,4 +846,16 @@ class Anywhere {
|
|
|
809
846
|
}
|
|
810
847
|
|
|
811
848
|
|
|
849
|
+
// A loopback port nothing is listening on right now, for the daemon's proxy.
|
|
850
|
+
function freeLoopbackPort() {
|
|
851
|
+
return new Promise((resolve, reject) => {
|
|
852
|
+
const probe = net.createServer()
|
|
853
|
+
probe.once('error', reject)
|
|
854
|
+
probe.listen(0, '127.0.0.1', () => {
|
|
855
|
+
const { port } = probe.address()
|
|
856
|
+
probe.close(() => resolve(port))
|
|
857
|
+
})
|
|
858
|
+
})
|
|
859
|
+
}
|
|
860
|
+
|
|
812
861
|
module.exports = Anywhere
|
package/kernel/anywhere/ports.js
CHANGED
|
@@ -37,7 +37,7 @@ function assignPorts(map, keys) {
|
|
|
37
37
|
for (const key of keys) {
|
|
38
38
|
if (Number.isInteger(out[key])) continue
|
|
39
39
|
while (used.has(next)) next += 1
|
|
40
|
-
if (next > LAST_PORT) throw new Error('
|
|
40
|
+
if (next > LAST_PORT) throw new Error('Remote has no free remote ports left')
|
|
41
41
|
out[key] = next
|
|
42
42
|
used.add(next)
|
|
43
43
|
changed = true
|
package/kernel/anywhere/serve.js
CHANGED
|
@@ -94,12 +94,28 @@ function sameServeConfig(a, b) {
|
|
|
94
94
|
return isDeepStrictEqual(a || null, b || null)
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
// the path every forwarder answers itself (spec/requirements/pinokio-anywhere-api.md, Peers)
|
|
98
|
+
const OPEN_PATH = '/.pinokio/open'
|
|
99
|
+
|
|
100
|
+
// A loopback link with a port that a Serve route carries: its route and the
|
|
101
|
+
// link's path, query and fragment; null for any other link.
|
|
102
|
+
function routedLink(href, routes) {
|
|
98
103
|
let url
|
|
99
|
-
try { url = new URL(String(href)) } catch (_) { return
|
|
100
|
-
if (!/^https?:$/.test(url.protocol) || !LOOPBACK_HOSTS.has(url.hostname) || !url.port) return
|
|
104
|
+
try { url = new URL(String(href)) } catch (_) { return null }
|
|
105
|
+
if (!/^https?:$/.test(url.protocol) || !LOOPBACK_HOSTS.has(url.hostname) || !url.port) return null
|
|
101
106
|
const route = routes.find((entry) => entry.local === Number(url.port))
|
|
102
|
-
return route ? route
|
|
107
|
+
return route ? { route, path: url.pathname + url.search + url.hash } : null
|
|
108
|
+
}
|
|
109
|
+
function rewriteHref(href, routes) {
|
|
110
|
+
const link = routedLink(href, routes)
|
|
111
|
+
return link ? link.route.url + link.path : href
|
|
112
|
+
}
|
|
113
|
+
// The same link for a visitor arriving through another Pinokio's forwarder: the
|
|
114
|
+
// published port and the path on the relative path the forwarder answers, so
|
|
115
|
+
// the caller maps the port on demand.
|
|
116
|
+
function rewriteHrefForwarded(href, routes) {
|
|
117
|
+
const link = routedLink(href, routes)
|
|
118
|
+
return link ? `${OPEN_PATH}?port=${link.route.port}&to=${encodeURIComponent(link.path)}` : href
|
|
103
119
|
}
|
|
104
120
|
|
|
105
|
-
module.exports = { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref, LOOPBACK_HOSTS, DEFAULT_HTTPS_PORT }
|
|
121
|
+
module.exports = { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref, rewriteHrefForwarded, LOOPBACK_HOSTS, DEFAULT_HTTPS_PORT, OPEN_PATH }
|
package/kernel/bin/conda.js
CHANGED
|
@@ -274,7 +274,7 @@ report_errors: false`)
|
|
|
274
274
|
}
|
|
275
275
|
|
|
276
276
|
if (this.kernel.anywhere) {
|
|
277
|
-
ondata({ raw: `stopping
|
|
277
|
+
ondata({ raw: `stopping Remote before replacing Miniforge...\r\n` })
|
|
278
278
|
await this.kernel.anywhere.stop()
|
|
279
279
|
}
|
|
280
280
|
|
package/kernel/bin/setup.js
CHANGED
|
@@ -275,7 +275,7 @@ module.exports = {
|
|
|
275
275
|
]
|
|
276
276
|
return {
|
|
277
277
|
icon: "fa-solid fa-mobile-screen",
|
|
278
|
-
title: "
|
|
278
|
+
title: "Remote",
|
|
279
279
|
description: "Reach this Pinokio and its apps from your phone or another computer over a private Tailscale network",
|
|
280
280
|
requirements,
|
|
281
281
|
conda_requirements: [
|
package/kernel/bin/tailscaled.js
CHANGED
|
@@ -7,7 +7,7 @@ const run = promisify(execFile)
|
|
|
7
7
|
const VERSION = '1.104.1'
|
|
8
8
|
|
|
9
9
|
class Tailscaled {
|
|
10
|
-
description = "Installs the Tailscale daemon for
|
|
10
|
+
description = "Installs the Tailscale daemon for Remote: reach this Pinokio and its apps from your phone or another computer over a private network."
|
|
11
11
|
cmd() {
|
|
12
12
|
return `tailscaled=${VERSION} tailscale=${VERSION}`
|
|
13
13
|
}
|
|
@@ -67,7 +67,7 @@ class Tailscaled {
|
|
|
67
67
|
})
|
|
68
68
|
}
|
|
69
69
|
async uninstall(req, ondata) {
|
|
70
|
-
ondata({ raw: "stopping
|
|
70
|
+
ondata({ raw: "stopping Remote\r\n" })
|
|
71
71
|
if (this.kernel.anywhere && typeof this.kernel.anywhere.stop === "function") {
|
|
72
72
|
await this.kernel.anywhere.stop()
|
|
73
73
|
}
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -35,6 +35,8 @@ const TerminalApi = require('../kernel/api/terminal')
|
|
|
35
35
|
const PluginSources = require("../kernel/plugin_sources")
|
|
36
36
|
const ManagedSkills = require("../kernel/managed_skills")
|
|
37
37
|
const { rewriteHref: rewriteAnywhereHref, LOOPBACK_HOSTS: ANYWHERE_LOOPBACK_HOSTS } = require("../kernel/anywhere/serve")
|
|
38
|
+
const { openPath: anywhereOpenPath, FORWARDED_HEADER: ANYWHERE_FORWARDED_HEADER } = require("../kernel/anywhere/forward")
|
|
39
|
+
const { FIRST_PORT: ANYWHERE_FIRST_PORT, LAST_PORT: ANYWHERE_LAST_PORT } = require("../kernel/anywhere/ports")
|
|
38
40
|
const { menuTargets: launcherMenuTargets, isWebUrl } = require("../kernel/api/launcher_target")
|
|
39
41
|
const { isPathRemovalBlockedError, removePath } = require("../kernel/path_removal")
|
|
40
42
|
|
|
@@ -1049,6 +1051,117 @@ class Server {
|
|
|
1049
1051
|
const dnsName = anywhere && typeof anywhere.dnsNameFrom === "function" ? anywhere.dnsNameFrom(anywhere.lastStatus) : null
|
|
1050
1052
|
return !!dnsName && parsed.protocol === "https:" && host === dnsName.toLowerCase()
|
|
1051
1053
|
}
|
|
1054
|
+
// a Host header on this computer itself (localhost, 127.0.0.1, [::1], with or without a port)
|
|
1055
|
+
isLoopbackHost(host) {
|
|
1056
|
+
const name = String(host || "").toLowerCase().replace(/:\d+$/, "")
|
|
1057
|
+
return name === "localhost" || name === "127.0.0.1" || name === "[::1]"
|
|
1058
|
+
}
|
|
1059
|
+
// A request from a browser on this computer, as opposed to one that came through Tailscale
|
|
1060
|
+
// Serve or another Pinokio: those also arrive on loopback, and a tailnet client chooses its
|
|
1061
|
+
// own Host, but it cannot strip the headers Serve adds on the way or the peer marker.
|
|
1062
|
+
isLocalRequest(req) {
|
|
1063
|
+
const socket = req.socket && req.socket.remoteAddress
|
|
1064
|
+
const loopback = socket === "127.0.0.1" || socket === "::1" || socket === "::ffff:127.0.0.1"
|
|
1065
|
+
return loopback && this.isLoopbackHost(req.get("host")) && !req.get("X-Forwarded-For") && !req.get("X-Pinokio-Peer")
|
|
1066
|
+
}
|
|
1067
|
+
// the peer routes act on this computer's behalf: served on this computer only, to its own
|
|
1068
|
+
// page only. Their redirects end on 127.0.0.1, which mean nothing anywhere else.
|
|
1069
|
+
anywherePeerAllowed(req) {
|
|
1070
|
+
if (!this.isLocalRequest(req)) return false
|
|
1071
|
+
const origin = req.get("Origin")
|
|
1072
|
+
if (!origin) return true
|
|
1073
|
+
try { return new URL(origin).host.toLowerCase() === String(req.get("host")).toLowerCase() } catch (_) { return false }
|
|
1074
|
+
}
|
|
1075
|
+
// the reason the peer routes cannot work right now, or null
|
|
1076
|
+
anywherePeerUnavailable() {
|
|
1077
|
+
const anywhere = this.kernel.anywhere
|
|
1078
|
+
if (anywhere.proxyUrl()) return null
|
|
1079
|
+
return anywhere.lastStatus && !anywhere.error ? "Turn Remote off and on to reach your other Pinokios" : "Remote is not running on this computer"
|
|
1080
|
+
}
|
|
1081
|
+
// Remote from computer to computer (spec/requirements/pinokio-anywhere-api.md, Peers):
|
|
1082
|
+
// the other Pinokios on the tailnet, found by asking each online computer for its status
|
|
1083
|
+
// through the daemon's proxy, their running apps linked through this node's forwarders.
|
|
1084
|
+
async composeAnywherePeers() {
|
|
1085
|
+
const anywhere = this.kernel.anywhere
|
|
1086
|
+
const unavailable = this.anywherePeerUnavailable()
|
|
1087
|
+
if (unavailable) return { status: 502, body: { error: unavailable } }
|
|
1088
|
+
const candidates = anywhere.peersFrom(anywhere.lastStatus).filter((peer) => peer.online && !peer.mobile && peer.dnsName)
|
|
1089
|
+
const probes = await Promise.allSettled(candidates.map(async (peer) => {
|
|
1090
|
+
const answer = await anywhere.forwarders.request(`https://${peer.dnsName}/anywhere/status`, { timeoutMs: 4000 })
|
|
1091
|
+
if (answer.status !== 200) return null
|
|
1092
|
+
let status
|
|
1093
|
+
try { status = JSON.parse(answer.body) } catch (_) { return null }
|
|
1094
|
+
if (!status || typeof status.phase !== "string") return null
|
|
1095
|
+
const label = peer.dnsName.split(".")[0]
|
|
1096
|
+
const apps = Array.isArray(status.apps) ? status.apps : []
|
|
1097
|
+
return {
|
|
1098
|
+
name: label,
|
|
1099
|
+
apps: apps.map((app) => {
|
|
1100
|
+
const { remote, ...rest } = app
|
|
1101
|
+
return Object.assign(rest, { open: this.anywherePeerOpenPath(label, remote) })
|
|
1102
|
+
}),
|
|
1103
|
+
}
|
|
1104
|
+
}))
|
|
1105
|
+
return { status: 200, body: { peers: probes.map((probe) => probe.status === "fulfilled" ? probe.value : null).filter(Boolean) } }
|
|
1106
|
+
}
|
|
1107
|
+
// this node's open route for a peer's published address, or null for no address
|
|
1108
|
+
anywherePeerOpenPath(label, remote) {
|
|
1109
|
+
if (typeof remote !== "string") return null
|
|
1110
|
+
let url
|
|
1111
|
+
try { url = new URL(remote) } catch (_) { return null }
|
|
1112
|
+
return anywhereOpenPath(label, url.port ? Number(url.port) : 443, url.pathname + url.search + url.hash)
|
|
1113
|
+
}
|
|
1114
|
+
// GET /anywhere/peers/<name>/open?port=<n>&to=<path>: { status, headers } or { status, body }
|
|
1115
|
+
async anywherePeerOpen(name, query = {}) {
|
|
1116
|
+
const anywhere = this.kernel.anywhere
|
|
1117
|
+
const peer = anywhere.peerFor(name)
|
|
1118
|
+
if (!peer) return { status: 400, body: { error: `Not a computer on your network: ${name}` } }
|
|
1119
|
+
const port = Number(query.port)
|
|
1120
|
+
if (!(port === 443 || (Number.isInteger(port) && port >= ANYWHERE_FIRST_PORT && port <= ANYWHERE_LAST_PORT))) {
|
|
1121
|
+
return { status: 400, body: { error: `Not a Remote port: ${query.port}` } }
|
|
1122
|
+
}
|
|
1123
|
+
const unavailable = this.anywherePeerUnavailable()
|
|
1124
|
+
if (unavailable) return { status: 502, body: { error: unavailable } }
|
|
1125
|
+
let local
|
|
1126
|
+
try {
|
|
1127
|
+
local = await anywhere.forwarders.open(peer.dnsName, port)
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
return { status: 500, body: { error: error && error.message ? error.message : String(error) } }
|
|
1130
|
+
}
|
|
1131
|
+
// `to` resolved against the forwarder: encoded for the header, and never another origin
|
|
1132
|
+
const base = `http://127.0.0.1:${local}/`
|
|
1133
|
+
let location
|
|
1134
|
+
try { location = new URL(typeof query.to === "string" ? query.to : "/", base) } catch (_) { location = new URL(base) }
|
|
1135
|
+
if (location.origin !== new URL(base).origin) location = new URL(base)
|
|
1136
|
+
return { status: 302, headers: { Location: location.href, "Cache-Control": "no-store" } }
|
|
1137
|
+
}
|
|
1138
|
+
// POST /anywhere/peers/<name>/apps/<launch|stop>: the peer's answer, an open URL turned into this node's open route
|
|
1139
|
+
async relayAnywherePeerAction(name, action, body = {}) {
|
|
1140
|
+
const anywhere = this.kernel.anywhere
|
|
1141
|
+
const peer = anywhere.peerFor(name)
|
|
1142
|
+
if (!peer) return { status: 400, body: { error: `Not a computer on your network: ${name}` } }
|
|
1143
|
+
const unavailable = this.anywherePeerUnavailable()
|
|
1144
|
+
if (unavailable) return { status: 502, body: { error: unavailable } }
|
|
1145
|
+
let answer
|
|
1146
|
+
try {
|
|
1147
|
+
answer = await anywhere.forwarders.request(`https://${peer.dnsName}/anywhere/apps/${action}`, {
|
|
1148
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}), timeoutMs: 30000,
|
|
1149
|
+
})
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
return { status: 502, body: { error: `Could not reach ${peer.name}: ${error && error.message ? error.message : error}` } }
|
|
1152
|
+
}
|
|
1153
|
+
let parsed
|
|
1154
|
+
try { parsed = JSON.parse(answer.body) } catch (_) {
|
|
1155
|
+
return { status: 502, body: { error: `${peer.name} did not answer with JSON (HTTP ${answer.status})` } }
|
|
1156
|
+
}
|
|
1157
|
+
// only the peer's own published address becomes this node's open route; any other URL passes through
|
|
1158
|
+
if (parsed && parsed.action === "open" && typeof parsed.url === "string") {
|
|
1159
|
+
let host = null
|
|
1160
|
+
try { host = new URL(parsed.url).hostname.toLowerCase() } catch (_) { }
|
|
1161
|
+
if (host === peer.dnsName.toLowerCase()) parsed.url = this.anywherePeerOpenPath(peer.dnsName.split(".")[0], parsed.url)
|
|
1162
|
+
}
|
|
1163
|
+
return { status: answer.status, body: parsed }
|
|
1164
|
+
}
|
|
1052
1165
|
// the app's menu as launch targets: scripts and web addresses, never interface links;
|
|
1053
1166
|
// text is plain: a launcher may write HTML, which the desktop renders and the phone does not
|
|
1054
1167
|
async anywhereMenu(api, repoPath) {
|
|
@@ -5076,7 +5189,8 @@ class Server {
|
|
|
5076
5189
|
// published app's fixed https address; any other host is left for the
|
|
5077
5190
|
// Home Server rewrite below, exactly as before.
|
|
5078
5191
|
if (this.kernel.anywhere) {
|
|
5079
|
-
|
|
5192
|
+
// a visitor forwarded by another Pinokio gets links its forwarder answers (API spec, Peers)
|
|
5193
|
+
await this.kernel.anywhere.rewriteMenu(req.$source && req.$source.host, config.menu, req.get(ANYWHERE_FORWARDED_HEADER) === "1")
|
|
5080
5194
|
}
|
|
5081
5195
|
config = Util.rewrite_localhost(this.kernel, config, req.$source)
|
|
5082
5196
|
return config
|
|
@@ -14360,6 +14474,8 @@ class Server {
|
|
|
14360
14474
|
status,
|
|
14361
14475
|
theme: this.theme,
|
|
14362
14476
|
agent: req.agent,
|
|
14477
|
+
// Your other Pinokios links end on 127.0.0.1, which mean nothing to a phone reading this page
|
|
14478
|
+
loopback: this.isLocalRequest(req),
|
|
14363
14479
|
})
|
|
14364
14480
|
}))
|
|
14365
14481
|
this.app.get("/anywhere/status", ex(async (req, res) => {
|
|
@@ -14386,10 +14502,30 @@ class Server {
|
|
|
14386
14502
|
})
|
|
14387
14503
|
this.app.post("/anywhere/apps/launch", anywhereAppAction((body) => this.composeAnywhereLaunch(body)))
|
|
14388
14504
|
this.app.post("/anywhere/apps/stop", anywhereAppAction((body) => this.composeAnywhereStop(body)))
|
|
14505
|
+
// the other Pinokios on the tailnet, for this computer's own Remote page (API spec, Peers)
|
|
14506
|
+
const anywherePeerRoute = (fn) => ex(async (req, res) => {
|
|
14507
|
+
res.set("Cache-Control", "no-store")
|
|
14508
|
+
if (!this.anywherePeerAllowed(req)) {
|
|
14509
|
+
res.status(403).json({ error: "Allowed only on this computer" })
|
|
14510
|
+
return
|
|
14511
|
+
}
|
|
14512
|
+
try {
|
|
14513
|
+
const answer = await fn(req)
|
|
14514
|
+
if (answer.headers) res.set(answer.headers)
|
|
14515
|
+
if (answer.body === undefined) res.status(answer.status).end()
|
|
14516
|
+
else res.status(answer.status).json(answer.body)
|
|
14517
|
+
} catch (error) {
|
|
14518
|
+
res.status(500).json({ error: error && error.message ? error.message : String(error) })
|
|
14519
|
+
}
|
|
14520
|
+
})
|
|
14521
|
+
this.app.get("/anywhere/peers", anywherePeerRoute(() => this.composeAnywherePeers()))
|
|
14522
|
+
this.app.get("/anywhere/peers/:name/open", anywherePeerRoute((req) => this.anywherePeerOpen(req.params.name, req.query)))
|
|
14523
|
+
this.app.post("/anywhere/peers/:name/apps/launch", anywherePeerRoute((req) => this.relayAnywherePeerAction(req.params.name, "launch", req.body)))
|
|
14524
|
+
this.app.post("/anywhere/peers/:name/apps/stop", anywherePeerRoute((req) => this.relayAnywherePeerAction(req.params.name, "stop", req.body)))
|
|
14389
14525
|
const anywhereAction = (label, fn) => ex(async (req, res) => {
|
|
14390
14526
|
const anywhere = this.kernel.anywhere
|
|
14391
14527
|
if (!anywhere) {
|
|
14392
|
-
res.status(500).json({ success: false, error: "
|
|
14528
|
+
res.status(500).json({ success: false, error: "Remote is not available" })
|
|
14393
14529
|
return
|
|
14394
14530
|
}
|
|
14395
14531
|
try {
|