dsh-mobile-plugin 0.4.9 → 0.5.0

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.
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 连接自检:把「手机连不上」拆成电脑这侧能查的几件事,最后落一个结论。
5
+ *
6
+ * 回答的是两个不同的问题,能查到的程度差很远:
7
+ *
8
+ * 1. **我这一侧整不整**——端口在不在听、隧道有没有注册上、防火墙放不放行。这三样
9
+ * 本机查得到,都是确定答案。
10
+ * 2. **手机到底拨得进来吗**——本机证明不了。朝自己的局域网地址发请求,包根本不走
11
+ * 物理网卡,测出来永远是通的(`test-network-addresses` 那边记过这个假阳性)。
12
+ * 所以这里只能退一步用**证据**:最近一次手机真的从外面来敲过门是什么时候
13
+ * (`mobileActivity` 的记账,只记带有效 token 的请求)。
14
+ *
15
+ * 于是结论分五档,`verdict` 就是这五档之一:
16
+ *
17
+ * - `not-listening`:直连端口没在听,手机拨不进来。下一步是去主页重连 / 换端口。
18
+ * - `firewall-blocked`:防火墙明确挡着这个程序。这是唯一能**点名**系统原因的一档。
19
+ * - `unknown`:防火墙状态读不到(Linux、或被系统策略限制)。如实说读不到,
20
+ * 不许把「不知道」写成「正常」。
21
+ * - `network`:电脑这侧没毛病,但手机三十分钟没来过 —— 多半没连同一个 WiFi、
22
+ * 路由器隔离了设备,或者该走中转。
23
+ * - `ok`:最近连上过。
24
+ *
25
+ * **判据顺序是有讲究的**:手机来过就说明这条链整体通着(哪怕它走的是中转、
26
+ * 局域网仍被挡着),所以「来过」排在防火墙前面 —— 不然会在人家正用着的时候报
27
+ * 一个吓人的红字。反过来,没来过才轮到防火墙说话。
28
+ */
29
+
30
+ const { readFirewall } = require('./firewall-probe.js');
31
+
32
+ /** 「最近来过」的窗口。比侧栏那 30 秒心跳宽得多:它要回答的是「人还在不在用」。 */
33
+ const MOBILE_RECENT_MS = 30 * 60 * 1000;
34
+
35
+ /**
36
+ * 纯判据。守卫直接喂五组输入断言这五档,不碰进程、不碰时钟。
37
+ *
38
+ * @param {{listeningState?: string, firewall?: object|null, lastMobileAt?: number, now?: number}} input
39
+ * @returns {'not-listening'|'firewall-blocked'|'unknown'|'network'|'ok'}
40
+ */
41
+ function verdictOf(input = {}) {
42
+ const now = typeof input.now === 'number' ? input.now : Date.now();
43
+ const lastMobileAt = typeof input.lastMobileAt === 'number' ? input.lastMobileAt : 0;
44
+ const firewall = input.firewall || null;
45
+ const listening = input.listeningState === 'listening';
46
+ const mobileRecent = lastMobileAt > 0 && now - lastMobileAt < MOBILE_RECENT_MS;
47
+
48
+ if (!listening) return 'not-listening';
49
+ if (mobileRecent) return 'ok';
50
+ if (firewall && firewall.allowsApp === false) return 'firewall-blocked';
51
+ if (!firewall || firewall.supported !== true || firewall.allowsApp === null) return 'unknown';
52
+ return 'network';
53
+ }
54
+
55
+ /**
56
+ * 装配一份自检载荷。
57
+ *
58
+ * 取值全部注入:`status` 是那份状态视图、`lanCandidates` 给监听地址清单、
59
+ * `mobileActivity` 给「手机最近来过没有」、`probeFirewall` 换掉真探测(守卫用)。
60
+ */
61
+ function createConnectionCheck(deps = {}) {
62
+ const status = typeof deps.status === 'function' ? deps.status : () => ({});
63
+ const lanCandidates = typeof deps.lanCandidates === 'function' ? deps.lanCandidates : () => [];
64
+ const mobileActivity = typeof deps.mobileActivity === 'function' ? deps.mobileActivity : () => ({ lastMobileAt: 0 });
65
+ const probe = typeof deps.probeFirewall === 'function' ? deps.probeFirewall : readFirewall;
66
+ const now = typeof deps.now === 'function' ? deps.now : Date.now;
67
+ const platform = typeof deps.platform === 'string' ? deps.platform : process.platform;
68
+
69
+ return {
70
+ /** @param {{force?: boolean}} [options] */
71
+ async run(options = {}) {
72
+ const snapshot = status() || {};
73
+ const lan = snapshot.lan && typeof snapshot.lan === 'object' ? snapshot.lan : {};
74
+ const tunnel = snapshot.tunnel && typeof snapshot.tunnel === 'object' ? snapshot.tunnel : {};
75
+ const port = Number.isInteger(lan.port) && lan.port > 0 ? lan.port : 0;
76
+ const urls = port > 0
77
+ ? (lanCandidates(port) || []).map((item) => (item && typeof item.url === 'string' ? item.url : '')).filter(Boolean)
78
+ : [];
79
+ // 防火墙查不到就让它是「查不到」:探测自己负责把 reason 说清楚,这里不兜底成 null 之外的值。
80
+ let firewall;
81
+ try {
82
+ firewall = await probe({ force: options.force === true, platform });
83
+ } catch {
84
+ firewall = null;
85
+ }
86
+ const mobile = mobileActivity() || {};
87
+ const lastMobileAt = Number.isFinite(mobile.lastMobileAt) ? mobile.lastMobileAt : 0;
88
+ const at = now();
89
+ const listeningState = typeof lan.state === 'string' ? lan.state : 'off';
90
+ return {
91
+ ok: true,
92
+ checkedAt: at,
93
+ platform,
94
+ verdict: verdictOf({ listeningState, firewall, lastMobileAt, now: at }),
95
+ listening: {
96
+ state: listeningState,
97
+ port,
98
+ urls,
99
+ portAdjusted: lan.adjusted === true,
100
+ },
101
+ tunnel: {
102
+ state: typeof tunnel.state === 'string' ? tunnel.state : 'off',
103
+ relayUrl: typeof snapshot.relayUrl === 'string' && snapshot.relayUrl ? snapshot.relayUrl : null,
104
+ },
105
+ firewall,
106
+ mobile: {
107
+ lastMobileAt,
108
+ idle: !(lastMobileAt > 0 && at - lastMobileAt < MOBILE_RECENT_MS),
109
+ },
110
+ };
111
+ },
112
+ };
113
+ }
114
+
115
+ module.exports = { MOBILE_RECENT_MS, verdictOf, createConnectionCheck };
@@ -0,0 +1,300 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 本机防火墙只读探测:这台电脑有没有把「手机连进来」这条路挡住。
5
+ *
6
+ * 只读、不提权、不改规则。查不到的实话是 `null`,「不知道」不许写成「正常」。
7
+ *
8
+ * ## 为什么只查「放不放行这个程序」,不查端口
9
+ *
10
+ * macOS 与 Windows 的入站规则都是按**程序**(可执行文件路径)挂的,不是按端口挂的:
11
+ * 用户装完之后第一次被系统问到「是否允许接受传入连接」,点一次「拒绝」就永久挡着,
12
+ * 面板上什么都看不出来。所以查的对象是 `process.execPath` —— 宿主进程自己的那个
13
+ * 可执行文件,不是写死的 `node`(DSH 自带的运行时、nvm 切的版本,路径都不一样)。
14
+ *
15
+ * ## 三个平台能查到什么,差很远
16
+ *
17
+ * - **macOS**:`socketfilterfw` 两条查询就够,都不用 root —— `--getglobalstate` 回答
18
+ * 防火墙开没开,`--getappblocked <path>` 回答这个程序允不允许接连接。
19
+ * - **Windows**:默认入站全拒,所以「有允许规则」才算放行,没有就是被挡;还要看那条
20
+ * 规则管的是哪个网络类别 —— 笔记本换个 WiFi、系统把那个网判成「公用」时,只放行
21
+ * 「专用」的老规则就不再生效。这就是「出去一趟就连不上、看一眼防火墙又好了」的
22
+ * 真身,所以当前接入的网络类别必须一起查。
23
+ * - **Linux**:只能读到「防火墙开着没」(`firewall-cmd --state` 不要 root),
24
+ * 「放行了没」要 root 才读得到 —— 那条我们不碰,一律回 `null`。
25
+ *
26
+ * ## 缓存
27
+ *
28
+ * 每条查询都要 fork 一个进程(Windows 那边还得起 PowerShell),所以结果按
29
+ * `CACHE_TTL_MS` 记着:面板弹窗点开、以及手机上久没来过时的那一次后台查询,
30
+ * 都走同一份。并发的调用共用同一个在飞的 Promise,不叠着开进程。
31
+ */
32
+
33
+ const { execFile } = require('node:child_process');
34
+
35
+ /** 单条查询的上限。Windows 起 PowerShell 冷启动就要一两秒,给得比另两个宽。 */
36
+ const PROBE_TIMEOUT_MS = { darwin: 3000, win32: 8000, linux: 3000 };
37
+ const CACHE_TTL_MS = 30 * 1000;
38
+
39
+ /** 查不到时的如实形状。`reason` 说明是哪一种查不到,界面据此决定要不要说「查不到」。 */
40
+ function unknownFirewall(appPath, reason) {
41
+ return { supported: false, enabled: null, allowsApp: null, appPath: appPath || '', reason };
42
+ }
43
+
44
+ /**
45
+ * macOS 的 `--getglobalstate`:`Firewall is disabled. (State = 0)`。
46
+ *
47
+ * 状态 1 是开、2 是「拦全部非必要入站连接」—— 2 也是开,只是更狠。按数字判,别按
48
+ * 那半句英文:文案会随系统版本变,`State = N` 不会。
49
+ */
50
+ function parseDarwinState(text) {
51
+ const hit = /State\s*=\s*(\d+)/.exec(String(text || ''));
52
+ if (!hit) return null;
53
+ return Number(hit[1]) !== 0;
54
+ }
55
+
56
+ /**
57
+ * macOS 的 `--getappblocked <path>`:
58
+ * `Incoming connection to /path is permitted.` / `... is blocked.`
59
+ *
60
+ * 认不出就回 `null`:这句话以外的东西(帮助文本、本地化的译文)都不算答案。
61
+ */
62
+ function parseDarwinAppAllowed(text) {
63
+ const value = String(text || '');
64
+ if (/is permitted/i.test(value)) return true;
65
+ if (/is (?:blocked|not allowed)/i.test(value)) return false;
66
+ return null;
67
+ }
68
+
69
+ /** PowerShell 的 JSON 里数组只有一条时会退化成对象,两种都收。 */
70
+ function asArray(value) {
71
+ if (Array.isArray(value)) return value;
72
+ return value === null || value === undefined ? [] : [value];
73
+ }
74
+
75
+ /**
76
+ * 解析 Windows 那份报告(脚本见 `WINDOWS_SCRIPT`)。
77
+ *
78
+ * 字段全 ASCII:规则里的程序路径、`Allow` / `Block`、`Domain` / `Private` / `Public`、
79
+ * 以及当前接入的网络类别。**不读 DisplayName**:它是用户语言下的可读文案,中文
80
+ * Windows 上根本没有能匹配的稳定值。
81
+ */
82
+ function parseWindowsReport(text) {
83
+ const data = JSON.parse(String(text || '').trim());
84
+ const profileObject = data && typeof data === 'object' ? data : {};
85
+ return {
86
+ profiles: asArray(profileObject.profiles).map((item) => ({
87
+ name: String(item && item.name ? item.name : ''),
88
+ enabled: item && item.enabled === true,
89
+ })),
90
+ apps: asArray(profileObject.apps).map((item) => ({
91
+ program: String(item && item.program ? item.program : ''),
92
+ action: String(item && item.action ? item.action : ''),
93
+ enabled: item ? item.enabled !== false : false,
94
+ profile: String(item && item.profile ? item.profile : ''),
95
+ })),
96
+ activeProfiles: asArray(profileObject.activeProfiles).map((item) => String(item)),
97
+ };
98
+ }
99
+
100
+ /** Windows 路径比较不分大小写、允许带引号。 */
101
+ function sameWindowsPath(a, b) {
102
+ const left = String(a || '').replace(/^"|"$/g, '').trim().toLowerCase();
103
+ const right = String(b || '').replace(/^"|"$/g, '').trim().toLowerCase();
104
+ return left !== '' && left === right;
105
+ }
106
+
107
+ /**
108
+ * 规则管的是不是当下这条网络。
109
+ *
110
+ * `Profile` 是 `Any`、或者与当前接入的类别有交集,才算生效 —— `Domain,Private` 这种
111
+ * 逗号串是真实存在的写法(用户在「允许在专用网络」那个勾里选的)。读不到当前类别
112
+ * 时按「管」算:宁可漏报一次「被挡」,也不要凭空说人家被挡。
113
+ */
114
+ function windowsRuleApplies(profileField, activeProfiles) {
115
+ const field = String(profileField || '').trim();
116
+ if (field === '' || /^any$/i.test(field)) return true;
117
+ if (!Array.isArray(activeProfiles) || activeProfiles.length === 0) return true;
118
+ const scopes = field.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean);
119
+ return scopes.some((scope) => activeProfiles.some((active) => String(active).trim().toLowerCase() === scope));
120
+ }
121
+
122
+ /**
123
+ * Windows 上的「放行」是个模型判断,不是一条规则。
124
+ *
125
+ * 顺序:显式 Block 优先 → 有生效中的 Allow → 防火墙开着却没有规则 = 默认拒绝(这是
126
+ * 第一次弹框点了取消之后的样子)→ 防火墙关着 = 不挡。
127
+ */
128
+ function decideWindowsAllows(report) {
129
+ if (!report || !Array.isArray(report.apps)) return null;
130
+ const enabled = report.profiles.some((item) => item.enabled === true);
131
+ if (!enabled) return true;
132
+ const hits = report.apps.filter((item) => item.program && sameWindowsPath(item.program, report.appPath));
133
+ const live = hits.filter((item) => item.enabled !== false && windowsRuleApplies(item.profile, report.activeProfiles));
134
+ if (live.some((item) => /block/i.test(item.action))) return false;
135
+ if (live.some((item) => /allow/i.test(item.action))) return true;
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * Windows 那份 PowerShell。
141
+ *
142
+ * 两个要点:
143
+ * 1. `Get-NetFirewallApplicationFilter -Program <path>` 是**服务端过滤**,只回这个程序
144
+ * 的过滤条件;换成枚举全部规则再逐条 join 会有几百次 CIM 查询,第一次跑要好几秒。
145
+ * 2. 输出必须是 JSON 且全 ASCII —— `netsh` 的文字输出会跟着系统语言变,中文 Windows
146
+ * 上没有办法稳定匹配。
147
+ */
148
+ const WINDOWS_SCRIPT = [
149
+ '$path = $env:DSH_FIREWALL_PROGRAM;',
150
+ '$profiles = @(Get-NetFirewallProfile | ForEach-Object { @{ name = "$($_.Name)"; enabled = [bool]$_.Enabled } });',
151
+ '$active = @(Get-NetConnectionProfile | ForEach-Object { $c = "$($_.NetworkCategory)"; if ($c -eq "DomainAuthenticated") { "Domain" } else { $c } });',
152
+ '$apps = @();',
153
+ 'foreach ($f in @(Get-NetFirewallApplicationFilter -Program $path)) {',
154
+ ' $r = Get-NetFirewallRule -Name $f.Name;',
155
+ ' if ($r) { $apps += @{ program = "$($f.Program)"; action = "$($r.Action)"; enabled = ("$($r.Enabled)" -eq "True"); profile = "$($r.Profile)" } }',
156
+ '}',
157
+ '@{ profiles = $profiles; apps = $apps; activeProfiles = $active } | ConvertTo-Json -Depth 6 -Compress',
158
+ ].join(' ');
159
+
160
+ /** 默认执行器:只跑写死的可执行文件与参数,超时杀进程,输出按 UTF-8 收。 */
161
+ function defaultExec(command, args, options) {
162
+ return new Promise((resolve) => {
163
+ execFile(command, args, {
164
+ timeout: options.timeoutMs,
165
+ maxBuffer: 1024 * 1024,
166
+ encoding: 'utf8',
167
+ windowsHide: true,
168
+ env: options.env,
169
+ }, (error, stdout, stderr) => {
170
+ const failed = Boolean(error);
171
+ resolve({
172
+ code: failed ? (typeof error.code === 'number' ? error.code : -1) : 0,
173
+ stdout: typeof stdout === 'string' ? stdout : '',
174
+ stderr: typeof stderr === 'string' ? stderr : '',
175
+ error: failed ? (error.killed ? 'TIMEOUT' : (error.code === 'ENOENT' ? 'NO_COMMAND' : 'FAILED')) : null,
176
+ });
177
+ });
178
+ });
179
+ }
180
+
181
+ /**
182
+ * 查一次防火墙。`exec` 与 `platform` 可注入,守卫才不用真去 fork 进程。
183
+ *
184
+ * @param {{platform?: string, execPath?: string, exec?: Function}} [options]
185
+ * @returns {Promise<{supported: boolean, enabled: boolean|null, allowsApp: boolean|null,
186
+ * appPath: string, reason: string|null}>}
187
+ */
188
+ async function probeFirewall(options = {}) {
189
+ const platform = options.platform || process.platform;
190
+ const execPath = options.execPath || process.execPath || '';
191
+ const exec = options.exec || defaultExec;
192
+ const timeoutMs = PROBE_TIMEOUT_MS[platform] || 3000;
193
+
194
+ if (platform === 'darwin') {
195
+ const socketfilter = '/usr/libexec/ApplicationFirewall/socketfilterfw';
196
+ // getappblocked 要的是这个程序本身;拿不到就退成查全局,别把一个空参数递过去。
197
+ const blockedArgs = execPath ? [socketfilter, ['--getappblocked', execPath]] : null;
198
+ const [state, app] = await Promise.all([
199
+ exec(socketfilter, ['--getglobalstate'], { timeoutMs }),
200
+ blockedArgs ? exec(blockedArgs[0], blockedArgs[1], { timeoutMs }) : Promise.resolve(null),
201
+ ]);
202
+ if (state.error === 'NO_COMMAND') return unknownFirewall(execPath, 'no-command');
203
+ if (state.error === 'TIMEOUT') return unknownFirewall(execPath, 'timeout');
204
+ const enabled = parseDarwinState(state.stdout);
205
+ const allowsApp = app && !app.error ? parseDarwinAppAllowed(app.stdout) : null;
206
+ if (enabled === null && allowsApp === null) return unknownFirewall(execPath, 'parse-failed');
207
+ return { supported: true, enabled, allowsApp, appPath: execPath, reason: null };
208
+ }
209
+
210
+ if (platform === 'win32') {
211
+ const result = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_SCRIPT], {
212
+ timeoutMs,
213
+ env: { ...process.env, DSH_FIREWALL_PROGRAM: execPath },
214
+ });
215
+ if (result.error === 'NO_COMMAND') return unknownFirewall(execPath, 'no-command');
216
+ if (result.error === 'TIMEOUT') return unknownFirewall(execPath, 'timeout');
217
+ let report = null;
218
+ try {
219
+ report = parseWindowsReport(result.stdout);
220
+ } catch {
221
+ return unknownFirewall(execPath, 'parse-failed');
222
+ }
223
+ if (report.profiles.length === 0) return unknownFirewall(execPath, 'parse-failed');
224
+ const enabled = report.profiles.some((item) => item.enabled === true);
225
+ return {
226
+ supported: true,
227
+ enabled,
228
+ allowsApp: decideWindowsAllows({ ...report, appPath: execPath }),
229
+ appPath: execPath,
230
+ reason: null,
231
+ };
232
+ }
233
+
234
+ if (platform === 'linux') {
235
+ // 只能读「开着没」:firewalld 的状态查询不要 root,规则读取要 root。
236
+ const firewalld = await exec('firewall-cmd', ['--state'], { timeoutMs });
237
+ if (!firewalld.error) {
238
+ // 只认整句 `running`:它另一句回答是 `not running`,用 /running/ 去配会把两句认成一句。
239
+ const running = String(firewalld.stdout || '').trim().toLowerCase() === 'running';
240
+ return { supported: true, enabled: running, allowsApp: null, appPath: execPath, reason: null };
241
+ }
242
+ if (firewalld.error === 'TIMEOUT') return unknownFirewall(execPath, 'timeout');
243
+ const unit = await exec('systemctl', ['is-active', 'firewalld'], { timeoutMs });
244
+ if (unit.error === 'NO_COMMAND') return unknownFirewall(execPath, 'no-command');
245
+ if (!unit.error) {
246
+ const active = String(unit.stdout || '').trim() === 'active';
247
+ return { supported: true, enabled: active, allowsApp: null, appPath: execPath, reason: null };
248
+ }
249
+ return unknownFirewall(execPath, 'parse-failed');
250
+ }
251
+
252
+ return unknownFirewall(execPath, 'unsupported');
253
+ }
254
+
255
+ /** 缓存:同一份结果给面板弹窗与后台那一次共用。`pending` 让并发调用不叠着开进程。 */
256
+ let cache = null;
257
+ let pending = null;
258
+
259
+ /**
260
+ * 带缓存的取法。`force` 只由「重新自检」那颗按钮用。
261
+ *
262
+ * @param {{force?: boolean, platform?: string, execPath?: string, exec?: Function}} [options]
263
+ */
264
+ async function readFirewall(options = {}) {
265
+ const now = Date.now();
266
+ if (!options.force && cache && now - cache.at < CACHE_TTL_MS && cache.key === (options.platform || process.platform)) {
267
+ return cache.value;
268
+ }
269
+ if (pending) return pending;
270
+ const key = options.platform || process.platform;
271
+ pending = probeFirewall(options)
272
+ .catch(() => unknownFirewall(options.execPath || process.execPath || '', 'parse-failed'))
273
+ .then((value) => {
274
+ cache = { at: Date.now(), key, value };
275
+ pending = null;
276
+ return value;
277
+ });
278
+ return pending;
279
+ }
280
+
281
+ /** 守卫用:清掉缓存,别让上一条用例的结果漏到下一条。 */
282
+ function resetFirewallCache() {
283
+ cache = null;
284
+ pending = null;
285
+ }
286
+
287
+ module.exports = {
288
+ PROBE_TIMEOUT_MS,
289
+ CACHE_TTL_MS,
290
+ WINDOWS_SCRIPT,
291
+ parseDarwinState,
292
+ parseDarwinAppAllowed,
293
+ parseWindowsReport,
294
+ windowsRuleApplies,
295
+ decideWindowsAllows,
296
+ probeFirewall,
297
+ readFirewall,
298
+ resetFirewallCache,
299
+ unknownFirewall,
300
+ };