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/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
|
+
}
|
package/lib/network.mjs
ADDED
|
@@ -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
|
+
}
|