pinokiod 8.2.1 → 8.2.3
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 +698 -0
- package/kernel/anywhere/localapi.js +147 -0
- package/kernel/anywhere/ports.js +51 -0
- package/kernel/anywhere/serve.js +95 -0
- package/kernel/bin/setup.js +29 -0
- package/kernel/bin/tailscaled.js +71 -0
- package/kernel/index.js +15 -0
- package/package.json +1 -1
- package/server/index.js +123 -0
- package/server/public/anywhere.webmanifest +1 -0
- package/server/views/anywhere.ejs +436 -0
- package/server/views/app.ejs +3 -1
- package/server/views/index.ejs +6 -0
- package/server/views/partials/main_sidebar.ejs +39 -0
- package/test/anywhere.test.js +443 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Minimal client for the tailscaled LocalAPI.
|
|
3
|
+
|
|
4
|
+
tailscaled exposes an HTTP API over a unix socket (mac/linux) or a named
|
|
5
|
+
pipe (Windows). We talk to it with plain node `http` by passing the socket
|
|
6
|
+
path as `socketPath`; node's net layer handles both unix sockets and
|
|
7
|
+
`\\.\pipe\...` names. The conda-forge `tailscaled` package ships only the
|
|
8
|
+
daemon (no `tailscale` CLI), so every operation goes through this client.
|
|
9
|
+
|
|
10
|
+
Verified against tailscaled 1.104.1 (see spec/requirements/pinokio-anywhere.md):
|
|
11
|
+
GET /localapi/v0/status -> ipnstate.Status JSON
|
|
12
|
+
GET /localapi/v0/prefs -> ipn.Prefs JSON
|
|
13
|
+
PATCH /localapi/v0/prefs <- ipn.MaskedPrefs ({HostnameSet:true, Hostname:"pinokio", ...})
|
|
14
|
+
POST /localapi/v0/login-interactive -> 204; AuthURL shows up in /status and on the IPN bus (BrowseToURL)
|
|
15
|
+
POST /localapi/v0/logout -> 204
|
|
16
|
+
GET /localapi/v0/serve-config -> ipn.ServeConfig JSON (or null) + Etag header
|
|
17
|
+
POST /localapi/v0/serve-config <- ipn.ServeConfig JSON, optional If-Match: <etag>
|
|
18
|
+
POST /localapi/v0/query-feature?feature=serve -> {Complete, Text, URL} (URL = one-click tailnet approval)
|
|
19
|
+
*/
|
|
20
|
+
const http = require('http')
|
|
21
|
+
|
|
22
|
+
class LocalAPIError extends Error {
|
|
23
|
+
constructor(message, status, body) {
|
|
24
|
+
super(message)
|
|
25
|
+
this.name = 'LocalAPIError'
|
|
26
|
+
this.status = status
|
|
27
|
+
this.body = body
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class LocalAPI {
|
|
32
|
+
constructor({ socketPath, timeout = 10000 } = {}) {
|
|
33
|
+
if (!socketPath) {
|
|
34
|
+
throw new Error('LocalAPI requires a socketPath')
|
|
35
|
+
}
|
|
36
|
+
this.socketPath = socketPath
|
|
37
|
+
this.timeout = timeout
|
|
38
|
+
this.base = '/localapi/v0'
|
|
39
|
+
}
|
|
40
|
+
request(method, route, { body, headers, timeout } = {}) {
|
|
41
|
+
const payload = typeof body === 'undefined' || body === null
|
|
42
|
+
? null
|
|
43
|
+
: (typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body))
|
|
44
|
+
const reqHeaders = Object.assign({
|
|
45
|
+
// tailscaled rejects non-loopback origins without this on some
|
|
46
|
+
// transports (the Windows GUI loopback port); harmless on sockets.
|
|
47
|
+
'Sec-Tailscale': 'localapi',
|
|
48
|
+
'Host': 'local-tailscaled.sock',
|
|
49
|
+
}, headers || {})
|
|
50
|
+
if (payload !== null) {
|
|
51
|
+
reqHeaders['Content-Type'] = reqHeaders['Content-Type'] || 'application/json'
|
|
52
|
+
reqHeaders['Content-Length'] = Buffer.byteLength(payload)
|
|
53
|
+
}
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const req = http.request({
|
|
56
|
+
socketPath: this.socketPath,
|
|
57
|
+
method,
|
|
58
|
+
path: this.base + route,
|
|
59
|
+
headers: reqHeaders,
|
|
60
|
+
}, (res) => {
|
|
61
|
+
const chunks = []
|
|
62
|
+
res.on('data', (chunk) => chunks.push(chunk))
|
|
63
|
+
res.on('end', () => {
|
|
64
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
65
|
+
resolve({
|
|
66
|
+
status: res.statusCode,
|
|
67
|
+
headers: res.headers,
|
|
68
|
+
text,
|
|
69
|
+
json: () => {
|
|
70
|
+
if (!text || !text.trim()) return null
|
|
71
|
+
return JSON.parse(text)
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
res.on('error', reject)
|
|
76
|
+
})
|
|
77
|
+
req.setTimeout(timeout || this.timeout, () => {
|
|
78
|
+
req.destroy(new Error(`LocalAPI ${method} ${route} timed out`))
|
|
79
|
+
})
|
|
80
|
+
req.on('error', reject)
|
|
81
|
+
if (payload !== null) {
|
|
82
|
+
req.write(payload)
|
|
83
|
+
}
|
|
84
|
+
req.end()
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
async expect(method, route, options, okStatuses = [200, 204]) {
|
|
88
|
+
const res = await this.request(method, route, options)
|
|
89
|
+
if (!okStatuses.includes(res.status)) {
|
|
90
|
+
throw new LocalAPIError(`LocalAPI ${method} ${route} failed: ${res.status} ${res.text.trim()}`, res.status, res.text)
|
|
91
|
+
}
|
|
92
|
+
return res
|
|
93
|
+
}
|
|
94
|
+
async status() {
|
|
95
|
+
const res = await this.expect('GET', '/status')
|
|
96
|
+
return res.json()
|
|
97
|
+
}
|
|
98
|
+
// masked: an ipn.MaskedPrefs object, e.g. { HostnameSet: true, Hostname: 'pinokio' }
|
|
99
|
+
async patchPrefs(masked) {
|
|
100
|
+
const res = await this.expect('PATCH', '/prefs', { body: masked })
|
|
101
|
+
return res.json()
|
|
102
|
+
}
|
|
103
|
+
async loginInteractive() {
|
|
104
|
+
await this.expect('POST', '/login-interactive', { body: '' })
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
async logout() {
|
|
108
|
+
await this.expect('POST', '/logout', { body: '' })
|
|
109
|
+
return true
|
|
110
|
+
}
|
|
111
|
+
// Restarts the backend's login state machine (what `tailscale up --force-reauth`
|
|
112
|
+
// does): a stale or invalidated login URL is dropped and a fresh one is minted
|
|
113
|
+
// on the next login-interactive.
|
|
114
|
+
async start(options = {}) {
|
|
115
|
+
await this.expect('POST', '/start', { body: options })
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
async serveConfig() {
|
|
119
|
+
const res = await this.expect('GET', '/serve-config')
|
|
120
|
+
return { config: res.json(), etag: res.headers.etag || res.headers.Etag || null }
|
|
121
|
+
}
|
|
122
|
+
async setServeConfig(config, etag) {
|
|
123
|
+
const headers = {}
|
|
124
|
+
if (etag) {
|
|
125
|
+
headers['If-Match'] = etag
|
|
126
|
+
}
|
|
127
|
+
await this.expect('POST', '/serve-config', { body: config || {}, headers })
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
async queryFeature(feature) {
|
|
131
|
+
const res = await this.request('POST', `/query-feature?feature=${encodeURIComponent(feature)}`, { body: '' })
|
|
132
|
+
if (res.status !== 200) {
|
|
133
|
+
throw new LocalAPIError(`query-feature ${feature} failed: ${res.status} ${res.text.trim()}`, res.status, res.text)
|
|
134
|
+
}
|
|
135
|
+
return res.json()
|
|
136
|
+
}
|
|
137
|
+
async ping() {
|
|
138
|
+
try {
|
|
139
|
+
const res = await this.request('GET', '/status', { timeout: 2000 })
|
|
140
|
+
return res.status === 200
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { LocalAPI, LocalAPIError }
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Stable remote ports for Pinokio Anywhere.
|
|
3
|
+
|
|
4
|
+
An app may bind a different local port from one launch to the next, but its
|
|
5
|
+
Tailscale address must not move: bookmarks, home-screen icons and links all
|
|
6
|
+
point at https://<node>.<tailnet>.ts.net:<port>. So every app is given a
|
|
7
|
+
remote port once, kept in ~/pinokio/anywhere/ports.json, and Serve maps that
|
|
8
|
+
fixed port to whatever local port the app has right now.
|
|
9
|
+
|
|
10
|
+
Remote ports live inside tailscaled's own network stack, so they never need
|
|
11
|
+
to be free on the machine itself.
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('fs')
|
|
14
|
+
const path = require('path')
|
|
15
|
+
|
|
16
|
+
const FIRST_PORT = 43000
|
|
17
|
+
const LAST_PORT = 43999
|
|
18
|
+
|
|
19
|
+
async function loadPortMap(file) {
|
|
20
|
+
try {
|
|
21
|
+
const map = JSON.parse(await fs.promises.readFile(file, 'utf8'))
|
|
22
|
+
return map && typeof map === 'object' && !Array.isArray(map) ? map : {}
|
|
23
|
+
} catch (_) {
|
|
24
|
+
return {}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function savePortMap(file, map) {
|
|
29
|
+
await fs.promises.mkdir(path.dirname(file), { recursive: true })
|
|
30
|
+
await fs.promises.writeFile(file, JSON.stringify(map, null, 2))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Returns a copy of `map` with a port for every key, giving new keys the
|
|
34
|
+
// lowest port not already taken. Existing assignments are never changed.
|
|
35
|
+
function assignPorts(map, keys) {
|
|
36
|
+
const out = Object.assign({}, map)
|
|
37
|
+
const used = new Set(Object.values(out).map(Number))
|
|
38
|
+
let next = FIRST_PORT
|
|
39
|
+
let changed = false
|
|
40
|
+
for (const key of keys) {
|
|
41
|
+
if (Number.isInteger(out[key])) continue
|
|
42
|
+
while (used.has(next)) next += 1
|
|
43
|
+
if (next > LAST_PORT) throw new Error('Pinokio Anywhere has no free remote ports left')
|
|
44
|
+
out[key] = next
|
|
45
|
+
used.add(next)
|
|
46
|
+
changed = true
|
|
47
|
+
}
|
|
48
|
+
return { map: out, changed }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { loadPortMap, savePortMap, assignPorts }
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Builds the ipn.ServeConfig that tailscaled needs so pinokiod and each running
|
|
3
|
+
Pinokio app are reachable over HTTPS at a fixed address.
|
|
4
|
+
|
|
5
|
+
MagicDNS has no subdomains, so routes are per port. The remote port is the
|
|
6
|
+
app's stable port (see ports.js), the target is whatever local port the app
|
|
7
|
+
has right now:
|
|
8
|
+
|
|
9
|
+
https://pinokio.<tailnet>.ts.net -> http://127.0.0.1:<pinokiod port>
|
|
10
|
+
https://pinokio.<tailnet>.ts.net:43000 -> http://127.0.0.1:7860 (comfy)
|
|
11
|
+
https://pinokio.<tailnet>.ts.net:43001 -> http://127.0.0.1:8188 (forge)
|
|
12
|
+
|
|
13
|
+
Serve-only: no AllowFunnel entries are ever emitted, so nothing is public.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const DEFAULT_HTTPS_PORT = 443
|
|
17
|
+
|
|
18
|
+
function normalizePort(value) {
|
|
19
|
+
const port = Number(value)
|
|
20
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
return port
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
options:
|
|
28
|
+
dnsName "pinokio.tail1234.ts.net" (no trailing dot)
|
|
29
|
+
serverPort pinokiod's own port (mapped to :443)
|
|
30
|
+
targets [{ listen, target }]: remote port -> local port
|
|
31
|
+
*/
|
|
32
|
+
function buildServeConfig({ dnsName, serverPort, targets = [] } = {}) {
|
|
33
|
+
const host = String(dnsName || '').replace(/\.$/, '')
|
|
34
|
+
if (!host) {
|
|
35
|
+
throw new Error('buildServeConfig requires dnsName')
|
|
36
|
+
}
|
|
37
|
+
const config = { TCP: {}, Web: {} }
|
|
38
|
+
const add = (listenPort, targetPort) => {
|
|
39
|
+
config.TCP[String(listenPort)] = { HTTPS: true }
|
|
40
|
+
config.Web[`${host}:${listenPort}`] = {
|
|
41
|
+
Handlers: {
|
|
42
|
+
'/': { Proxy: `http://127.0.0.1:${targetPort}` }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const server = normalizePort(serverPort)
|
|
47
|
+
if (server) {
|
|
48
|
+
add(DEFAULT_HTTPS_PORT, server)
|
|
49
|
+
}
|
|
50
|
+
const seen = new Set([DEFAULT_HTTPS_PORT])
|
|
51
|
+
const entries = []
|
|
52
|
+
for (const raw of targets) {
|
|
53
|
+
const listen = normalizePort(raw && raw.listen)
|
|
54
|
+
const target = normalizePort(raw && raw.target)
|
|
55
|
+
if (!listen || !target || seen.has(listen)) continue
|
|
56
|
+
seen.add(listen)
|
|
57
|
+
entries.push({ listen, target })
|
|
58
|
+
}
|
|
59
|
+
// deterministic key order so unchanged mappings compare equal
|
|
60
|
+
for (const entry of entries.sort((a, b) => a.listen - b.listen)) {
|
|
61
|
+
add(entry.listen, entry.target)
|
|
62
|
+
}
|
|
63
|
+
return config
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Lists what a serve config exposes: [{ port, local, target, url, name }]
|
|
67
|
+
// `names` maps a remote port to the app name it was assigned to.
|
|
68
|
+
function describeServeConfig(config, dnsName, names = {}) {
|
|
69
|
+
const host = String(dnsName || '').replace(/\.$/, '')
|
|
70
|
+
const out = []
|
|
71
|
+
if (!config || !config.Web) return out
|
|
72
|
+
for (const key of Object.keys(config.Web)) {
|
|
73
|
+
const match = key.match(/^(.*):(\d+)$/)
|
|
74
|
+
if (!match) continue
|
|
75
|
+
const port = Number(match[2])
|
|
76
|
+
const handlers = config.Web[key].Handlers || {}
|
|
77
|
+
const root = handlers['/'] || {}
|
|
78
|
+
const local = root.Proxy ? Number((String(root.Proxy).match(/:(\d+)$/) || [])[1]) || null : null
|
|
79
|
+
out.push({
|
|
80
|
+
port,
|
|
81
|
+
local,
|
|
82
|
+
target: root.Proxy || null,
|
|
83
|
+
url: port === DEFAULT_HTTPS_PORT ? `https://${host || match[1]}` : `https://${host || match[1]}:${port}`,
|
|
84
|
+
name: names[port] || null,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
out.sort((a, b) => a.port - b.port)
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function sameServeConfig(a, b) {
|
|
92
|
+
return JSON.stringify(a || null) === JSON.stringify(b || null)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { buildServeConfig, describeServeConfig, sameServeConfig, DEFAULT_HTTPS_PORT }
|
package/kernel/bin/setup.js
CHANGED
|
@@ -270,6 +270,35 @@ module.exports = {
|
|
|
270
270
|
]
|
|
271
271
|
}
|
|
272
272
|
},
|
|
273
|
+
anywhere: (kernel) => {
|
|
274
|
+
let requirements = [
|
|
275
|
+
{ name: "conda", },
|
|
276
|
+
{ name: "zip", },
|
|
277
|
+
]
|
|
278
|
+
if (platform === "darwin") {
|
|
279
|
+
requirements.push({ name: "brew" })
|
|
280
|
+
}
|
|
281
|
+
requirements = requirements.concat([
|
|
282
|
+
{ name: "git", },
|
|
283
|
+
{ name: "uv", },
|
|
284
|
+
{ name: "node", },
|
|
285
|
+
{ name: "tailscaled", },
|
|
286
|
+
{ name: "py" },
|
|
287
|
+
{ name: "bluefairy" },
|
|
288
|
+
])
|
|
289
|
+
return {
|
|
290
|
+
icon: "fa-solid fa-mobile-screen",
|
|
291
|
+
title: "Pinokio Anywhere",
|
|
292
|
+
description: "Reach this Pinokio and its apps from your phone or another computer over a private Tailscale network",
|
|
293
|
+
requirements,
|
|
294
|
+
conda_requirements: [
|
|
295
|
+
zip_cmd,
|
|
296
|
+
"uv",
|
|
297
|
+
"git",
|
|
298
|
+
"tailscaled"
|
|
299
|
+
]
|
|
300
|
+
}
|
|
301
|
+
},
|
|
273
302
|
connect: (kernel) => {
|
|
274
303
|
let requirements = [
|
|
275
304
|
{ name: "conda", },
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const semver = require('semver')
|
|
3
|
+
|
|
4
|
+
// conda-forge ships only the daemon binary (no `tailscale` CLI); Pinokio
|
|
5
|
+
// drives it over its LocalAPI (see kernel/anywhere).
|
|
6
|
+
const TAILSCALED_SPEC = "tailscaled=1.104.1"
|
|
7
|
+
const TAILSCALED_REQUIREMENT = ">=1.104.0" // 1.104 added --windows-mode=dev
|
|
8
|
+
|
|
9
|
+
class Tailscaled {
|
|
10
|
+
description = "Installs the Tailscale daemon for Pinokio Anywhere: reach this Pinokio and its apps from your phone or another computer over a private network."
|
|
11
|
+
cmd() {
|
|
12
|
+
return TAILSCALED_SPEC
|
|
13
|
+
}
|
|
14
|
+
binPath() {
|
|
15
|
+
if (this.kernel.platform === "win32") {
|
|
16
|
+
return this.kernel.bin.path("miniforge", "Library", "bin", "tailscaled.exe")
|
|
17
|
+
}
|
|
18
|
+
return this.kernel.bin.path("miniforge", "bin", "tailscaled")
|
|
19
|
+
}
|
|
20
|
+
async install(req, ondata) {
|
|
21
|
+
await this.kernel.bin.exec({
|
|
22
|
+
message: [
|
|
23
|
+
// a stale libmamba index cache reports the package as missing even
|
|
24
|
+
// though `conda search` sees it; clearing just the index fixes that.
|
|
25
|
+
"conda clean -y -i",
|
|
26
|
+
`conda install -y --override-channels -c conda-forge ${this.cmd()}`
|
|
27
|
+
]
|
|
28
|
+
}, ondata)
|
|
29
|
+
}
|
|
30
|
+
async installed() {
|
|
31
|
+
try {
|
|
32
|
+
const conda = this.kernel.bin.installed && this.kernel.bin.installed.conda
|
|
33
|
+
if (!conda || !conda.has("tailscaled")) {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
const versions = this.kernel.bin.installed.conda_versions || {}
|
|
37
|
+
const coerced = semver.coerce(versions.tailscaled)
|
|
38
|
+
if (!coerced) {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
return semver.satisfies(coerced, TAILSCALED_REQUIREMENT)
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.log("tailscaled installed check error", e)
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Called by kernel.bin.init() for every module: resume the daemon when the
|
|
48
|
+
// user had Pinokio Anywhere switched on.
|
|
49
|
+
async start() {
|
|
50
|
+
const anywhere = this.kernel.anywhere
|
|
51
|
+
if (!anywhere || typeof anywhere.autostart !== "function") {
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
// not awaited: a slow or broken daemon must not hold up the rest of bin init
|
|
55
|
+
anywhere.autostart().catch((e) => {
|
|
56
|
+
console.log("[anywhere] autostart failed", e && e.message ? e.message : e)
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
async uninstall(req, ondata) {
|
|
60
|
+
ondata({ raw: "stopping Pinokio Anywhere\r\n" })
|
|
61
|
+
if (this.kernel.anywhere && typeof this.kernel.anywhere.stop === "function") {
|
|
62
|
+
await this.kernel.anywhere.stop().catch(() => {})
|
|
63
|
+
}
|
|
64
|
+
ondata({ raw: "cleaning up\r\n" })
|
|
65
|
+
await this.kernel.bin.exec({
|
|
66
|
+
message: `conda remove -y tailscaled`,
|
|
67
|
+
}, ondata)
|
|
68
|
+
ondata({ raw: "finished cleaning up\r\n" })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
module.exports = Tailscaled
|
package/kernel/index.js
CHANGED
|
@@ -35,6 +35,7 @@ const Plugin = require('./plugin')
|
|
|
35
35
|
const Router = require("./router")
|
|
36
36
|
const PinokioDomainRouter = require("./router/pinokio_domain_router")
|
|
37
37
|
const Procs = require('./procs')
|
|
38
|
+
const Anywhere = require('./anywhere')
|
|
38
39
|
const Peer = require('./peer')
|
|
39
40
|
const Git = require('./git')
|
|
40
41
|
const Vault = require('./vault')
|
|
@@ -711,6 +712,15 @@ class Kernel {
|
|
|
711
712
|
|
|
712
713
|
}
|
|
713
714
|
}
|
|
715
|
+
// 8b. Pinokio Anywhere: refresh the tailscale serve config from the apps'
|
|
716
|
+
// published URLs. No-op unless Anywhere is on and its daemon is running.
|
|
717
|
+
if (this.anywhere) {
|
|
718
|
+
try {
|
|
719
|
+
await this.anywhere.sync()
|
|
720
|
+
} catch (err) {
|
|
721
|
+
console.warn('[anywhere] sync failed:', err && err.message ? err.message : err)
|
|
722
|
+
}
|
|
723
|
+
}
|
|
714
724
|
// 6. tell peers to refresh
|
|
715
725
|
let changed
|
|
716
726
|
let new_config = JSON.stringify(await this.peer.current_host())
|
|
@@ -1103,12 +1113,17 @@ class Kernel {
|
|
|
1103
1113
|
if (this.peer) {
|
|
1104
1114
|
this.peer.stop()
|
|
1105
1115
|
}
|
|
1116
|
+
if (this.anywhere) {
|
|
1117
|
+
// re-initializing the kernel (home change): drop the old daemon
|
|
1118
|
+
await this.anywhere.stop().catch(() => {})
|
|
1119
|
+
}
|
|
1106
1120
|
|
|
1107
1121
|
this.info = new Info(this)
|
|
1108
1122
|
this.pipe = new Pipe(this)
|
|
1109
1123
|
this.proto = new Proto(this)
|
|
1110
1124
|
this.plugin = new Plugin(this)
|
|
1111
1125
|
this.processes = new Procs(this)
|
|
1126
|
+
this.anywhere = new Anywhere(this)
|
|
1112
1127
|
this.kv = new KV(this)
|
|
1113
1128
|
this.cloudflare = new Cloudflare()
|
|
1114
1129
|
this.peer = new Peer(this)
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -497,6 +497,13 @@ class Server {
|
|
|
497
497
|
if (this.kernel && this.kernel.processes && this.kernel.processes.caddy_pid) {
|
|
498
498
|
this.killProcessTree(this.kernel.processes.caddy_pid, 'kill caddy')
|
|
499
499
|
}
|
|
500
|
+
if (this.kernel && this.kernel.anywhere) {
|
|
501
|
+
try {
|
|
502
|
+
this.kernel.anywhere.killSync()
|
|
503
|
+
} catch (error) {
|
|
504
|
+
console.error('Failed to kill tailscaled', error)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
500
507
|
this.killProcessTree(process.pid, 'kill self')
|
|
501
508
|
}
|
|
502
509
|
exists (s) {
|
|
@@ -1011,6 +1018,64 @@ class Server {
|
|
|
1011
1018
|
routes.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")))
|
|
1012
1019
|
return routes
|
|
1013
1020
|
}
|
|
1021
|
+
// ---------------- Pinokio Anywhere ----------------
|
|
1022
|
+
portFromUrl(value) {
|
|
1023
|
+
if (!value || typeof value !== "string") return null
|
|
1024
|
+
try {
|
|
1025
|
+
const parsed = new URL(value)
|
|
1026
|
+
if (parsed.port) return Number(parsed.port)
|
|
1027
|
+
if (parsed.protocol === "https:") return 443
|
|
1028
|
+
if (parsed.protocol === "http:") return 80
|
|
1029
|
+
} catch (_) { }
|
|
1030
|
+
return null
|
|
1031
|
+
}
|
|
1032
|
+
async composeAnywhereStatus({ light } = {}) {
|
|
1033
|
+
const anywhere = this.kernel ? this.kernel.anywhere : null
|
|
1034
|
+
if (!anywhere) {
|
|
1035
|
+
return { phase: "setup", installed: false, enabled: false, running: false }
|
|
1036
|
+
}
|
|
1037
|
+
let snap
|
|
1038
|
+
try {
|
|
1039
|
+
snap = await anywhere.snapshot()
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
return { phase: "error", installed: true, enabled: true, running: false, error: error && error.message ? error.message : String(error) }
|
|
1042
|
+
}
|
|
1043
|
+
if (light) {
|
|
1044
|
+
return snap
|
|
1045
|
+
}
|
|
1046
|
+
snap.apps = []
|
|
1047
|
+
if (snap.phase === "ready") {
|
|
1048
|
+
const serveByLocalPort = new Map((snap.serve || []).map((entry) => [entry.local, entry]))
|
|
1049
|
+
let running = []
|
|
1050
|
+
try {
|
|
1051
|
+
running = await this.collectHomeServerApps()
|
|
1052
|
+
} catch (_) {
|
|
1053
|
+
running = []
|
|
1054
|
+
}
|
|
1055
|
+
for (const app of running) {
|
|
1056
|
+
const port = this.portFromUrl(app.ready_url || app.url)
|
|
1057
|
+
const entry = port ? serveByLocalPort.get(port) : null
|
|
1058
|
+
snap.apps.push({
|
|
1059
|
+
name: app.name,
|
|
1060
|
+
icon: app.icon,
|
|
1061
|
+
state: app.state,
|
|
1062
|
+
remote: entry ? entry.url : null,
|
|
1063
|
+
exposed: !!entry,
|
|
1064
|
+
})
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
if (snap.connectUrl) {
|
|
1068
|
+
if (!this.anywhereQrCache || this.anywhereQrCache.url !== snap.connectUrl) {
|
|
1069
|
+
try {
|
|
1070
|
+
this.anywhereQrCache = { url: snap.connectUrl, data: await QRCode.toDataURL(snap.connectUrl, { margin: 1, scale: 6 }) }
|
|
1071
|
+
} catch (_) {
|
|
1072
|
+
this.anywhereQrCache = { url: snap.connectUrl, data: null }
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
snap.qr = this.anywhereQrCache.data
|
|
1076
|
+
}
|
|
1077
|
+
return snap
|
|
1078
|
+
}
|
|
1014
1079
|
async collectHomeServerApps() {
|
|
1015
1080
|
const apps = []
|
|
1016
1081
|
if (!this.kernel || !this.kernel.api || typeof this.kernel.api.listApps !== "function") {
|
|
@@ -4900,6 +4965,12 @@ class Server {
|
|
|
4900
4965
|
// config.icon = "/pinokio-white.png"
|
|
4901
4966
|
// }
|
|
4902
4967
|
// }
|
|
4968
|
+
// Pinokio Anywhere: a visitor on the node's own Tailscale name gets each
|
|
4969
|
+
// published app's fixed https address; any other host is left for the
|
|
4970
|
+
// Home Server rewrite below, exactly as before.
|
|
4971
|
+
if (this.kernel.anywhere) {
|
|
4972
|
+
await this.kernel.anywhere.rewriteMenu(req.$source && req.$source.host, config.menu)
|
|
4973
|
+
}
|
|
4903
4974
|
config = Util.rewrite_localhost(this.kernel, config, req.$source)
|
|
4904
4975
|
return config
|
|
4905
4976
|
} else {
|
|
@@ -6757,6 +6828,8 @@ class Server {
|
|
|
6757
6828
|
protocol,
|
|
6758
6829
|
host: forwardedHost || req.get("host")
|
|
6759
6830
|
}
|
|
6831
|
+
// Pinokio Anywhere: only a visitor on the node's own Tailscale name gets the web-app manifest
|
|
6832
|
+
res.locals.anywhere_host = !!(this.kernel && this.kernel.anywhere && this.kernel.anywhere.isNodeHost(req.$source.host))
|
|
6760
6833
|
next()
|
|
6761
6834
|
})
|
|
6762
6835
|
if (this.kernel.homedir) {
|
|
@@ -14159,6 +14232,56 @@ class Server {
|
|
|
14159
14232
|
allow_dns_creation,
|
|
14160
14233
|
})
|
|
14161
14234
|
}))
|
|
14235
|
+
this.app.get("/anywhere", ex(async (req, res) => {
|
|
14236
|
+
let { requirements, install_required, requirements_pending, error } = await this.kernel.bin.check({
|
|
14237
|
+
bin: this.kernel.bin.preset("anywhere"),
|
|
14238
|
+
})
|
|
14239
|
+
if (!requirements_pending && install_required) {
|
|
14240
|
+
res.redirect("/setup/anywhere?callback=/anywhere")
|
|
14241
|
+
return
|
|
14242
|
+
}
|
|
14243
|
+
const anywhere = this.kernel.anywhere
|
|
14244
|
+
// first visit after install: switch it on, the page then walks the user
|
|
14245
|
+
// through sign-in. Later visits respect the on/off switch.
|
|
14246
|
+
if (anywhere && !requirements_pending && !(await anywhere.configured())) {
|
|
14247
|
+
anywhere.enable().catch((err) => {
|
|
14248
|
+
console.log("[anywhere] enable failed", err && err.message ? err.message : err)
|
|
14249
|
+
})
|
|
14250
|
+
}
|
|
14251
|
+
const status = await this.composeAnywhereStatus()
|
|
14252
|
+
res.render("anywhere", {
|
|
14253
|
+
status,
|
|
14254
|
+
requirements_pending,
|
|
14255
|
+
install_required,
|
|
14256
|
+
error,
|
|
14257
|
+
docs: this.docs,
|
|
14258
|
+
portal: this.portal,
|
|
14259
|
+
logo: this.logo,
|
|
14260
|
+
theme: this.theme,
|
|
14261
|
+
agent: req.agent,
|
|
14262
|
+
})
|
|
14263
|
+
}))
|
|
14264
|
+
this.app.get("/anywhere/status", ex(async (req, res) => {
|
|
14265
|
+
res.set("Cache-Control", "no-store")
|
|
14266
|
+
res.json(await this.composeAnywhereStatus({ light: req.query.light === "1" }))
|
|
14267
|
+
}))
|
|
14268
|
+
const anywhereAction = (label, fn) => ex(async (req, res) => {
|
|
14269
|
+
const anywhere = this.kernel.anywhere
|
|
14270
|
+
if (!anywhere) {
|
|
14271
|
+
res.status(500).json({ success: false, error: "Pinokio Anywhere is not available" })
|
|
14272
|
+
return
|
|
14273
|
+
}
|
|
14274
|
+
try {
|
|
14275
|
+
await fn(anywhere, req)
|
|
14276
|
+
res.json({ success: true, status: await this.composeAnywhereStatus() })
|
|
14277
|
+
} catch (error) {
|
|
14278
|
+
console.log(`[anywhere] ${label} failed`, error)
|
|
14279
|
+
res.status(500).json({ success: false, error: error && error.message ? error.message : String(error) })
|
|
14280
|
+
}
|
|
14281
|
+
})
|
|
14282
|
+
this.app.post("/anywhere/start", anywhereAction("start", (anywhere) => anywhere.enable()))
|
|
14283
|
+
this.app.post("/anywhere/stop", anywhereAction("stop", (anywhere) => anywhere.disable()))
|
|
14284
|
+
this.app.post("/anywhere/logout", anywhereAction("logout", (anywhere) => anywhere.logout()))
|
|
14162
14285
|
this.app.get("/network", ex(async (req, res) => {
|
|
14163
14286
|
let protocol = req.get('X-Forwarded-Proto') || "http"
|
|
14164
14287
|
let { requirements, install_required, requirements_pending, error } = await this.kernel.bin.check({
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name":"Pinokio","short_name":"Pinokio","start_url":"/","scope":"/","display":"standalone","background_color":"#ffffff","theme_color":"#000000","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"},{"src":"/apple-touch-icon.png","sizes":"180x180","type":"image/png"}]}
|