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/forward.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { call } from './tailnet.mjs';
1
+ import { call, callSync } from './tailnet.mjs';
2
2
 
3
3
  // The phone reaches the service through a forwarding of Tailscale: the port
4
4
  // of the service is published inside the tailnet and points at this computer.
@@ -86,3 +86,25 @@ export async function removeForward(publishedPort, phonePort, runner, owned = nu
86
86
  const result = await call(['serve', '--tcp=' + publishedPort, 'off'], 15000, runner);
87
87
  return result.ok ? { ok: true } : { ok: false, error: result.error };
88
88
  }
89
+
90
+ // The last way out of a run: the forwarding is taken away when the program
91
+ // ends. The rule is the same one — only a forwarding that is written down as
92
+ // ours and that still points where it was pointed is taken away.
93
+ export function removeForwardSync(publishedPort, owned, runner) {
94
+ if (!owned || owned.publishedPort !== publishedPort) {
95
+ return { ok: false, error: '没有找到属于这个插件的转发。' };
96
+ }
97
+ const status = callSync(['serve', 'status', '--json'], 15000, runner);
98
+ if (!status.ok) return { ok: false, error: status.error };
99
+ let existing = null;
100
+ try {
101
+ existing = publishedFor({ data: JSON.parse(status.stdout) }, publishedPort);
102
+ } catch {
103
+ return { ok: false, error: 'tailscale serve status did not answer with JSON.' };
104
+ }
105
+ if (!existing || existing.TCPForward !== owned.target) {
106
+ return { ok: false, error: '没有找到属于这个插件的转发。' };
107
+ }
108
+ const result = callSync(['serve', '--tcp=' + publishedPort, 'off'], 15000, runner);
109
+ return result.ok ? { ok: true } : { ok: false, error: result.error };
110
+ }
@@ -0,0 +1,179 @@
1
+ // The addresses this service is willing to talk to.
2
+ //
3
+ // Two different questions live here.
4
+ //
5
+ // The first one is the address the phone is told to use (the "external
6
+ // address" of the user). A public address over plain HTTP would carry the
7
+ // token of the phone in the clear to anybody who listens on the way, so only
8
+ // a name or an address of a trusted network is accepted over HTTP. Over
9
+ // HTTPS the transport itself is the answer, and any name is accepted.
10
+ //
11
+ // The second one is the gateway of Magpie that this plugin reads. The
12
+ // plugin lives inside Magpie, so the gateway is Magpie itself, on this
13
+ // computer. A gateway somewhere else would be a door this plugin opens
14
+ // without the user asking for it.
15
+ //
16
+ // Everything here is decided from the text of the address itself. The name
17
+ // is never resolved: a name that resolves to a private address now may
18
+ // resolve to a public one later (that is the rebinding trick), and the
19
+ // decision has to hold for the whole life of the address.
20
+ import { isIP } from 'node:net';
21
+
22
+ // The names that are kept for the networks of the user. They are either
23
+ // reserved for private use (RFC 6762, RFC 8375, ICANN 2024) or the domain
24
+ // of the tailnet of Tailscale.
25
+ const INTERNAL_SUFFIXES = [
26
+ '.localhost',
27
+ '.local',
28
+ '.lan',
29
+ '.home.arpa',
30
+ '.internal',
31
+ '.ts.net',
32
+ ];
33
+
34
+ // 127.0.0.0/8: the whole range belongs to this computer.
35
+ export const isLoopbackIPv4 = (ip) => /^127\./.test(String(ip));
36
+ // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16: the private addresses of a
37
+ // house or an office.
38
+ export const isPrivateIPv4 = (ip) =>
39
+ /^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(String(ip));
40
+ // 100.64.0.0/10: the range a tailnet hands out. A Headscale of the user
41
+ // hands out the same range.
42
+ export const isTailnetIPv4 = (ip) =>
43
+ /^100\.(?:6[4-9]|[789]\d|1[01]\d|12[0-7])\./.test(String(ip));
44
+ // 169.254.0.0/16: the link of this network only, never routed away.
45
+ export const isLinkLocalIPv4 = (ip) => /^169\.254\./.test(String(ip));
46
+ // fc00::/7: the private addresses of IPv6.
47
+ export const isUlaIPv6 = (ip) => /^f[cd][0-9a-f]{2}:/i.test(String(ip));
48
+ // fe80::/10: the link of this network only.
49
+ export const isLinkLocalIPv6 = (ip) => /^fe[89ab][0-9a-f]:/i.test(String(ip));
50
+
51
+ // An address of a network that is trusted to carry the token in the clear:
52
+ // the loopback of this computer, a private network, the tailnet, or the
53
+ // link of this very network.
54
+ export const isTrustedIPv4 = (ip) =>
55
+ isLoopbackIPv4(ip) || isPrivateIPv4(ip) || isTailnetIPv4(ip) || isLinkLocalIPv4(ip);
56
+
57
+ export const isTrustedIPv6 = (ip) =>
58
+ ip === '::1' || isUlaIPv6(ip) || isLinkLocalIPv6(ip);
59
+
60
+ // A name of a network that does not belong to the public internet:
61
+ // a single word (the local resolver finds it), or one of the names that
62
+ // are kept for private use.
63
+ export const isInternalName = (host) => {
64
+ const name = String(host || '').toLowerCase().replace(/\.$/, '');
65
+ if (!name) return false;
66
+ // A name without a dot cannot be a name of the public internet.
67
+ if (!name.includes('.')) return true;
68
+ return INTERNAL_SUFFIXES.some((suffix) => name.endsWith(suffix));
69
+ };
70
+
71
+ // The host of an address, as the plain text of it: the brackets of an IPv6
72
+ // address are taken away, so that an address is read as an address and not as
73
+ // a name.
74
+ export const plainHost = (host) =>
75
+ String(host || '').toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
76
+
77
+ // Is this address of a trusted network? An address that is written as a
78
+ // number is read as a number; a name is a name and is never resolved.
79
+ export const isTrustedAddress = (host) => {
80
+ const name = plainHost(host);
81
+ const kind = isIP(name);
82
+ if (kind === 4) return isTrustedIPv4(name);
83
+ if (kind === 6) return isTrustedIPv6(name);
84
+ return isInternalName(name);
85
+ };
86
+
87
+ // The port of an address. A URL without a port means the default port of
88
+ // its scheme.
89
+ export const defaultPort = (scheme) => (scheme === 'https:' ? 443 : 80);
90
+
91
+ const PLAIN_TEXT_REFUSAL =
92
+ '公网的明文 HTTP 地址不接受:令牌会在路上被看见。请用 HTTPS,或者换成内网地址(Tailscale、RFC1918、ULA)。';
93
+
94
+ // The address the phone is told to use. It is checked here and in the
95
+ // installer of the phone, and the two must agree.
96
+ export function checkExternalURL(value) {
97
+ const text = String(value || '').trim();
98
+ if (!text) return { ok: true, empty: true, url: '' };
99
+ let parsed;
100
+ try {
101
+ parsed = new URL(text);
102
+ } catch {
103
+ return { ok: false, error: '这个地址不是一个网址。' };
104
+ }
105
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
106
+ return { ok: false, error: '只支持 http 和 https。' };
107
+ }
108
+ if (parsed.username || parsed.password) {
109
+ return { ok: false, error: '地址里不能带用户名或密码。' };
110
+ }
111
+ if (parsed.search) {
112
+ return { ok: false, error: '地址里不能带问号后面的参数。' };
113
+ }
114
+ if (parsed.hash) {
115
+ return { ok: false, error: '地址里不能带 # 后面的内容。' };
116
+ }
117
+ if (parsed.pathname && parsed.pathname !== '/') {
118
+ // A path is not carried through the routes of the service: the phone
119
+ // asks for /api/v1/overview and the assets live at the root. A proxy in
120
+ // front has to map the root of its own name to this service.
121
+ return { ok: false, error: '这个版本不支持带路径的地址。反向代理请用一个独立域名,把它的根路径转到插件服务。' };
122
+ }
123
+ const host = plainHost(parsed.hostname);
124
+ if (!host) return { ok: false, error: '地址里没有主机名。' };
125
+ const port = parsed.port ? Number(parsed.port) : defaultPort(parsed.protocol);
126
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
127
+ return { ok: false, error: '端口不对。' };
128
+ }
129
+ if (parsed.protocol === 'http:' && !isTrustedAddress(host)) {
130
+ return { ok: false, error: PLAIN_TEXT_REFUSAL };
131
+ }
132
+ // The address as it is written, without a trailing slash and without the
133
+ // default port of its scheme.
134
+ const url = `${parsed.protocol}//${parsed.host}`;
135
+ return {
136
+ ok: true,
137
+ url,
138
+ // The origin of the address: the scheme, the name and the port. It is
139
+ // what a browser sends in the Origin header.
140
+ origin: `${parsed.protocol}//${parsed.host}`,
141
+ name: host,
142
+ port,
143
+ scheme: parsed.protocol,
144
+ secure: parsed.protocol === 'https:',
145
+ };
146
+ }
147
+
148
+ // The gateway of Magpie that this plugin reads. It is Magpie itself, on
149
+ // this computer: a gateway somewhere else would be a request this plugin
150
+ // sends without being asked.
151
+ export function checkGatewayURL(value) {
152
+ const text = String(value || '').trim();
153
+ if (!text) return { ok: true, url: 'http://127.0.0.1:3425' };
154
+ let parsed;
155
+ try {
156
+ parsed = new URL(text);
157
+ } catch {
158
+ return { ok: false, error: '网关地址不是一个网址。' };
159
+ }
160
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
161
+ return { ok: false, error: '网关只支持 http 和 https。' };
162
+ }
163
+ if (parsed.username || parsed.password) {
164
+ return { ok: false, error: '网关地址里不能带用户名或密码。' };
165
+ }
166
+ if (parsed.pathname && parsed.pathname !== '/') {
167
+ return { ok: false, error: '网关地址里不能带路径。' };
168
+ }
169
+ const host = plainHost(parsed.hostname);
170
+ const loopback = isIP(host) === 4
171
+ ? isLoopbackIPv4(host)
172
+ : isIP(host) === 6
173
+ ? host === '::1' || host === '::ffff:127.0.0.1'
174
+ : host === 'localhost' || host.endsWith('.localhost');
175
+ if (!loopback) {
176
+ return { ok: false, error: '网关必须是本机的 Magpie(127.0.0.1 或 ::1),别的地址这个插件不去读。' };
177
+ }
178
+ return { ok: true, url: text.replace(/\/+$/, '') };
179
+ }