pinokiod 8.2.30 → 8.2.32

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.
@@ -0,0 +1,166 @@
1
+ /*
2
+ Forwarders for Remote from computer to computer
3
+ (spec/requirements/pinokio-anywhere.md, Other Pinokios; the routes in
4
+ spec/requirements/pinokio-anywhere-api.md, Peers).
5
+
6
+ The bundled tailscaled runs in userspace networking mode, so the browser on
7
+ this computer has no route to tailnet addresses. The daemon does, and
8
+ exposes it as a plain HTTP proxy on loopback (--outbound-http-proxy-listen).
9
+ A forwarder is one loopback port that relays HTTP requests and upgrades
10
+ through that proxy, with TLS to one peer's .ts.net name on one remote port.
11
+ The peer sees an ordinary tailnet client; the browser talks plain HTTP to
12
+ 127.0.0.1, as it does to Pinokio itself.
13
+
14
+ Every forwarder answers /.pinokio/open itself, never relaying it: a peer on
15
+ this revision rewrites the links inside its interface to that path, and the
16
+ redirect lands on this node's `open` route, which maps the port on demand.
17
+ */
18
+ const http = require('http')
19
+ const https = require('https')
20
+ const httpProxy = require('http-proxy')
21
+ const { ProxyAgent } = require('proxy-agent')
22
+ const { OPEN_PATH } = require('./serve')
23
+
24
+ const FORWARDED_HEADER = 'x-pinokio-forwarded'
25
+ // honoured by the peer middleware's trust; a forwarder never sends them
26
+ const STRIPPED_HEADERS = ['x-pinokio-peer', 'x-pinokio-source-host', 'x-pinokio-source-proto']
27
+
28
+ // This node's `open` route for a peer and port, relative for the page's links.
29
+ function openPath(label, port, to) {
30
+ return `/anywhere/peers/${encodeURIComponent(label)}/open?port=${Number(port)}&to=${encodeURIComponent(to || '/')}`
31
+ }
32
+
33
+ class Forwarders {
34
+ // proxyUrl: () => 'http://127.0.0.1:<port>' or null while the daemon is down
35
+ // serverPort: () => pinokiod's own port
36
+ // scheme exists for the tests, which forward to a plain local server instead of
37
+ // TLS through the proxy; the product never passes it.
38
+ constructor({ proxyUrl, serverPort, scheme = 'https' } = {}) {
39
+ this.proxyUrl = proxyUrl
40
+ this.serverPort = serverPort
41
+ this.scheme = scheme
42
+ this.agents = new Map() // proxy URL -> agent, one per daemon start
43
+ this.servers = new Map() // "<dnsName>:<port>" -> { port, server, proxy, sockets } or { pending }
44
+ }
45
+ agent() {
46
+ const url = this.proxyUrl()
47
+ if (this.scheme === 'https' && !url) throw new Error('Remote is not running on this computer')
48
+ if (!url) return undefined
49
+ // proxy-agent picks CONNECT-then-TLS for an http:// proxy and an https target
50
+ if (!this.agents.has(url)) this.agents.set(url, new ProxyAgent({ keepAlive: true, getProxyForUrl: () => url }))
51
+ return this.agents.get(url)
52
+ }
53
+ // One request to a peer through the proxy, for the status probe and the launch/stop relay.
54
+ async request(url, options) {
55
+ return requestThroughProxy(this.agent(), url, options)
56
+ }
57
+ // Finds or starts the forwarder for (dnsName, port); resolves to its loopback port.
58
+ async open(dnsName, port) {
59
+ const key = `${String(dnsName).toLowerCase()}:${Number(port)}`
60
+ const existing = this.servers.get(key)
61
+ if (existing) return existing.port || (await existing.pending).port
62
+ const pending = this.start(dnsName, Number(port))
63
+ this.servers.set(key, { pending })
64
+ try {
65
+ const entry = await pending
66
+ this.servers.set(key, entry)
67
+ return entry.port
68
+ } catch (error) {
69
+ this.servers.delete(key)
70
+ throw error
71
+ }
72
+ }
73
+ async start(dnsName, port) {
74
+ const label = String(dnsName).split('.')[0]
75
+ const target = `${this.scheme}://${dnsName}:${port}`
76
+ const proxy = httpProxy.createProxyServer({ target, agent: this.agent(), changeOrigin: true, secure: true, ws: true })
77
+ proxy.on('error', (error, req, res) => {
78
+ const message = `Could not reach ${dnsName}: ${error && error.message ? error.message : error}`
79
+ if (res && typeof res.writeHead === 'function' && !res.headersSent) {
80
+ res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' })
81
+ res.end(message)
82
+ } else if (res && typeof res.destroy === 'function') {
83
+ res.destroy()
84
+ }
85
+ })
86
+ // The headers go out as the incoming request's headers. Setting them in http-proxy's proxyReq
87
+ // hook instead would throw: with the proxy agent the hook fires once the CONNECT completes, and
88
+ // by then the outgoing headers are already written.
89
+ const mark = (req) => {
90
+ for (const name of STRIPPED_HEADERS) delete req.headers[name]
91
+ req.headers[FORWARDED_HEADER] = '1'
92
+ }
93
+ const server = http.createServer((req, res) => {
94
+ const url = new URL(req.url, 'http://127.0.0.1')
95
+ if (url.pathname === OPEN_PATH) {
96
+ const port = Number(url.searchParams.get('port'))
97
+ if (!Number.isInteger(port) || port <= 0) {
98
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
99
+ res.end('Not a Remote port')
100
+ return
101
+ }
102
+ const route = openPath(label, port, url.searchParams.get('to') || '/')
103
+ res.writeHead(302, { Location: `http://127.0.0.1:${this.serverPort()}${route}`, 'Cache-Control': 'no-store' })
104
+ res.end()
105
+ return
106
+ }
107
+ mark(req)
108
+ proxy.web(req, res)
109
+ })
110
+ server.on('upgrade', (req, socket, head) => {
111
+ mark(req)
112
+ proxy.ws(req, socket, head)
113
+ })
114
+ // every connection on both sides, upgraded ones included, so stopAll can end a stream a
115
+ // tab still holds; destroying only the browser's side would leave the peer's side piped open
116
+ const sockets = new Set()
117
+ const track = (socket) => {
118
+ sockets.add(socket)
119
+ socket.once('close', () => sockets.delete(socket))
120
+ }
121
+ server.on('connection', track)
122
+ proxy.on('open', track)
123
+ await new Promise((resolve, reject) => {
124
+ server.once('error', reject)
125
+ server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve() })
126
+ })
127
+ return { port: server.address().port, server, proxy, sockets }
128
+ }
129
+ // Ends every forwarder, open streams included: they belong to the daemon that is going away.
130
+ async stopAll() {
131
+ const entries = [...this.servers.values()]
132
+ this.servers.clear()
133
+ for (const agent of this.agents.values()) if (typeof agent.destroy === 'function') agent.destroy()
134
+ this.agents.clear()
135
+ await Promise.all(entries.map(async (entry) => {
136
+ const live = entry.server ? entry : await entry.pending.catch(() => null)
137
+ if (!live) return
138
+ live.proxy.close()
139
+ for (const socket of live.sockets) socket.destroy()
140
+ await new Promise((resolve) => live.server.close(resolve))
141
+ }))
142
+ }
143
+ }
144
+
145
+ // Resolves { status, body }; rejects on a transport failure or when the whole
146
+ // request, the proxy's CONNECT included, exceeds the deadline.
147
+ function requestThroughProxy(agent, url, { method = 'GET', headers = {}, body = null, timeoutMs = 4000 } = {}) {
148
+ return new Promise((resolve, reject) => {
149
+ const target = new URL(url)
150
+ const lib = target.protocol === 'https:' ? https : http
151
+ // the deadline is kept here, not as the request's signal: while the agent's CONNECT is still
152
+ // pending the request has no socket, and Node reports an abort only once it gets one
153
+ const deadline = setTimeout(() => { reject(new Error(`no answer in ${Math.round(timeoutMs / 1000)} s`)); req.destroy() }, timeoutMs)
154
+ const req = lib.request(target, { method, headers, agent }, (res) => {
155
+ const chunks = []
156
+ res.on('data', (chunk) => chunks.push(chunk))
157
+ res.on('end', () => { clearTimeout(deadline); resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }) })
158
+ res.on('error', (error) => { clearTimeout(deadline); reject(error) })
159
+ })
160
+ req.on('error', (error) => { clearTimeout(deadline); reject(error) })
161
+ if (body != null) req.write(body)
162
+ req.end()
163
+ })
164
+ }
165
+
166
+ module.exports = { Forwarders, openPath, FORWARDED_HEADER }
@@ -22,8 +22,10 @@ const crypto = require('crypto')
22
22
  const { spawn, execFile, execFileSync } = require('child_process')
