pinokiod 8.2.33 → 8.2.34
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 +1 -60
- 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
package/kernel/anywhere/index.js
CHANGED
|
@@ -22,10 +22,8 @@ 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,
|
|
25
|
+
const { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref, LOOPBACK_HOSTS } = require('./serve')
|
|
26
26
|
const { loadPortMap, savePortMap, assignPorts } = require('./ports')
|
|
27
|
-
const { Forwarders } = require('./forward')
|
|
28
|
-
const net = require('net')
|
|
29
27
|
const Util = require('../util')
|
|
30
28
|
|
|
31
29
|
const DEFAULT_HOSTNAME = 'pinokio'
|
|
@@ -41,10 +39,6 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
41
39
|
class Anywhere {
|
|
42
40
|
constructor(kernel) {
|
|
43
41
|
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() })
|
|
48
42
|
this.platform = kernel && kernel.platform ? kernel.platform : os.platform()
|
|
49
43
|
this.child = null
|
|
50
44
|
this.api = null
|
|
@@ -82,9 +76,6 @@ class Anywhere {
|
|
|
82
76
|
pidPath() {
|
|
83
77
|
return this.stateDir('tailscaled.pid')
|
|
84
78
|
}
|
|
85
|
-
proxyUrl() {
|
|
86
|
-
return this.proxyPort ? `http://127.0.0.1:${this.proxyPort}` : null
|
|
87
|
-
}
|
|
88
79
|
socketPath() {
|
|
89
80
|
if (this._socketPath) return this._socketPath
|
|
90
81
|
if (this.platform === 'win32') {
|
|
@@ -107,8 +98,7 @@ class Anywhere {
|
|
|
107
98
|
}
|
|
108
99
|
return null
|
|
109
100
|
}
|
|
110
|
-
|
|
111
|
-
daemonArgs(proxyPort = this.proxyPort) {
|
|
101
|
+
daemonArgs() {
|
|
112
102
|
const args = [
|
|
113
103
|
'--tun=userspace-networking',
|
|
114
104
|
'--state', this.stateDir('tailscaled.state'),
|
|
@@ -116,7 +106,6 @@ class Anywhere {
|
|
|
116
106
|
'--socket', this.socketPath(),
|
|
117
107
|
'--port', '0',
|
|
118
108
|
]
|
|
119
|
-
if (proxyPort) args.push(`--outbound-http-proxy-listen=127.0.0.1:${proxyPort}`)
|
|
120
109
|
if (this.platform === 'win32') {
|
|
121
110
|
// 1.104+: run as a per-user console process, no service / admin needed
|
|
122
111
|
args.push('--windows-mode=dev')
|
|
@@ -245,17 +234,10 @@ class Anywhere {
|
|
|
245
234
|
const alive = await this.daemonAlive()
|
|
246
235
|
if (this.stopping) return false
|
|
247
236
|
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
|
|
251
237
|
if (!alive) {
|
|
252
238
|
const pid = await this.readPid()
|
|
253
239
|
if (this.stopping) return false
|
|
254
240
|
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()
|
|
259
241
|
const logFd = fs.openSync(this.logPath(), 'a')
|
|
260
242
|
const env = Object.assign({}, process.env, {
|
|
261
243
|
TS_LOGS_DIR: this.stateDir(),
|
|
@@ -273,7 +255,6 @@ class Anywhere {
|
|
|
273
255
|
if (this.child === child) {
|
|
274
256
|
// not a stop() of ours: surface it, the page offers "Try again"
|
|
275
257
|
this.child = null
|
|
276
|
-
this.proxyPort = null // its proxy went with it; the next start replaces the forwarders
|
|
277
258
|
if (!this.stopping && !this.error) this.error = `tailscaled exited (code=${code} signal=${signal})`
|
|
278
259
|
try { fs.rmSync(this.pidPath(), { force: true }) }
|
|
279
260
|
catch (error) { this.error = `Could not remove tailscaled PID record: ${error.message}` }
|
|
@@ -344,18 +325,12 @@ class Anywhere {
|
|
|
344
325
|
}
|
|
345
326
|
ownedPid(inspection, command) {
|
|
346
327
|
command = command.trim()
|
|
347
|
-
|
|
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(' ')
|
|
328
|
+
let expected = [this.binPath(), ...this.daemonArgs()].join(' ')
|
|
352
329
|
if (this.platform === 'win32') {
|
|
353
330
|
command = command.replace(/"/g, '').toLowerCase()
|
|
354
331
|
expected = expected.toLowerCase()
|
|
355
332
|
}
|
|
356
|
-
|
|
357
|
-
this.ownedProxyPort = port
|
|
358
|
-
return inspection.pid
|
|
333
|
+
return command === expected ? inspection.pid : null
|
|
359
334
|
}
|
|
360
335
|
async readPid() {
|
|
361
336
|
const inspection = this.pidInspection()
|
|
@@ -397,8 +372,6 @@ class Anywhere {
|
|
|
397
372
|
this.child = null
|
|
398
373
|
this.login = null
|
|
399
374
|
this.api = null
|
|
400
|
-
this.proxyPort = null
|
|
401
|
-
await this.forwarders.stopAll()
|
|
402
375
|
fs.rmSync(this.pidPath(), { force: true })
|
|
403
376
|
this.lastStatus = null
|
|
404
377
|
this.lastStatusAt = 0
|
|
@@ -545,7 +518,6 @@ class Anywhere {
|
|
|
545
518
|
const generic = !hostName || /^(localhost|iphone|ipad|android)$/i.test(hostName)
|
|
546
519
|
peers.push({
|
|
547
520
|
name: (generic && dnsLabel) ? dnsLabel : (hostName || dnsLabel || key),
|
|
548
|
-
dnsName: p.DNSName ? String(p.DNSName).replace(/\.$/, '') : null,
|
|
549
521
|
os: osName,
|
|
550
522
|
mobile: MOBILE_OS.has(osName.toLowerCase()),
|
|
551
523
|
online: !!p.Online,
|
|
@@ -641,24 +613,15 @@ class Anywhere {
|
|
|
641
613
|
if (!dnsName || !this.config || !this.config.enabled) return false
|
|
642
614
|
return String(host || '').toLowerCase().replace(/:443$/, '') === dnsName.toLowerCase()
|
|
643
615
|
}
|
|
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
|
-
}
|
|
651
616
|
// Rewrites one menu link for a visitor arriving through the Tailscale
|
|
652
617
|
// address. Only a loopback link with a port is touched, and only when the
|
|
653
618
|
// request's Host header is this node's name; any other request gets the
|
|
654
|
-
// href back unchanged.
|
|
655
|
-
|
|
656
|
-
rewriteHref(host, href, forwarded = false) {
|
|
619
|
+
// href back unchanged.
|
|
620
|
+
rewriteHref(host, href) {
|
|
657
621
|
if (!this.isNodeHost(host)) return href
|
|
658
|
-
|
|
659
|
-
return forwarded ? rewriteHrefForwarded(href, routes) : rewriteHref(href, routes)
|
|
622
|
+
return rewriteHref(href, describeServeConfig(this.lastServe, this.dnsNameFrom(this.lastStatus)))
|
|
660
623
|
}
|
|
661
|
-
async rewriteMenu(host, menu
|
|
624
|
+
async rewriteMenu(host, menu) {
|
|
662
625
|
if (!Array.isArray(menu) || !this.isNodeHost(host)) return menu
|
|
663
626
|
// Confirm new or changed routes on first render; refresh checks unchanged ones.
|
|
664
627
|
const apps = this.appTargets()
|
|
@@ -671,7 +634,7 @@ class Anywhere {
|
|
|
671
634
|
for (const item of items) {
|
|
672
635
|
if (!item || typeof item !== 'object') continue
|
|
673
636
|
if (typeof item.href === 'string') {
|
|
674
|
-
const next = this.rewriteHref(host, item.href
|
|
637
|
+
const next = this.rewriteHref(host, item.href)
|
|
675
638
|
if (next !== item.href) {
|
|
676
639
|
if (item.src === item.href) item.src = next
|
|
677
640
|
if (item.target_full === item.href) item.target_full = next
|
|
@@ -779,7 +742,7 @@ class Anywhere {
|
|
|
779
742
|
const dnsName = this.dnsNameFrom(status)
|
|
780
743
|
const address = dnsName ? `https://${dnsName}` : null
|
|
781
744
|
const account = this.accountFrom(status)
|
|
782
|
-
const peers = this.peersFrom(status)
|
|
745
|
+
const peers = this.peersFrom(status)
|
|
783
746
|
const out = Object.assign(base, {
|
|
784
747
|
running: true,
|
|
785
748
|
version: status.Version,
|
|
@@ -846,16 +809,4 @@ class Anywhere {
|
|
|
846
809
|
}
|
|
847
810
|
|
|
848
811
|
|
|
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
|
-
|
|
861
812
|
module.exports = Anywhere
|
package/kernel/anywhere/serve.js
CHANGED
|
@@ -94,28 +94,12 @@ function sameServeConfig(a, b) {
|
|
|
94
94
|
return isDeepStrictEqual(a || null, b || null)
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
|
|
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) {
|
|
97
|
+
function rewriteHref(href, routes) {
|
|
103
98
|
let url
|
|
104
|
-
try { url = new URL(String(href)) } catch (_) { return
|
|
105
|
-
if (!/^https?:$/.test(url.protocol) || !LOOPBACK_HOSTS.has(url.hostname) || !url.port) return
|
|
99
|
+
try { url = new URL(String(href)) } catch (_) { return href }
|
|
100
|
+
if (!/^https?:$/.test(url.protocol) || !LOOPBACK_HOSTS.has(url.hostname) || !url.port) return href
|
|
106
101
|
const route = routes.find((entry) => entry.local === Number(url.port))
|
|
107
|
-
return route ?
|
|
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
|
|
102
|
+
return route ? route.url + url.pathname + url.search + url.hash : href
|
|
119
103
|
}
|
|
120
104
|
|
|
121
|
-
module.exports = { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref,
|
|
105
|
+
module.exports = { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref, LOOPBACK_HOSTS, DEFAULT_HTTPS_PORT }
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -35,8 +35,6 @@ 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")
|
|
40
38
|
const { menuTargets: launcherMenuTargets, isWebUrl } = require("../kernel/api/launcher_target")
|
|
41
39
|
const { isPathRemovalBlockedError, removePath } = require("../kernel/path_removal")
|
|
42
40
|
|
|
@@ -1051,122 +1049,6 @@ class Server {
|
|
|
1051
1049
|
const dnsName = anywhere && typeof anywhere.dnsNameFrom === "function" ? anywhere.dnsNameFrom(anywhere.lastStatus) : null
|
|
1052
1050
|
return !!dnsName && parsed.protocol === "https:" && host === dnsName.toLowerCase()
|
|
1053
1051
|
}
|
|
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 || origin === "https://pinokio.co") 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
|
-
address: `https://${peer.dnsName}`,
|
|
1100
|
-
apps: apps.map((app) => {
|
|
1101
|
-
const { remote, ...rest } = app
|
|
1102
|
-
return Object.assign(rest, { open: this.anywherePeerOpenPath(label, remote) })
|
|
1103
|
-
}),
|
|
1104
|
-
}
|
|
1105
|
-
}))
|
|
1106
|
-
return { status: 200, body: { peers: probes.map((probe) => probe.status === "fulfilled" ? probe.value : null).filter(Boolean) } }
|
|
1107
|
-
}
|
|
1108
|
-
// this node's open route for a peer's published address, or null for no address
|
|
1109
|
-
anywherePeerOpenPath(label, remote) {
|
|
1110
|
-
if (typeof remote !== "string") return null
|
|
1111
|
-
let url
|
|
1112
|
-
try { url = new URL(remote) } catch (_) { return null }
|
|
1113
|
-
return anywhereOpenPath(label, url.port ? Number(url.port) : 443, url.pathname + url.search + url.hash)
|
|
1114
|
-
}
|
|
1115
|
-
// GET /anywhere/peers/<name>/open?port=<n>&to=<path>: { status, headers } or { status, body }
|
|
1116
|
-
async anywherePeerOpen(name, query = {}) {
|
|
1117
|
-
const anywhere = this.kernel.anywhere
|
|
1118
|
-
const peer = anywhere.peerFor(name)
|
|
1119
|
-
if (!peer) return { status: 400, body: { error: `Not a computer on your network: ${name}` } }
|
|
1120
|
-
const port = Number(query.port)
|
|
1121
|
-
if (!(port === 443 || (Number.isInteger(port) && port >= ANYWHERE_FIRST_PORT && port <= ANYWHERE_LAST_PORT))) {
|
|
1122
|
-
return { status: 400, body: { error: `Not a Remote port: ${query.port}` } }
|
|
1123
|
-
}
|
|
1124
|
-
const unavailable = this.anywherePeerUnavailable()
|
|
1125
|
-
if (unavailable) return { status: 502, body: { error: unavailable } }
|
|
1126
|
-
let local
|
|
1127
|
-
try {
|
|
1128
|
-
local = await anywhere.forwarders.open(peer.dnsName, port)
|
|
1129
|
-
} catch (error) {
|
|
1130
|
-
return { status: 500, body: { error: error && error.message ? error.message : String(error) } }
|
|
1131
|
-
}
|
|
1132
|
-
// `to` resolved against the forwarder: encoded for the header, and never another origin
|
|
1133
|
-
const base = `http://127.0.0.1:${local}/`
|
|
1134
|
-
let location
|
|
1135
|
-
try { location = new URL(typeof query.to === "string" ? query.to : "/", base) } catch (_) { location = new URL(base) }
|
|
1136
|
-
if (location.origin !== new URL(base).origin) location = new URL(base)
|
|
1137
|
-
return { status: 302, headers: { Location: location.href, "Cache-Control": "no-store" } }
|
|
1138
|
-
}
|
|
1139
|
-
// /anywhere/peers/<name>/<path>: the peer's own API through the daemon's proxy (API spec, Relays).
|
|
1140
|
-
// JSON routes get the peer's published addresses turned into this node's open routes, absolute,
|
|
1141
|
-
// since the client may be pinokio.co on another origin; an asset is passed through as it is.
|
|
1142
|
-
async relayAnywherePeer(name, method, relayPath, body = {}) {
|
|
1143
|
-
const anywhere = this.kernel.anywhere
|
|
1144
|
-
const peer = anywhere.peerFor(name)
|
|
1145
|
-
if (!peer) return { status: 400, body: { error: `Not a computer on your network: ${name}` } }
|
|
1146
|
-
const unavailable = this.anywherePeerUnavailable()
|
|
1147
|
-
if (unavailable) return { status: 502, body: { error: unavailable } }
|
|
1148
|
-
const json = relayPath.startsWith("anywhere/")
|
|
1149
|
-
let answer
|
|
1150
|
-
try {
|
|
1151
|
-
answer = await anywhere.forwarders.request(`https://${peer.dnsName}/${relayPath}`, method === "POST"
|
|
1152
|
-
? { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}), timeoutMs: 30000 }
|
|
1153
|
-
: { method, timeoutMs: 8000 })
|
|
1154
|
-
} catch (error) {
|
|
1155
|
-
return { status: 502, body: { error: `Could not reach ${peer.name}: ${error && error.message ? error.message : error}` } }
|
|
1156
|
-
}
|
|
1157
|
-
if (!json) return { status: answer.status, raw: answer.raw, contentType: answer.headers["content-type"] || "application/octet-stream" }
|
|
1158
|
-
let parsed
|
|
1159
|
-
try { parsed = JSON.parse(answer.body) } catch (_) {
|
|
1160
|
-
return { status: 502, body: { error: `${peer.name} did not answer with JSON (HTTP ${answer.status})` } }
|
|
1161
|
-
}
|
|
1162
|
-
const own = (url) => { try { return new URL(url).hostname.toLowerCase() === peer.dnsName.toLowerCase() } catch (_) { return false } }
|
|
1163
|
-
const route = (url) => `http://127.0.0.1:${anywhere.serverPort()}${this.anywherePeerOpenPath(peer.dnsName.split(".")[0], url)}`
|
|
1164
|
-
if (parsed && Array.isArray(parsed.apps)) {
|
|
1165
|
-
for (const app of parsed.apps) if (app && typeof app.remote === "string" && own(app.remote)) app.remote = route(app.remote)
|
|
1166
|
-
}
|
|
1167
|
-
if (parsed && parsed.action === "open" && typeof parsed.url === "string" && own(parsed.url)) parsed.url = route(parsed.url)
|
|
1168
|
-
return { status: answer.status, body: parsed }
|
|
1169
|
-
}
|
|
1170
1052
|
// the app's menu as launch targets: scripts and web addresses, never interface links;
|
|
1171
1053
|
// text is plain: a launcher may write HTML, which the desktop renders and the phone does not
|
|
1172
1054
|
async anywhereMenu(api, repoPath) {
|
|
@@ -5194,8 +5076,7 @@ class Server {
|
|
|
5194
5076
|
// published app's fixed https address; any other host is left for the
|
|
5195
5077
|
// Home Server rewrite below, exactly as before.
|
|
5196
5078
|
if (this.kernel.anywhere) {
|
|
5197
|
-
|
|
5198
|
-
await this.kernel.anywhere.rewriteMenu(req.$source && req.$source.host, config.menu, req.get(ANYWHERE_FORWARDED_HEADER) === "1")
|
|
5079
|
+
await this.kernel.anywhere.rewriteMenu(req.$source && req.$source.host, config.menu)
|
|
5199
5080
|
}
|
|
5200
5081
|
config = Util.rewrite_localhost(this.kernel, config, req.$source)
|
|
5201
5082
|
return config
|
|
@@ -7007,12 +6888,6 @@ class Server {
|
|
|
7007
6888
|
this.kernel.vault.automaticScans.supported)
|
|
7008
6889
|
next()
|
|
7009
6890
|
})
|
|
7010
|
-
// pinokio.co's Remote tab reaches the peer routes from a browser on this computer; Chrome's
|
|
7011
|
-
// local network access wants this on the preflight, which the cors middleware below answers
|
|
7012
|
-
this.app.use("/anywhere/peers", (req, res, next) => {
|
|
7013
|
-
if (req.get("Access-Control-Request-Private-Network")) res.set("Access-Control-Allow-Private-Network", "true")
|
|
7014
|
-
next()
|
|
7015
|
-
})
|
|
7016
6891
|
this.app.use(cors({
|
|
7017
6892
|
origin: '*'
|
|
7018
6893
|
}));
|
|
@@ -14485,8 +14360,6 @@ class Server {
|
|
|
14485
14360
|
status,
|
|
14486
14361
|
theme: this.theme,
|
|
14487
14362
|
agent: req.agent,
|
|
14488
|
-
// Your other Pinokios links end on 127.0.0.1, which mean nothing to a phone reading this page
|
|
14489
|
-
loopback: this.isLocalRequest(req),
|
|
14490
14363
|
})
|
|
14491
14364
|
}))
|
|
14492
14365
|
this.app.get("/anywhere/status", ex(async (req, res) => {
|
|
@@ -14513,32 +14386,6 @@ class Server {
|
|
|
14513
14386
|
})
|
|
14514
14387
|
this.app.post("/anywhere/apps/launch", anywhereAppAction((body) => this.composeAnywhereLaunch(body)))
|
|
14515
14388
|
this.app.post("/anywhere/apps/stop", anywhereAppAction((body) => this.composeAnywhereStop(body)))
|
|
14516
|
-
// the other Pinokios on the tailnet, for this computer's own Remote page (API spec, Peers)
|
|
14517
|
-
const anywherePeerRoute = (fn) => ex(async (req, res) => {
|
|
14518
|
-
res.set("Cache-Control", "no-store")
|
|
14519
|
-
if (!this.anywherePeerAllowed(req)) {
|
|
14520
|
-
res.status(403).json({ error: "Allowed only on this computer" })
|
|
14521
|
-
return
|
|
14522
|
-
}
|
|
14523
|
-
try {
|
|
14524
|
-
const answer = await fn(req)
|
|
14525
|
-
if (answer.headers) res.set(answer.headers)
|
|
14526
|
-
if (answer.raw) res.status(answer.status).type(answer.contentType).send(answer.raw)
|
|
14527
|
-
else if (answer.body === undefined) res.status(answer.status).end()
|
|
14528
|
-
else res.status(answer.status).json(answer.body)
|
|
14529
|
-
} catch (error) {
|
|
14530
|
-
res.status(500).json({ error: error && error.message ? error.message : String(error) })
|
|
14531
|
-
}
|
|
14532
|
-
})
|
|
14533
|
-
this.app.get("/anywhere/peers", anywherePeerRoute(() => this.composeAnywherePeers()))
|
|
14534
|
-
this.app.get("/anywhere/peers/:name/open", anywherePeerRoute((req) => this.anywherePeerOpen(req.params.name, req.query)))
|
|
14535
|
-
// the peer's own API under its prefix: a client swaps the peer's address for this prefix and changes nothing else
|
|
14536
|
-
const anywhereRelay = (method, relayPath) => anywherePeerRoute((req) => this.relayAnywherePeer(req.params.name, method, relayPath(req), req.body))
|
|
14537
|
-
this.app.get("/anywhere/peers/:name/anywhere/status", anywhereRelay("GET", () => "anywhere/status"))
|
|
14538
|
-
this.app.get("/anywhere/peers/:name/anywhere/apps", anywhereRelay("GET", () => "anywhere/apps"))
|
|
14539
|
-
this.app.post("/anywhere/peers/:name/anywhere/apps/launch", anywhereRelay("POST", () => "anywhere/apps/launch"))
|
|
14540
|
-
this.app.post("/anywhere/peers/:name/anywhere/apps/stop", anywhereRelay("POST", () => "anywhere/apps/stop"))
|
|
14541
|
-
this.app.get("/anywhere/peers/:name/asset/*", anywhereRelay("GET", (req) => "asset/" + encodeURI(req.params[0])))
|
|
14542
14389
|
const anywhereAction = (label, fn) => ex(async (req, res) => {
|
|
14543
14390
|
const anywhere = this.kernel.anywhere
|
|
14544
14391
|
if (!anywhere) {
|
|
@@ -40,7 +40,7 @@ body.dark .anywhere-card { border-color: rgba(255,255,255,0.1); background: rgba
|
|
|
40
40
|
.anywhere-muted { opacity: 0.7; font-size: 13px; }
|
|
41
41
|
.anywhere-grid { display: grid; grid-template-columns: minmax(280px, 380px) 1fr; gap: 16px; align-items: start; }
|
|
42
42
|
@media (max-width: 900px) { .anywhere-grid { grid-template-columns: 1fr; } }
|
|
43
|
-
.anywhere-signout
|
|
43
|
+
.anywhere-signout { background: none; border: none; padding: 0; font: inherit; color: inherit; text-decoration: underline; cursor: pointer; }
|
|
44
44
|
/* which device is connecting: a segmented control, the phone panel by default */
|
|
45
45
|
.anywhere-segments { display: grid; grid-template-columns: 1fr 1fr; width: 100%; padding: 4px; border-radius: 12px; background: rgba(0,0,0,0.05); }
|
|
46
46
|
body.dark .anywhere-segments { background: rgba(255,255,255,0.08); }
|
|
@@ -258,13 +258,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
258
258
|
<h3>Published addresses</h3>
|
|
259
259
|
<div id='anywhere-ports' class='anywhere-muted'></div>
|
|
260
260
|
</div>
|
|
261
|
-
<% if (locals.loopback) { %>
|
|
262
|
-
<div class='anywhere-card'>
|
|
263
|
-
<h2>Your other Pinokios</h2>
|
|
264
|
-
<p class='anywhere-muted'>Computers on your network with Remote on. They open through this Pinokio; nothing else to install.</p>
|
|
265
|
-
<div id='anywhere-others'><p class='anywhere-muted'>Looking…</p></div>
|
|
266
|
-
</div>
|
|
267
|
-
<% } %>
|
|
268
261
|
<div class='anywhere-card'>
|
|
269
262
|
<h2>Devices on your network</h2>
|
|
270
263
|
<div id='anywhere-peers'></div>
|
|
@@ -477,56 +470,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
477
470
|
}
|
|
478
471
|
}
|
|
479
472
|
|
|
480
|
-
// Your other Pinokios: only on a loopback host, refreshed with each status poll while ready
|
|
481
|
-
const others = document.getElementById('anywhere-others')
|
|
482
|
-
let othersBusy = false
|
|
483
|
-
let othersNote = '' // the last failed action, shown until the next one
|
|
484
|
-
const renderOthers = (peers) => {
|
|
485
|
-
if (!others) return
|
|
486
|
-
others.innerHTML = (othersNote ? `<p class='anywhere-error'>${esc(othersNote)}</p>` : '') + (peers.length === 0
|
|
487
|
-
? `<p class='anywhere-muted'>No other Pinokio with Remote on is online.</p>`
|
|
488
|
-
: peers.map((peer) => `
|
|
489
|
-
<div class='anywhere-row anywhere-other' data-peer='${esc(peer.name)}'>
|
|
490
|
-
<i class='fa-solid fa-desktop'></i>
|
|
491
|
-
<div class='grow'>
|
|
492
|
-
<div class='title'>${esc(peer.name)} <a class='remote' href='/anywhere/peers/${encodeURIComponent(peer.name)}/open?port=443&to=%2F' target='_blank' rel='noopener'>Open Pinokio</a></div>
|
|
493
|
-
${(peer.apps || []).length === 0 ? `<div class='sub'>no app running</div>` : peer.apps.map((app) => `
|
|
494
|
-
<div class='sub'>${esc(app.name)} ${app.open ? `<a class='remote' href='${esc(app.open)}' target='_blank' rel='noopener'>Open</a>` : `<span class='anywhere-badge'>${esc(app.state || 'starting')}</span>`} · <button type='button' class='anywhere-linkbtn' data-stop='${esc(app.id)}'>Stop</button></div>`).join('')}
|
|
495
|
-
</div>
|
|
496
|
-
</div>`).join(''))
|
|
497
|
-
}
|
|
498
|
-
const refreshOthers = async () => {
|
|
499
|
-
if (!others || othersBusy || current.phase !== 'ready') return
|
|
500
|
-
othersBusy = true
|
|
501
|
-
try {
|
|
502
|
-
const response = await fetch('/anywhere/peers', { cache: 'no-store' })
|
|
503
|
-
const answer = await response.json().catch(() => ({}))
|
|
504
|
-
if (!response.ok) throw new Error(answer.error || `HTTP ${response.status}`)
|
|
505
|
-
renderOthers(answer.peers || [])
|
|
506
|
-
} catch (e) {
|
|
507
|
-
others.innerHTML = `<p class='anywhere-muted'>Could not look for other Pinokios: ${esc(e.message)}</p>`
|
|
508
|
-
} finally {
|
|
509
|
-
othersBusy = false
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
if (others) {
|
|
513
|
-
others.addEventListener('click', async (event) => {
|
|
514
|
-
const button = event.target.closest('button[data-stop]')
|
|
515
|
-
if (!button) return
|
|
516
|
-
const peer = button.closest('[data-peer]').dataset.peer
|
|
517
|
-
button.disabled = true
|
|
518
|
-
othersNote = ''
|
|
519
|
-
try {
|
|
520
|
-
const response = await fetch(`/anywhere/peers/${encodeURIComponent(peer)}/anywhere/apps/stop`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: button.dataset.stop }) })
|
|
521
|
-
const answer = await response.json().catch(() => ({}))
|
|
522
|
-
if (!response.ok) othersNote = `Could not stop it on ${peer}: ${answer.error || `HTTP ${response.status}`}`
|
|
523
|
-
} catch (e) {
|
|
524
|
-
othersNote = `Could not stop it on ${peer}: ${e.message}`
|
|
525
|
-
}
|
|
526
|
-
await refreshOthers()
|
|
527
|
-
})
|
|
528
|
-
}
|
|
529
|
-
|
|
530
473
|
const poll = async () => {
|
|
531
474
|
try {
|
|
532
475
|
const response = await fetch('/anywhere/status', { cache: 'no-store' })
|
|
@@ -535,7 +478,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
535
478
|
} catch (e) {
|
|
536
479
|
render(Object.assign({}, current, { phase: 'error', error: e.message }))
|
|
537
480
|
}
|
|
538
|
-
refreshOthers()
|
|
539
481
|
const phase = current.phase || ''
|
|
540
482
|
const delay = (phase === 'ready' || phase === 'off') ? 5000 : 2000
|
|
541
483
|
setTimeout(poll, delay)
|
|
@@ -595,7 +537,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
595
537
|
})
|
|
596
538
|
|
|
597
539
|
render(current)
|
|
598
|
-
refreshOthers()
|
|
599
540
|
setTimeout(poll, 1500)
|
|
600
541
|
})()
|
|
601
542
|
</script>
|
|
@@ -1,171 +0,0 @@
|
|
|
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
|
-
// The headers go out as the incoming request's headers. Setting them in http-proxy's proxyReq
|
|
34
|
-
// hook instead would throw: with the proxy agent the hook fires once the CONNECT completes, and
|
|
35
|
-
// by then the outgoing headers are already written.
|
|
36
|
-
function mark(req) {
|
|
37
|
-
for (const name of STRIPPED_HEADERS) delete req.headers[name]
|
|
38
|
-
req.headers[FORWARDED_HEADER] = '1'
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
class Forwarders {
|
|
42
|
-
// proxyUrl: () => 'http://127.0.0.1:<port>' or null while the daemon is down
|
|
43
|
-
// serverPort: () => pinokiod's own port
|
|
44
|
-
// scheme exists for the tests, which forward to a plain local server instead of
|
|
45
|
-
// TLS through the proxy; the product never passes it.
|
|
46
|
-
constructor({ proxyUrl, serverPort, scheme = 'https' } = {}) {
|
|
47
|
-
this.proxyUrl = proxyUrl
|
|
48
|
-
this.serverPort = serverPort
|
|
49
|
-
this.scheme = scheme
|
|
50
|
-
this.agents = new Map() // proxy URL -> agent, one per daemon start
|
|
51
|
-
this.servers = new Map() // "<dnsName>:<port>" -> { port, server, proxy, sockets } or { pending }
|
|
52
|
-
}
|
|
53
|
-
agent() {
|
|
54
|
-
const url = this.proxyUrl()
|
|
55
|
-
if (this.scheme === 'https' && !url) throw new Error('Remote is not running on this computer')
|
|
56
|
-
if (!url) return undefined
|
|
57
|
-
// proxy-agent picks CONNECT-then-TLS for an http:// proxy and an https target
|
|
58
|
-
if (!this.agents.has(url)) this.agents.set(url, new ProxyAgent({ keepAlive: true, getProxyForUrl: () => url }))
|
|
59
|
-
return this.agents.get(url)
|
|
60
|
-
}
|
|
61
|
-
// One request to a peer through the proxy, for the status probe and the launch/stop relay.
|
|
62
|
-
async request(url, options) {
|
|
63
|
-
return requestThroughProxy(this.agent(), url, options)
|
|
64
|
-
}
|
|
65
|
-
// Finds or starts the forwarder for (dnsName, port); resolves to its loopback port.
|
|
66
|
-
async open(dnsName, port) {
|
|
67
|
-
const key = `${String(dnsName).toLowerCase()}:${Number(port)}`
|
|
68
|
-
const existing = this.servers.get(key)
|
|
69
|
-
if (existing) return existing.port || (await existing.pending).port
|
|
70
|
-
const pending = this.start(dnsName, Number(port))
|
|
71
|
-
this.servers.set(key, { pending })
|
|
72
|
-
try {
|
|
73
|
-
const entry = await pending
|
|
74
|
-
this.servers.set(key, entry)
|
|
75
|
-
return entry.port
|
|
76
|
-
} catch (error) {
|
|
77
|
-
this.servers.delete(key)
|
|
78
|
-
throw error
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
async start(dnsName, port) {
|
|
82
|
-
const label = String(dnsName).split('.')[0]
|
|
83
|
-
const target = `${this.scheme}://${dnsName}:${port}`
|
|
84
|
-
const proxy = httpProxy.createProxyServer({ target, agent: this.agent(), changeOrigin: true, secure: true, ws: true })
|
|
85
|
-
proxy.on('error', (error, req, res) => {
|
|
86
|
-
const message = `Could not reach ${dnsName}: ${error && error.message ? error.message : error}`
|
|
87
|
-
if (res && typeof res.writeHead === 'function' && !res.headersSent) {
|
|
88
|
-
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
89
|
-
res.end(message)
|
|
90
|
-
} else if (res && typeof res.destroy === 'function') {
|
|
91
|
-
res.destroy()
|
|
92
|
-
}
|
|
93
|
-
})
|
|
94
|
-
const server = http.createServer((req, res) => {
|
|
95
|
-
const url = new URL(req.url, 'http://127.0.0.1')
|
|
96
|
-
if (url.pathname === OPEN_PATH) {
|
|
97
|
-
const port = Number(url.searchParams.get('port'))
|
|
98
|
-
if (!Number.isInteger(port) || port <= 0) {
|
|
99
|
-
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
100
|
-
res.end('Not a Remote port')
|
|
101
|
-
return
|
|
102
|
-
}
|
|
103
|
-
const route = openPath(label, port, url.searchParams.get('to') || '/')
|
|
104
|
-
res.writeHead(302, { Location: `http://127.0.0.1:${this.serverPort()}${route}`, 'Cache-Control': 'no-store' })
|
|
105
|
-
res.end()
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
mark(req)
|
|
109
|
-
proxy.web(req, res)
|
|
110
|
-
})
|
|
111
|
-
server.on('upgrade', (req, socket, head) => {
|
|
112
|
-
mark(req)
|
|
113
|
-
proxy.ws(req, socket, head)
|
|
114
|
-
})
|
|
115
|
-
// every connection on both sides, upgraded ones included, so stopAll can end a stream a
|
|
116
|
-
// tab still holds; destroying only the browser's side would leave the peer's side piped open
|
|
117
|
-
const sockets = new Set()
|
|
118
|
-
const track = (socket) => {
|
|
119
|
-
sockets.add(socket)
|
|
120
|
-
socket.once('close', () => sockets.delete(socket))
|
|
121
|
-
}
|
|
122
|
-
server.on('connection', track)
|
|
123
|
-
proxy.on('open', track)
|
|
124
|
-
await new Promise((resolve, reject) => {
|
|
125
|
-
server.once('error', reject)
|
|
126
|
-
server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve() })
|
|
127
|
-
})
|
|
128
|
-
return { port: server.address().port, server, proxy, sockets }
|
|
129
|
-
}
|
|
130
|
-
// Ends every forwarder, open streams included: they belong to the daemon that is going away.
|
|
131
|
-
async stopAll() {
|
|
132
|
-
const entries = [...this.servers.values()]
|
|
133
|
-
this.servers.clear()
|
|
134
|
-
for (const agent of this.agents.values()) if (typeof agent.destroy === 'function') agent.destroy()
|
|
135
|
-
this.agents.clear()
|
|
136
|
-
await Promise.all(entries.map(async (entry) => {
|
|
137
|
-
const live = entry.server ? entry : await entry.pending.catch(() => null)
|
|
138
|
-
if (!live) return
|
|
139
|
-
live.proxy.close()
|
|
140
|
-
for (const socket of live.sockets) socket.destroy()
|
|
141
|
-
await new Promise((resolve) => live.server.close(resolve))
|
|
142
|
-
}))
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Resolves { status, headers, raw, body }; rejects on a transport failure or when the whole
|
|
147
|
-
// request, the proxy's CONNECT included, exceeds the deadline.
|
|
148
|
-
function requestThroughProxy(agent, url, { method = 'GET', headers = {}, body = null, timeoutMs = 4000 } = {}) {
|
|
149
|
-
return new Promise((resolve, reject) => {
|
|
150
|
-
const target = new URL(url)
|
|
151
|
-
const lib = target.protocol === 'https:' ? https : http
|
|
152
|
-
// the deadline is kept here, not as the request's signal: while the agent's CONNECT is still
|
|
153
|
-
// pending the request has no socket, and Node reports an abort only once it gets one
|
|
154
|
-
const deadline = setTimeout(() => { reject(new Error(`no answer in ${Math.round(timeoutMs / 1000)} s`)); req.destroy() }, timeoutMs)
|
|
155
|
-
const req = lib.request(target, { method, headers, agent }, (res) => {
|
|
156
|
-
const chunks = []
|
|
157
|
-
res.on('data', (chunk) => chunks.push(chunk))
|
|
158
|
-
res.on('end', () => {
|
|
159
|
-
clearTimeout(deadline)
|
|
160
|
-
const raw = Buffer.concat(chunks)
|
|
161
|
-
resolve({ status: res.statusCode, headers: res.headers, raw, body: raw.toString('utf8') })
|
|
162
|
-
})
|
|
163
|
-
res.on('error', (error) => { clearTimeout(deadline); reject(error) })
|
|
164
|
-
})
|
|
165
|
-
req.on('error', (error) => { clearTimeout(deadline); reject(error) })
|
|
166
|
-
if (body != null) req.write(body)
|
|
167
|
-
req.end()
|
|
168
|
-
})
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
module.exports = { Forwarders, openPath, FORWARDED_HEADER }
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
const assert = require('node:assert/strict')
|
|
2
|
-
const http = require('node:http')
|
|
3
|
-
const test = require('node:test')
|
|
4
|
-
const { once } = require('node:events')
|
|
5
|
-
const WebSocket = require('ws')
|
|
6
|
-
const { Forwarders } = require('../kernel/anywhere/forward')
|
|
7
|
-
|
|
8
|
-
// Forwarders (spec/requirements/pinokio-anywhere.md, Other Pinokios; the
|
|
9
|
-
// API spec, Peers): one loopback port relaying to one peer and port. The
|
|
10
|
-
// peer here is a plain local server, so the TLS-through-proxy layer is
|
|
11
|
-
// replaced by the http scheme and no agent; what is checked is the relay.
|
|
12
|
-
|
|
13
|
-
async function peer(t) {
|
|
14
|
-
const seen = []
|
|
15
|
-
const server = http.createServer((req, res) => {
|
|
16
|
-
seen.push({ url: req.url, headers: req.headers, method: req.method })
|
|
17
|
-
let body = ''
|
|
18
|
-
req.on('data', (chunk) => { body += chunk })
|
|
19
|
-
req.on('end', () => {
|
|
20
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
21
|
-
res.end(JSON.stringify({ url: req.url, host: req.headers.host, forwarded: req.headers['x-pinokio-forwarded'] || null, peer: req.headers['x-pinokio-peer'] || null, body }))
|
|
22
|
-
})
|
|
23
|
-
})
|
|
24
|
-
const wss = new WebSocket.Server({ server })
|
|
25
|
-
wss.on('connection', (socket, req) => {
|
|
26
|
-
socket.on('message', (message) => socket.send(`echo:${message} forwarded=${req.headers['x-pinokio-forwarded'] || 'none'} peer=${req.headers['x-pinokio-peer'] || 'none'}`))
|
|
27
|
-
})
|
|
28
|
-
server.listen(0, '127.0.0.1')
|
|
29
|
-
await once(server, 'listening')
|
|
30
|
-
t.after(() => new Promise((resolve) => { wss.close(); server.close(resolve); server.closeAllConnections() }))
|
|
31
|
-
return { port: server.address().port, seen }
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const get = (port, path, headers = {}) => new Promise((resolve, reject) => {
|
|
35
|
-
http.get({ host: '127.0.0.1', port, path, headers }, (res) => {
|
|
36
|
-
let body = ''
|
|
37
|
-
res.on('data', (chunk) => { body += chunk })
|
|
38
|
-
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }))
|
|
39
|
-
}).on('error', reject)
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
test('a forwarder relays requests with the forwarded header, the peer as Host, and no trusted peer headers', async (t) => {
|
|
43
|
-
const target = await peer(t)
|
|
44
|
-
const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
|
|
45
|
-
t.after(() => forwarders.stopAll())
|
|
46
|
-
const local = await forwarders.open('127.0.0.1', target.port)
|
|
47
|
-
assert.equal(await forwarders.open('127.0.0.1', target.port), local, 'the same peer and port reuse the forwarder')
|
|
48
|
-
const answer = await get(local, '/ui/?a=1', { 'X-Pinokio-Peer': '1', 'X-Pinokio-Source-Host': 'evil', 'X-Pinokio-Source-Proto': 'https' })
|
|
49
|
-
assert.equal(answer.status, 200)
|
|
50
|
-
const body = JSON.parse(answer.body)
|
|
51
|
-
assert.equal(body.url, '/ui/?a=1')
|
|
52
|
-
assert.equal(body.host, `127.0.0.1:${target.port}`, 'Host names the peer, not the loopback port')
|
|
53
|
-
assert.equal(body.forwarded, '1')
|
|
54
|
-
assert.equal(body.peer, null, 'the existing peer-trust headers never pass through')
|
|
55
|
-
assert.equal(target.seen[0].headers['x-pinokio-source-host'], undefined)
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
// A forward proxy for plain http targets: the real ProxyAgent sends it absolute URLs, and
|
|
59
|
-
// connects asynchronously, which is what broke the proxyReq-hook version in production.
|
|
60
|
-
async function forwardProxy(t) {
|
|
61
|
-
const server = http.createServer((req, res) => {
|
|
62
|
-
const target = new URL(req.url)
|
|
63
|
-
const upstream = http.request({ host: target.hostname, port: target.port, path: target.pathname + target.search, method: req.method, headers: req.headers }, (answer) => {
|
|
64
|
-
res.writeHead(answer.statusCode, answer.headers)
|
|
65
|
-
answer.pipe(res)
|
|
66
|
-
})
|
|
67
|
-
upstream.on('error', (error) => { res.writeHead(502); res.end(error.message) })
|
|
68
|
-
req.pipe(upstream)
|
|
69
|
-
})
|
|
70
|
-
server.listen(0, '127.0.0.1')
|
|
71
|
-
await once(server, 'listening')
|
|
72
|
-
t.after(() => { server.closeAllConnections(); server.close() })
|
|
73
|
-
return `http://127.0.0.1:${server.address().port}`
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
test('through the real proxy agent, whose connection completes later, the forwarded header still goes out and nothing throws', async (t) => {
|
|
77
|
-
const target = await peer(t)
|
|
78
|
-
const proxyUrl = await forwardProxy(t)
|
|
79
|
-
const forwarders = new Forwarders({ proxyUrl: () => proxyUrl, serverPort: () => 42000, scheme: 'http' })
|
|
80
|
-
t.after(() => forwarders.stopAll())
|
|
81
|
-
const local = await forwarders.open('127.0.0.1', target.port)
|
|
82
|
-
const answer = await get(local, '/ui/?a=1', { 'X-Pinokio-Peer': '1' })
|
|
83
|
-
assert.equal(answer.status, 200)
|
|
84
|
-
const body = JSON.parse(answer.body)
|
|
85
|
-
assert.equal(body.forwarded, '1')
|
|
86
|
-
assert.equal(body.peer, null)
|
|
87
|
-
assert.equal(body.host, `127.0.0.1:${target.port}`)
|
|
88
|
-
assert.equal(target.seen.length, 1)
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
test('two opens of the same peer and port at once share one forwarder', async (t) => {
|
|
92
|
-
const target = await peer(t)
|
|
93
|
-
const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
|
|
94
|
-
t.after(() => forwarders.stopAll())
|
|
95
|
-
const [a, b] = await Promise.all([forwarders.open('127.0.0.1', target.port), forwarders.open('127.0.0.1', target.port)])
|
|
96
|
-
assert.ok(Number.isInteger(a) && a > 0)
|
|
97
|
-
assert.equal(a, b)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
test('a forwarder answers /.pinokio/open itself with a redirect to this node\'s open route', async (t) => {
|
|
101
|
-
const target = await peer(t)
|
|
102
|
-
const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
|
|
103
|
-
t.after(() => forwarders.stopAll())
|
|
104
|
-
const local = await forwarders.open('pinokio-1.tail1.ts.net', target.port)
|
|
105
|
-
const answer = await get(local, '/.pinokio/open?port=43001&to=%2Fui%2F%3Fa%3D1%23view')
|
|
106
|
-
assert.equal(answer.status, 302)
|
|
107
|
-
assert.equal(answer.headers.location, 'http://127.0.0.1:42000/anywhere/peers/pinokio-1/open?port=43001&to=%2Fui%2F%3Fa%3D1%23view')
|
|
108
|
-
assert.equal((await get(local, '/.pinokio/open?to=%2F')).status, 400, 'a link without a port does not bounce through pinokiod')
|
|
109
|
-
assert.equal(target.seen.length, 0, 'never relayed')
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
test('a forwarder relays a WebSocket upgrade', async (t) => {
|
|
113
|
-
const target = await peer(t)
|
|
114
|
-
const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
|
|
115
|
-
t.after(() => forwarders.stopAll())
|
|
116
|
-
const local = await forwarders.open('127.0.0.1', target.port)
|
|
117
|
-
const socket = new WebSocket(`ws://127.0.0.1:${local}/socket`, { headers: { 'X-Pinokio-Peer': '1' } })
|
|
118
|
-
await once(socket, 'open')
|
|
119
|
-
socket.send('hi')
|
|
120
|
-
const [message] = await once(socket, 'message')
|
|
121
|
-
assert.equal(String(message), 'echo:hi forwarded=1 peer=none', 'the forwarded header is added and the trust header dropped on the upgrade path too')
|
|
122
|
-
socket.close()
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
test('a peer that cannot be reached answers 502, and stopAll closes every forwarder, an open WebSocket included', async (t) => {
|
|
126
|
-
const target = await peer(t)
|
|
127
|
-
const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
|
|
128
|
-
const dead = await forwarders.open('127.0.0.1', 1)
|
|
129
|
-
const answer = await get(dead, '/')
|
|
130
|
-
assert.equal(answer.status, 502)
|
|
131
|
-
assert.match(answer.body, /Could not reach 127\.0\.0\.1/)
|
|
132
|
-
const live = await forwarders.open('127.0.0.1', target.port)
|
|
133
|
-
const socket = new WebSocket(`ws://127.0.0.1:${live}/socket`)
|
|
134
|
-
await once(socket, 'open')
|
|
135
|
-
const closed = once(socket, 'close')
|
|
136
|
-
const stopped = forwarders.stopAll()
|
|
137
|
-
await Promise.race([stopped, new Promise((_, reject) => setTimeout(() => reject(new Error('stopAll hung on the open WebSocket')), 2000))])
|
|
138
|
-
await closed
|
|
139
|
-
await assert.rejects(get(live, '/'), /ECONNREFUSED/)
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
test('the https scheme refuses to forward while the daemon is down', () => {
|
|
143
|
-
const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000 })
|
|
144
|
-
assert.throws(() => forwarders.agent(), /Remote is not running/)
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
test('a request to a peer returns status and body, and the deadline covers the connection itself', async (t) => {
|
|
148
|
-
const target = await peer(t)
|
|
149
|
-
const plain = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
|
|
150
|
-
const answer = await plain.request(`http://127.0.0.1:${target.port}/anywhere/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"id":"x"}' })
|
|
151
|
-
assert.equal(answer.status, 200)
|
|
152
|
-
assert.equal(JSON.parse(answer.body).body, '{"id":"x"}')
|
|
153
|
-
const silent = http.createServer(() => { /* never answers */ })
|
|
154
|
-
silent.listen(0, '127.0.0.1')
|
|
155
|
-
await once(silent, 'listening')
|
|
156
|
-
t.after(() => { silent.closeAllConnections(); silent.close() })
|
|
157
|
-
await assert.rejects(plain.request(`http://127.0.0.1:${silent.address().port}/`, { timeoutMs: 200 }), /no answer in 0 s/)
|
|
158
|
-
// a proxy that accepts and never answers the CONNECT, through the real agent: the deadline
|
|
159
|
-
// still fires there, where a socket timeout would not have started yet
|
|
160
|
-
const held = []
|
|
161
|
-
const stuckProxy = require('node:net').createServer((socket) => { held.push(socket) /* never answers */ })
|
|
162
|
-
stuckProxy.listen(0, '127.0.0.1')
|
|
163
|
-
await once(stuckProxy, 'listening')
|
|
164
|
-
t.after(() => { for (const socket of held) socket.destroy(); stuckProxy.close() })
|
|
165
|
-
const forwarders = new Forwarders({ proxyUrl: () => `http://127.0.0.1:${stuckProxy.address().port}`, serverPort: () => 42000 })
|
|
166
|
-
t.after(() => forwarders.stopAll())
|
|
167
|
-
await assert.rejects(forwarders.request('https://peer.tail1.ts.net/anywhere/status', { timeoutMs: 300 }), /no answer in 0 s/)
|
|
168
|
-
})
|
|
@@ -1,96 +0,0 @@
|
|
|
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/anywhere/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
|
-
})
|
|
@@ -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
|
-
})
|