dsh-mini-remote 0.1.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.
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/README.zh.md +138 -0
- package/client/client.js +387 -0
- package/client/verify.mjs +514 -0
- package/cordis.patch.yml +5 -0
- package/lib/art/work-1-ready.webp +0 -0
- package/lib/art/work-2-reading.webp +0 -0
- package/lib/art/work-3-typing.webp +0 -0
- package/lib/art/work-4-checking.webp +0 -0
- package/lib/art/work-5-thinking.webp +0 -0
- package/lib/art/work-6-running.webp +0 -0
- package/lib/art/work-7-waiting.webp +0 -0
- package/lib/art/work-8-sprinting.webp +0 -0
- package/lib/build.js +59 -0
- package/lib/cloudflared.js +165 -0
- package/lib/events.js +165 -0
- package/lib/index.js +1206 -0
- package/lib/net.js +104 -0
- package/lib/notify.js +82 -0
- package/lib/page.html +1891 -0
- package/lib/page.js +46 -0
- package/lib/pairing.js +172 -0
- package/lib/server.js +590 -0
- package/lib/store.js +334 -0
- package/lib/tree.js +185 -0
- package/lib/tunnel.js +144 -0
- package/package.json +74 -0
package/lib/net.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 网卡识别:只挑出真正能用、也真正该让手机连的地址。
|
|
3
|
+
*
|
|
4
|
+
* 一台电脑上通常挂着好几个地址,其中大部分是废的。没拿到 IP 的空网卡会
|
|
5
|
+
* 自报一个 169.254.x.x(系统给的临时占位地址,连不通),WSL、Hyper-V 这些
|
|
6
|
+
* 虚拟网卡也各有各的内网地址,但它们只给虚拟机之间用。把这些一股脑列出来,
|
|
7
|
+
* 等于让用户从六个地址里猜哪个是对的——本机实测就是这样:八个地址里只有
|
|
8
|
+
* 两个能用。
|
|
9
|
+
*/
|
|
10
|
+
import { networkInterfaces } from 'node:os'
|
|
11
|
+
|
|
12
|
+
/** 169.254.0.0/16:没拿到 IP 时空网卡自报的占位地址。 */
|
|
13
|
+
function isLinkLocal(ip) {
|
|
14
|
+
return ip.startsWith('169.254.')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 100.64.0.0/10:Tailscale 使用的运营商级 NAT 网段。 */
|
|
18
|
+
function isTailscale(ip) {
|
|
19
|
+
if (!ip.startsWith('100.')) return false
|
|
20
|
+
const second = Number(ip.split('.')[1])
|
|
21
|
+
return second >= 64 && second <= 127
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isLoopback(ip) {
|
|
25
|
+
return ip.startsWith('127.')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** RFC1918 的三段内网地址。 */
|
|
29
|
+
function isPrivate(ip) {
|
|
30
|
+
if (ip.startsWith('10.') || ip.startsWith('192.168.')) return true
|
|
31
|
+
const [a, b] = ip.split('.').map(Number)
|
|
32
|
+
return a === 172 && b >= 16 && b <= 31
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 虚拟网卡按名字排除。它们的地址段(172.x、192.168.x)和真内网长得一样,
|
|
37
|
+
* 靠地址本身分不出来,只能看网卡名。
|
|
38
|
+
*/
|
|
39
|
+
const VIRTUAL_ADAPTER = /vEthernet|WSL|Hyper-V|VMware|VirtualBox|Docker|Loopback|Npcap|TAP-|Bluetooth|蓝牙|Virtual Adapter/i
|
|
40
|
+
|
|
41
|
+
/** 返回 'lan' | 'tailscale' | 'loopback',或 null 表示这个地址不该给用户。 */
|
|
42
|
+
export function classify(name, ip) {
|
|
43
|
+
if (isLoopback(ip)) return 'loopback'
|
|
44
|
+
if (isLinkLocal(ip)) return null
|
|
45
|
+
if (isTailscale(ip)) return 'tailscale'
|
|
46
|
+
if (VIRTUAL_ADAPTER.test(name)) return null
|
|
47
|
+
if (isPrivate(ip)) return 'lan'
|
|
48
|
+
// 公网地址:不主动暴露,用户没要求过
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const LABELS = {
|
|
53
|
+
lan: '内网',
|
|
54
|
+
tailscale: 'Tailscale',
|
|
55
|
+
loopback: '本机',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 手机能用来连接的地址,内网排前面(在家时更常用)。
|
|
60
|
+
* 每项形如 { kind, label, address, name }。
|
|
61
|
+
*/
|
|
62
|
+
export function reachableAddresses() {
|
|
63
|
+
const found = []
|
|
64
|
+
for (const [name, addrs] of Object.entries(networkInterfaces())) {
|
|
65
|
+
for (const addr of addrs ?? []) {
|
|
66
|
+
if (addr.family !== 'IPv4' || addr.internal) continue
|
|
67
|
+
const kind = classify(name, addr.address)
|
|
68
|
+
if (!kind) continue
|
|
69
|
+
found.push({ kind, label: LABELS[kind], address: addr.address, name })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const rank = { lan: 0, tailscale: 1, loopback: 2 }
|
|
73
|
+
found.sort((a, b) => rank[a.kind] - rank[b.kind] || a.address.localeCompare(b.address))
|
|
74
|
+
return found
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 这个请求是不是来自这台电脑自己。
|
|
79
|
+
*
|
|
80
|
+
* 配对信息里带着 token,只允许本机读取——否则同一个 Wi-Fi 下谁都能把密码
|
|
81
|
+
* 取走,token 也就形同虚设了。
|
|
82
|
+
*/
|
|
83
|
+
export function isLocalRequest(req) {
|
|
84
|
+
const raw = req.socket?.remoteAddress ?? ''
|
|
85
|
+
if (!raw) return false
|
|
86
|
+
// Node 在双栈监听时会把 IPv4 报成 ::ffff:127.0.0.1
|
|
87
|
+
const ip = raw.startsWith('::ffff:') ? raw.slice(7) : raw
|
|
88
|
+
return ip === '::1' || isLoopback(ip)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 该在哪些地址上开门。
|
|
93
|
+
*
|
|
94
|
+
* 'auto' 是默认值:内网 + Tailscale + 本机。比 0.0.0.0 精确——WSL 那些虚拟
|
|
95
|
+
* 网卡不开门;也比只绑回环实用——手机连得上。传具体地址或 '0.0.0.0' 则照办。
|
|
96
|
+
*/
|
|
97
|
+
export function resolveBindAddresses(setting) {
|
|
98
|
+
if (!setting || setting === 'auto') {
|
|
99
|
+
// 本机始终留着——配对页要在电脑上打得开。内网/Tailscale 一个都没有
|
|
100
|
+
// (比如网线没插、Tailscale 没登录)时,至少服务还在。
|
|
101
|
+
return ['127.0.0.1', ...reachableAddresses().map((a) => a.address)]
|
|
102
|
+
}
|
|
103
|
+
return [setting]
|
|
104
|
+
}
|
package/lib/notify.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L3 外部推送:手机锁屏 / 浏览器被挂起时,WebSocket 和 SSE 都靠不住,
|
|
3
|
+
* 只有走系统级推送通道才叫得醒人。
|
|
4
|
+
*
|
|
5
|
+
* 支持 Bark(iOS 首选)、ntfy(跨平台、可自托管)、Telegram Bot。
|
|
6
|
+
* 全部用全局 fetch,不引任何依赖。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const TIMEOUT_MS = 8000
|
|
10
|
+
|
|
11
|
+
function truncate(text, maxLength) {
|
|
12
|
+
const max = Number.isFinite(maxLength) && maxLength > 0 ? maxLength : 200
|
|
13
|
+
const flat = text.replace(/\s+/g, ' ').trim()
|
|
14
|
+
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function post(url, { headers, body, method = 'POST' } = {}) {
|
|
18
|
+
const res = await fetch(url, {
|
|
19
|
+
method,
|
|
20
|
+
headers,
|
|
21
|
+
body,
|
|
22
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
23
|
+
})
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`)
|
|
26
|
+
}
|
|
27
|
+
return res
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 按配置发一条外部推送。
|
|
32
|
+
* @returns {Promise<{ok: boolean, error?: string}>} 永不抛出——推送失败不该影响 agent。
|
|
33
|
+
*/
|
|
34
|
+
export async function sendExternalNotification(notify, text) {
|
|
35
|
+
if (!notify?.enabled || notify.channel === 'none') return { ok: false, error: 'disabled' }
|
|
36
|
+
|
|
37
|
+
const body = truncate(text, notify.maxLength)
|
|
38
|
+
const title = 'DSH 任务完成'
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
switch (notify.channel) {
|
|
42
|
+
case 'bark': {
|
|
43
|
+
if (!notify.barkUrl) throw new Error('barkUrl 未配置')
|
|
44
|
+
const base = notify.barkUrl.replace(/\/+$/, '')
|
|
45
|
+
const url = `${base}/${encodeURIComponent(title)}/${encodeURIComponent(body)}`
|
|
46
|
+
+ `?group=dsh&sound=${encodeURIComponent(notify.sound || 'default')}`
|
|
47
|
+
await post(url, { method: 'GET' })
|
|
48
|
+
break
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
case 'ntfy': {
|
|
52
|
+
if (!notify.ntfyTopic) throw new Error('ntfyTopic 未配置')
|
|
53
|
+
const base = (notify.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '')
|
|
54
|
+
await post(`${base}/${encodeURIComponent(notify.ntfyTopic)}`, {
|
|
55
|
+
headers: { Title: title, Priority: 'high', Tags: 'robot' },
|
|
56
|
+
body,
|
|
57
|
+
})
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
case 'telegram': {
|
|
62
|
+
if (!notify.telegramBotToken || !notify.telegramChatId) {
|
|
63
|
+
throw new Error('telegramBotToken / telegramChatId 未配置')
|
|
64
|
+
}
|
|
65
|
+
await post(`https://api.telegram.org/bot${notify.telegramBotToken}/sendMessage`, {
|
|
66
|
+
headers: { 'Content-Type': 'application/json' },
|
|
67
|
+
body: JSON.stringify({
|
|
68
|
+
chat_id: notify.telegramChatId,
|
|
69
|
+
text: `${title}\n${body}`,
|
|
70
|
+
}),
|
|
71
|
+
})
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
default:
|
|
76
|
+
return { ok: false, error: `未知通道 ${notify.channel}` }
|
|
77
|
+
}
|
|
78
|
+
return { ok: true }
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return { ok: false, error: err?.message ?? String(err) }
|
|
81
|
+
}
|
|
82
|
+
}
|