pinokiod 8.2.10 → 8.2.12
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/cli.js +100 -0
- package/kernel/anywhere/index.js +237 -161
- package/kernel/anywhere/ports.js +6 -9
- package/kernel/anywhere/serve.js +17 -7
- package/kernel/anywhere/state.js +14 -0
- package/kernel/bin/conda-meta.js +19 -1
- package/kernel/bin/conda-pins.js +7 -3
- package/kernel/bin/conda.js +67 -15
- package/kernel/bin/index.js +5 -2
- package/kernel/bin/setup.js +3 -17
- package/kernel/bin/tailscaled.js +23 -13
- package/kernel/index.js +11 -9
- package/kernel/shell.js +14 -6
- package/kernel/util.js +1 -0
- package/package.json +1 -1
- package/server/index.js +15 -19
- package/server/public/universal-launcher.js +5 -0
- package/server/views/anywhere.ejs +13 -13
- package/server/views/partials/main_sidebar.ejs +0 -1
- package/server/views/remote.ejs +12 -4
- package/system/pip_seed.json +6 -0
- package/system/repair_pip_seed.py +100 -0
- package/test/anywhere-remote.test.js +182 -8
- package/test/anywhere.test.js +1063 -131
- package/test/conda-bin.test.js +254 -4
- package/test/pip_seed_test.py +183 -0
- package/test/shell-run-template.test.js +147 -140
- package/test/tailscale-cli.test.js +239 -0
- package/kernel/anywhere/localapi.js +0 -147
package/test/anywhere.test.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
const assert = require('node:assert/strict')
|
|
2
2
|
const test = require('node:test')
|
|
3
|
-
const http = require('node:http')
|
|
4
3
|
const os = require('node:os')
|
|
5
4
|
const fs = require('node:fs')
|
|
6
5
|
const path = require('node:path')
|
|
7
6
|
|
|
8
7
|
const { buildServeConfig, describeServeConfig, sameServeConfig } = require('../kernel/anywhere/serve')
|
|
9
|
-
const { assignPorts } = require('../kernel/anywhere/ports')
|
|
10
|
-
const
|
|
8
|
+
const { assignPorts, loadPortMap, savePortMap } = require('../kernel/anywhere/ports')
|
|
9
|
+
const vm = require('node:vm')
|
|
11
10
|
const Anywhere = require('../kernel/anywhere')
|
|
12
|
-
const
|
|
11
|
+
const TailscaleCLI = require('../kernel/anywhere/cli')
|
|
13
12
|
const Tailscaled = require('../kernel/bin/tailscaled')
|
|
13
|
+
const { spawn } = require('node:child_process')
|
|
14
|
+
const { once, EventEmitter } = require('node:events')
|
|
14
15
|
const Setup = require('../kernel/bin/setup')
|
|
15
16
|
|
|
16
17
|
test('serve config maps pinokiod to 443 and each app to its stable remote port', () => {
|
|
@@ -53,87 +54,49 @@ test('stable ports are allocated once, lowest free first, and never move', () =>
|
|
|
53
54
|
test('anywhere preset requires tailscaled but not caddy', () => {
|
|
54
55
|
const preset = Setup.anywhere({})
|
|
55
56
|
const names = preset.requirements.map((r) => r.name)
|
|
56
|
-
assert.
|
|
57
|
+
assert.deepEqual(names, ['conda', 'tailscaled'])
|
|
58
|
+
assert.deepEqual(preset.conda_requirements, ['tailscaled', 'tailscale'])
|
|
57
59
|
assert.ok(!names.includes('caddy'))
|
|
58
60
|
assert.ok(preset.conda_requirements.includes('tailscaled'))
|
|
59
61
|
assert.ok(!preset.conda_requirements.includes('caddy'))
|
|
60
62
|
})
|
|
61
63
|
|
|
62
|
-
test('tailscaled
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
test('tailscaled checks both package versions, binary versions and missing executables', async () => {
|
|
65
|
+
const files = new Map()
|
|
66
|
+
const calls = []
|
|
67
|
+
const context = { module: { exports: {} }, console, require(name) {
|
|
68
|
+
if (name === 'fs') return { promises: { stat: async (file) => {
|
|
69
|
+
if (!files.has(file)) throw new Error('missing binary')
|
|
70
|
+
return { mtimeMs: 1, size: 1 }
|
|
71
|
+
} } }
|
|
72
|
+
if (name === 'child_process') return { execFile(file, args, options, done) {
|
|
73
|
+
calls.push([file, args, options])
|
|
74
|
+
done(null, files.get(file), '')
|
|
75
|
+
} }
|
|
76
|
+
if (name === 'util') return { promisify: (fn) => (...args) => new Promise((resolve, reject) => {
|
|
77
|
+
fn(...args, (error, stdout) => error ? reject(error) : resolve({ stdout }))
|
|
78
|
+
}) }
|
|
79
|
+
return require(name)
|
|
80
|
+
} }
|
|
81
|
+
vm.runInNewContext(fs.readFileSync(require.resolve('../kernel/bin/tailscaled'), 'utf8'), context)
|
|
82
|
+
for (const platform of ['darwin', 'win32', 'linux']) {
|
|
83
|
+
const mod = new context.module.exports()
|
|
84
|
+
mod.kernel = { platform, bin: { path: (...parts) => path.join('/managed', ...parts),
|
|
85
|
+
installed: { conda: new Set(['tailscaled', 'tailscale']), conda_versions: { tailscaled: '1.104.1', tailscale: '1.104.1' } } } }
|
|
86
|
+
assert.equal(mod.cmd(), 'tailscaled=1.104.1 tailscale=1.104.1')
|
|
87
|
+
files.clear()
|
|
88
|
+
files.set(mod.binPath(), '1.104.1\n')
|
|
89
|
+
assert.equal(await mod.installed(), false, 'missing CLI fails setup')
|
|
90
|
+
files.set(mod.cliPath(), '1.104.1\n')
|
|
91
|
+
assert.equal(await mod.installed(), true)
|
|
92
|
+
mod.kernel.bin.installed.conda_versions.tailscale = '1.102.2'
|
|
93
|
+
assert.equal(await mod.installed(), false, 'mismatched CLI package fails setup')
|
|
94
|
+
mod.kernel.bin.installed.conda_versions.tailscale = '1.104.1'
|
|
95
|
+
mod.verified.clear()
|
|
96
|
+
files.set(mod.cliPath(), '1.102.2\n')
|
|
97
|
+
assert.equal(await mod.installed(), false, 'binary version is verified independently')
|
|
71
98
|
}
|
|
72
|
-
assert.
|
|
73
|
-
assert.equal(mod.binPath(), 'miniforge/bin/tailscaled')
|
|
74
|
-
mod.kernel.bin.installed.conda_versions.tailscaled = '1.98.5'
|
|
75
|
-
assert.equal(await mod.installed(), false, 'older than 1.104 (no --windows-mode=dev) is not accepted')
|
|
76
|
-
mod.kernel.bin.installed.conda = new Set()
|
|
77
|
-
assert.equal(await mod.installed(), false)
|
|
78
|
-
mod.kernel.platform = 'win32'
|
|
79
|
-
assert.equal(mod.binPath(), 'miniforge/Library/bin/tailscaled.exe')
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
function withMockDaemon(handler, fn) {
|
|
83
|
-
const socketPath = path.join(os.tmpdir(), `pk-ts-test-${process.pid}-${Date.now()}.sock`)
|
|
84
|
-
const server = http.createServer(handler)
|
|
85
|
-
return new Promise((resolve, reject) => {
|
|
86
|
-
server.listen(socketPath, async () => {
|
|
87
|
-
try {
|
|
88
|
-
resolve(await fn(socketPath))
|
|
89
|
-
} catch (e) {
|
|
90
|
-
reject(e)
|
|
91
|
-
} finally {
|
|
92
|
-
server.close()
|
|
93
|
-
fs.rmSync(socketPath, { force: true })
|
|
94
|
-
}
|
|
95
|
-
})
|
|
96
|
-
})
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
test('LocalAPI client talks plain HTTP over the socket with the Sec-Tailscale header', { skip: process.platform === 'win32' }, async () => {
|
|
100
|
-
const seen = []
|
|
101
|
-
await withMockDaemon((req, res) => {
|
|
102
|
-
let body = ''
|
|
103
|
-
req.on('data', (c) => body += c)
|
|
104
|
-
req.on('end', () => {
|
|
105
|
-
seen.push({ method: req.method, url: req.url, sec: req.headers['sec-tailscale'], ifMatch: req.headers['if-match'], body })
|
|
106
|
-
if (req.url === '/localapi/v0/status') {
|
|
107
|
-
res.setHeader('Content-Type', 'application/json')
|
|
108
|
-
res.end(JSON.stringify({ BackendState: 'Running', Self: { DNSName: 'pinokio.tail1.ts.net.' } }))
|
|
109
|
-
} else if (req.url === '/localapi/v0/serve-config' && req.method === 'GET') {
|
|
110
|
-
res.setHeader('Etag', 'abc')
|
|
111
|
-
res.end('null')
|
|
112
|
-
} else if (req.url === '/localapi/v0/serve-config' && req.method === 'POST') {
|
|
113
|
-
res.statusCode = 200
|
|
114
|
-
res.end()
|
|
115
|
-
} else if (req.url === '/localapi/v0/login-interactive') {
|
|
116
|
-
res.statusCode = 204
|
|
117
|
-
res.end()
|
|
118
|
-
} else {
|
|
119
|
-
res.statusCode = 404
|
|
120
|
-
res.end('nope')
|
|
121
|
-
}
|
|
122
|
-
})
|
|
123
|
-
}, async (socketPath) => {
|
|
124
|
-
const api = new LocalAPI({ socketPath })
|
|
125
|
-
const status = await api.status()
|
|
126
|
-
assert.equal(status.BackendState, 'Running')
|
|
127
|
-
const { etag } = await api.serveConfig()
|
|
128
|
-
assert.equal(etag, 'abc')
|
|
129
|
-
await api.setServeConfig({ TCP: {} }, etag)
|
|
130
|
-
await api.loginInteractive()
|
|
131
|
-
await assert.rejects(() => api.expect('GET', '/missing'), /404/)
|
|
132
|
-
})
|
|
133
|
-
const post = seen.find((s) => s.method === 'POST' && s.url === '/localapi/v0/serve-config')
|
|
134
|
-
assert.equal(post.sec, 'localapi')
|
|
135
|
-
assert.equal(post.ifMatch, 'abc')
|
|
136
|
-
assert.equal(post.body, JSON.stringify({ TCP: {} }))
|
|
99
|
+
assert.ok(calls.every(([, args, options]) => args[0] === '--version' && options.shell === false))
|
|
137
100
|
})
|
|
138
101
|
|
|
139
102
|
function fakeKernel(tmp, { installed = true } = {}) {
|
|
@@ -146,11 +109,73 @@ function fakeKernel(tmp, { installed = true } = {}) {
|
|
|
146
109
|
memory: { local: {} },
|
|
147
110
|
bin: {
|
|
148
111
|
path: (...segments) => path.join(tmp, 'bin', ...segments),
|
|
149
|
-
mod: { tailscaled: { installed: async () => installed, binPath: () => path.join(tmp, 'bin', 'tailscaled') } },
|
|
112
|
+
mod: { tailscaled: { installed: async () => installed, binPath: () => path.join(tmp, 'bin', 'tailscaled'), cliPath: () => path.join(tmp, 'bin', 'tailscale') } },
|
|
150
113
|
},
|
|
151
114
|
}
|
|
152
115
|
}
|
|
153
116
|
|
|
117
|
+
function runningStatus() {
|
|
118
|
+
return { BackendState: 'Running', Self: { DNSName: 'pinokio.tail1.ts.net.' }, CertDomains: ['pinokio.tail1.ts.net'] }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const method of ['writeFile', 'rename']) {
|
|
122
|
+
test(`failed ${method} preserves Anywhere settings, cache and port assignments`, async (t) => {
|
|
123
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-save-'))
|
|
124
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
125
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
126
|
+
await anywhere.saveConfig({ enabled: true, hostname: 'keep-name' })
|
|
127
|
+
await savePortMap(anywhere.portMapPath(), { comfy: 43000 })
|
|
128
|
+
const files = [anywhere.configPath(), anywhere.portMapPath()]
|
|
129
|
+
const before = files.map((file) => fs.readFileSync(file, 'utf8'))
|
|
130
|
+
const original = fs.promises[method]
|
|
131
|
+
const code = method === 'writeFile' ? 'ENOSPC' : 'EACCES'
|
|
132
|
+
t.mock.method(fs.promises, method, async function (...args) {
|
|
133
|
+
const target = method === 'rename' ? args[1] : args[0]
|
|
134
|
+
if (files.some((file) => target === file || String(target).startsWith(`${file}.`))) {
|
|
135
|
+
if (method === 'writeFile') await original.call(this, target, '{"partial":')
|
|
136
|
+
throw Object.assign(new Error('injected save failure'), { code })
|
|
137
|
+
}
|
|
138
|
+
return original.apply(this, args)
|
|
139
|
+
})
|
|
140
|
+
await assert.rejects(anywhere.saveConfig({ enabled: false, hostname: 'lost-name' }), { code })
|
|
141
|
+
await assert.rejects(savePortMap(files[1], { comfy: 43000, other: 43001 }), { code })
|
|
142
|
+
assert.deepEqual(files.map((file) => fs.readFileSync(file, 'utf8')), before)
|
|
143
|
+
assert.equal(await anywhere.enabled(), true)
|
|
144
|
+
assert.equal(await anywhere.hostname(), 'keep-name')
|
|
145
|
+
assert.deepEqual(await loadPortMap(files[1]), { comfy: 43000 })
|
|
146
|
+
assert.deepEqual(fs.readdirSync(anywhere.dir()).sort(), ['config.json', 'ports.json'])
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
test('failed first Anywhere save leaves no partial config or enabled cache', async (t) => {
|
|
151
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-save-'))
|
|
152
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
153
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
154
|
+
t.mock.method(fs.promises, 'writeFile', async () => {
|
|
155
|
+
throw Object.assign(new Error('disk full'), { code: 'ENOSPC' })
|
|
156
|
+
})
|
|
157
|
+
await assert.rejects(anywhere.saveConfig({ enabled: true }), { code: 'ENOSPC' })
|
|
158
|
+
assert.equal(await anywhere.enabled(), false)
|
|
159
|
+
assert.equal(await anywhere.configured(), false)
|
|
160
|
+
assert.deepEqual(fs.readdirSync(anywhere.dir()), [])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('Anywhere replacements persist settings and stable ports for a fresh reader', async (t) => {
|
|
164
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-save-'))
|
|
165
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
166
|
+
const kernel = fakeKernel(tmp)
|
|
167
|
+
const anywhere = new Anywhere(kernel)
|
|
168
|
+
await anywhere.saveConfig({ enabled: true, hostname: 'keep-name' })
|
|
169
|
+
await anywhere.saveConfig({ enabled: false })
|
|
170
|
+
await savePortMap(anywhere.portMapPath(), { comfy: 43000 })
|
|
171
|
+
await savePortMap(anywhere.portMapPath(), { comfy: 43000, other: 43001 })
|
|
172
|
+
const fresh = new Anywhere(kernel)
|
|
173
|
+
assert.equal(await fresh.enabled(), false)
|
|
174
|
+
assert.equal(await fresh.hostname(), 'keep-name')
|
|
175
|
+
assert.deepEqual(await loadPortMap(fresh.portMapPath()), { comfy: 43000, other: 43001 })
|
|
176
|
+
assert.deepEqual(fs.readdirSync(anywhere.dir()).sort(), ['config.json', 'ports.json'])
|
|
177
|
+
})
|
|
178
|
+
|
|
154
179
|
test('anywhere snapshot walks setup -> off -> login -> https -> ready', async () => {
|
|
155
180
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
156
181
|
try {
|
|
@@ -165,27 +190,31 @@ test('anywhere snapshot walks setup -> off -> login -> https -> ready', async ()
|
|
|
165
190
|
assert.equal(await anywhere.configured(), true)
|
|
166
191
|
assert.equal(JSON.parse(fs.readFileSync(path.join(tmp, 'anywhere', 'config.json'), 'utf8')).enabled, true)
|
|
167
192
|
|
|
168
|
-
//
|
|
193
|
+
// Stub a verified owned daemon.
|
|
194
|
+
anywhere.readPid = () => 123
|
|
169
195
|
let status = { BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/abc', Self: {}, Peer: null, User: null }
|
|
170
196
|
let loginRequests = 0
|
|
171
197
|
let pushed = null
|
|
172
|
-
|
|
198
|
+
let applied = null
|
|
173
199
|
anywhere.api = {
|
|
174
200
|
status: async () => status,
|
|
175
|
-
|
|
201
|
+
up: (update) => { loginRequests += 1; update({ AuthURL: status.AuthURL }); return new Promise(() => {}) },
|
|
176
202
|
queryFeature: async () => ({ Complete: false, Text: 'approve', URL: 'https://login.tailscale.com/f/serve?node=1' }),
|
|
177
|
-
serveConfig: async () =>
|
|
178
|
-
setServeConfig: async (config) => { pushed = config },
|
|
203
|
+
serveConfig: async () => applied,
|
|
204
|
+
setServeConfig: async (config) => { pushed = applied = config; return applied },
|
|
179
205
|
}
|
|
180
206
|
let snap = await anywhere.snapshot()
|
|
207
|
+
assert.equal(snap.phase, 'starting', 'the first poll does not wait for asynchronous login ownership inspection')
|
|
208
|
+
await new Promise(setImmediate)
|
|
209
|
+
snap = await anywhere.snapshot()
|
|
181
210
|
assert.equal(snap.phase, 'login')
|
|
182
211
|
assert.equal(snap.loginUrl, 'https://login.tailscale.com/a/abc')
|
|
183
|
-
assert.equal(loginRequests,
|
|
212
|
+
assert.equal(loginRequests, 1, 'owns one CLI login flow')
|
|
184
213
|
|
|
185
214
|
anywhere.lastStatusAt = 0
|
|
186
215
|
status = { BackendState: 'NeedsLogin', AuthURL: '', Self: {}, Peer: null, User: null }
|
|
187
216
|
snap = await anywhere.snapshot()
|
|
188
|
-
assert.equal(loginRequests, 1, '
|
|
217
|
+
assert.equal(loginRequests, 1, 'polling does not duplicate the pending CLI login')
|
|
189
218
|
|
|
190
219
|
anywhere.lastStatusAt = 0
|
|
191
220
|
status = {
|
|
@@ -237,8 +266,8 @@ test('anywhere snapshot walks setup -> off -> login -> https -> ready', async ()
|
|
|
237
266
|
assert.equal(rewritten[2].href, '/files/api/comfy')
|
|
238
267
|
assert.equal(rewritten[3].href, 'https://example.com:7860/')
|
|
239
268
|
}
|
|
240
|
-
//
|
|
241
|
-
assert.equal(await anywhere.sync(),
|
|
269
|
+
// The ready snapshot already verified the routes; unchanged syncs do not write.
|
|
270
|
+
assert.equal(await anywhere.sync(), false)
|
|
242
271
|
assert.deepEqual(Object.keys(pushed.TCP), ['443', '43000'])
|
|
243
272
|
pushed = null
|
|
244
273
|
assert.equal(await anywhere.sync(), false)
|
|
@@ -262,6 +291,77 @@ test('anywhere snapshot walks setup -> off -> login -> https -> ready', async ()
|
|
|
262
291
|
}
|
|
263
292
|
})
|
|
264
293
|
|
|
294
|
+
test('unchanged menus reuse confirmed routes and changed apps sync before publishing links', async t => {
|
|
295
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-menu-sync-'))
|
|
296
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
297
|
+
const kernel = fakeKernel(tmp)
|
|
298
|
+
const local = kernel.memory.local
|
|
299
|
+
const comfy = path.join(tmp, 'api/comfy/start.js')
|
|
300
|
+
const forge = path.join(tmp, 'api/forge/start.js')
|
|
301
|
+
local[comfy] = { url: 'http://localhost:7860' }
|
|
302
|
+
const anywhere = new Anywhere(kernel)
|
|
303
|
+
anywhere.config = { enabled: true }
|
|
304
|
+
let applied = null, reads = 0, release
|
|
305
|
+
anywhere.readPid = () => 123
|
|
306
|
+
anywhere.api = {
|
|
307
|
+
status: async () => runningStatus(),
|
|
308
|
+
serveConfig: async () => { reads++; return applied },
|
|
309
|
+
setServeConfig: async desired => {
|
|
310
|
+
if (release) await release.promise
|
|
311
|
+
return (applied = desired)
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
await anywhere.sync()
|
|
315
|
+
reads = 0
|
|
316
|
+
for (let i = 0; i < 10; i++) {
|
|
317
|
+
const menu = await anywhere.rewriteMenu('pinokio.tail1.ts.net', [{ href: local[comfy].url }])
|
|
318
|
+
assert.equal(menu[0].href, 'https://pinokio.tail1.ts.net:43000/')
|
|
319
|
+
}
|
|
320
|
+
assert.equal(reads, 0, 'unchanged menu requests do not spawn more CLI reads')
|
|
321
|
+
await anywhere.sync()
|
|
322
|
+
assert.equal(reads, 1, 'the periodic refresh still checks the daemon')
|
|
323
|
+
|
|
324
|
+
local[forge] = { url: 'http://localhost:8188' }
|
|
325
|
+
const fresh = await anywhere.rewriteMenu('pinokio.tail1.ts.net', [{ href: local[forge].url }])
|
|
326
|
+
assert.equal(fresh[0].href, 'https://pinokio.tail1.ts.net:43001/')
|
|
327
|
+
delete local[forge]
|
|
328
|
+
await anywhere.rewriteMenu('pinokio.tail1.ts.net', [])
|
|
329
|
+
assert.equal(applied.TCP[43001], undefined)
|
|
330
|
+
|
|
331
|
+
local[comfy].url = 'http://localhost:7861'
|
|
332
|
+
release = {}
|
|
333
|
+
release.promise = new Promise(resolve => { release.resolve = resolve })
|
|
334
|
+
const sync = anywhere.sync()
|
|
335
|
+
await new Promise(setImmediate)
|
|
336
|
+
let published = false
|
|
337
|
+
const menu = anywhere.rewriteMenu('pinokio.tail1.ts.net', [{ href: local[comfy].url }])
|
|
338
|
+
.then(value => { published = true; return value })
|
|
339
|
+
await new Promise(setImmediate)
|
|
340
|
+
assert.equal(published, false, 'the new link waits for its pending route update')
|
|
341
|
+
release.resolve()
|
|
342
|
+
await sync
|
|
343
|
+
assert.equal((await menu)[0].href, 'https://pinokio.tail1.ts.net:43000/')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
test('concurrent syncs share one follow-up pass and include the latest targets', async () => {
|
|
347
|
+
const anywhere = new Anywhere(fakeKernel(os.tmpdir()))
|
|
348
|
+
let release, target = 'old'
|
|
349
|
+
const pending = new Promise(resolve => { release = resolve })
|
|
350
|
+
const seen = []
|
|
351
|
+
anywhere.pushServeConfig = async () => {
|
|
352
|
+
seen.push(target)
|
|
353
|
+
if (seen.length === 1) await pending
|
|
354
|
+
return seen.length === 1
|
|
355
|
+
}
|
|
356
|
+
const calls = [anywhere.sync()]
|
|
357
|
+
target = 'new'
|
|
358
|
+
for (let i = 0; i < 7; i++) calls.push(anywhere.sync())
|
|
359
|
+
release()
|
|
360
|
+
assert.deepEqual(await Promise.all(calls), Array(8).fill(true))
|
|
361
|
+
assert.deepEqual(seen, ['old', 'new'])
|
|
362
|
+
assert.equal(anywhere.syncing, null)
|
|
363
|
+
})
|
|
364
|
+
|
|
265
365
|
test('anywhere uses dev mode flags on windows and a short socket path on long homes', () => {
|
|
266
366
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
267
367
|
try {
|
|
@@ -323,26 +423,58 @@ test('serve targets follow the Home Server router rules for an app\'s URL variab
|
|
|
323
423
|
}
|
|
324
424
|
const anywhere = new Anywhere(kernel)
|
|
325
425
|
assert.deepEqual(anywhere.appTargets(), [ // `url` first, then the other keys alphabetically
|
|
326
|
-
{ key: 'comfy', name: 'comfy', local: 7860 },
|
|
327
|
-
{ key: 'api.comfy', name: 'comfy', local: 7861 },
|
|
328
|
-
{ key: 'nocfg', name: 'nocfg', local: 8000 },
|
|
426
|
+
{ key: 'comfy', name: 'comfy', local: 7860, origin: 'http://localhost:7860' },
|
|
427
|
+
{ key: 'api.comfy', name: 'comfy', local: 7861, origin: 'http://127.0.0.1:7861' },
|
|
428
|
+
{ key: 'nocfg', name: 'nocfg', local: 8000, origin: 'http://localhost:8000' },
|
|
329
429
|
])
|
|
330
430
|
})
|
|
331
431
|
|
|
332
|
-
test('
|
|
432
|
+
test('Serve preserves upstream protocol and host, including changes on the same port', async t => {
|
|
433
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-origin-'))
|
|
434
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
435
|
+
const kernel = fakeKernel(tmp)
|
|
436
|
+
const app = path.join(tmp, 'api/app/start.js')
|
|
437
|
+
const anywhere = new Anywhere(kernel)
|
|
438
|
+
anywhere.config = { enabled: true }
|
|
439
|
+
anywhere.lastStatus = runningStatus()
|
|
440
|
+
let applied = null
|
|
441
|
+
anywhere.readPid = () => 123
|
|
442
|
+
anywhere.api = {
|
|
443
|
+
status: async () => runningStatus(),
|
|
444
|
+
serveConfig: async () => applied,
|
|
445
|
+
setServeConfig: async desired => (applied = desired),
|
|
446
|
+
}
|
|
447
|
+
for (const [url, origin] of [
|
|
448
|
+
['http://127.0.0.1:7860/ui/', 'http://127.0.0.1:7860'],
|
|
449
|
+
['https://127.0.0.1:7860/ui/', 'https://127.0.0.1:7860'],
|
|
450
|
+
['https://localhost:7860/ui/', 'https://localhost:7860'],
|
|
451
|
+
['http://[::1]:7860/ui/', 'http://[::1]:7860'],
|
|
452
|
+
['http://0.0.0.0:7860/ui/', 'http://127.0.0.1:7860'],
|
|
453
|
+
]) {
|
|
454
|
+
kernel.memory.local[app] = { url }
|
|
455
|
+
await anywhere.rewriteMenu('pinokio.tail1.ts.net', [{ href: url }])
|
|
456
|
+
assert.equal(applied.Web['pinokio.tail1.ts.net:43000'].Handlers['/'].Proxy, origin)
|
|
457
|
+
assert.equal(describeServeConfig(applied, 'pinokio.tail1.ts.net')[1].local, 7860)
|
|
458
|
+
assert.equal(anywhere.targets[0].listen, 43000, 'the remote address stays stable')
|
|
459
|
+
}
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
test('a failed HTTPS query surfaces an error instead of looping in starting', async () => {
|
|
333
463
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
334
464
|
try {
|
|
335
465
|
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
336
466
|
await anywhere.saveConfig({ enabled: true })
|
|
337
|
-
anywhere.pid = 123
|
|
338
467
|
let feature = () => { throw new Error('503 no netmap') }
|
|
468
|
+
anywhere.readPid = () => 123
|
|
339
469
|
anywhere.api = {
|
|
340
470
|
status: async () => ({ BackendState: 'Running', CertDomains: null, Self: { DNSName: 'pinokio.tail1.ts.net.', UserID: 7 }, User: {}, Peer: null }),
|
|
341
471
|
queryFeature: async () => feature(),
|
|
342
472
|
}
|
|
343
473
|
let snap = await anywhere.snapshot()
|
|
344
|
-
assert.equal(snap.phase, '
|
|
474
|
+
assert.equal(snap.phase, 'error')
|
|
475
|
+
assert.match(snap.error, /503 no netmap/)
|
|
345
476
|
assert.equal(snap.approval, null)
|
|
477
|
+
anywhere.error = null
|
|
346
478
|
feature = () => ({ Complete: false, Text: 'approve', URL: 'https://login.tailscale.com/f/serve?node=1' })
|
|
347
479
|
anywhere.lastStatusAt = 0
|
|
348
480
|
snap = await anywhere.snapshot()
|
|
@@ -360,31 +492,270 @@ function spawnIdle() {
|
|
|
360
492
|
return { child, exited: (ms = 3000) => Promise.race([exited, new Promise((resolve) => setTimeout(() => resolve(false), ms))]) }
|
|
361
493
|
}
|
|
362
494
|
|
|
363
|
-
test('
|
|
495
|
+
test('a stored PID never authorizes signaling an unrelated process', async () => {
|
|
364
496
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
365
497
|
const idle = spawnIdle()
|
|
366
498
|
try {
|
|
367
|
-
const
|
|
368
|
-
|
|
499
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
500
|
+
await anywhere.saveConfig({ enabled: true })
|
|
369
501
|
fs.mkdirSync(anywhere.stateDir(), { recursive: true })
|
|
370
|
-
const dead = spawnIdle(); dead.child.kill('SIGKILL'); await dead.exited()
|
|
371
|
-
fs.writeFileSync(anywhere.pidPath(), String(dead.child.pid))
|
|
372
|
-
assert.equal(anywhere.readPid(), null, 'a pid that no longer exists is ignored')
|
|
373
502
|
fs.writeFileSync(anywhere.pidPath(), String(idle.child.pid))
|
|
374
|
-
|
|
503
|
+
let mutations = 0
|
|
375
504
|
anywhere.api = {
|
|
376
|
-
ping: async () => true,
|
|
377
|
-
|
|
378
|
-
status: async () => ({ BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/abc', Self: {}, User: null, Peer: null }),
|
|
505
|
+
cancel() {}, ping: async () => true, setPrefs: async () => { mutations++ },
|
|
506
|
+
status: async () => ({ BackendState: 'Running' }),
|
|
379
507
|
}
|
|
380
|
-
await anywhere.start()
|
|
381
|
-
assert.equal(
|
|
508
|
+
await assert.rejects(() => anywhere.start(), /Cannot verify ownership/)
|
|
509
|
+
assert.equal(mutations, 0, 'an unverified daemon must not receive settings changes')
|
|
510
|
+
assert.equal(anywhere.child, null, 'does not adopt a child handle from disk')
|
|
511
|
+
assert.equal(await anywhere.readPid(), null)
|
|
512
|
+
await assert.rejects(() => anywhere.stop(), /Cannot verify ownership/)
|
|
513
|
+
anywhere.killSync()
|
|
514
|
+
process.kill(idle.child.pid, 0)
|
|
515
|
+
assert.equal(fs.readFileSync(anywhere.pidPath(), 'utf8'), String(idle.child.pid))
|
|
382
516
|
} finally {
|
|
383
517
|
idle.child.kill('SIGKILL')
|
|
384
518
|
fs.rmSync(tmp, { recursive: true, force: true })
|
|
385
519
|
}
|
|
386
520
|
})
|
|
387
521
|
|
|
522
|
+
test('a responding daemon requires a verified PID or a live owned child', async t => {
|
|
523
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-adopt-'))
|
|
524
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
525
|
+
for (const platform of ['win32', 'darwin', 'linux']) {
|
|
526
|
+
for (const owner of ['missing', 'verified', 'child']) {
|
|
527
|
+
const kernel = fakeKernel(path.join(tmp, platform, owner))
|
|
528
|
+
kernel.platform = platform
|
|
529
|
+
const anywhere = new Anywhere(kernel)
|
|
530
|
+
anywhere.config = { enabled: true }
|
|
531
|
+
if (owner === 'child') anywhere.child = { pid: 123, exitCode: null }
|
|
532
|
+
anywhere.readPid = () => owner === 'verified' ? 123 : null
|
|
533
|
+
let mutations = 0
|
|
534
|
+
anywhere.api = { cancel() {}, ping: async () => true,
|
|
535
|
+
setPrefs: async () => { mutations++ }, status: async () => runningStatus() }
|
|
536
|
+
if (owner === 'missing') await assert.rejects(anywhere.start(), /Cannot verify ownership/)
|
|
537
|
+
else assert.equal(await anywhere.start(), true)
|
|
538
|
+
assert.equal(mutations, owner === 'missing' ? 0 : 1)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
test('unverified daemons cannot receive logout, login, approval or Serve changes', async t => {
|
|
544
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-unowned-control-'))
|
|
545
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
546
|
+
for (const platform of ['win32', 'darwin', 'linux']) {
|
|
547
|
+
const kernel = fakeKernel(path.join(tmp, platform))
|
|
548
|
+
kernel.platform = platform
|
|
549
|
+
const anywhere = new Anywhere(kernel)
|
|
550
|
+
anywhere.config = { enabled: true }
|
|
551
|
+
anywhere.readPid = () => null
|
|
552
|
+
const mutations = []
|
|
553
|
+
anywhere.api = {
|
|
554
|
+
cancel() {}, ping: async () => true, status: async () => runningStatus(),
|
|
555
|
+
setPrefs: async () => { mutations.push('prefs') },
|
|
556
|
+
logout: async () => { mutations.push('logout') },
|
|
557
|
+
up: async () => { mutations.push('login') },
|
|
558
|
+
queryFeature: async () => { mutations.push('approval'); return { Complete: true } },
|
|
559
|
+
serveConfig: async () => null,
|
|
560
|
+
setServeConfig: async config => { mutations.push('serve'); return config },
|
|
561
|
+
}
|
|
562
|
+
await assert.rejects(anywhere.start(), /Cannot verify ownership/)
|
|
563
|
+
await assert.rejects(anywhere.logout(), /Cannot verify ownership/)
|
|
564
|
+
anywhere.error = null
|
|
565
|
+
anywhere.requestLogin()
|
|
566
|
+
await anywhere.login.promise
|
|
567
|
+
assert.match(anywhere.error, /Cannot verify ownership/)
|
|
568
|
+
anywhere.error = null
|
|
569
|
+
for (const backend of ['NeedsLogin', 'Stopped', 'Running']) {
|
|
570
|
+
anywhere.lastStatus = null
|
|
571
|
+
anywhere.api.status = async () => ({ ...runningStatus(), BackendState: backend, CertDomains: null })
|
|
572
|
+
await assert.rejects(anywhere.snapshot(), /Cannot verify ownership/)
|
|
573
|
+
}
|
|
574
|
+
anywhere.api.status = async () => runningStatus()
|
|
575
|
+
await assert.rejects(anywhere.sync(), /Cannot verify ownership/)
|
|
576
|
+
assert.deepEqual(mutations, [])
|
|
577
|
+
}
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
test('status reports starting before autostart begins', async () => {
|
|
581
|
+
const anywhere = new Anywhere(fakeKernel(os.tmpdir()))
|
|
582
|
+
anywhere.config = { enabled: true }
|
|
583
|
+
anywhere.readPid = () => assert.fail('ownership must wait for autostart')
|
|
584
|
+
assert.equal(anywhere.starting, null)
|
|
585
|
+
const snapshot = await anywhere.snapshot()
|
|
586
|
+
assert.equal(snapshot.phase, 'starting')
|
|
587
|
+
assert.equal(snapshot.running, false)
|
|
588
|
+
assert.equal(snapshot.error, null)
|
|
589
|
+
assert.equal(anywhere.api, null, 'polling must not create a client before autostart')
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
test('status cannot control an unowned daemon while autostart is checking installation', async t => {
|
|
593
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-autostart-owner-'))
|
|
594
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
595
|
+
const kernel = fakeKernel(tmp)
|
|
596
|
+
const anywhere = new Anywhere(kernel)
|
|
597
|
+
anywhere.config = { enabled: true }
|
|
598
|
+
anywhere.readPid = () => null
|
|
599
|
+
let entered, release, checks = 0, writes = 0
|
|
600
|
+
const checking = new Promise(resolve => { entered = resolve })
|
|
601
|
+
const pending = new Promise(resolve => { release = resolve })
|
|
602
|
+
kernel.bin.mod.tailscaled.installed = () => ++checks === 1 ? (entered(), pending) : Promise.resolve(true)
|
|
603
|
+
anywhere.api = {
|
|
604
|
+
cancel() {}, ping: async () => true, status: async () => runningStatus(),
|
|
605
|
+
serveConfig: async () => null, setServeConfig: async config => { writes++; return config },
|
|
606
|
+
}
|
|
607
|
+
const startup = anywhere.autostart().catch(error => error)
|
|
608
|
+
await checking
|
|
609
|
+
try {
|
|
610
|
+
assert.equal((await anywhere.snapshot()).phase, 'starting')
|
|
611
|
+
assert.equal(writes, 0)
|
|
612
|
+
} finally { release(true) }
|
|
613
|
+
assert.match((await startup).message, /Cannot verify ownership/)
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
test('Stop drains autostart installation checks before a home change', async t => {
|
|
617
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-autostart-stop-'))
|
|
618
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
619
|
+
const kernel = fakeKernel(path.join(tmp, 'old'))
|
|
620
|
+
kernel.path = (...parts) => path.join(kernel.homedir, ...parts)
|
|
621
|
+
const anywhere = new Anywhere(kernel)
|
|
622
|
+
await anywhere.saveConfig({ enabled: true, hostname: 'old-home' })
|
|
623
|
+
let entered, release
|
|
624
|
+
const checking = new Promise(resolve => { entered = resolve })
|
|
625
|
+
kernel.bin.mod.tailscaled.installed = () => new Promise(resolve => { release = resolve; entered() })
|
|
626
|
+
anywhere.readPid = () => null
|
|
627
|
+
anywhere.api = { cancel() {}, ping: async () => false,
|
|
628
|
+
setPrefs: async () => assert.fail('cancelled startup must not change daemon preferences') }
|
|
629
|
+
anywhere.waitForDaemon = async () => assert.fail('cancelled startup must not spawn a daemon')
|
|
630
|
+
const startup = anywhere.autostart()
|
|
631
|
+
await checking
|
|
632
|
+
let stopped = false
|
|
633
|
+
const stop = anywhere.stop().then(() => { stopped = true })
|
|
634
|
+
await new Promise(setImmediate)
|
|
635
|
+
assert.equal(stopped, false, 'the installation check is part of the pending startup')
|
|
636
|
+
release(true)
|
|
637
|
+
assert.equal(await startup, false)
|
|
638
|
+
await stop
|
|
639
|
+
assert.equal(anywhere.child, null)
|
|
640
|
+
kernel.homedir = path.join(tmp, 'new')
|
|
641
|
+
anywhere.config = null
|
|
642
|
+
await anywhere.saveConfig({ enabled: false, hostname: 'new-home' })
|
|
643
|
+
assert.equal(anywhere.child, null)
|
|
644
|
+
assert.equal(fs.existsSync(anywhere.pidPath()), false)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
test('a late status response after Off cannot start an HTTPS approval query', async t => {
|
|
648
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-status-stop-'))
|
|
649
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
650
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
651
|
+
await anywhere.saveConfig({ enabled: true })
|
|
652
|
+
let owned = true, entered, release, queries = 0
|
|
653
|
+
const checking = new Promise(resolve => { entered = resolve })
|
|
654
|
+
const status = new Promise(resolve => { release = resolve })
|
|
655
|
+
anywhere.readPid = () => owned ? 123 : null
|
|
656
|
+
anywhere.api = { cancel() { owned = false }, ping: async () => false,
|
|
657
|
+
status() { entered(); return status },
|
|
658
|
+
queryFeature: async () => { queries++; return { Complete: false } } }
|
|
659
|
+
const pending = anywhere.snapshot()
|
|
660
|
+
await checking
|
|
661
|
+
await anywhere.disable()
|
|
662
|
+
release({ ...runningStatus(), CertDomains: null })
|
|
663
|
+
const snapshot = await pending
|
|
664
|
+
assert.equal(snapshot.phase, 'off')
|
|
665
|
+
assert.equal(snapshot.enabled, false)
|
|
666
|
+
assert.equal(queries, 0)
|
|
667
|
+
assert.equal(anywhere.api, null, 'the late request must not recreate a CLI client')
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
test('status replies from a stopped client cannot control or overwrite a later session', async t => {
|
|
671
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-status-session-'))
|
|
672
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
673
|
+
for (const failure of [false, true]) {
|
|
674
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
675
|
+
anywhere.config = { enabled: true }
|
|
676
|
+
let release, reject, entered, owned = true
|
|
677
|
+
const checking = new Promise(resolve => { entered = resolve })
|
|
678
|
+
const status = new Promise((resolve, fail) => { release = resolve; reject = fail })
|
|
679
|
+
anywhere.readPid = () => owned ? 123 : null
|
|
680
|
+
anywhere.api = { cancel() { owned = false }, ping: async () => false,
|
|
681
|
+
status() { entered(); return status } }
|
|
682
|
+
const pending = anywhere.snapshot()
|
|
683
|
+
await checking
|
|
684
|
+
await anywhere.stop()
|
|
685
|
+
// A home change or re-enable may have already installed a different client.
|
|
686
|
+
anywhere.api = { queryFeature: async () => assert.fail('stale status must not act on the later session') }
|
|
687
|
+
const fresh = { BackendState: 'Stopped' }
|
|
688
|
+
anywhere.lastStatus = fresh
|
|
689
|
+
anywhere.lastStatusAt = Date.now()
|
|
690
|
+
if (failure) reject(new Error('old client failed'))
|
|
691
|
+
else release({ ...runningStatus(), CertDomains: null })
|
|
692
|
+
await pending
|
|
693
|
+
assert.equal(anywhere.lastStatus, fresh)
|
|
694
|
+
assert.equal(anywhere.error, null)
|
|
695
|
+
}
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
test('Stop drains pending ownership checks without starting login or approval afterward', async t => {
|
|
699
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-owner-stop-'))
|
|
700
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
701
|
+
for (const operation of ['login', 'approval']) {
|
|
702
|
+
const anywhere = new Anywhere(fakeKernel(path.join(tmp, operation)))
|
|
703
|
+
await anywhere.saveConfig({ enabled: true })
|
|
704
|
+
let release, reads = 0, controls = 0, enteredStop
|
|
705
|
+
const stopping = new Promise(resolve => { enteredStop = resolve })
|
|
706
|
+
anywhere.readPid = () => ++reads === 1 ? new Promise(resolve => { release = resolve }) : null
|
|
707
|
+
anywhere.api = { cancel() { enteredStop() }, ping: async () => false,
|
|
708
|
+
up: async () => { controls++ }, queryFeature: async () => { controls++; return { Complete: false } } }
|
|
709
|
+
let pending
|
|
710
|
+
if (operation === 'login') {
|
|
711
|
+
anywhere.requestLogin()
|
|
712
|
+
pending = anywhere.login.promise
|
|
713
|
+
} else pending = anywhere.httpsFeature({ ...runningStatus(), CertDomains: null })
|
|
714
|
+
let stopped = false
|
|
715
|
+
const stop = anywhere.disable().then(() => { stopped = true })
|
|
716
|
+
await stopping
|
|
717
|
+
assert.equal(stopped, false, operation)
|
|
718
|
+
release(123)
|
|
719
|
+
await Promise.all([pending, stop])
|
|
720
|
+
assert.equal(controls, 0, operation)
|
|
721
|
+
assert.equal(anywhere.api, null)
|
|
722
|
+
}
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
for (const adopted of [false, true]) {
|
|
726
|
+
test(`Stop waits for ${adopted ? 'an adopted' : 'its owned'} process after endpoint closure`, {
|
|
727
|
+
skip: process.platform === 'win32' && 'requires a POSIX SIGTERM handler', timeout: 10000,
|
|
728
|
+
}, async () => {
|
|
729
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-stop-exit-'))
|
|
730
|
+
const closed = path.join(tmp, 'closed')
|
|
731
|
+
const child = spawn(process.execPath, ['-e', `
|
|
732
|
+
const fs = require('node:fs')
|
|
733
|
+
process.on('SIGTERM', () => {
|
|
734
|
+
fs.writeFileSync(process.argv[1], '')
|
|
735
|
+
setTimeout(() => process.exit(0), 300)
|
|
736
|
+
})
|
|
737
|
+
process.stdout.write('ready')
|
|
738
|
+
setInterval(() => {}, 1000)
|
|
739
|
+
`, closed], { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
740
|
+
const exited = once(child, 'exit')
|
|
741
|
+
try {
|
|
742
|
+
await once(child.stdout, 'data')
|
|
743
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
744
|
+
if (!adopted) anywhere.child = child
|
|
745
|
+
anywhere.readPid = () => child.exitCode === null ? child.pid : null
|
|
746
|
+
anywhere.api = { cancel() {}, ping: async () => !fs.existsSync(closed) }
|
|
747
|
+
fs.mkdirSync(anywhere.stateDir(), { recursive: true })
|
|
748
|
+
fs.writeFileSync(anywhere.pidPath(), String(child.pid))
|
|
749
|
+
await anywhere.stop()
|
|
750
|
+
assert.equal(child.exitCode, 0, 'endpoint absence alone must not complete Stop')
|
|
751
|
+
assert.equal(fs.existsSync(anywhere.pidPath()), false)
|
|
752
|
+
} finally {
|
|
753
|
+
if (child.exitCode === null) child.kill('SIGKILL')
|
|
754
|
+
await exited
|
|
755
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
756
|
+
}
|
|
757
|
+
})
|
|
758
|
+
}
|
|
388
759
|
|
|
389
760
|
test('a daemon that cannot be spawned reports the cause at once instead of waiting 20 s', async () => {
|
|
390
761
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
@@ -403,41 +774,602 @@ test('a daemon that cannot be spawned reports the cause at once instead of waiti
|
|
|
403
774
|
}
|
|
404
775
|
})
|
|
405
776
|
|
|
406
|
-
|
|
777
|
+
for (const operation of ['writeFileSync', 'rmSync']) {
|
|
778
|
+
test(`PID ${operation} failure is handled without leaving an unmanaged child or throwing from exit`, async t => {
|
|
779
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-pid-io-'))
|
|
780
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
781
|
+
const child = new EventEmitter()
|
|
782
|
+
child.pid = 123
|
|
783
|
+
child.exitCode = null
|
|
784
|
+
let killed = false
|
|
785
|
+
child.kill = () => { killed = true }
|
|
786
|
+
const failure = Object.assign(new Error(`PID ${operation} failed`), { code: operation === 'writeFileSync' ? 'ENOSPC' : 'EACCES' })
|
|
787
|
+
const fixtureFs = Object.create(fs)
|
|
788
|
+
fixtureFs[operation] = (file, ...args) => {
|
|
789
|
+
if (file.endsWith('tailscaled.pid')) throw failure
|
|
790
|
+
return fs[operation](file, ...args)
|
|
791
|
+
}
|
|
792
|
+
const filename = require.resolve('../kernel/anywhere')
|
|
793
|
+
const baseRequire = require('node:module').createRequire(filename)
|
|
794
|
+
const context = { module: { exports: {} }, console, process, Buffer, URL, setTimeout,
|
|
795
|
+
require(name) {
|
|
796
|
+
if (name === 'fs') return fixtureFs
|
|
797
|
+
if (name === 'child_process') return { spawn: () => child }
|
|
798
|
+
return baseRequire(name)
|
|
799
|
+
} }
|
|
800
|
+
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), context)
|
|
801
|
+
const anywhere = new context.module.exports(fakeKernel(tmp))
|
|
802
|
+
anywhere.config = { enabled: true }
|
|
803
|
+
anywhere.api = { cancel() {}, ping: async () => false, setPrefs: async () => {}, status: async () => runningStatus() }
|
|
804
|
+
anywhere.waitForDaemon = async () => true
|
|
805
|
+
if (operation === 'writeFileSync') {
|
|
806
|
+
await assert.rejects(anywhere.start(), error => error === failure)
|
|
807
|
+
assert.equal(killed, true)
|
|
808
|
+
} else await anywhere.start()
|
|
809
|
+
assert.equal(child.listenerCount('error'), 1)
|
|
810
|
+
assert.doesNotThrow(() => child.emit('exit', null, 'SIGTERM'))
|
|
811
|
+
assert.equal(anywhere.child, null)
|
|
812
|
+
assert.match(anywhere.error, new RegExp(failure.message))
|
|
813
|
+
if (operation === 'rmSync') assert.equal(fs.readFileSync(anywhere.pidPath(), 'utf8'), '123')
|
|
814
|
+
})
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
test('logout and rejected links are handled by one CLI flow, with a fresh retry', async () => {
|
|
407
818
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-anywhere-'))
|
|
408
819
|
try {
|
|
409
820
|
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
410
821
|
await anywhere.saveConfig({ enabled: true })
|
|
411
|
-
anywhere.
|
|
822
|
+
anywhere.readPid = () => 123
|
|
412
823
|
const calls = []
|
|
413
|
-
let status = { BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/dead', Self: {}, User: null, Peer: null,
|
|
414
|
-
Health: ['You are logged out. The last login error was: register request: http 410: auth path not found'] }
|
|
415
824
|
anywhere.api = {
|
|
416
|
-
|
|
825
|
+
cancel() { calls.push('cancel') },
|
|
826
|
+
ping: async () => true, setPrefs: async () => {},
|
|
827
|
+
status: async () => ({ BackendState: 'NeedsLogin', AuthURL: 'https://login.tailscale.com/a/stale' }),
|
|
417
828
|
logout: async () => { calls.push('logout') },
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
829
|
+
up: (update, force) => {
|
|
830
|
+
calls.push(force ? 'up:force' : 'up')
|
|
831
|
+
if (!force) return Promise.reject(new Error('login expired'))
|
|
832
|
+
update({ AuthURL: 'https://login.tailscale.com/a/fresh' })
|
|
833
|
+
return new Promise(() => {})
|
|
834
|
+
},
|
|
421
835
|
}
|
|
422
836
|
await anywhere.logout()
|
|
423
|
-
|
|
837
|
+
await anywhere.login.promise
|
|
838
|
+
assert.deepEqual(calls, ['cancel', 'logout', 'up'])
|
|
839
|
+
for (let i = 0; i < 3; i++) assert.equal((await anywhere.snapshot()).phase, 'error')
|
|
840
|
+
assert.equal(calls.filter((call) => call === 'up').length, 1, 'no automatic login loop after rejection')
|
|
841
|
+
await anywhere.start()
|
|
842
|
+
const snap = await anywhere.snapshot()
|
|
843
|
+
assert.equal(snap.phase, 'login')
|
|
844
|
+
assert.equal(snap.loginUrl, 'https://login.tailscale.com/a/fresh')
|
|
845
|
+
assert.ok(calls.includes('up:force'))
|
|
846
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
847
|
+
})
|
|
424
848
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
849
|
+
test('control failures preserve live-daemon records and logs on every platform', async () => {
|
|
850
|
+
for (const platform of ['darwin', 'win32', 'linux']) {
|
|
851
|
+
for (const cause of ['401', '403', 'busy', 'reset', 'timeout']) {
|
|
852
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-error-'))
|
|
853
|
+
try {
|
|
854
|
+
const kernel = fakeKernel(tmp)
|
|
855
|
+
kernel.platform = platform
|
|
856
|
+
const anywhere = new Anywhere(kernel)
|
|
857
|
+
await anywhere.saveConfig({ enabled: true })
|
|
858
|
+
fs.mkdirSync(anywhere.stateDir(), { recursive: true })
|
|
859
|
+
const files = [anywhere.pidPath(), anywhere.logPath(), anywhere.portMapPath(), anywhere.stateDir('tailscaled.state')]
|
|
860
|
+
for (const file of files) fs.writeFileSync(file, 'preserve me')
|
|
861
|
+
anywhere.api = { cancel() {}, ping: async () => { throw new Error(cause) } }
|
|
862
|
+
await assert.rejects(() => anywhere.autostart(), new RegExp(cause))
|
|
863
|
+
assert.equal((await anywhere.snapshot()).phase, 'error')
|
|
864
|
+
assert.equal(anywhere.child, null)
|
|
865
|
+
for (const file of files) assert.equal(fs.readFileSync(file, 'utf8'), 'preserve me')
|
|
866
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
test('saved Off neither probes the daemon nor removes its records', async () => {
|
|
872
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-off-'))
|
|
873
|
+
try {
|
|
874
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
875
|
+
await anywhere.saveConfig({ enabled: false })
|
|
876
|
+
fs.mkdirSync(anywhere.stateDir(), { recursive: true })
|
|
877
|
+
fs.writeFileSync(anywhere.pidPath(), '123')
|
|
878
|
+
anywhere.api = new Proxy({}, { get() { throw new Error('off must not control the daemon') } })
|
|
879
|
+
assert.equal(await anywhere.autostart(), false)
|
|
880
|
+
assert.equal(fs.readFileSync(anywhere.pidPath(), 'utf8'), '123')
|
|
881
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
test('stopping during a pending start prevents a later spawn', async () => {
|
|
885
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-stop-'))
|
|
886
|
+
try {
|
|
887
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
888
|
+
await anywhere.saveConfig({ enabled: true })
|
|
889
|
+
let release
|
|
890
|
+
let probing
|
|
891
|
+
const probeStarted = new Promise((resolve) => { probing = resolve })
|
|
892
|
+
anywhere.api = {
|
|
893
|
+
cancel() {},
|
|
894
|
+
ping: () => { probing(); return new Promise((resolve) => { release = resolve }) },
|
|
895
|
+
}
|
|
896
|
+
const starting = anywhere.start()
|
|
897
|
+
await probeStarted
|
|
898
|
+
const stopping = anywhere.stop()
|
|
899
|
+
anywhere.api.ping = async () => false
|
|
900
|
+
release(false)
|
|
901
|
+
await Promise.all([starting, stopping])
|
|
902
|
+
assert.equal(anywhere.child, null)
|
|
903
|
+
assert.equal(fs.existsSync(anywhere.logPath()), false)
|
|
904
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
test('PID ownership checks require the exact private daemon command on each platform', async () => {
|
|
908
|
+
const source = fs.readFileSync(require.resolve('../kernel/anywhere'), 'utf8')
|
|
909
|
+
const baseRequire = require('node:module').createRequire(require.resolve('../kernel/anywhere'))
|
|
910
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-owner-'))
|
|
911
|
+
try {
|
|
912
|
+
for (const platform of ['darwin', 'win32', 'linux']) {
|
|
913
|
+
let command
|
|
914
|
+
const signals = []
|
|
915
|
+
const context = { module: { exports: {} }, console, URL, Buffer, setTimeout,
|
|
916
|
+
process: { ...process, kill(pid, signal) { signals.push(signal) } },
|
|
917
|
+
require(name) {
|
|
918
|
+
if (name === 'child_process') return { spawn, execFileSync: () => command,
|
|
919
|
+
execFile: (file, args, options, done) => done(null, command) }
|
|
920
|
+
return baseRequire(name)
|
|
921
|
+
},
|
|
922
|
+
}
|
|
923
|
+
vm.runInNewContext(source, context)
|
|
924
|
+
const kernel = fakeKernel(path.join(tmp, 'home with spaces é'))
|
|
925
|
+
kernel.platform = platform
|
|
926
|
+
const anywhere = new context.module.exports(kernel)
|
|
927
|
+
fs.mkdirSync(anywhere.stateDir(), { recursive: true })
|
|
928
|
+
const expected = [anywhere.binPath(), ...anywhere.daemonArgs()].join(' ')
|
|
929
|
+
fs.writeFileSync(anywhere.pidPath(), '123')
|
|
930
|
+
command = platform === 'win32' ? '"' + anywhere.binPath().toUpperCase() + '" ' + anywhere.daemonArgs().join(' ') : expected
|
|
931
|
+
assert.equal(await anywhere.readPid(), 123, platform)
|
|
932
|
+
assert.equal(anywhere.readPidSync(), 123, platform)
|
|
933
|
+
for (const wrong of [expected.replace('tailscaled', 'unrelated'), expected + ' --other-state', expected.replace(anywhere.socketPath(), anywhere.socketPath() + '.wrong')]) {
|
|
934
|
+
command = wrong
|
|
935
|
+
assert.equal(await anywhere.readPid(), null, platform)
|
|
936
|
+
assert.equal(anywhere.readPidSync(), null, platform)
|
|
937
|
+
anywhere.killSync()
|
|
938
|
+
assert.ok(!signals.includes('SIGKILL'), 'no wrong-process signal')
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
test('adopted-daemon status inspection leaves the event loop responsive on every platform', async t => {
|
|
945
|
+
const source = fs.readFileSync(require.resolve('../kernel/anywhere'), 'utf8')
|
|
946
|
+
const baseRequire = require('node:module').createRequire(require.resolve('../kernel/anywhere'))
|
|
947
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-async-owner-'))
|
|
948
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
949
|
+
for (const platform of ['darwin', 'win32', 'linux']) {
|
|
950
|
+
let release, first = true, command, statusReads = 0
|
|
951
|
+
const context = { module: { exports: {} }, console, URL, Buffer, setTimeout,
|
|
952
|
+
process: { ...process, kill() {} }, require(name) {
|
|
953
|
+
if (name === 'child_process') return { spawn,
|
|
954
|
+
execFileSync() { assert.fail('request paths must not run synchronous process inspection') },
|
|
955
|
+
execFile(file, args, options, done) {
|
|
956
|
+
assert.equal(options.shell, false)
|
|
957
|
+
assert.equal(options.timeout, 2000)
|
|
958
|
+
assert.equal(file, platform === 'win32' ? 'powershell.exe' : '/bin/ps')
|
|
959
|
+
if (first) { first = false; release = () => done(null, command) }
|
|
960
|
+
else done(null, command)
|
|
961
|
+
} }
|
|
962
|
+
return baseRequire(name)
|
|
963
|
+
} }
|
|
964
|
+
vm.runInNewContext(source, context)
|
|
965
|
+
const kernel = fakeKernel(path.join(tmp, platform))
|
|
966
|
+
kernel.platform = platform
|
|
967
|
+
const anywhere = new context.module.exports(kernel)
|
|
968
|
+
await anywhere.saveConfig({ enabled: true })
|
|
969
|
+
fs.mkdirSync(anywhere.stateDir(), { recursive: true })
|
|
970
|
+
fs.writeFileSync(anywhere.pidPath(), '123')
|
|
971
|
+
command = [anywhere.binPath(), ...anywhere.daemonArgs()].join(' ')
|
|
972
|
+
anywhere.api = { status: async () => { statusReads++; return runningStatus() },
|
|
973
|
+
serveConfig: async () => buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 }) }
|
|
974
|
+
const pending = anywhere.snapshot()
|
|
975
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
976
|
+
assert.equal(typeof release, 'function')
|
|
977
|
+
assert.equal(statusReads, 0, 'the status call waits for ownership while other work continues')
|
|
978
|
+
release()
|
|
979
|
+
assert.equal((await pending).phase, 'ready')
|
|
980
|
+
}
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
test('a daemon exiting during process inspection is absent; live inspection failures still propagate', async t => {
|
|
984
|
+
const source = fs.readFileSync(require.resolve('../kernel/anywhere'), 'utf8')
|
|
985
|
+
const baseRequire = require('node:module').createRequire(require.resolve('../kernel/anywhere'))
|
|
986
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-exit-inspect-'))
|
|
987
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
988
|
+
for (const platform of ['darwin', 'win32', 'linux']) {
|
|
989
|
+
for (const disappeared of [true, false]) {
|
|
990
|
+
let inspected = false
|
|
991
|
+
const failure = Object.assign(new Error('inspection failed'), { code: 1 })
|
|
992
|
+
const context = { module: { exports: {} }, console, URL, Buffer, setTimeout,
|
|
993
|
+
process: { ...process, kill(pid, signal) {
|
|
994
|
+
assert.equal(signal, 0, 'the existence probe never signals the process')
|
|
995
|
+
if (inspected && disappeared) throw Object.assign(new Error('gone'), { code: 'ESRCH' })
|
|
996
|
+
} }, require(name) {
|
|
997
|
+
if (name === 'child_process') return { execFile(file, args, options, done) {
|
|
998
|
+
inspected = true
|
|
999
|
+
done(failure)
|
|
1000
|
+
} }
|
|
1001
|
+
return baseRequire(name)
|
|
1002
|
+
} }
|
|
1003
|
+
vm.runInNewContext(source, context)
|
|
1004
|
+
const kernel = fakeKernel(path.join(tmp, platform))
|
|
1005
|
+
kernel.platform = platform
|
|
1006
|
+
const anywhere = new context.module.exports(kernel)
|
|
1007
|
+
fs.mkdirSync(anywhere.stateDir(), { recursive: true })
|
|
1008
|
+
fs.writeFileSync(anywhere.pidPath(), '123')
|
|
1009
|
+
if (disappeared) assert.equal(await anywhere.readPid(), null)
|
|
1010
|
+
else await assert.rejects(anywhere.readPid(), error => error === failure)
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
test('invalid port maps remain intact and failed saves cannot become ready', async () => {
|
|
1016
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-map-'))
|
|
1017
|
+
try {
|
|
1018
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
1019
|
+
await anywhere.saveConfig({ enabled: true })
|
|
1020
|
+
for (const text of ['invalid JSON', '[]', 'null']) {
|
|
1021
|
+
fs.writeFileSync(anywhere.portMapPath(), text)
|
|
1022
|
+
await assert.rejects(() => loadPortMap(anywhere.portMapPath()))
|
|
1023
|
+
assert.equal(fs.readFileSync(anywhere.portMapPath(), 'utf8'), text)
|
|
1024
|
+
}
|
|
1025
|
+
fs.rmSync(anywhere.portMapPath())
|
|
1026
|
+
fs.mkdirSync(anywhere.portMapPath())
|
|
1027
|
+
anywhere.portMap = {}
|
|
1028
|
+
anywhere.kernel.memory.local = { [path.join(tmp, 'api/comfy/start.js')]: { url: 'http://127.0.0.1:7860' } }
|
|
1029
|
+
await assert.rejects(() => anywhere.refreshTargets())
|
|
1030
|
+
assert.deepEqual(anywhere.portMap, {}, 'failed persistence does not cache an unsaved assignment')
|
|
1031
|
+
fs.rmSync(anywhere.portMapPath(), { recursive: true })
|
|
1032
|
+
await anywhere.refreshTargets()
|
|
1033
|
+
assert.equal(JSON.parse(fs.readFileSync(anywhere.portMapPath(), 'utf8')).comfy, 43000)
|
|
1034
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
1035
|
+
})
|
|
430
1036
|
|
|
431
|
-
|
|
1037
|
+
test('first home selection skips Stop when there is no previous home', async () => {
|
|
1038
|
+
const Kernel = require('../kernel')
|
|
1039
|
+
const reached = new Error('reached existing initialization')
|
|
1040
|
+
const kernel = {
|
|
1041
|
+
homedir: undefined, path: Kernel.prototype.path,
|
|
1042
|
+
store: { get: () => '/new/home' },
|
|
1043
|
+
disposeVault: async () => { throw reached },
|
|
1044
|
+
}
|
|
1045
|
+
kernel.anywhere = new Anywhere(kernel)
|
|
1046
|
+
await assert.rejects(() => Kernel.prototype.init.call(kernel, {}), (error) => error === reached)
|
|
1047
|
+
})
|
|
1048
|
+
|
|
1049
|
+
test('peer refresh proceeds during an Anywhere sync without queuing more syncs', async () => {
|
|
1050
|
+
const Kernel = require('../kernel')
|
|
1051
|
+
for (const fail of [false, true]) {
|
|
1052
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-peer-refresh-'))
|
|
1053
|
+
const kernel = fakeKernel(tmp)
|
|
1054
|
+
let announced = 0, calls = 0, release
|
|
1055
|
+
const pending = new Promise((resolve) => { release = resolve })
|
|
1056
|
+
kernel.peer = {
|
|
1057
|
+
check: async () => {}, current_host: async () => ({}),
|
|
1058
|
+
notify_refresh: async () => {}, announce: () => { announced += 1 },
|
|
1059
|
+
}
|
|
1060
|
+
kernel.ensureRouterMode = async () => {}
|
|
1061
|
+
const anywhere = kernel.anywhere = new Anywhere(kernel)
|
|
1062
|
+
anywhere.config = { enabled: true }
|
|
1063
|
+
anywhere.readPid = () => 123
|
|
1064
|
+
anywhere.portMap = {}
|
|
1065
|
+
anywhere.lastStatus = runningStatus()
|
|
1066
|
+
anywhere.lastStatusAt = Date.now()
|
|
1067
|
+
anywhere.api = { serveConfig: async () => {
|
|
1068
|
+
calls += 1
|
|
1069
|
+
await pending
|
|
1070
|
+
if (fail) throw new Error('Serve unavailable')
|
|
1071
|
+
return buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 })
|
|
1072
|
+
} }
|
|
1073
|
+
const refresh = Kernel.prototype.refresh.call(kernel)
|
|
1074
|
+
try {
|
|
1075
|
+
await new Promise(setImmediate)
|
|
1076
|
+
assert.equal(announced, 1, 'peer notifications must not wait for Tailscale')
|
|
1077
|
+
await Kernel.prototype.refresh.call(kernel)
|
|
1078
|
+
assert.equal(announced, 2)
|
|
1079
|
+
assert.equal(calls, 1, 'the pending sync must not accumulate more work')
|
|
1080
|
+
} finally {
|
|
1081
|
+
release()
|
|
1082
|
+
await refresh
|
|
1083
|
+
if (anywhere.syncing) await anywhere.syncing
|
|
1084
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
1085
|
+
}
|
|
1086
|
+
assert.equal(anywhere.lastServeError, fail ? 'Serve unavailable' : null)
|
|
1087
|
+
}
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
test('home changes stop Anywhere against the old home and preserve it on failure', async () => {
|
|
1091
|
+
const Kernel = require('../kernel')
|
|
1092
|
+
for (const failStop of [true, false]) {
|
|
1093
|
+
const failure = new Error('stop test before the rest of initialization')
|
|
1094
|
+
const calls = []
|
|
1095
|
+
const ready = Promise.resolve()
|
|
1096
|
+
const kernel = {
|
|
1097
|
+
homedir: '/old/home', sysReady: ready, store: { get: () => '/new/home' },
|
|
1098
|
+
anywhere: { stop: async () => {
|
|
1099
|
+
assert.equal(kernel.homedir, '/old/home')
|
|
1100
|
+
calls.push('stop')
|
|
1101
|
+
if (failStop) throw failure
|
|
1102
|
+
} },
|
|
1103
|
+
disposeVault: async () => { calls.push('dispose'); throw failure },
|
|
1104
|
+
}
|
|
1105
|
+
await assert.rejects(() => Kernel.prototype.init.call(kernel, {}), (error) => error === failure)
|
|
1106
|
+
assert.equal(kernel.homedir, '/old/home')
|
|
1107
|
+
if (failStop) assert.equal(kernel.sysReady, ready)
|
|
1108
|
+
assert.deepEqual(calls, failStop ? ['stop'] : ['stop', 'dispose'])
|
|
1109
|
+
}
|
|
1110
|
+
})
|
|
1111
|
+
|
|
1112
|
+
test('an unowned daemon does not block kernel re-init or lose its records', async t => {
|
|
1113
|
+
const Kernel = require('../kernel')
|
|
1114
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-init-unowned-'))
|
|
1115
|
+
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }))
|
|
1116
|
+
t.mock.method(console, 'warn', () => {})
|
|
1117
|
+
const kernel = fakeKernel(tmp)
|
|
1118
|
+
const anywhere = kernel.anywhere = new Anywhere(kernel)
|
|
1119
|
+
const reached = new Error('reached existing initialization')
|
|
1120
|
+
kernel.store = { get: () => '/new/home' }
|
|
1121
|
+
kernel.disposeVault = async () => { throw reached }
|
|
1122
|
+
anywhere.readPid = () => null
|
|
1123
|
+
let cancelled = 0
|
|
1124
|
+
anywhere.api = { cancel() { cancelled++ }, ping: async () => true }
|
|
1125
|
+
fs.mkdirSync(anywhere.stateDir(), { recursive: true })
|
|
1126
|
+
const files = [anywhere.pidPath(), anywhere.logPath(), anywhere.stateDir('tailscaled.state')]
|
|
1127
|
+
for (const file of files) fs.writeFileSync(file, 'preserve')
|
|
1128
|
+
await assert.rejects(Kernel.prototype.init.call(kernel, {}), error => error === reached)
|
|
1129
|
+
assert.equal(cancelled, 1)
|
|
1130
|
+
assert.equal(kernel.homedir, tmp)
|
|
1131
|
+
for (const file of files) assert.equal(fs.readFileSync(file, 'utf8'), 'preserve')
|
|
1132
|
+
for (const code of ['EACCES', 'ETIMEDOUT']) {
|
|
1133
|
+
const failure = Object.assign(new Error('owned stop failed'), { code })
|
|
1134
|
+
anywhere.stop = async () => { throw failure }
|
|
1135
|
+
await assert.rejects(Kernel.prototype.init.call(kernel, {}), error => error === failure)
|
|
1136
|
+
}
|
|
1137
|
+
})
|
|
1138
|
+
|
|
1139
|
+
test('a completed login allows a later stopped backend to resume', async () => {
|
|
1140
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-resume-'))
|
|
1141
|
+
try {
|
|
1142
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
1143
|
+
await anywhere.saveConfig({ enabled: true })
|
|
1144
|
+
anywhere.readPid = () => 123
|
|
1145
|
+
let status = runningStatus(), ups = 0
|
|
1146
|
+
anywhere.api = {
|
|
1147
|
+
status: async () => status,
|
|
1148
|
+
up: async () => { ups++; status = runningStatus() },
|
|
1149
|
+
serveConfig: async () => buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 }),
|
|
1150
|
+
}
|
|
1151
|
+
anywhere.requestLogin()
|
|
1152
|
+
await anywhere.login.promise
|
|
1153
|
+
status = { ...runningStatus(), BackendState: 'Stopped' }
|
|
1154
|
+
assert.equal((await anywhere.snapshot()).phase, 'starting')
|
|
432
1155
|
anywhere.lastStatusAt = 0
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
1156
|
+
assert.equal((await anywhere.snapshot()).phase, 'ready')
|
|
1157
|
+
assert.equal(ups, 2)
|
|
1158
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
1159
|
+
})
|
|
1160
|
+
|
|
1161
|
+
test('logout while Off does not start another login', async () => {
|
|
1162
|
+
const anywhere = new Anywhere(fakeKernel(os.tmpdir()))
|
|
1163
|
+
anywhere.config = { enabled: false }
|
|
1164
|
+
anywhere.readPid = () => 123
|
|
1165
|
+
let logouts = 0, logins = 0
|
|
1166
|
+
anywhere.api = { cancel() {}, logout: async () => { logouts++ }, up: async () => { logins++ } }
|
|
1167
|
+
await anywhere.logout()
|
|
1168
|
+
assert.equal(logouts, 1)
|
|
1169
|
+
assert.equal(logins, 0)
|
|
1170
|
+
assert.equal(anywhere.login, null)
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
test('readiness requires a running backend and confirmed routes after sync', async () => {
|
|
1174
|
+
for (const failure of ['control error', 'stopped backend', 'skipped sync']) {
|
|
1175
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-ready-'))
|
|
1176
|
+
try {
|
|
1177
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
1178
|
+
await anywhere.saveConfig({ enabled: true })
|
|
1179
|
+
anywhere.readPid = () => 123
|
|
1180
|
+
let reads = 0
|
|
1181
|
+
anywhere.api = { status: async () => {
|
|
1182
|
+
if (++reads === 1) return runningStatus()
|
|
1183
|
+
if (failure === 'control error') throw new Error('control unavailable during sync')
|
|
1184
|
+
return { ...runningStatus(), BackendState: 'Stopped' }
|
|
1185
|
+
} }
|
|
1186
|
+
if (failure === 'stopped backend') anywhere.lastServe = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 })
|
|
1187
|
+
anywhere.httpsFeature = async () => {
|
|
1188
|
+
anywhere.lastStatusAt = 0
|
|
1189
|
+
if (failure === 'skipped sync') anywhere.stopping = Promise.resolve()
|
|
1190
|
+
return { Complete: true }
|
|
1191
|
+
}
|
|
1192
|
+
const snapshot = await anywhere.snapshot()
|
|
1193
|
+
assert.equal(snapshot.phase, failure === 'skipped sync' ? 'starting' : 'error', failure)
|
|
1194
|
+
if (failure !== 'skipped sync') assert.match(snapshot.error, failure === 'control error' ? /control unavailable/ : /Routes are not confirmed/)
|
|
1195
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
1196
|
+
}
|
|
1197
|
+
})
|
|
1198
|
+
|
|
1199
|
+
test('a failed stop stays visible while disabled and a successful retry clears it', async () => {
|
|
1200
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-stop-error-'))
|
|
1201
|
+
try {
|
|
1202
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
1203
|
+
await anywhere.saveConfig({ enabled: true })
|
|
1204
|
+
fs.mkdirSync(anywhere.stateDir(), { recursive: true })
|
|
1205
|
+
fs.writeFileSync(anywhere.pidPath(), '123')
|
|
1206
|
+
fs.writeFileSync(anywhere.logPath(), 'preserve log')
|
|
1207
|
+
anywhere.readPid = () => null
|
|
1208
|
+
anywhere.api = { cancel() {}, ping: async () => { throw new Error('pipe access denied') } }
|
|
1209
|
+
await assert.rejects(() => anywhere.disable(), /pipe access denied/)
|
|
1210
|
+
for (let i = 0; i < 2; i++) {
|
|
1211
|
+
const snapshot = await anywhere.snapshot()
|
|
1212
|
+
assert.equal(snapshot.enabled, false)
|
|
1213
|
+
assert.equal(snapshot.phase, 'error')
|
|
1214
|
+
assert.match(snapshot.error, /pipe access denied/)
|
|
1215
|
+
}
|
|
1216
|
+
assert.equal(fs.readFileSync(anywhere.pidPath(), 'utf8'), '123')
|
|
1217
|
+
assert.equal(fs.readFileSync(anywhere.logPath(), 'utf8'), 'preserve log')
|
|
1218
|
+
anywhere.api.ping = async () => false
|
|
1219
|
+
await anywhere.stop()
|
|
1220
|
+
assert.equal((await anywhere.snapshot()).phase, 'off')
|
|
1221
|
+
assert.equal(anywhere.error, null)
|
|
1222
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
1223
|
+
})
|
|
1224
|
+
|
|
1225
|
+
test('ownership-inspection failures cannot spawn a daemon or overwrite its records', async () => {
|
|
1226
|
+
const source = fs.readFileSync(require.resolve('../kernel/anywhere'), 'utf8')
|
|
1227
|
+
const baseRequire = require('node:module').createRequire(require.resolve('../kernel/anywhere'))
|
|
1228
|
+
for (const platform of ['darwin', 'win32', 'linux']) {
|
|
1229
|
+
for (const code of ['ETIMEDOUT', 'EACCES', 'ENOENT']) {
|
|
1230
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-inspect-'))
|
|
1231
|
+
try {
|
|
1232
|
+
const failure = Object.assign(new Error('process inspection failed'), { code })
|
|
1233
|
+
let spawns = 0
|
|
1234
|
+
const context = { module: { exports: {} }, console, URL, Buffer, setTimeout,
|
|
1235
|
+
process: { ...process, kill() {} },
|
|
1236
|
+
require(name) {
|
|
1237
|
+
if (name === 'child_process') return {
|
|
1238
|
+
execFileSync() { throw failure },
|
|
1239
|
+
execFile(file, args, options, done) { done(failure) },
|
|
1240
|
+
spawn() { spawns++; throw new Error('must not spawn') },
|
|
1241
|
+
}
|
|
1242
|
+
return baseRequire(name)
|
|
1243
|
+
},
|
|
1244
|
+
}
|
|
1245
|
+
vm.runInNewContext(source, context)
|
|
1246
|
+
const kernel = fakeKernel(tmp)
|
|
1247
|
+
kernel.platform = platform
|
|
1248
|
+
const anywhere = new context.module.exports(kernel)
|
|
1249
|
+
await anywhere.saveConfig({ enabled: true })
|
|
1250
|
+
fs.mkdirSync(anywhere.stateDir(), { recursive: true })
|
|
1251
|
+
fs.writeFileSync(anywhere.pidPath(), '123')
|
|
1252
|
+
fs.writeFileSync(anywhere.logPath(), 'preserve log')
|
|
1253
|
+
for (const alive of [false, true]) {
|
|
1254
|
+
anywhere.api = { cancel() {}, ping: async () => alive }
|
|
1255
|
+
await assert.rejects(() => anywhere.start(), (error) => error === failure)
|
|
1256
|
+
}
|
|
1257
|
+
assert.equal(spawns, 0)
|
|
1258
|
+
assert.equal(fs.readFileSync(anywhere.pidPath(), 'utf8'), '123')
|
|
1259
|
+
assert.equal(fs.readFileSync(anywhere.logPath(), 'utf8'), 'preserve log')
|
|
1260
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
})
|
|
1264
|
+
|
|
1265
|
+
test('uninstall aborts when stopping fails and removes packages only after success', async () => {
|
|
1266
|
+
const mod = new Tailscaled()
|
|
1267
|
+
const order = []
|
|
1268
|
+
mod.kernel = {
|
|
1269
|
+
anywhere: { stop: async () => { throw new Error('cannot verify ownership') } },
|
|
1270
|
+
bin: { exec: async ({ message }) => { order.push(message) } },
|
|
442
1271
|
}
|
|
1272
|
+
await assert.rejects(() => mod.uninstall({}, () => {}), /cannot verify ownership/)
|
|
1273
|
+
assert.deepEqual(order, [])
|
|
1274
|
+
mod.kernel.anywhere.stop = async () => { order.push('stop') }
|
|
1275
|
+
await mod.uninstall({}, () => {})
|
|
1276
|
+
assert.deepEqual(order, ['stop', 'conda remove -y tailscaled tailscale'])
|
|
1277
|
+
})
|
|
1278
|
+
|
|
1279
|
+
test('a changed Serve update reuses the initial read and confirms its result', async () => {
|
|
1280
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-reads-'))
|
|
1281
|
+
try {
|
|
1282
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
1283
|
+
await anywhere.saveConfig({ enabled: true })
|
|
1284
|
+
anywhere.readPid = () => 123
|
|
1285
|
+
const desired = buildServeConfig({ dnsName: 'pinokio.tail1.ts.net', serverPort: 42000 })
|
|
1286
|
+
let reads = 0, writes = 0, applied = null
|
|
1287
|
+
anywhere.api = {
|
|
1288
|
+
status: async () => runningStatus(),
|
|
1289
|
+
serveConfig: async () => { reads++; return applied },
|
|
1290
|
+
run: async () => { writes++; applied = desired },
|
|
1291
|
+
setServeConfig(...args) { return TailscaleCLI.prototype.setServeConfig.call(this, ...args) },
|
|
1292
|
+
}
|
|
1293
|
+
assert.equal(await anywhere.sync(), true)
|
|
1294
|
+
assert.equal(reads, 2, 'one initial read and one confirmation read')
|
|
1295
|
+
assert.equal(writes, 1)
|
|
1296
|
+
assert.equal(await anywhere.sync(), false)
|
|
1297
|
+
assert.equal(reads, 3, 'unchanged routes need only one additional read')
|
|
1298
|
+
assert.equal(writes, 1)
|
|
1299
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
1300
|
+
})
|
|
1301
|
+
|
|
1302
|
+
test('the first failed status response includes the actual cause', async () => {
|
|
1303
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-status-error-'))
|
|
1304
|
+
try {
|
|
1305
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
1306
|
+
await anywhere.saveConfig({ enabled: true })
|
|
1307
|
+
anywhere.readPid = () => 123
|
|
1308
|
+
anywhere.api = { status: async () => { throw new Error('status access denied') } }
|
|
1309
|
+
const snapshot = await anywhere.snapshot()
|
|
1310
|
+
assert.equal(snapshot.phase, 'error')
|
|
1311
|
+
assert.equal(snapshot.error, 'status access denied')
|
|
1312
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
1313
|
+
})
|
|
1314
|
+
|
|
1315
|
+
test('concurrent status requests share a pending HTTPS approval query', async () => {
|
|
1316
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-approval-query-'))
|
|
1317
|
+
try {
|
|
1318
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
1319
|
+
await anywhere.saveConfig({ enabled: true })
|
|
1320
|
+
anywhere.readPid = () => 123
|
|
1321
|
+
let calls = 0, release
|
|
1322
|
+
const pendingQuery = new Promise((resolve) => { release = resolve })
|
|
1323
|
+
anywhere.api = {
|
|
1324
|
+
status: async () => ({ ...runningStatus(), CertDomains: null }),
|
|
1325
|
+
queryFeature: () => { calls++; return pendingQuery },
|
|
1326
|
+
}
|
|
1327
|
+
const pending = Promise.all(Array.from({ length: 4 }, () => anywhere.snapshot()))
|
|
1328
|
+
await new Promise(setImmediate)
|
|
1329
|
+
assert.equal(calls, 1)
|
|
1330
|
+
const url = 'https://login.tailscale.com/f/serve?node=1'
|
|
1331
|
+
release({ Complete: false, URL: url })
|
|
1332
|
+
for (const snapshot of await pending) {
|
|
1333
|
+
assert.equal(snapshot.phase, 'https')
|
|
1334
|
+
assert.equal(snapshot.approval.url, url)
|
|
1335
|
+
}
|
|
1336
|
+
assert.equal((await anywhere.snapshot()).approval.url, url)
|
|
1337
|
+
assert.equal(calls, 1, 'completed queries remain cached')
|
|
1338
|
+
anywhere.featureAt = 0
|
|
1339
|
+
anywhere.api.queryFeature = async () => ({ Complete: false, URL: url + '&fresh=1' })
|
|
1340
|
+
assert.equal((await anywhere.snapshot()).approval.url, url + '&fresh=1', 'an expired cache can query again')
|
|
1341
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
1342
|
+
})
|
|
1343
|
+
|
|
1344
|
+
test('cancelling a pending HTTPS approval during Stop keeps the result Off', async () => {
|
|
1345
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-approval-stop-'))
|
|
1346
|
+
try {
|
|
1347
|
+
const anywhere = new Anywhere(fakeKernel(tmp))
|
|
1348
|
+
await anywhere.saveConfig({ enabled: true })
|
|
1349
|
+
let rejectQuery
|
|
1350
|
+
const pendingQuery = new Promise((_, reject) => { rejectQuery = reject })
|
|
1351
|
+
let owned = true
|
|
1352
|
+
anywhere.readPid = () => owned ? 123 : null
|
|
1353
|
+
anywhere.api = {
|
|
1354
|
+
status: async () => ({ ...runningStatus(), CertDomains: null }),
|
|
1355
|
+
queryFeature: () => pendingQuery,
|
|
1356
|
+
ping: async () => false,
|
|
1357
|
+
cancel() { owned = false; setImmediate(() => rejectQuery(Object.assign(new Error('query cancelled'), { cancelled: true }))) },
|
|
1358
|
+
}
|
|
1359
|
+
const pending = anywhere.snapshot()
|
|
1360
|
+
await new Promise(setImmediate)
|
|
1361
|
+
await anywhere.disable()
|
|
1362
|
+
await pending
|
|
1363
|
+
const snapshot = await anywhere.snapshot()
|
|
1364
|
+
assert.equal(snapshot.phase, 'off')
|
|
1365
|
+
assert.equal(snapshot.error, null)
|
|
1366
|
+
assert.equal(JSON.parse(fs.readFileSync(anywhere.configPath(), 'utf8')).enabled, false)
|
|
1367
|
+
await anywhere.saveConfig({ enabled: true })
|
|
1368
|
+
owned = true
|
|
1369
|
+
anywhere.api = {
|
|
1370
|
+
status: async () => ({ ...runningStatus(), CertDomains: null }),
|
|
1371
|
+
queryFeature: async () => ({ Complete: false, URL: 'https://login.tailscale.com/f/fresh' }),
|
|
1372
|
+
}
|
|
1373
|
+
assert.equal((await anywhere.snapshot()).approval.url, 'https://login.tailscale.com/f/fresh')
|
|
1374
|
+
} finally { fs.rmSync(tmp, { recursive: true, force: true }) }
|
|
443
1375
|
})
|