pinokiod 8.2.33 → 8.2.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/kernel/anywhere/index.js +10 -59
- package/kernel/anywhere/serve.js +5 -21
- package/package.json +1 -1
- package/server/index.js +1 -154
- package/server/views/anywhere.ejs +30 -111
- package/test/anywhere-onboarding.test.js +15 -13
- package/kernel/anywhere/forward.js +0 -171
- package/test/anywhere-forward.test.js +0 -168
- package/test/anywhere-others.test.js +0 -96
- package/test/anywhere-peers.test.js +0 -223
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) {
|
|
@@ -38,31 +38,15 @@ body.dark .anywhere-card { border-color: rgba(255,255,255,0.1); background: rgba
|
|
|
38
38
|
.anywhere-card h3 { margin: 18px 0 6px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.7; }
|
|
39
39
|
.anywhere-card p { margin: 6px 0; line-height: 1.5; }
|
|
40
40
|
.anywhere-muted { opacity: 0.7; font-size: 13px; }
|
|
41
|
-
.anywhere-
|
|
42
|
-
@media (max-width: 900px) { .anywhere-
|
|
43
|
-
.anywhere-
|
|
44
|
-
|
|
45
|
-
.anywhere-
|
|
46
|
-
|
|
47
|
-
.anywhere-
|
|
48
|
-
.anywhere-
|
|
49
|
-
.anywhere-
|
|
50
|
-
.anywhere-segments span:hover { opacity: 1; }
|
|
51
|
-
.anywhere-segments input:checked + span { background: white; font-weight: bold; opacity: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.12); }
|
|
52
|
-
body.dark .anywhere-segments input:checked + span { background: rgba(255,255,255,0.16); box-shadow: none; }
|
|
53
|
-
.anywhere-segments input:focus-visible + span { outline: 2px solid currentColor; outline-offset: 1px; }
|
|
54
|
-
#anywhere-connect[data-device=phone] [data-panel=computer], #anywhere-connect[data-device=computer] [data-panel=phone] { display: none; }
|
|
55
|
-
#anywhere-connect [data-panel] { display: flex; flex-direction: column; align-items: center; gap: 14px; width: 100%; }
|
|
56
|
-
#anywhere-connect [data-panel] p { margin: 0; max-width: 360px; font-size: 15px; line-height: 1.5; }
|
|
57
|
-
#anywhere-connect [data-panel=computer] { padding-top: 10px; }
|
|
58
|
-
.anywhere-warning { padding: 10px 14px; border-radius: 8px; background: rgba(255,140,0,0.14); font-size: 13px; line-height: 1.5; margin: 16px 0 0; }
|
|
59
|
-
.anywhere-warning a { color: inherit; }
|
|
60
|
-
.anywhere-connect-card { max-width: 440px; padding: 22px 24px 24px; }
|
|
61
|
-
.anywhere-connect-title { text-align: center; font-size: 17px; margin: 0 0 14px; }
|
|
62
|
-
.anywhere-qr { display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; }
|
|
63
|
-
.anywhere-qr img { width: 212px; height: 212px; border-radius: 14px; background: white; padding: 12px; border: 1px solid rgba(0,0,0,0.08); margin-top: 4px; }
|
|
64
|
-
body.dark .anywhere-qr img { border-color: transparent; }
|
|
65
|
-
.anywhere-qr > .anywhere-muted { font-size: 12px; margin-top: 2px; }
|
|
41
|
+
.anywhere-connect { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: stretch; }
|
|
42
|
+
@media (max-width: 900px) { .anywhere-connect { grid-template-columns: 1fr; } }
|
|
43
|
+
.anywhere-device-card { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; gap: 12px; text-align: center; padding: 22px 24px 24px; }
|
|
44
|
+
.anywhere-device-icon { font-size: 32px; line-height: 1; opacity: 0.7; margin-bottom: -2px; }
|
|
45
|
+
.anywhere-device-card h2 { font-size: 17px; margin: 0; }
|
|
46
|
+
.anywhere-device-card p { margin: 0; max-width: 360px; font-size: 15px; line-height: 1.5; }
|
|
47
|
+
.anywhere-device-card img { width: 212px; height: 212px; border-radius: 14px; background: white; padding: 12px; border: 1px solid rgba(0,0,0,0.08); }
|
|
48
|
+
body.dark .anywhere-device-card img { border-color: transparent; }
|
|
49
|
+
.anywhere-account-line { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin: 14px 0 18px; }
|
|
66
50
|
.anywhere-address {
|
|
67
51
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
68
52
|
font-size: 15px; word-break: break-all;
|
|
@@ -225,31 +209,29 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
225
209
|
|
|
226
210
|
<div class='anywhere-state' data-state='ready'>
|
|
227
211
|
<div class='anywhere-warning' id='anywhere-expiry-warning' hidden></div>
|
|
228
|
-
<div class='anywhere-
|
|
229
|
-
<div class='anywhere-card anywhere-
|
|
230
|
-
<
|
|
231
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
<div id='anywhere-copy-status' class='anywhere-muted' role='status' aria-live='polite'></div>
|
|
245
|
-
<div id='anywhere-copy-manual' class='anywhere-copy-manual' hidden>
|
|
246
|
-
<input id='anywhere-copy-value' type='text' readonly aria-label='Remote link' aria-describedby='anywhere-copy-status'>
|
|
247
|
-
</div>
|
|
248
|
-
</div>
|
|
249
|
-
<div class='anywhere-muted'>signed in as <b id='anywhere-account'></b> · <button type='button' class='anywhere-signout' data-action='logout'>Sign out</button></div>
|
|
250
|
-
<div class='anywhere-waiting' id='anywhere-waiting'><i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for another device to join…</div>
|
|
212
|
+
<div class='anywhere-connect' id='anywhere-connect'>
|
|
213
|
+
<div class='anywhere-card anywhere-device-card'>
|
|
214
|
+
<i class='fa-solid fa-mobile-screen anywhere-device-icon' aria-hidden='true'></i>
|
|
215
|
+
<h2>Connect a phone or tablet</h2>
|
|
216
|
+
<p>Scan with your phone. It walks you through the rest.</p>
|
|
217
|
+
<img id='anywhere-qr' alt='QR code to connect' src=''>
|
|
218
|
+
</div>
|
|
219
|
+
<div class='anywhere-card anywhere-device-card'>
|
|
220
|
+
<i class='fa-solid fa-laptop anywhere-device-icon' aria-hidden='true'></i>
|
|
221
|
+
<h2>Connect another computer</h2>
|
|
222
|
+
<p>Open this link on the other computer. It walks you through installing Tailscale and signing in as <b class='anywhere-account-inline'></b>, then opens this Pinokio.</p>
|
|
223
|
+
<div class='anywhere-address'><a id='anywhere-address' href='#' target='_blank' rel='noopener'></a></div>
|
|
224
|
+
<p><button type='button' class='anywhere-btn secondary anywhere-copy' id='anywhere-copy' disabled><i class='fa-regular fa-copy' aria-hidden='true'></i> Copy link</button></p>
|
|
225
|
+
<div id='anywhere-copy-status' class='anywhere-muted' role='status' aria-live='polite'></div>
|
|
226
|
+
<div id='anywhere-copy-manual' class='anywhere-copy-manual' hidden>
|
|
227
|
+
<input id='anywhere-copy-value' type='text' readonly aria-label='Remote link' aria-describedby='anywhere-copy-status'>
|
|
251
228
|
</div>
|
|
252
229
|
</div>
|
|
230
|
+
</div>
|
|
231
|
+
<div class='anywhere-account-line'>
|
|
232
|
+
<span class='anywhere-muted'>signed in as <b id='anywhere-account'></b> · <button type='button' class='anywhere-signout' data-action='logout'>Sign out</button></span>
|
|
233
|
+
<div class='anywhere-waiting' id='anywhere-waiting'><i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for another device to join…</div>
|
|
234
|
+
</div>
|
|
253
235
|
<div id='anywhere-details' hidden>
|
|
254
236
|
<div class='anywhere-card'>
|
|
255
237
|
<h2>Running apps</h2>
|
|
@@ -258,13 +240,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
258
240
|
<h3>Published addresses</h3>
|
|
259
241
|
<div id='anywhere-ports' class='anywhere-muted'></div>
|
|
260
242
|
</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
243
|
<div class='anywhere-card'>
|
|
269
244
|
<h2>Devices on your network</h2>
|
|
270
245
|
<div id='anywhere-peers'></div>
|
|
@@ -282,7 +257,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
282
257
|
</p>
|
|
283
258
|
</div>
|
|
284
259
|
</div>
|
|
285
|
-
</div>
|
|
286
260
|
</div>
|
|
287
261
|
|
|
288
262
|
</div>
|
|
@@ -477,56 +451,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
477
451
|
}
|
|
478
452
|
}
|
|
479
453
|
|
|
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
454
|
const poll = async () => {
|
|
531
455
|
try {
|
|
532
456
|
const response = await fetch('/anywhere/status', { cache: 'no-store' })
|
|
@@ -535,7 +459,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
535
459
|
} catch (e) {
|
|
536
460
|
render(Object.assign({}, current, { phase: 'error', error: e.message }))
|
|
537
461
|
}
|
|
538
|
-
refreshOthers()
|
|
539
462
|
const phase = current.phase || ''
|
|
540
463
|
const delay = (phase === 'ready' || phase === 'off') ? 5000 : 2000
|
|
541
464
|
setTimeout(poll, delay)
|
|
@@ -583,9 +506,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
583
506
|
openExternal(link.href)
|
|
584
507
|
}
|
|
585
508
|
})
|
|
586
|
-
for (const input of document.querySelectorAll('input[name=anywhere-device]')) {
|
|
587
|
-
input.addEventListener('change', () => { document.getElementById('anywhere-connect').dataset.device = input.value })
|
|
588
|
-
}
|
|
589
509
|
switchEl.addEventListener('click', () => {
|
|
590
510
|
if (switchEl.dataset.on === 'true') {
|
|
591
511
|
act('stop')
|
|
@@ -595,7 +515,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
595
515
|
})
|
|
596
516
|
|
|
597
517
|
render(current)
|
|
598
|
-
refreshOthers()
|
|
599
518
|
setTimeout(poll, 1500)
|
|
600
519
|
})()
|
|
601
520
|
</script>
|