23
23
  const TailscaleCLI = require('./cli')
24
24
  const writeJson = require('./state')
25
- const { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref, LOOPBACK_HOSTS } = require('./serve')
25
+ const { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref, rewriteHrefForwarded, LOOPBACK_HOSTS } = require('./serve')
26
26
  const { loadPortMap, savePortMap, assignPorts } = require('./ports')
27
+ const { Forwarders } = require('./forward')
28
+ const net = require('net')
27
29
  const Util = require('../util')
28
30
 
29
31
  const DEFAULT_HOSTNAME = 'pinokio'
@@ -39,6 +41,10 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
39
41
  class Anywhere {
40
42
  constructor(kernel) {
41
43
  this.kernel = kernel
44
+ // the daemon's outbound HTTP proxy, picked by pinokiod at each daemon start; null while down
45
+ this.proxyPort = null
46
+ this.ownedProxyPort = null // the port on an adopted daemon's command line, set by ownedPid
47
+ this.forwarders = new Forwarders({ proxyUrl: () => this.proxyUrl(), serverPort: () => this.serverPort() })
42
48
  this.platform = kernel && kernel.platform ? kernel.platform : os.platform()
43
49
  this.child = null
44
50
  this.api = null
@@ -76,6 +82,9 @@ class Anywhere {
76
82
  pidPath() {
77
83
  return this.stateDir('tailscaled.pid')
78
84
  }
85
+ proxyUrl() {
86
+ return this.proxyPort ? `http://127.0.0.1:${this.proxyPort}` : null
87
+ }
79
88
  socketPath() {
80
89
  if (this._socketPath) return this._socketPath
81
90
  if (this.platform === 'win32') {
@@ -98,7 +107,8 @@ class Anywhere {
98
107
  }
99
108
  return null
100
109
  }
101
- daemonArgs() {
110
+ // proxyPort: the outbound proxy's loopback port; null describes a daemon from before the proxy existed
111
+ daemonArgs(proxyPort = this.proxyPort) {
102
112
  const args = [
103
113
  '--tun=userspace-networking',
104
114
  '--state', this.stateDir('tailscaled.state'),
@@ -106,6 +116,7 @@ class Anywhere {
106
116
  '--socket', this.socketPath(),
107
117
  '--port', '0',
108
118
  ]
119
+ if (proxyPort) args.push(`--outbound-http-proxy-listen=127.0.0.1:${proxyPort}`)
109
120
  if (this.platform === 'win32') {
110
121
  // 1.104+: run as a per-user console process, no service / admin needed
111
122
  args.push('--windows-mode=dev')
@@ -234,10 +245,17 @@ class Anywhere {
234
245
  const alive = await this.daemonAlive()
235
246
  if (this.stopping) return false
236
247
  if (alive && (!(await this.assertOwnership()) || this.stopping)) return false
248
+ // a daemon this process started keeps its port; an adopted one gives the port on its command
249
+ // line, none for a daemon from before the proxy existed, so forwarding waits for its next start
250
+ if (alive) this.proxyPort = this.child ? this.proxyPort : this.ownedProxyPort
237
251
  if (!alive) {
238
252
  const pid = await this.readPid()
239
253
  if (this.stopping) return false
240
254
  if (pid) throw new Error('tailscaled is still running but its private endpoint is unavailable; no second daemon was started.')
255
+ // forwarders of a daemon that died relay to its proxy port; a new daemon gets new ones
256
+ await this.forwarders.stopAll()
257
+ // a free port now; the daemon fails to start if something takes it first, and Try again picks another
258
+ this.proxyPort = await freeLoopbackPort()
241
259
  const logFd = fs.openSync(this.logPath(), 'a')
242
260
  const env = Object.assign({}, process.env, {
243
261
  TS_LOGS_DIR: this.stateDir(),
@@ -255,6 +273,7 @@ class Anywhere {
255
273
  if (this.child === child) {
256
274
  // not a stop() of ours: surface it, the page offers "Try again"
257
275
  this.child = null
276
+ this.proxyPort = null // its proxy went with it; the next start replaces the forwarders
258
277
  if (!this.stopping && !this.error) this.error = `tailscaled exited (code=${code} signal=${signal})`
259
278
  try { fs.rmSync(this.pidPath(), { force: true }) }
260
279
  catch (error) { this.error = `Could not remove tailscaled PID record: ${error.message}` }
@@ -325,12 +344,18 @@ class Anywhere {
325
344
  }
326
345
  ownedPid(inspection, command) {
327
346
  command = command.trim()
328
- let expected = [this.binPath(), ...this.daemonArgs()].join(' ')
347
+ // the proxy port is whatever the running daemon was given; a daemon from before the
348
+ // proxy existed has none and is still ours. A match records the port for adoption.
349
+ const match = command.match(/--outbound-http-proxy-listen=127\.0\.0\.1:(\d+)/i)
350
+ const port = match ? Number(match[1]) : null
351
+ let expected = [this.binPath(), ...this.daemonArgs(port)].join(' ')
329
352
  if (this.platform === 'win32') {
330
353
  command = command.replace(/"/g, '').toLowerCase()
331
354
  expected = expected.toLowerCase()
332
355
  }
333
- return command === expected ? inspection.pid : null
356
+ if (command !== expected) return null
357
+ this.ownedProxyPort = port
358
+ return inspection.pid
334
359
  }
335
360
  async readPid() {
336
361
  const inspection = this.pidInspection()
@@ -372,6 +397,8 @@ class Anywhere {
372
397
  this.child = null
373
398
  this.login = null
374
399
  this.api = null
400
+ this.proxyPort = null
401
+ await this.forwarders.stopAll()
375
402
  fs.rmSync(this.pidPath(), { force: true })
376
403
  this.lastStatus = null
377
404
  this.lastStatusAt = 0
@@ -518,6 +545,7 @@ class Anywhere {
518
545
  const generic = !hostName || /^(localhost|iphone|ipad|android)$/i.test(hostName)
519
546
  peers.push({
520
547
  name: (generic && dnsLabel) ? dnsLabel : (hostName || dnsLabel || key),
548
+ dnsName: p.DNSName ? String(p.DNSName).replace(/\.$/, '') : null,
521
549
  os: osName,
522
550
  mobile: MOBILE_OS.has(osName.toLowerCase()),
523
551
  online: !!p.Online,
@@ -613,15 +641,24 @@ class Anywhere {
613
641
  if (!dnsName || !this.config || !this.config.enabled) return false
614
642
  return String(host || '').toLowerCase().replace(/:443$/, '') === dnsName.toLowerCase()
615
643
  }
644
+ // A peer from `status --json` by the first label of its DNS name, the key
645
+ // of the peer routes; null for a name that is not a peer.
646
+ peerFor(label) {
647
+ if (!this.lastStatus || typeof label !== 'string') return null
648
+ const wanted = label.toLowerCase()
649
+ return this.peersFrom(this.lastStatus).find((p) => p.dnsName && p.dnsName.split('.')[0].toLowerCase() === wanted) || null
650
+ }
616
651
  // Rewrites one menu link for a visitor arriving through the Tailscale
617
652
  // address. Only a loopback link with a port is touched, and only when the
618
653
  // request's Host header is this node's name; any other request gets the
619
- // href back unchanged.
620
- rewriteHref(host, href) {
654
+ // href back unchanged. A visitor forwarded by another Pinokio gets the
655
+ // relative path its forwarder answers instead of this node's address.
656
+ rewriteHref(host, href, forwarded = false) {
621
657
  if (!this.isNodeHost(host)) return href
622
- return rewriteHref(href, describeServeConfig(this.lastServe, this.dnsNameFrom(this.lastStatus)))
658
+ const routes = describeServeConfig(this.lastServe, this.dnsNameFrom(this.lastStatus))
659
+ return forwarded ? rewriteHrefForwarded(href, routes) : rewriteHref(href, routes)
623
660
  }
624
- async rewriteMenu(host, menu) {
661
+ async rewriteMenu(host, menu, forwarded = false) {
625
662
  if (!Array.isArray(menu) || !this.isNodeHost(host)) return menu
626
663
  // Confirm new or changed routes on first render; refresh checks unchanged ones.
627
664
  const apps = this.appTargets()
@@ -634,7 +671,7 @@ class Anywhere {
634
671
  for (const item of items) {
635
672
  if (!item || typeof item !== 'object') continue
636
673
  if (typeof item.href === 'string') {
637
- const next = this.rewriteHref(host, item.href)
674
+ const next = this.rewriteHref(host, item.href, forwarded)
638
675
  if (next !== item.href) {
639
676
  if (item.src === item.href) item.src = next
640
677
  if (item.target_full === item.href) item.target_full = next
@@ -742,7 +779,7 @@ class Anywhere {
742
779
  const dnsName = this.dnsNameFrom(status)
743
780
  const address = dnsName ? `https://${dnsName}` : null
744
781
  const account = this.accountFrom(status)
745
- const peers = this.peersFrom(status)
782
+ const peers = this.peersFrom(status).map(({ dnsName, ...peer }) => peer) // DNS names serve the peer routes, not the payload
746
783
  const out = Object.assign(base, {
747
784
  running: true,
748
785
  version: status.Version,
@@ -809,4 +846,16 @@ class Anywhere {
809
846
  }
810
847
 
811
848
 
849
+ // A loopback port nothing is listening on right now, for the daemon's proxy.
850
+ function freeLoopbackPort() {
851
+ return new Promise((resolve, reject) => {
852
+ const probe = net.createServer()
853
+ probe.once('error', reject)
854
+ probe.listen(0, '127.0.0.1', () => {
855
+ const { port } = probe.address()
856
+ probe.close(() => resolve(port))
857
+ })
858
+ })
859
+ }
860
+
812
861
  module.exports = Anywhere
@@ -94,12 +94,28 @@ function sameServeConfig(a, b) {
94
94
  return isDeepStrictEqual(a || null, b || null)
95
95
  }
96
96
 
97
- function rewriteHref(href, routes) {
97
+ // the path every forwarder answers itself (spec/requirements/pinokio-anywhere-api.md, Peers)
98
+ const OPEN_PATH = '/.pinokio/open'
99
+
100
+ // A loopback link with a port that a Serve route carries: its route and the
101
+ // link's path, query and fragment; null for any other link.
102
+ function routedLink(href, routes) {
98
103
  let url
99
- try { url = new URL(String(href)) } catch (_) { return href }
100
- if (!/^https?:$/.test(url.protocol) || !LOOPBACK_HOSTS.has(url.hostname) || !url.port) return href
104
+ try { url = new URL(String(href)) } catch (_) { return null }
105
+ if (!/^https?:$/.test(url.protocol) || !LOOPBACK_HOSTS.has(url.hostname) || !url.port) return null
101
106
  const route = routes.find((entry) => entry.local === Number(url.port))
102
- return route ? route.url + url.pathname + url.search + url.hash : href
107
+ return route ? { route, path: url.pathname + url.search + url.hash } : null
108
+ }
109
+ function rewriteHref(href, routes) {
110
+ const link = routedLink(href, routes)
111
+ return link ? link.route.url + link.path : href
112
+ }
113
+ // The same link for a visitor arriving through another Pinokio's forwarder: the
114
+ // published port and the path on the relative path the forwarder answers, so
115
+ // the caller maps the port on demand.
116
+ function rewriteHrefForwarded(href, routes) {
117
+ const link = routedLink(href, routes)
118
+ return link ? `${OPEN_PATH}?port=${link.route.port}&to=${encodeURIComponent(link.path)}` : href
103
119
  }
104
120
 
105
- module.exports = { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref, LOOPBACK_HOSTS, DEFAULT_HTTPS_PORT }
121
+ module.exports = { buildServeConfig, describeServeConfig, sameServeConfig, rewriteHref, rewriteHrefForwarded, LOOPBACK_HOSTS, DEFAULT_HTTPS_PORT, OPEN_PATH }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.2.30",
3
+ "version": "8.2.32",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -35,6 +35,8 @@ const TerminalApi = require('../kernel/api/terminal')
35
35
  const PluginSources = require("../kernel/plugin_sources")
36
36
  const ManagedSkills = require("../kernel/managed_skills")
37
37
  const { rewriteHref: rewriteAnywhereHref, LOOPBACK_HOSTS: ANYWHERE_LOOPBACK_HOSTS } = require("../kernel/anywhere/serve")
38
+ const { openPath: anywhereOpenPath, FORWARDED_HEADER: ANYWHERE_FORWARDED_HEADER } = require("../kernel/anywhere/forward")
39
+ const { FIRST_PORT: ANYWHERE_FIRST_PORT, LAST_PORT: ANYWHERE_LAST_PORT } = require("../kernel/anywhere/ports")
38
40
  const { menuTargets: launcherMenuTargets, isWebUrl } = require("../kernel/api/launcher_target")
39
41
  const { isPathRemovalBlockedError, removePath } = require("../kernel/path_removal")
40
42
 
@@ -1049,6 +1051,117 @@ class Server {
1049
1051
  const dnsName = anywhere && typeof anywhere.dnsNameFrom === "function" ? anywhere.dnsNameFrom(anywhere.lastStatus) : null
1050
1052
  return !!dnsName && parsed.protocol === "https:" && host === dnsName.toLowerCase()
1051
1053
  }
1054
+ // a Host header on this computer itself (localhost, 127.0.0.1, [::1], with or without a port)
1055
+ isLoopbackHost(host) {
1056
+ const name = String(host || "").toLowerCase().replace(/:\d+$/, "")
1057
+ return name === "localhost" || name === "127.0.0.1" || name === "[::1]"
1058
+ }
1059
+ // A request from a browser on this computer, as opposed to one that came through Tailscale
1060
+ // Serve or another Pinokio: those also arrive on loopback, and a tailnet client chooses its
1061
+ // own Host, but it cannot strip the headers Serve adds on the way or the peer marker.
1062
+ isLocalRequest(req) {
1063
+ const socket = req.socket && req.socket.remoteAddress
1064
+ const loopback = socket === "127.0.0.1" || socket === "::1" || socket === "::ffff:127.0.0.1"
1065
+ return loopback && this.isLoopbackHost(req.get("host")) && !req.get("X-Forwarded-For") && !req.get("X-Pinokio-Peer")
1066
+ }
1067
+ // the peer routes act on this computer's behalf: served on this computer only, to its own
1068
+ // page only. Their redirects end on 127.0.0.1, which mean nothing anywhere else.
1069
+ anywherePeerAllowed(req) {
1070
+ if (!this.isLocalRequest(req)) return false
1071
+ const origin = req.get("Origin")
1072
+ if (!origin) return true
1073
+ try { return new URL(origin).host.toLowerCase() === String(req.get("host")).toLowerCase() } catch (_) { return false }
1074
+ }
1075
+ // the reason the peer routes cannot work right now, or null
1076
+ anywherePeerUnavailable() {
1077
+ const anywhere = this.kernel.anywhere
1078
+ if (anywhere.proxyUrl()) return null
1079
+ return anywhere.lastStatus && !anywhere.error ? "Turn Remote off and on to reach your other Pinokios" : "Remote is not running on this computer"
1080
+ }
1081
+ // Remote from computer to computer (spec/requirements/pinokio-anywhere-api.md, Peers):
1082
+ // the other Pinokios on the tailnet, found by asking each online computer for its status
1083
+ // through the daemon's proxy, their running apps linked through this node's forwarders.
1084
+ async composeAnywherePeers() {
1085
+ const anywhere = this.kernel.anywhere
1086
+ const unavailable = this.anywherePeerUnavailable()
1087
+ if (unavailable) return { status: 502, body: { error: unavailable } }
1088
+ const candidates = anywhere.peersFrom(anywhere.lastStatus).filter((peer) => peer.online && !peer.mobile && peer.dnsName)
1089
+ const probes = await Promise.allSettled(candidates.map(async (peer) => {
1090
+ const answer = await anywhere.forwarders.request(`https://${peer.dnsName}/anywhere/status`, { timeoutMs: 4000 })
1091
+ if (answer.status !== 200) return null
1092
+ let status
1093
+ try { status = JSON.parse(answer.body) } catch (_) { return null }
1094
+ if (!status || typeof status.phase !== "string") return null
1095
+ const label = peer.dnsName.split(".")[0]
1096
+ const apps = Array.isArray(status.apps) ? status.apps : []
1097
+ return {
1098
+ name: label,
1099
+ apps: apps.map((app) => {
1100
+ const { remote, ...rest } = app
1101
+ return Object.assign(rest, { open: this.anywherePeerOpenPath(label, remote) })
1102
+ }),
1103
+ }
1104
+ }))
1105
+ return { status: 200, body: { peers: probes.map((probe) => probe.status === "fulfilled" ? probe.value : null).filter(Boolean) } }
1106
+ }
1107
+ // this node's open route for a peer's published address, or null for no address
1108
+ anywherePeerOpenPath(label, remote) {
1109
+ if (typeof remote !== "string") return null
1110
+ let url
1111
+ try { url = new URL(remote) } catch (_) { return null }
1112
+ return anywhereOpenPath(label, url.port ? Number(url.port) : 443, url.pathname + url.search + url.hash)
1113
+ }
1114
+ // GET /anywhere/peers/<name>/open?port=<n>&to=<path>: { status, headers } or { status, body }
1115
+ async anywherePeerOpen(name, query = {}) {
1116
+ const anywhere = this.kernel.anywhere
1117
+ const peer = anywhere.peerFor(name)
1118
+ if (!peer) return { status: 400, body: { error: `Not a computer on your network: ${name}` } }
1119
+ const port = Number(query.port)
1120
+ if (!(port === 443 || (Number.isInteger(port) && port >= ANYWHERE_FIRST_PORT && port <= ANYWHERE_LAST_PORT))) {
1121
+ return { status: 400, body: { error: `Not a Remote port: ${query.port}` } }
1122
+ }
1123
+ const unavailable = this.anywherePeerUnavailable()
1124
+ if (unavailable) return { status: 502, body: { error: unavailable } }
1125
+ let local
1126
+ try {
1127
+ local = await anywhere.forwarders.open(peer.dnsName, port)
1128
+ } catch (error) {
1129
+ return { status: 500, body: { error: error && error.message ? error.message : String(error) } }
1130
+ }
1131
+ // `to` resolved against the forwarder: encoded for the header, and never another origin
1132
+ const base = `http://127.0.0.1:${local}/`
1133
+ let location
1134
+ try { location = new URL(typeof query.to === "string" ? query.to : "/", base) } catch (_) { location = new URL(base) }
1135
+ if (location.origin !== new URL(base).origin) location = new URL(base)
1136
+ return { status: 302, headers: { Location: location.href, "Cache-Control": "no-store" } }
1137
+ }
1138
+ // POST /anywhere/peers/<name>/apps/<launch|stop>: the peer's answer, an open URL turned into this node's open route
1139
+ async relayAnywherePeerAction(name, action, body = {}) {
1140
+ const anywhere = this.kernel.anywhere
1141
+ const peer = anywhere.peerFor(name)
1142
+ if (!peer) return { status: 400, body: { error: `Not a computer on your network: ${name}` } }
1143
+ const unavailable = this.anywherePeerUnavailable()
1144
+ if (unavailable) return { status: 502, body: { error: unavailable } }
1145
+ let answer
1146
+ try {
1147
+ answer = await anywhere.forwarders.request(`https://${peer.dnsName}/anywhere/apps/${action}`, {
1148
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}), timeoutMs: 30000,
1149
+ })
1150
+ } catch (error) {
1151
+ return { status: 502, body: { error: `Could not reach ${peer.name}: ${error && error.message ? error.message : error}` } }
1152
+ }
1153
+ let parsed
1154
+ try { parsed = JSON.parse(answer.body) } catch (_) {
1155
+ return { status: 502, body: { error: `${peer.name} did not answer with JSON (HTTP ${answer.status})` } }
1156
+ }
1157
+ // only the peer's own published address becomes this node's open route; any other URL passes through
1158
+ if (parsed && parsed.action === "open" && typeof parsed.url === "string") {
1159
+ let host = null
1160
+ try { host = new URL(parsed.url).hostname.toLowerCase() } catch (_) { }
1161
+ if (host === peer.dnsName.toLowerCase()) parsed.url = this.anywherePeerOpenPath(peer.dnsName.split(".")[0], parsed.url)
1162
+ }
1163
+ return { status: answer.status, body: parsed }
1164
+ }
1052
1165
  // the app's menu as launch targets: scripts and web addresses, never interface links;
1053
1166
  // text is plain: a launcher may write HTML, which the desktop renders and the phone does not
1054
1167
  async anywhereMenu(api, repoPath) {
@@ -5076,7 +5189,8 @@ class Server {
5076
5189
  // published app's fixed https address; any other host is left for the
5077
5190
  // Home Server rewrite below, exactly as before.
5078
5191
  if (this.kernel.anywhere) {
5079
- await this.kernel.anywhere.rewriteMenu(req.$source && req.$source.host, config.menu)
5192
+ // a visitor forwarded by another Pinokio gets links its forwarder answers (API spec, Peers)
5193
+ await this.kernel.anywhere.rewriteMenu(req.$source && req.$source.host, config.menu, req.get(ANYWHERE_FORWARDED_HEADER) === "1")
5080
5194
  }
5081
5195
  config = Util.rewrite_localhost(this.kernel, config, req.$source)
5082
5196
  return config
@@ -14360,6 +14474,8 @@ class Server {
14360
14474
  status,
14361
14475
  theme: this.theme,
14362
14476
  agent: req.agent,
14477
+ // Your other Pinokios links end on 127.0.0.1, which mean nothing to a phone reading this page
14478
+ loopback: this.isLocalRequest(req),
14363
14479
  })
14364
14480
  }))
14365
14481
  this.app.get("/anywhere/status", ex(async (req, res) => {
@@ -14386,6 +14502,26 @@ class Server {
14386
14502
  })
14387
14503
  this.app.post("/anywhere/apps/launch", anywhereAppAction((body) => this.composeAnywhereLaunch(body)))
14388
14504
  this.app.post("/anywhere/apps/stop", anywhereAppAction((body) => this.composeAnywhereStop(body)))
14505
+ // the other Pinokios on the tailnet, for this computer's own Remote page (API spec, Peers)
14506
+ const anywherePeerRoute = (fn) => ex(async (req, res) => {
14507
+ res.set("Cache-Control", "no-store")
14508
+ if (!this.anywherePeerAllowed(req)) {
14509
+ res.status(403).json({ error: "Allowed only on this computer" })
14510
+ return
14511
+ }
14512
+ try {
14513
+ const answer = await fn(req)
14514
+ if (answer.headers) res.set(answer.headers)
14515
+ if (answer.body === undefined) res.status(answer.status).end()
14516
+ else res.status(answer.status).json(answer.body)
14517
+ } catch (error) {
14518
+ res.status(500).json({ error: error && error.message ? error.message : String(error) })
14519
+ }
14520
+ })
14521
+ this.app.get("/anywhere/peers", anywherePeerRoute(() => this.composeAnywherePeers()))
14522
+ this.app.get("/anywhere/peers/:name/open", anywherePeerRoute((req) => this.anywherePeerOpen(req.params.name, req.query)))
14523
+ this.app.post("/anywhere/peers/:name/apps/launch", anywherePeerRoute((req) => this.relayAnywherePeerAction(req.params.name, "launch", req.body)))
14524
+ this.app.post("/anywhere/peers/:name/apps/stop", anywherePeerRoute((req) => this.relayAnywherePeerAction(req.params.name, "stop", req.body)))
14389
14525
  const anywhereAction = (label, fn) => ex(async (req, res) => {
14390
14526
  const anywhere = this.kernel.anywhere
14391
14527
  if (!anywhere) {
@@ -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 { background: none; border: none; padding: 0; font: inherit; color: inherit; text-decoration: underline; cursor: pointer; }
43
+ .anywhere-signout, .anywhere-linkbtn { 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,6 +258,13 @@ 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
+ <% } %>
261
268
  <div class='anywhere-card'>
262
269
  <h2>Devices on your network</h2>
263
270
  <div id='anywhere-peers'></div>
@@ -470,6 +477,56 @@ document.addEventListener('DOMContentLoaded', function() {
470
477
  }
471
478
  }
472
479
 
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)}/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
+
473
530
  const poll = async () => {
474
531
  try {
475
532
  const response = await fetch('/anywhere/status', { cache: 'no-store' })
@@ -478,6 +535,7 @@ document.addEventListener('DOMContentLoaded', function() {
478
535
  } catch (e) {
479
536
  render(Object.assign({}, current, { phase: 'error', error: e.message }))
480
537
  }
538
+ refreshOthers()
481
539
  const phase = current.phase || ''
482
540
  const delay = (phase === 'ready' || phase === 'off') ? 5000 : 2000
483
541
  setTimeout(poll, delay)
@@ -537,6 +595,7 @@ document.addEventListener('DOMContentLoaded', function() {
537
595
  })
538
596
 
539
597
  render(current)
598
+ refreshOthers()
540
599
  setTimeout(poll, 1500)
541
600
  })()
542
601
  </script>
@@ -0,0 +1,168 @@
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
+ })
@@ -0,0 +1,96 @@
1
+ const assert = require('node:assert/strict')
2
+ const fs = require('node:fs')
3
+ const path = require('node:path')
4
+ const test = require('node:test')
5
+ const ejs = require('ejs')
6
+ const { JSDOM, VirtualConsole } = require('jsdom')
7
+
8
+ // The Remote page's "Your other Pinokios" section (spec/requirements/
9
+ // pinokio-anywhere.md, Other Pinokios): on a loopback host only, filled from
10
+ // /anywhere/peers at each status poll while ready, stop relayed per app.
11
+ const filename = path.resolve(__dirname, '../server/views/anywhere.ejs')
12
+ const template = fs.readFileSync(filename, 'utf8')
13
+ const address = 'https://pinokio.tail123.ts.net'
14
+ const ready = { phase: 'ready', enabled: true, address, connectUrl: 'https://pinokio.co/anywhere/connect#u=x', dnsName: 'pinokio.tail123.ts.net', account: { email: 'me@example.com' }, peers: [{ name: 'STUDIO', os: 'windows', online: true }], apps: [], serve: [] }
15
+
16
+ function render(t, { loopback, peers }) {
17
+ const state = { calls: [], timers: [], peers }
18
+ const html = ejs.render(template, { theme: 'light', agent: 'electron', status: ready, loopback }, { filename, includer: () => ({ template: ' ' }) })
19
+ const virtualConsole = new VirtualConsole()
20
+ virtualConsole.on('jsdomError', (error) => { throw error })
21
+ const dom = new JSDOM(html, {
22
+ url: 'http://127.0.0.1:42000/anywhere', runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole,
23
+ beforeParse(window) {
24
+ window.setTimeout = (callback, delay) => state.timers.push({ callback, delay })
25
+ window.fetch = async (url, options = {}) => {
26
+ state.calls.push({ url, method: options.method || 'GET', body: options.body })
27
+ if (url === '/anywhere/status') return { ok: true, json: async () => ({ ...ready }) }
28
+ if (url === '/anywhere/peers') return state.peersError ? { ok: false, status: 502, json: async () => ({ error: state.peersError }) } : { ok: true, json: async () => ({ peers: state.peers }) }
29
+ if (state.stopFails) return { ok: false, status: 502, json: async () => ({ error: 'no answer in 30 s' }) }
30
+ return { ok: true, json: async () => ({ action: 'stopped' }) }
31
+ }
32
+ window.open = () => {}
33
+ },
34
+ })
35
+ t.after(() => dom.window.close())
36
+ const document = dom.window.document
37
+ // the poll: status, then the peers refresh it starts; let both settle
38
+ const poll = async () => { state.timers.shift().callback(); await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)) }
39
+ return { state, document, poll, section: () => document.getElementById('anywhere-others') }
40
+ }
41
+
42
+ test('on a loopback host the section lists each other Pinokio with Open Pinokio and its apps through the open route', async (t) => {
43
+ const h = render(t, { loopback: true, peers: [
44
+ { name: 'pinokio-1', apps: [
45
+ { id: 'comfy', name: 'ComfyUI', state: 'ready', exposed: true, open: '/anywhere/peers/pinokio-1/open?port=43000&to=%2F' },
46
+ { id: 'forge', name: 'Forge', state: 'starting', exposed: false, open: null },
47
+ ] },
48
+ { name: 'other', apps: [] },
49
+ ] })
50
+ assert.ok(h.section(), 'the card exists before the first answer')
51
+ await h.poll()
52
+ assert.deepEqual(h.state.calls.map((call) => call.url), ['/anywhere/peers', '/anywhere/status', '/anywhere/peers'], 'once on load, then with each status poll')
53
+ const rows = [...h.section().querySelectorAll('[data-peer]')]
54
+ assert.deepEqual(rows.map((row) => row.dataset.peer), ['pinokio-1', 'other'])
55
+ const links = [...rows[0].querySelectorAll('a')].map((a) => [a.textContent, a.getAttribute('href')])
56
+ assert.deepEqual(links, [['Open Pinokio', '/anywhere/peers/pinokio-1/open?port=443&to=%2F'], ['Open', '/anywhere/peers/pinokio-1/open?port=43000&to=%2F']])
57
+ assert.match(rows[0].textContent, /Forge/)
58
+ assert.ok(rows[0].querySelector('.anywhere-badge'), 'an app without an address shows its state instead of a link')
59
+ assert.match(rows[1].textContent, /no app running/)
60
+ assert.doesNotMatch(h.section().textContent, /Looking…/)
61
+ })
62
+
63
+ test('with none found the section says so, and a stop is relayed to the peer then refreshed', async (t) => {
64
+ const h = render(t, { loopback: true, peers: [] })
65
+ await h.poll()
66
+ assert.match(h.section().textContent, /No other Pinokio with Remote on is online/)
67
+ h.state.peers = [{ name: 'pinokio-1', apps: [{ id: 'comfy', name: 'ComfyUI', state: 'ready', exposed: true, open: '/anywhere/peers/pinokio-1/open?port=43000&to=%2F' }] }]
68
+ await h.poll()
69
+ const stop = h.section().querySelector('button[data-stop="comfy"]')
70
+ assert.ok(stop)
71
+ stop.click()
72
+ await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve))
73
+ const posted = h.state.calls.find((call) => call.method === 'POST')
74
+ assert.deepEqual([posted.url, posted.body], ['/anywhere/peers/pinokio-1/apps/stop', '{"id":"comfy"}'])
75
+ assert.equal(h.state.calls.filter((call) => call.url === '/anywhere/peers').length, 4, 'load, two polls, and once more after the stop')
76
+ assert.equal(h.section().querySelector('.anywhere-error'), null)
77
+ h.state.stopFails = true
78
+ h.section().querySelector('button[data-stop="comfy"]').click()
79
+ await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve))
80
+ assert.match(h.section().querySelector('.anywhere-error').textContent, /Could not stop it on pinokio-1: no answer in 30 s/)
81
+ assert.ok(h.section().querySelector('[data-peer="pinokio-1"]'), 'the list is still there under the note')
82
+ })
83
+
84
+ test('while the daemon runs without the proxy, the section shows the reason the server gives', async (t) => {
85
+ const h = render(t, { loopback: true, peers: [] })
86
+ h.state.peersError = 'Turn Remote off and on to reach your other Pinokios'
87
+ await h.poll()
88
+ assert.match(h.section().textContent, /Could not look for other Pinokios: Turn Remote off and on to reach your other Pinokios/)
89
+ })
90
+
91
+ test('through the node\'s own Tailscale name there is no section and no peers request', async (t) => {
92
+ const h = render(t, { loopback: false, peers: [{ name: 'pinokio-1', apps: [] }] })
93
+ assert.equal(h.section(), null)
94
+ await h.poll()
95
+ assert.deepEqual(h.state.calls.map((call) => call.url), ['/anywhere/status'], 'no peers request at load or at the poll')
96
+ })
@@ -0,0 +1,199 @@
1
+ const assert = require('node:assert/strict')
2
+ const test = require('node:test')
3
+ const Server = require('../server/index.js')
4
+ const Anywhere = require('../kernel/anywhere')
5
+ const { buildServeConfig } = require('../kernel/anywhere/serve')
6
+
7
+ // The peer routes (spec/requirements/pinokio-anywhere-api.md, Peers) and the
8
+ // forwarded link rewrite, with the proxy request and the forwarders stubbed.
9
+
10
+ const status = {
11
+ Self: { DNSName: 'pinokio.tail1.ts.net.' },
12
+ Peer: {
13
+ a: { HostName: 'STUDIO', DNSName: 'pinokio-1.tail1.ts.net.', OS: 'windows', Online: true, TailscaleIPs: ['100.1.1.1'] },
14
+ b: { HostName: 'iphone', DNSName: 'iphone-14-pro.tail1.ts.net.', OS: 'iOS', Online: true },
15
+ c: { HostName: 'asleep', DNSName: 'asleep.tail1.ts.net.', OS: 'macOS', Online: false },
16
+ d: { HostName: 'other', DNSName: 'other.tail1.ts.net.', OS: 'linux', Online: true },
17
+ },
18
+ }
19
+
20
+ function anywhereWith(requests = {}, { proxy = true } = {}) {
21
+ const anywhere = new Anywhere({})
22
+ anywhere.config = { enabled: true }
23
+ anywhere.lastStatus = status
24
+ anywhere.proxyPort = proxy ? 1055 : null
25
+ anywhere.forwarders.request = async (url, options) => {
26
+ const handler = requests[url]
27
+ if (!handler) throw new Error(`no route to ${url}`)
28
+ return handler(options)
29
+ }
30
+ anywhere.forwarders.open = async (dnsName, port) => 51000 + (port % 1000)
31
+ return anywhere
32
+ }
33
+ const json = (body, code = 200) => async () => ({ status: code, headers: {}, body: JSON.stringify(body) })
34
+ const fake = (anywhere) => ({ kernel: { anywhere }, anywherePeerOpenPath: Server.prototype.anywherePeerOpenPath, anywherePeerUnavailable: Server.prototype.anywherePeerUnavailable })
35
+
36
+ test('peers lists the online computers that answered a status, with apps linked through the open route', async () => {
37
+ const anywhere = anywhereWith({
38
+ 'https://pinokio-1.tail1.ts.net/anywhere/status': json({ phase: 'ready', apps: [
39
+ { id: 'comfy', name: 'ComfyUI', state: 'ready', exposed: true, remote: 'https://pinokio-1.tail1.ts.net:43000/ui/?a=1#v' },
40
+ { id: 'forge', name: 'Forge', state: 'starting', exposed: false, remote: null },
41
+ ] }),
42
+ 'https://other.tail1.ts.net/anywhere/status': async () => ({ status: 404, headers: {}, body: 'not pinokio' }),
43
+ })
44
+ const out = await Server.prototype.composeAnywherePeers.call(fake(anywhere))
45
+ assert.equal(out.status, 200)
46
+ assert.deepEqual(out.body, { peers: [{ name: 'pinokio-1', apps: [
47
+ { id: 'comfy', name: 'ComfyUI', state: 'ready', exposed: true, open: '/anywhere/peers/pinokio-1/open?port=43000&to=%2Fui%2F%3Fa%3D1%23v' },
48
+ { id: 'forge', name: 'Forge', state: 'starting', exposed: false, open: null },
49
+ ] }] })
50
+ })
51
+
52
+ test('peers skips phones and offline peers, tolerates a peer that fails, and says why while the proxy is missing', async () => {
53
+ const asked = []
54
+ const anywhere = anywhereWith({
55
+ 'https://pinokio-1.tail1.ts.net/anywhere/status': async () => { asked.push('pinokio-1'); throw new Error('no answer in 4 s') },
56
+ 'https://other.tail1.ts.net/anywhere/status': async () => { asked.push('other'); return { status: 200, headers: {}, body: 'not json' } },
57
+ })
58
+ assert.deepEqual((await Server.prototype.composeAnywherePeers.call(fake(anywhere))).body, { peers: [] })
59
+ assert.deepEqual(asked.sort(), ['other', 'pinokio-1'], 'the phone and the offline computer are never asked')
60
+ const adopted = anywhereWith({}, { proxy: false })
61
+ assert.deepEqual(await Server.prototype.composeAnywherePeers.call(fake(adopted)), { status: 502, body: { error: 'Turn Remote off and on to reach your other Pinokios' } }, 'a daemon from before the proxy existed')
62
+ adopted.error = 'tailscaled exited (code=1 signal=null)'
63
+ assert.deepEqual((await Server.prototype.composeAnywherePeers.call(fake(adopted))).body, { error: 'Remote is not running on this computer' }, 'a daemon that died')
64
+ })
65
+
66
+ test('open validates the peer, the port and the path, then redirects to the forwarder', async () => {
67
+ const server = fake(anywhereWith())
68
+ assert.equal((await Server.prototype.anywherePeerOpen.call(server, 'nobody', { port: '443' })).status, 400)
69
+ assert.equal((await Server.prototype.anywherePeerOpen.call(server, 'pinokio-1', { port: '8080' })).status, 400)
70
+ assert.equal((await Server.prototype.anywherePeerOpen.call(server, 'pinokio-1', { port: '443.5' })).status, 400)
71
+ const root = await Server.prototype.anywherePeerOpen.call(server, 'pinokio-1', { port: '443' })
72
+ assert.equal(root.status, 302)
73
+ assert.equal(root.headers.Location, 'http://127.0.0.1:51443/')
74
+ const deep = await Server.prototype.anywherePeerOpen.call(server, 'PINOKIO-1', { port: '43000', to: '/ui/?a=1#v' })
75
+ assert.equal(deep.headers.Location, 'http://127.0.0.1:51000/ui/?a=1#v', 'the path, query and fragment survive; the name is case-insensitive')
76
+ for (const bad of ['//evil.example', 'https://evil.example/', '\\\\evil.example', 'http://127.0.0.1:51443@evil.example/']) {
77
+ const answer = await Server.prototype.anywherePeerOpen.call(server, 'pinokio-1', { port: '443', to: bad })
78
+ assert.equal(new URL(answer.headers.Location).origin, 'http://127.0.0.1:51443', `a path that could leave the forwarder (${bad}) stays on it`)
79
+ }
80
+ const odd = await Server.prototype.anywherePeerOpen.call(server, 'pinokio-1', { port: '443', to: '/ui/?q=caf\u00e9 x#\u00fc' })
81
+ assert.equal(odd.headers.Location, 'http://127.0.0.1:51443/ui/?q=caf%C3%A9%20x#%C3%BC', 'a path with characters a header cannot carry is encoded, fragment kept')
82
+ const unavailable = await Server.prototype.anywherePeerOpen.call(fake(anywhereWith({}, { proxy: false })), 'pinokio-1', { port: '443' })
83
+ assert.deepEqual(unavailable, { status: 502, body: { error: 'Turn Remote off and on to reach your other Pinokios' } }, 'a daemon from before the proxy existed')
84
+ })
85
+
86
+ test('launch and stop relay the peer\'s status and body, turning an open URL into the open route', async () => {
87
+ const sent = []
88
+ const anywhere = anywhereWith({
89
+ 'https://pinokio-1.tail1.ts.net/anywhere/apps/launch': async (options) => { sent.push(options); return { status: 200, headers: {}, body: JSON.stringify({ action: 'open', url: 'https://pinokio-1.tail1.ts.net:43000/' }) } },
90
+ 'https://pinokio-1.tail1.ts.net/anywhere/apps/stop': json({ error: 'Unknown app: x' }, 400),
91
+ })
92
+ const server = fake(anywhere)
93
+ const launch = await Server.prototype.relayAnywherePeerAction.call(server, 'pinokio-1', 'launch', { id: 'comfy' })
94
+ assert.deepEqual(launch, { status: 200, body: { action: 'open', url: '/anywhere/peers/pinokio-1/open?port=43000&to=%2F' } })
95
+ assert.equal(sent[0].method, 'POST')
96
+ assert.equal(sent[0].body, '{"id":"comfy"}')
97
+ assert.equal(sent[0].headers['Content-Type'], 'application/json')
98
+ assert.deepEqual(await Server.prototype.relayAnywherePeerAction.call(server, 'pinokio-1', 'stop', { id: 'x' }), { status: 400, body: { error: 'Unknown app: x' } })
99
+ assert.equal((await Server.prototype.relayAnywherePeerAction.call(server, 'nobody', 'stop', {})).status, 400)
100
+ })
101
+
102
+ test('a launch whose default is an outside address passes through unchanged', async () => {
103
+ const anywhere = anywhereWith({
104
+ 'https://pinokio-1.tail1.ts.net/anywhere/apps/launch': json({ action: 'open', url: 'https://github.com/x/y' }),
105
+ })
106
+ const launch = await Server.prototype.relayAnywherePeerAction.call(fake(anywhere), 'pinokio-1', 'launch', { id: 'docs' })
107
+ assert.deepEqual(launch.body, { action: 'open', url: 'https://github.com/x/y' })
108
+ })
109
+
110
+ test('a peer that cannot be reached or answers without JSON relays as 502', async () => {
111
+ const anywhere = anywhereWith({
112
+ 'https://pinokio-1.tail1.ts.net/anywhere/apps/stop': async () => { throw new Error('no answer in 30 s') },
113
+ 'https://other.tail1.ts.net/anywhere/apps/stop': async () => ({ status: 404, headers: {}, body: '<html>' }),
114
+ })
115
+ const server = fake(anywhere)
116
+ const down = await Server.prototype.relayAnywherePeerAction.call(server, 'pinokio-1', 'stop', { id: 'x' })
117
+ assert.equal(down.status, 502)
118
+ assert.match(down.body.error, /Could not reach STUDIO: no answer/)
119
+ const old = await Server.prototype.relayAnywherePeerAction.call(server, 'other', 'stop', { id: 'x' })
120
+ assert.deepEqual(old, { status: 502, body: { error: 'other did not answer with JSON (HTTP 404)' } })
121
+ const stopped = await Server.prototype.relayAnywherePeerAction.call(fake(anywhereWith({}, { proxy: false })), 'other', 'stop', {})
122
+ assert.equal(stopped.status, 502)
123
+ })
124
+
125
+ test('the peer routes answer a browser on this computer only, never a tailnet client through Serve, whatever Host it sends', () => {
126
+ const server = { kernel: { anywhere: anywhereWith() }, isLoopbackHost: Server.prototype.isLoopbackHost, isLocalRequest: Server.prototype.isLocalRequest }
127
+ const request = ({ remote = '127.0.0.1', host = 'localhost:42000', origin, headers = {} } = {}) => ({
128
+ socket: { remoteAddress: remote },
129
+ get: (name) => ({ host, origin, ...Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])) })[name.toLowerCase()],
130
+ })
131
+ const allowed = (req) => Server.prototype.anywherePeerAllowed.call(server, req)
132
+ assert.equal(allowed(request()), true, 'a top-level navigation on this computer')
133
+ assert.equal(allowed(request({ remote: '::1', host: '[::1]:42000', origin: 'http://[::1]:42000' })), true)
134
+ assert.equal(allowed(request({ remote: '::ffff:127.0.0.1', host: '127.0.0.1:42000', origin: 'http://127.0.0.1:42000' })), true)
135
+ assert.equal(allowed(request({ origin: 'http://localhost:3000' })), false, 'another local site is not this page')
136
+ assert.equal(allowed(request({ origin: 'https://pinokio.co' })), false)
137
+ assert.equal(allowed(request({ origin: 'not a url' })), false)
138
+ assert.equal(allowed(request({ host: 'pinokio.tail1.ts.net' })), false, 'the node\'s own name')
139
+ assert.equal(allowed(request({ headers: { 'X-Forwarded-For': '100.1.1.1' } })), false, 'through Serve with a forged loopback Host')
140
+ assert.equal(allowed(request({ headers: { 'X-Pinokio-Peer': '1', 'X-Pinokio-Source-Host': 'localhost' } })), false, 'through another Pinokio')
141
+ assert.equal(allowed(request({ remote: '192.168.1.5' })), false, 'Home Server on the LAN')
142
+ assert.equal(allowed(request({ remote: null })), false, 'no socket address at all')
143
+ assert.equal(Server.prototype.isLoopbackHost('LOCALHOST'), true)
144
+ assert.equal(Server.prototype.isLoopbackHost('localhost.evil'), false)
145
+ })
146
+
147
+ test('a live daemon is still ours across a pinokiod restart, its proxy port read from its command line', () => {
148
+ const anywhere = new Anywhere({ platform: 'linux', path: (...parts) => ['/home/x', ...parts].join('/'), bin: { mod: { tailscaled: { binPath: () => '/opt/tailscaled' } } } })
149
+ const line = (port) => ['/opt/tailscaled', ...anywhere.daemonArgs(port)].join(' ')
150
+ assert.equal(anywhere.proxyPort, null, 'a fresh pinokiod process knows no port yet')
151
+ assert.equal(anywhere.ownedPid({ pid: 7 }, line(1055)), 7)
152
+ assert.equal(anywhere.ownedProxyPort, 1055, 'the port the daemon was given, for adoption')
153
+ assert.equal(anywhere.ownedPid({ pid: 7 }, line(null)), 7, 'a daemon started before the proxy existed')
154
+ assert.equal(anywhere.ownedProxyPort, null, 'and it has no proxy to use')
155
+ assert.equal(anywhere.ownedPid({ pid: 7 }, line(1055) + ' --extra'), null, 'anything else is not ours')
156
+ assert.equal(anywhere.ownedPid({ pid: 7 }, '/usr/bin/tailscaled --tun=userspace-networking'), null)
157
+ anywhere.platform = 'win32'
158
+ assert.equal(anywhere.ownedPid({ pid: 9 }, `"${line(1055).toUpperCase()}"`), 9, 'Windows compares without quotes, case-insensitively')
159
+ })
160
+
161
+ test('peerFor finds a peer by the first label of its DNS name, which may differ from its displayed name', () => {
162
+ const anywhere = anywhereWith()
163
+ assert.equal(anywhere.peerFor('pinokio-1').name, 'STUDIO')
164
+ assert.equal(anywhere.peerFor('iphone-14-pro').mobile, true)
165
+ assert.equal(anywhere.peerFor('STUDIO'), null, 'the hostname is not the key')
166
+ assert.equal(anywhere.peerFor(''), null)
167
+ assert.equal(anywhere.peerFor(null), null)
168
+ })
169
+
170
+ test('a forwarded visitor gets menu links on the forwarder\'s open path; every other visitor gets today\'s links', async () => {
171
+ const anywhere = new Anywhere({})
172
+ anywhere.config = { enabled: true }
173
+ anywhere.lastStatus = status
174
+ anywhere.lastServe = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', targets: [{ listen: 43000, target: 7860 }] })
175
+ const href = 'http://127.0.0.1:7860/ui/?a=1#v'
176
+ assert.equal(anywhere.rewriteHref('pinokio.tail1.ts.net', href, true), '/.pinokio/open?port=43000&to=%2Fui%2F%3Fa%3D1%23v')
177
+ assert.equal(anywhere.rewriteHref('pinokio.tail1.ts.net', href), 'https://pinokio.tail1.ts.net:43000/ui/?a=1#v')
178
+ assert.equal(anywhere.rewriteHref('pinokio.tail1.ts.net', href, false), 'https://pinokio.tail1.ts.net:43000/ui/?a=1#v')
179
+ assert.equal(anywhere.rewriteHref('localhost:42000', href, true), href, 'the Host gate still applies with the header')
180
+ assert.equal(anywhere.rewriteHref('pinokio.tail1.ts.net', 'http://127.0.0.1:9999/', true), 'http://127.0.0.1:9999/', 'a port without a route is left alone, as today')
181
+ const menu = [{ href, src: href, menu: [{ href: 'https://example.com/' }] }]
182
+ await anywhere.rewriteMenu('pinokio.tail1.ts.net', menu, true)
183
+ assert.equal(menu[0].href, '/.pinokio/open?port=43000&to=%2Fui%2F%3Fa%3D1%23v')
184
+ assert.equal(menu[0].src, menu[0].href)
185
+ assert.equal(menu[0].menu[0].href, 'https://example.com/')
186
+ })
187
+
188
+ test('the daemon is started with the proxy on the port pinokiod picked, and peers carry their DNS name', () => {
189
+ const anywhere = new Anywhere({ platform: 'linux', path: (...parts) => ['/home/x', ...parts].join('/') })
190
+ anywhere.proxyPort = 1055
191
+ assert.ok(anywhere.daemonArgs().includes('--outbound-http-proxy-listen=127.0.0.1:1055'))
192
+ assert.equal(anywhere.proxyUrl(), 'http://127.0.0.1:1055')
193
+ anywhere.proxyPort = null
194
+ assert.equal(anywhere.proxyUrl(), null)
195
+ assert.ok(!anywhere.daemonArgs().some((arg) => arg.includes('proxy')), 'no port, no flag')
196
+ assert.deepEqual(anywhere.peersFrom(status).map((p) => [p.name, p.dnsName]), [
197
+ ['iphone-14-pro', 'iphone-14-pro.tail1.ts.net'], ['other', 'other.tail1.ts.net'], ['STUDIO', 'pinokio-1.tail1.ts.net'], ['asleep', 'asleep.tail1.ts.net'],
198
+ ])
199
+ })