magpie-page-token-widget 0.2.0 → 0.3.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/CHANGELOG.md +45 -0
- package/NOTICE +1 -1
- package/README.en.md +235 -0
- package/README.md +97 -163
- package/index.mjs +51 -0
- package/lib/collector.mjs +92 -13
- package/lib/forward.mjs +23 -1
- package/lib/network.mjs +179 -0
- package/lib/server.mjs +325 -28
- package/lib/state.mjs +33 -34
- package/lib/tailnet.mjs +64 -1
- package/mobile/app.js +2 -2
- package/mobile/index.html +1 -1
- package/mobile/style.css +3 -3
- package/package.json +6 -2
- package/scriptable/SetupUsageWidget.js +63 -9
- package/scriptable/UsageWidget.js +85 -22
- package/scripts/check-syntax.mjs +43 -0
- package/scripts/run-tests.mjs +28 -0
- package/ui/app.js +200 -10
- package/ui/index.html +15 -6
- package/ui/style.css +23 -1
- package/ui/timing.mjs +16 -0
package/lib/server.mjs
CHANGED
|
@@ -10,7 +10,8 @@ import {
|
|
|
10
10
|
ensureState, notePairing, rotatePairing, writeState, pairingUsable,
|
|
11
11
|
addDevice, verifyToken, noteAccess, revokeDevice, renameDevice, deviceList, newToken, hashToken,
|
|
12
12
|
} from './state.mjs';
|
|
13
|
-
import { detect, interfaces, lanCandidates, ping } from './tailnet.mjs';
|
|
13
|
+
import { bindCandidates, detect, interfaces, lanCandidates, ping } from './tailnet.mjs';
|
|
14
|
+
import { checkExternalURL, checkGatewayURL } from './network.mjs';
|
|
14
15
|
import { applyForward, publishedFor, removeForward, serveState, forwardRecord } from './forward.mjs';
|
|
15
16
|
import { dataUri } from './qr.mjs';
|
|
16
17
|
|
|
@@ -22,6 +23,26 @@ const LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
|
22
23
|
// Ten wrong codes from one computer, and then the door is shut for five
|
|
23
24
|
// minutes. The record of the attempts is short and is cleaned up.
|
|
24
25
|
const PAIR_ATTEMPTS = 10;
|
|
26
|
+
// The address the phone is told to use. The user may have a network of
|
|
27
|
+
// his own (a Headscale of his own, a server of his own, a tunnel of his own),
|
|
28
|
+
// and then this address is the one the phone goes to.
|
|
29
|
+
//
|
|
30
|
+
// The rules for such an address live in lib/network.mjs, and the installer
|
|
31
|
+
// of the phone keeps the same rules: over plain HTTP only a network of the
|
|
32
|
+
// user is accepted, over HTTPS any name is. The address is never resolved,
|
|
33
|
+
// so a name that points somewhere else later cannot sneak past the check.
|
|
34
|
+
const externalOf = (value) => {
|
|
35
|
+
const checked = checkExternalURL(value);
|
|
36
|
+
if (!checked.ok || checked.empty) return { name: '', port: 0, origin: '', secure: false };
|
|
37
|
+
return { name: checked.name, port: checked.port, origin: checked.origin, secure: checked.secure };
|
|
38
|
+
};
|
|
39
|
+
// The address the phone side listens on. It has to be an address of this
|
|
40
|
+
// computer: the service does not listen just anywhere.
|
|
41
|
+
const normalizeListenAddress = (value, candidates) => {
|
|
42
|
+
const text = String(value || '').trim();
|
|
43
|
+
if (!text) return '';
|
|
44
|
+
return candidates.some((entry) => entry.address === text) ? text : null;
|
|
45
|
+
};
|
|
25
46
|
// The page of the pairing code is opened more often than the door itself: a
|
|
26
47
|
// phone that scans a code that has gone stale comes back here. It may be
|
|
27
48
|
// tried more often before the door is shut.
|
|
@@ -129,11 +150,11 @@ const hostOf = (request) => {
|
|
|
129
150
|
const raw = String(request.headers.host || '').trim().toLowerCase();
|
|
130
151
|
if (raw.startsWith('[')) {
|
|
131
152
|
const end = raw.indexOf(']');
|
|
132
|
-
return { name: raw.slice(1, end), port: Number(raw.slice(end + 2)) || 0 };
|
|
153
|
+
return { raw, name: raw.slice(1, end), port: Number(raw.slice(end + 2)) || 0 };
|
|
133
154
|
}
|
|
134
155
|
const index = raw.lastIndexOf(':');
|
|
135
|
-
if (index === -1) return { name: raw, port: 0 };
|
|
136
|
-
return { name: raw.slice(0, index), port: Number(raw.slice(index + 1)) || 0 };
|
|
156
|
+
if (index === -1) return { raw, name: raw, port: 0 };
|
|
157
|
+
return { raw, name: raw.slice(0, index), port: Number(raw.slice(index + 1)) || 0 };
|
|
137
158
|
};
|
|
138
159
|
|
|
139
160
|
const jsonBody = async (request) => {
|
|
@@ -159,11 +180,41 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
159
180
|
// on a different socket than the console of this computer. The scope of a
|
|
160
181
|
// request follows the socket it came in on, not the Host header it carries.
|
|
161
182
|
const phonePort = Number(options.phonePort ?? port + 1);
|
|
162
|
-
|
|
183
|
+
// The gateway of Magpie that this plugin reads. It is Magpie itself, on
|
|
184
|
+
// this computer. An address that is not a loopback address is refused
|
|
185
|
+
// with a clear reason, and nothing is asked of it: a wrong address would
|
|
186
|
+
// only make the service send requests somewhere it should not.
|
|
187
|
+
const gateway = checkGatewayURL(options.gatewayURL);
|
|
188
|
+
const upstream = gateway.ok ? gateway.url : '';
|
|
189
|
+
const gatewayError = gateway.ok ? '' : gateway.error;
|
|
163
190
|
const stateFile = path.join(dataDir, 'state.json');
|
|
164
191
|
const snapshotFile = path.join(dataDir, 'snapshot.json');
|
|
165
192
|
|
|
166
193
|
const { state } = await ensureState(stateFile);
|
|
194
|
+
// The address the phone is told to use, and the address the phone side
|
|
195
|
+
// listens on when the user has a network of his own.
|
|
196
|
+
let listenAddress = normalizeListenAddress(state.settings?.listenAddress, bindCandidates()) || '';
|
|
197
|
+
let extraListener = null;
|
|
198
|
+
let listenError = '';
|
|
199
|
+
if (state.settings?.listenAddress && !listenAddress) {
|
|
200
|
+
// The address of the last time is gone; the network of the office moved.
|
|
201
|
+
// It is not kept in the file any more.
|
|
202
|
+
delete state.settings.listenAddress;
|
|
203
|
+
await writeState(stateFile, state).catch(() => {});
|
|
204
|
+
}
|
|
205
|
+
// The address the phone is told to use. An address that the rules of
|
|
206
|
+
// lib/network.mjs refuse is not used: it is taken out of the file, and the
|
|
207
|
+
// console says why. The tailnet is the way in until the user writes an
|
|
208
|
+
// address that is good.
|
|
209
|
+
const storedExternal = checkExternalURL(state.settings?.externalURL);
|
|
210
|
+
let externalURL = storedExternal.ok && !storedExternal.empty ? storedExternal.url : '';
|
|
211
|
+
let external = externalOf(externalURL);
|
|
212
|
+
let externalError = '';
|
|
213
|
+
if (!storedExternal.ok && state.settings?.externalURL) {
|
|
214
|
+
externalError = `之前的地址「${state.settings.externalURL}」不能用了:${storedExternal.error}`;
|
|
215
|
+
delete state.settings.externalURL;
|
|
216
|
+
await writeState(stateFile, state).catch(() => {});
|
|
217
|
+
}
|
|
167
218
|
// The temporary files of a run that was killed stay behind; they are litter
|
|
168
219
|
// and nothing ever reads them.
|
|
169
220
|
await sweepTemporaries(stateFile);
|
|
@@ -173,14 +224,20 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
173
224
|
// is never touched while the tests run.
|
|
174
225
|
const runner = options.tailscaleRunner || null;
|
|
175
226
|
const collector = new Collector({
|
|
176
|
-
|
|
227
|
+
// A gateway that was refused is never asked. The address below is one
|
|
228
|
+
// that answers nobody: it is here so that the collector always has
|
|
229
|
+
// something to work with, and it is never used for a reading.
|
|
230
|
+
upstream: upstream || 'http://127.0.0.1:1',
|
|
231
|
+
enabled: gateway.ok,
|
|
177
232
|
magpieDir,
|
|
178
233
|
snapshotFile,
|
|
179
234
|
display: displayOptions({ ...state.settings, ...pluginSettings }),
|
|
180
235
|
strategy: refreshStrategy(state.settings?.refreshStrategy),
|
|
181
236
|
});
|
|
182
237
|
await collector.load();
|
|
183
|
-
|
|
238
|
+
// A gateway that was refused is never asked: nothing is sent to an address
|
|
239
|
+
// the user did not mean.
|
|
240
|
+
if (gateway.ok) void collector.refresh();
|
|
184
241
|
|
|
185
242
|
let tailnet = {
|
|
186
243
|
installed: false,
|
|
@@ -202,7 +259,9 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
202
259
|
let consoleOpen = false;
|
|
203
260
|
let phoneBound = false;
|
|
204
261
|
|
|
205
|
-
|
|
262
|
+
// The address the phone is told to use. When the user has an address of
|
|
263
|
+
// his own, that one is used; otherwise the address of the tailnet.
|
|
264
|
+
const serviceURL = () => externalURL || (tailnet.tailnetIP ? `http://${tailnet.tailnetIP}:${port}` : '');
|
|
206
265
|
const pairingURL = () => (serviceURL() ? `${serviceURL()}/p/${state.pairing.code}` : '');
|
|
207
266
|
// How long the pairing code is still good. The code is good for ten
|
|
208
267
|
// minutes, and it is good only once.
|
|
@@ -214,14 +273,52 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
214
273
|
|
|
215
274
|
// The Host header is only a guard against a name that points at this
|
|
216
275
|
// computer by accident. It says nothing about who is asking.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
276
|
+
// The phone side may be reached under the address the user gave, so that
|
|
277
|
+
// name and that port belong to it. The console side keeps the port of the
|
|
278
|
+
// plugin, and never the port of the phone.
|
|
279
|
+
const allowedHosts = (host, scope) => {
|
|
280
|
+
const name = host.name.toLowerCase();
|
|
281
|
+
// The name the user gave: the phone may come under it, with the port of
|
|
282
|
+
// the address or with the port it really arrives at (a proxy of his own
|
|
283
|
+
// may sit in front). The console keeps to the port of the plugin.
|
|
284
|
+
if (external.name && name === external.name) {
|
|
285
|
+
return scope === 'phone' || host.port === port;
|
|
286
|
+
}
|
|
287
|
+
if (scope === 'console') {
|
|
288
|
+
return host.port === port && LOCAL_HOSTS.has(name);
|
|
289
|
+
}
|
|
290
|
+
if (host.port !== port && host.port !== phonePort) return false;
|
|
291
|
+
if (LOCAL_HOSTS.has(name)) return true;
|
|
292
|
+
if (tailnet.tailnetIP && name === tailnet.tailnetIP.toLowerCase()) return true;
|
|
293
|
+
if (tailnet.dnsName && name === tailnet.dnsName.toLowerCase()) return true;
|
|
294
|
+
if (listenAddress && name === listenAddress.toLowerCase()) return true;
|
|
222
295
|
return false;
|
|
223
296
|
};
|
|
224
297
|
|
|
298
|
+
// The origin of a request is the page it comes from. Only the origins that
|
|
299
|
+
// belong to this service are let in.
|
|
300
|
+
//
|
|
301
|
+
// The address the user gave is one of them, exactly as it was given: an
|
|
302
|
+
// https proxy in front is never taken for an http one, and a page that was
|
|
303
|
+
// loaded over http may not read the answers of an https service. The
|
|
304
|
+
// addresses of this computer are the others: the phone reaches the service
|
|
305
|
+
// there directly, over plain http.
|
|
306
|
+
//
|
|
307
|
+
// The headers of a proxy are never believed: X-Forwarded-Proto and
|
|
308
|
+
// X-Forwarded-Host are written by whoever sends the request.
|
|
309
|
+
const originAllowed = (origin, host) => {
|
|
310
|
+
// A request that carries no origin is not a request from a page of
|
|
311
|
+
// another site.
|
|
312
|
+
if (!origin) return true;
|
|
313
|
+
// A page in a frame with no origin of its own (the window of Magpie).
|
|
314
|
+
if (origin === 'null') return true;
|
|
315
|
+
if (external.origin && origin === external.origin) return true;
|
|
316
|
+
// Under the name of the user the service is only reached through the
|
|
317
|
+
// address the user gave. Any other scheme there is refused.
|
|
318
|
+
if (external.name && host.name === external.name) return false;
|
|
319
|
+
return origin === `http://${host.raw}`;
|
|
320
|
+
};
|
|
321
|
+
|
|
225
322
|
const readToken = (request) => {
|
|
226
323
|
const header = request.headers[TOKEN_HEADER];
|
|
227
324
|
if (typeof header === 'string' && header) return header;
|
|
@@ -231,9 +328,8 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
231
328
|
};
|
|
232
329
|
|
|
233
330
|
// Two doors of their own. The console of this computer is opened by its own
|
|
234
|
-
// token; the phone is opened by the token of its own device
|
|
235
|
-
//
|
|
236
|
-
// paired before keep working.
|
|
331
|
+
// token; the phone is opened by the token of its own device, and one phone
|
|
332
|
+
// never opens the door of another.
|
|
237
333
|
const phoneAuthorized = (request) => {
|
|
238
334
|
const device = verifyToken(state, readToken(request));
|
|
239
335
|
if (!device) return false;
|
|
@@ -254,7 +350,8 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
254
350
|
plugin: PLUGIN,
|
|
255
351
|
port,
|
|
256
352
|
phonePort,
|
|
257
|
-
upstream,
|
|
353
|
+
upstream: upstream,
|
|
354
|
+
upstreamError: gatewayError,
|
|
258
355
|
serviceURL: serviceURL(),
|
|
259
356
|
pairingURL: pairingURL(),
|
|
260
357
|
pairingCode: state.pairing.code,
|
|
@@ -262,7 +359,6 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
262
359
|
pairingExpiresIn: pairingExpiresIn(),
|
|
263
360
|
// How long the door is still shut after too many wrong tries.
|
|
264
361
|
pairingBlocked: budget.until > Date.now() ? Math.ceil((budget.until - Date.now()) / 1000) : 0,
|
|
265
|
-
token: state.token,
|
|
266
362
|
qr: pairingURL() ? dataUri(pairingURL()) : '',
|
|
267
363
|
tailnet: {
|
|
268
364
|
installed: tailnet.installed,
|
|
@@ -278,6 +374,20 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
278
374
|
interfaces: interfaces(),
|
|
279
375
|
lan: lanCandidates(),
|
|
280
376
|
},
|
|
377
|
+
// The network of the user, when Tailscale is not wanted.
|
|
378
|
+
external: {
|
|
379
|
+
url: externalURL,
|
|
380
|
+
listenAddress,
|
|
381
|
+
candidates: bindCandidates(),
|
|
382
|
+
error: externalError || listenError,
|
|
383
|
+
listenError,
|
|
384
|
+
// How the phone is let in: through the tailnet, through an address of
|
|
385
|
+
// the user, or through the address of this computer on the own
|
|
386
|
+
// network.
|
|
387
|
+
mode: externalURL
|
|
388
|
+
? (external.secure ? 'https' : 'own-address')
|
|
389
|
+
: (listenAddress ? 'lan' : 'tailscale'),
|
|
390
|
+
},
|
|
281
391
|
paired: state.paired,
|
|
282
392
|
devices: deviceList(state),
|
|
283
393
|
forward: {
|
|
@@ -300,9 +410,26 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
300
410
|
// forwarding of an older version of this plugin points at the console of
|
|
301
411
|
// this computer, and it is moved to the phone side as well. A forwarding
|
|
302
412
|
// that belongs to somebody else is left alone.
|
|
303
|
-
const
|
|
304
|
-
if (options.forward === false) {
|
|
305
|
-
forward = {
|
|
413
|
+
const settleForwardOnce = async () => {
|
|
414
|
+
if (options.forward === false || externalURL) {
|
|
415
|
+
forward = {
|
|
416
|
+
ready: false,
|
|
417
|
+
port,
|
|
418
|
+
phonePort,
|
|
419
|
+
error: options.forward === false
|
|
420
|
+
? '这次没有建尾网转发,手机直接连尾网地址。'
|
|
421
|
+
: '已经填了自己的地址,没有建尾网转发。',
|
|
422
|
+
};
|
|
423
|
+
// When the user has an address of his own, the forwarding of the
|
|
424
|
+
// tailnet is taken away, if it is ours to take. Otherwise the tailnet
|
|
425
|
+
// would keep a door open that the user has closed.
|
|
426
|
+
if (externalURL && state.forward) {
|
|
427
|
+
const removed = await removeForward(port, phonePort, runner, state.forward);
|
|
428
|
+
if (removed?.ok) {
|
|
429
|
+
state.forward = null;
|
|
430
|
+
await writeState(stateFile, state).catch(() => {});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
306
433
|
// Even when no forwarding is wanted, the console of this computer must
|
|
307
434
|
// not be reachable from the tailnet. A rule that an older version left
|
|
308
435
|
// behind can still point the published port at the console, so the
|
|
@@ -347,6 +474,14 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
347
474
|
consoleOpen = !now || now.TCPForward !== `127.0.0.1:${port}`;
|
|
348
475
|
};
|
|
349
476
|
|
|
477
|
+
// Only one settling at a time.
|
|
478
|
+
let settling = null;
|
|
479
|
+
const settleForward = () => {
|
|
480
|
+
if (settling) return settling;
|
|
481
|
+
settling = settleForwardOnce().finally(() => { settling = null; });
|
|
482
|
+
return settling;
|
|
483
|
+
};
|
|
484
|
+
|
|
350
485
|
// Is the published port already taken by a forwarding of Tailscale? Then
|
|
351
486
|
// Tailscale itself holds the tailnet address of that port.
|
|
352
487
|
const portPublished = async () => {
|
|
@@ -550,10 +685,18 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
550
685
|
if (stripped) pathname = stripped[1] || '/';
|
|
551
686
|
|
|
552
687
|
const host = hostOf(request);
|
|
553
|
-
if (!allowedHosts(host)) return json(response, 403, { error: 'Invalid host.' });
|
|
688
|
+
if (!allowedHosts(host, scope)) return json(response, 403, { error: 'Invalid host.' });
|
|
554
689
|
|
|
690
|
+
// The origin of the request. Only the origins that belong to this
|
|
691
|
+
// service are let in. The address the user gave is one of them, exactly
|
|
692
|
+
// as it was given: an https proxy is never taken for an http one. The
|
|
693
|
+
// addresses of this computer are the others: the phone reaches the
|
|
694
|
+
// service there directly, over plain http.
|
|
695
|
+
//
|
|
696
|
+
// The headers of a proxy are never believed: X-Forwarded-Proto and
|
|
697
|
+
// X-Forwarded-Host are written by whoever sends the request.
|
|
555
698
|
const origin = request.headers.origin;
|
|
556
|
-
if (origin
|
|
699
|
+
if (!originAllowed(origin, host)) {
|
|
557
700
|
return json(response, 403, { error: 'Invalid origin.' });
|
|
558
701
|
}
|
|
559
702
|
|
|
@@ -561,6 +704,19 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
561
704
|
const method = request.method || 'GET';
|
|
562
705
|
const allowOrigin = { 'Access-Control-Allow-Origin': origin || `http://${request.headers.host}`, Vary: 'Origin' };
|
|
563
706
|
|
|
707
|
+
// The question a browser asks before it sends a request that carries a
|
|
708
|
+
// token. It has no token of its own, so it is answered before the door
|
|
709
|
+
// of the token is looked at.
|
|
710
|
+
if (method === 'OPTIONS') {
|
|
711
|
+
response.writeHead(204, {
|
|
712
|
+
...allowOrigin,
|
|
713
|
+
'Access-Control-Allow-Headers': `Content-Type, ${TOKEN_HEADER}, Authorization`,
|
|
714
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
715
|
+
'Access-Control-Max-Age': '600',
|
|
716
|
+
});
|
|
717
|
+
return response.end();
|
|
718
|
+
}
|
|
719
|
+
|
|
564
720
|
if (pathname === '/health') {
|
|
565
721
|
return json(response, 200, {
|
|
566
722
|
ok: true,
|
|
@@ -569,7 +725,7 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
569
725
|
phonePort,
|
|
570
726
|
tailnetIP: tailnet.tailnetIP,
|
|
571
727
|
connected: tailnet.connected,
|
|
572
|
-
});
|
|
728
|
+
}, allowOrigin);
|
|
573
729
|
}
|
|
574
730
|
|
|
575
731
|
if (pathname.startsWith('/api/v1/console/')) {
|
|
@@ -607,17 +763,77 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
607
763
|
if (pathname === '/api/v1/console/options' && method === 'POST') {
|
|
608
764
|
const body = await jsonBody(request);
|
|
609
765
|
if (!body) return json(response, 400, { error: 'Bad request.' }, allowOrigin);
|
|
766
|
+
// The address of the phone and the address the phone side listens
|
|
767
|
+
// on are checked before anything is written. The address the phone
|
|
768
|
+
// is told to use is checked here and in the installer of the phone,
|
|
769
|
+
// and the two keep the same rules.
|
|
770
|
+
let chosenExternal = null;
|
|
771
|
+
if (body.externalURL !== undefined) {
|
|
772
|
+
const checked = checkExternalURL(body.externalURL);
|
|
773
|
+
if (!checked.ok) {
|
|
774
|
+
return json(response, 400, { error: checked.error }, allowOrigin);
|
|
775
|
+
}
|
|
776
|
+
chosenExternal = checked.empty ? '' : checked.url;
|
|
777
|
+
}
|
|
778
|
+
let chosenListen = null;
|
|
779
|
+
if (body.listenAddress !== undefined) {
|
|
780
|
+
chosenListen = normalizeListenAddress(body.listenAddress, bindCandidates());
|
|
781
|
+
if (chosenListen === null) {
|
|
782
|
+
return json(response, 400, { error: 'That is not an address of this computer.' }, allowOrigin);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
610
785
|
const next = displayOptions({ ...state.settings, ...body });
|
|
611
786
|
state.settings = { ...state.settings, ...next };
|
|
612
787
|
if (body.refreshStrategy !== undefined) {
|
|
613
788
|
state.settings.refreshStrategy = refreshStrategy(body.refreshStrategy);
|
|
614
789
|
}
|
|
790
|
+
if (chosenExternal !== null) {
|
|
791
|
+
externalURL = chosenExternal;
|
|
792
|
+
external = externalOf(chosenExternal);
|
|
793
|
+
externalError = '';
|
|
794
|
+
if (chosenExternal) state.settings.externalURL = chosenExternal;
|
|
795
|
+
else delete state.settings.externalURL;
|
|
796
|
+
}
|
|
797
|
+
if (chosenListen !== null) {
|
|
798
|
+
listenAddress = chosenListen;
|
|
799
|
+
if (chosenListen) state.settings.listenAddress = chosenListen;
|
|
800
|
+
else delete state.settings.listenAddress;
|
|
801
|
+
await openExtraListener(listenAddress);
|
|
802
|
+
if (listenError) {
|
|
803
|
+
// The port did not open. Nothing is written down, so the file
|
|
804
|
+
// does not remember an address that is not listening.
|
|
805
|
+
delete state.settings.listenAddress;
|
|
806
|
+
listenAddress = '';
|
|
807
|
+
await writeState(stateFile, state).catch(() => {});
|
|
808
|
+
return json(response, 400, { error: listenError, external: consoleState().external }, allowOrigin);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
615
811
|
await writeState(stateFile, state);
|
|
812
|
+
// The forwarding of the tailnet follows the address the phone uses:
|
|
813
|
+
// with an address of his own there is no forwarding to build.
|
|
814
|
+
void settleForward();
|
|
616
815
|
collector.setDisplay(displayOptions({ ...state.settings, ...pluginSettings }));
|
|
617
816
|
// The rhythm of the reading changes at once. The service does not have
|
|
618
817
|
// to be started again for that.
|
|
619
818
|
collector.setStrategy(state.settings.refreshStrategy);
|
|
620
|
-
return json(response, 200, {
|
|
819
|
+
return json(response, 200, {
|
|
820
|
+
display: collector.display,
|
|
821
|
+
refresh: consoleState().refresh,
|
|
822
|
+
external: consoleState().external,
|
|
823
|
+
}, allowOrigin);
|
|
824
|
+
}
|
|
825
|
+
if (pathname === '/api/v1/console/network/reset' && method === 'POST') {
|
|
826
|
+
// The way back to the default: the tailnet. Everything the user set
|
|
827
|
+
// for a network of his own is taken away, and the port that was
|
|
828
|
+
// opened on the own network is closed for real.
|
|
829
|
+
const result = await resetNetwork();
|
|
830
|
+
if (!result.ok) {
|
|
831
|
+
return json(response, 500, { error: result.error, external: consoleState().external }, allowOrigin);
|
|
832
|
+
}
|
|
833
|
+
return json(response, 200, {
|
|
834
|
+
...consoleState(),
|
|
835
|
+
network: { restored: true, wasListening: result.wasListening, mode: consoleState().external.mode },
|
|
836
|
+
}, allowOrigin);
|
|
621
837
|
}
|
|
622
838
|
if (pathname === '/api/v1/console/device' && method === 'POST') {
|
|
623
839
|
const body = await jsonBody(request);
|
|
@@ -706,7 +922,19 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
706
922
|
});
|
|
707
923
|
// The port of this service is written into the script, so the phone
|
|
708
924
|
// does not have to guess it.
|
|
709
|
-
|
|
925
|
+
// The phone is told the port of the address it is given: in the mode
|
|
926
|
+
// of an address of the user's own that is the port of the phone side.
|
|
927
|
+
let servicePort = port;
|
|
928
|
+
try {
|
|
929
|
+
const address = serviceURL();
|
|
930
|
+
if (address) {
|
|
931
|
+
const parsed = new URL(address);
|
|
932
|
+
servicePort = Number(parsed.port) || (parsed.protocol === 'https:' ? 443 : 80);
|
|
933
|
+
}
|
|
934
|
+
} catch {
|
|
935
|
+
servicePort = port;
|
|
936
|
+
}
|
|
937
|
+
return response.end(body.replace('const DEFAULT_PORT = 8790;', `const DEFAULT_PORT = ${servicePort};`));
|
|
710
938
|
} catch {
|
|
711
939
|
return text(response, 404, 'Not found');
|
|
712
940
|
}
|
|
@@ -768,6 +996,9 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
768
996
|
if (pathname === '/app.js' && method === 'GET') {
|
|
769
997
|
return serveUi(response, 'app.js', 'text/javascript; charset=utf-8', port);
|
|
770
998
|
}
|
|
999
|
+
if (pathname === '/timing.mjs' && method === 'GET') {
|
|
1000
|
+
return serveUi(response, 'timing.mjs', 'text/javascript; charset=utf-8', port);
|
|
1001
|
+
}
|
|
771
1002
|
if (pathname === '/style.css' && method === 'GET') {
|
|
772
1003
|
return serveUi(response, 'style.css', 'text/css; charset=utf-8', port);
|
|
773
1004
|
}
|
|
@@ -828,6 +1059,70 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
828
1059
|
phoneServer.close(() => {});
|
|
829
1060
|
}
|
|
830
1061
|
|
|
1062
|
+
// The phone side may also listen on an address of the own network of the
|
|
1063
|
+
// user, so a phone that is not on the tailnet can reach it. Only the phone
|
|
1064
|
+
// side listens there; the console of this computer never does.
|
|
1065
|
+
async function openExtraListener(address) {
|
|
1066
|
+
if (extraListener) {
|
|
1067
|
+
const old = extraListener;
|
|
1068
|
+
extraListener = null;
|
|
1069
|
+
// The connections are closed right away; the socket does not have to
|
|
1070
|
+
// wait for them before the new one opens. A port that is closed for
|
|
1071
|
+
// good must not keep a phone hanging on it.
|
|
1072
|
+
if (typeof old.closeAllConnections === 'function') old.closeAllConnections();
|
|
1073
|
+
else if (typeof old.closeIdleConnections === 'function') old.closeIdleConnections();
|
|
1074
|
+
await new Promise((resolve) => old.close(resolve));
|
|
1075
|
+
}
|
|
1076
|
+
listenError = '';
|
|
1077
|
+
if (!address) return;
|
|
1078
|
+
const listener = makeServer('phone');
|
|
1079
|
+
try {
|
|
1080
|
+
await new Promise((resolve, reject) => {
|
|
1081
|
+
listener.once('error', reject);
|
|
1082
|
+
listener.listen(phonePort, address, resolve);
|
|
1083
|
+
});
|
|
1084
|
+
extraListener = listener;
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
listenError = `手机那一侧的端口没有在 ${address} 上打开:${error.message}`;
|
|
1087
|
+
listener.close(() => {});
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
await openExtraListener(listenAddress);
|
|
1091
|
+
|
|
1092
|
+
// The way back to the default connection, which is the tailnet.
|
|
1093
|
+
//
|
|
1094
|
+
// Everything the user set for a network of his own is taken away: the
|
|
1095
|
+
// address the phone was told to use, the address the phone side listened
|
|
1096
|
+
// on, and what is written down about it. The port that was opened on the
|
|
1097
|
+
// own network is closed for real, and only then is the forwarding of the
|
|
1098
|
+
// tailnet built again.
|
|
1099
|
+
//
|
|
1100
|
+
// The order matters. The forwarding follows the address of the phone: a
|
|
1101
|
+
// forwarding is only built when the phone does not have an address of his
|
|
1102
|
+
// own. And the port has to be shut before the console says that the
|
|
1103
|
+
// service is back on the tailnet, or the user would believe that the LAN
|
|
1104
|
+
// port is closed while it is still open.
|
|
1105
|
+
const resetNetwork = async () => {
|
|
1106
|
+
const wasListening = Boolean(extraListener);
|
|
1107
|
+
await openExtraListener('');
|
|
1108
|
+
if (extraListener) {
|
|
1109
|
+
// The socket did not go. The console says so instead of pretending
|
|
1110
|
+
// that everything is back the way it was.
|
|
1111
|
+
listenError = '手机那一侧的端口没有关上,关掉它之后再来试一次。';
|
|
1112
|
+
return { ok: false, error: listenError };
|
|
1113
|
+
}
|
|
1114
|
+
listenAddress = '';
|
|
1115
|
+
externalURL = '';
|
|
1116
|
+
external = externalOf('');
|
|
1117
|
+
externalError = '';
|
|
1118
|
+
delete state.settings.listenAddress;
|
|
1119
|
+
delete state.settings.externalURL;
|
|
1120
|
+
await writeState(stateFile, state).catch(() => {});
|
|
1121
|
+
// The forwarding of the tailnet comes back.
|
|
1122
|
+
await settleForward();
|
|
1123
|
+
return { ok: true, wasListening };
|
|
1124
|
+
};
|
|
1125
|
+
|
|
831
1126
|
const tailnetListener = makeServer('phone');
|
|
832
1127
|
const timers = [];
|
|
833
1128
|
|
|
@@ -847,8 +1142,9 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
847
1142
|
void background();
|
|
848
1143
|
|
|
849
1144
|
// The rhythm of the reading is kept by the collector itself: it knows when
|
|
850
|
-
// somebody is looking and when nobody is.
|
|
851
|
-
|
|
1145
|
+
// somebody is looking and when nobody is. A gateway that was refused is
|
|
1146
|
+
// never asked, so there is nothing to keep up.
|
|
1147
|
+
if (gateway.ok) collector.start();
|
|
852
1148
|
|
|
853
1149
|
// The state of the tailnet is looked at every five minutes, and not once a
|
|
854
1150
|
// minute for nothing: the command of Tailscale is not free. When it fails,
|
|
@@ -895,6 +1191,7 @@ export async function startServer({ root, dataDir, magpieDir, options = {} }) {
|
|
|
895
1191
|
await stopping;
|
|
896
1192
|
await new Promise((resolve) => server.close(resolve));
|
|
897
1193
|
await new Promise((resolve) => phoneServer.close(resolve));
|
|
1194
|
+
if (extraListener) await new Promise((resolve) => extraListener.close(resolve));
|
|
898
1195
|
await new Promise((resolve) => tailnetListener.close(resolve));
|
|
899
1196
|
},
|
|
900
1197
|
};
|
package/lib/state.mjs
CHANGED
|
@@ -3,8 +3,9 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { writeAtomic } from './atomic.mjs';
|
|
5
5
|
|
|
6
|
-
// The shape of the file does not change:
|
|
7
|
-
//
|
|
6
|
+
// The shape of the file does not change: it holds the token of the console of
|
|
7
|
+
// this computer, the devices of the phones, the pairing code and the settings.
|
|
8
|
+
// A file of the older version is cleaned up when it is read (see migrate).
|
|
8
9
|
const VERSION = 1;
|
|
9
10
|
|
|
10
11
|
// A pairing code is a door key that is opened once: it lives ten minutes at
|
|
@@ -50,14 +51,26 @@ export const sameSecret = (a, b) => {
|
|
|
50
51
|
export async function readState(file) {
|
|
51
52
|
try {
|
|
52
53
|
const saved = JSON.parse(await readFile(file, 'utf8'));
|
|
53
|
-
if (saved && saved.version === VERSION && typeof saved.
|
|
54
|
+
if (saved && saved.version === VERSION && typeof saved.consoleToken === 'string') return saved;
|
|
54
55
|
} catch { /* A missing or damaged file must not stop the service. */ }
|
|
55
56
|
return null;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
// The writes of one file go one after another. Two writes that follow each
|
|
60
|
+
// other (the last visit of a phone and a new pairing code, for instance) would
|
|
61
|
+
// otherwise race: the older one could be written to the disk after the newer
|
|
62
|
+
// one and put an old pairing code back into the file.
|
|
63
|
+
const writeQueues = new Map();
|
|
64
|
+
|
|
65
|
+
export function writeState(file, state) {
|
|
66
|
+
const previous = writeQueues.get(file) || Promise.resolve();
|
|
67
|
+
const next = previous.then(async () => {
|
|
68
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
69
|
+
await writeAtomic(file, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
70
|
+
});
|
|
71
|
+
// The queue keeps going even when one write fails.
|
|
72
|
+
writeQueues.set(file, next.catch(() => {}));
|
|
73
|
+
return next;
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
export async function ensureState(file) {
|
|
@@ -71,8 +84,6 @@ export async function ensureState(file) {
|
|
|
71
84
|
version: VERSION,
|
|
72
85
|
// The token of the console of this computer. It never leaves the console.
|
|
73
86
|
consoleToken: newToken(),
|
|
74
|
-
// The shared token of the phones that were paired before this version.
|
|
75
|
-
token: newToken(),
|
|
76
87
|
pairing: { code: newPairingCode(), createdAt: new Date().toISOString() },
|
|
77
88
|
settings: {},
|
|
78
89
|
paired: [],
|
|
@@ -83,10 +94,10 @@ export async function ensureState(file) {
|
|
|
83
94
|
return { state, created: true };
|
|
84
95
|
}
|
|
85
96
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
97
|
+
// A file of the older version still carries the shared token of the phones and
|
|
98
|
+
// the device that held it. Nobody paired a phone with that version, so both are
|
|
99
|
+
// taken away here. The token of the console of this computer is kept, so that
|
|
100
|
+
// the console stays open across the update.
|
|
90
101
|
export function migrate(state, now = Date.now()) {
|
|
91
102
|
let changed = false;
|
|
92
103
|
if (typeof state.consoleToken !== 'string' || !state.consoleToken) {
|
|
@@ -97,16 +108,12 @@ export function migrate(state, now = Date.now()) {
|
|
|
97
108
|
state.devices = [];
|
|
98
109
|
changed = true;
|
|
99
110
|
}
|
|
100
|
-
if (
|
|
101
|
-
state.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
lastAccessAt: null,
|
|
107
|
-
revokedAt: null,
|
|
108
|
-
legacy: true,
|
|
109
|
-
});
|
|
111
|
+
if (state.token) {
|
|
112
|
+
delete state.token;
|
|
113
|
+
changed = true;
|
|
114
|
+
}
|
|
115
|
+
if (state.devices.some(device => device?.legacy)) {
|
|
116
|
+
state.devices = state.devices.filter(device => !device?.legacy);
|
|
110
117
|
changed = true;
|
|
111
118
|
}
|
|
112
119
|
if (!state.pairing || typeof state.pairing.code !== 'string') {
|
|
@@ -180,24 +187,19 @@ export function addDevice(state, { name, token, now = Date.now() }) {
|
|
|
180
187
|
createdAt: new Date(now).toISOString(),
|
|
181
188
|
lastAccessAt: null,
|
|
182
189
|
revokedAt: null,
|
|
183
|
-
legacy: false,
|
|
184
190
|
};
|
|
185
191
|
state.devices.push(device);
|
|
186
192
|
return device;
|
|
187
193
|
}
|
|
188
194
|
|
|
189
195
|
// The token of a phone is checked against every device that was not revoked.
|
|
190
|
-
// The shared token of the old version is the legacy device.
|
|
191
196
|
export function verifyToken(state, token, now = Date.now()) {
|
|
192
197
|
if (typeof token !== 'string' || !token) return null;
|
|
193
198
|
const devices = Array.isArray(state.devices) ? state.devices : [];
|
|
199
|
+
const wanted = hashToken(token);
|
|
194
200
|
for (const device of devices) {
|
|
195
201
|
if (!device || device.revokedAt) continue;
|
|
196
|
-
if (device.
|
|
197
|
-
if (state.token && sameSecret(state.token, token)) return device;
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
if (device.hash && sameSecret(device.hash, hashToken(token))) return device;
|
|
202
|
+
if (device.hash && sameSecret(device.hash, wanted)) return device;
|
|
201
203
|
}
|
|
202
204
|
return null;
|
|
203
205
|
}
|
|
@@ -212,14 +214,12 @@ export function noteAccess(device, now = Date.now()) {
|
|
|
212
214
|
return true;
|
|
213
215
|
}
|
|
214
216
|
|
|
215
|
-
// One device is taken away on its own. The
|
|
216
|
-
//
|
|
217
|
-
// this version at once.
|
|
217
|
+
// One device is taken away on its own. The phone that carries it stops at
|
|
218
|
+
// once; the other phones keep reading.
|
|
218
219
|
export function revokeDevice(state, id) {
|
|
219
220
|
const device = (state.devices || []).find(entry => entry?.id === id);
|
|
220
221
|
if (!device) return null;
|
|
221
222
|
if (!device.revokedAt) device.revokedAt = new Date().toISOString();
|
|
222
|
-
if (device.legacy) state.token = '';
|
|
223
223
|
return device;
|
|
224
224
|
}
|
|
225
225
|
|
|
@@ -236,7 +236,6 @@ export function deviceList(state) {
|
|
|
236
236
|
return (state.devices || []).map(device => ({
|
|
237
237
|
id: device.id,
|
|
238
238
|
name: device.name,
|
|
239
|
-
legacy: device.legacy === true,
|
|
240
239
|
createdAt: device.createdAt || null,
|
|
241
240
|
lastAccessAt: device.lastAccessAt || null,
|
|
242
241
|
revokedAt: device.revokedAt || null,
|