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/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
- const upstream = options.gatewayURL || 'http://127.0.0.1:3425';
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
- upstream,
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
- void collector.refresh();
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
- const serviceURL = () => (tailnet.tailnetIP ? `http://${tailnet.tailnetIP}:${port}` : '');
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
- const allowedHosts = (host) => {
218
- if (host.port !== port) return false;
219
- if (LOCAL_HOSTS.has(host.name)) return true;
220
- if (tailnet.tailnetIP && host.name === tailnet.tailnetIP.toLowerCase()) return true;
221
- if (tailnet.dnsName && host.name === tailnet.dnsName.toLowerCase()) return true;
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. The old shared
235
- // token stays the token of the legacy device, so the phones that were
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 settleForward = async () => {
304
- if (options.forward === false) {
305
- forward = { ready: false, port, phonePort, error: '这次没有建尾网转发,手机直接连尾网地址。' };
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 && origin !== 'null' && origin !== `http://${request.headers.host}`) {
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, { display: collector.display, refresh: consoleState().refresh }, allowOrigin);
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
- return response.end(body.replace('const DEFAULT_PORT = 8790;', `const DEFAULT_PORT = ${port};`));
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
- collector.start();
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: an older file stays readable, so the
7
- // phones that are already paired keep working when the plugin is updated.
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.token === 'string') return 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
- export async function writeState(file, state) {
59
- await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
60
- await writeAtomic(file, JSON.stringify(state, null, 2), { mode: 0o600 });
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
- // An older file knows nothing of devices and of the token of the console. The
87
- // shared token it holds becomes the authorization of one legacy device, so the
88
- // phones that carry it keep working, and every new phone gets a token of its
89
- // own.
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 (!state.devices.some(device => device?.legacy)) {
101
- state.devices.push({
102
- id: 'legacy',
103
- name: '旧版共享令牌',
104
- hash: hashToken(state.token || ''),
105
- createdAt: new Date(now).toISOString(),
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.legacy) {
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 legacy device is the shared token
216
- // of the old version: taking it away stops every phone that was paired before
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,