dsh-remote-panel 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +132 -2
- package/cordis.patch.yml +8 -0
- package/deploy/templates/Caddyfile.tmpl +50 -0
- package/deploy/templates/dsh-proxy.service.tmpl +15 -0
- package/deploy/templates/easytier.env.tmpl +7 -0
- package/deploy/templates/easytier.service.tmpl +24 -0
- package/deploy/templates/install.sh.tmpl +185 -0
- package/lib/allowlist.js +101 -0
- package/lib/api.js +107 -0
- package/lib/client.js +1091 -0
- package/lib/deploy/assets.js +28 -0
- package/lib/deploy/render.js +217 -0
- package/lib/doctor.js +356 -0
- package/lib/index.js +259 -0
- package/lib/logs.js +56 -0
- package/lib/netcheck.js +42 -0
- package/lib/pair.js +109 -0
- package/lib/probe.js +105 -0
- package/lib/state.js +153 -0
- package/lib/token.js +46 -0
- package/package.json +61 -4
package/lib/allowlist.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 白名单编辑。
|
|
3
|
+
*
|
|
4
|
+
* 只改 Caddyfile 里那一行 `@lan remote_ip ...`,其余字符原样保留;
|
|
5
|
+
* 写前留 .bak,写后由调用方 reload。纯函数部分不碰磁盘,便于单测。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { chmodSync, copyFileSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
const LINE = /^(\s*)@lan\s+remote_ip\s+(.+?)\s*$/
|
|
13
|
+
|
|
14
|
+
/** 允许的条目:IPv4 / IPv6 / CIDR,不含空白。 */
|
|
15
|
+
const ENTRY = /^[0-9A-Fa-f:.]{2,45}(?:\/\d{1,3})?$/
|
|
16
|
+
|
|
17
|
+
/** 天然危险、需要二次确认的条目。 */
|
|
18
|
+
const DANGEROUS = new Set(['0.0.0.0/0', '::/0', '0.0.0.0', '::'])
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} entry
|
|
22
|
+
* @param {{ force?: boolean }} [options]
|
|
23
|
+
*/
|
|
24
|
+
export function validateEntry(entry, { force = false } = {}) {
|
|
25
|
+
const value = String(entry ?? '').trim()
|
|
26
|
+
if (value === '') throw new Error('条目为空')
|
|
27
|
+
if (!ENTRY.test(value)) throw new Error(`条目格式不合法:${value}`)
|
|
28
|
+
if (DANGEROUS.has(value) && !force) throw new Error(`${value} 会放行全部来源,需要二次确认`)
|
|
29
|
+
return value
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 解析当前的远程来源列表。
|
|
34
|
+
* @param {string} text Caddyfile 内容
|
|
35
|
+
* @returns {string[]}
|
|
36
|
+
*/
|
|
37
|
+
export function parseEntries(text) {
|
|
38
|
+
for (const line of String(text).split('\n')) {
|
|
39
|
+
const match = LINE.exec(line)
|
|
40
|
+
if (match !== null) return match[2].split(/\s+/).filter(Boolean)
|
|
41
|
+
}
|
|
42
|
+
return []
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 增删一条,返回新的 Caddyfile 文本。
|
|
47
|
+
* @param {string} text
|
|
48
|
+
* @param {string} entry
|
|
49
|
+
* @param {'add'|'remove'} mode
|
|
50
|
+
*/
|
|
51
|
+
export function withEntry(text, entry, mode) {
|
|
52
|
+
const lines = String(text).split('\n')
|
|
53
|
+
const index = lines.findIndex((line) => LINE.test(line))
|
|
54
|
+
if (index === -1) throw new Error('Caddyfile 里找不到 @lan remote_ip 行')
|
|
55
|
+
|
|
56
|
+
const match = LINE.exec(lines[index])
|
|
57
|
+
const indent = match[1]
|
|
58
|
+
const current = match[2].split(/\s+/).filter(Boolean)
|
|
59
|
+
const next =
|
|
60
|
+
mode === 'add'
|
|
61
|
+
? current.includes(entry)
|
|
62
|
+
? current
|
|
63
|
+
: [...current, entry]
|
|
64
|
+
: current.filter((item) => item !== entry)
|
|
65
|
+
|
|
66
|
+
if (next.length === 0) throw new Error('白名单不能为空:至少保留本机 127.0.0.1')
|
|
67
|
+
|
|
68
|
+
// 每行最多 6 条,避免 Caddyfile 出现超长行
|
|
69
|
+
const chunks = []
|
|
70
|
+
for (let i = 0; i < next.length; i += 6) chunks.push(next.slice(i, i + 6))
|
|
71
|
+
lines[index] = chunks.map((chunk) => `${indent}@lan remote_ip ${chunk.join(' ')}`).join('\n')
|
|
72
|
+
return lines.join('\n')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 默认路径。 */
|
|
76
|
+
export function caddyfilePath(home = homedir()) {
|
|
77
|
+
return join(home, '.config', 'caddy', 'Caddyfile')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 读 → 校验 → 写(原子)→ 留备份。
|
|
82
|
+
* @param {{ entry: string, mode: 'add'|'remove', force?: boolean, home?: string }} input
|
|
83
|
+
*/
|
|
84
|
+
export function mutateAllowlist({ entry, mode, force = false, home }) {
|
|
85
|
+
const value = validateEntry(entry, { force })
|
|
86
|
+
if (mode !== 'add' && mode !== 'remove') throw new Error('mode 必须是 add 或 remove')
|
|
87
|
+
|
|
88
|
+
const path = caddyfilePath(home)
|
|
89
|
+
if (!existsSync(path)) throw new Error(`Caddyfile 不存在:${path}(先完成部署)`)
|
|
90
|
+
|
|
91
|
+
const before = readFileSync(path, 'utf8')
|
|
92
|
+
const after = withEntry(before, value, mode)
|
|
93
|
+
if (after === before) return { changed: false, entries: parseEntries(after), path }
|
|
94
|
+
|
|
95
|
+
copyFileSync(path, `${path}.bak`)
|
|
96
|
+
const temp = `${path}.tmp`
|
|
97
|
+
writeFileSync(temp, after, 'utf8')
|
|
98
|
+
renameSync(temp, path)
|
|
99
|
+
chmodSync(path, 0o644)
|
|
100
|
+
return { changed: true, entries: parseEntries(after), path }
|
|
101
|
+
}
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 控制面的纯逻辑:请求体读取、默认值探测、口令生成。
|
|
3
|
+
* 与 HTTP 服务解耦,方便单测。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomBytes } from 'node:crypto'
|
|
7
|
+
import { createServer } from 'node:net'
|
|
8
|
+
import { homedir, networkInterfaces, userInfo } from 'node:os'
|
|
9
|
+
|
|
10
|
+
import { renderDeploy } from './deploy/render.js'
|
|
11
|
+
|
|
12
|
+
/** 客户端输入有问题(回 400,而不是 500)。 */
|
|
13
|
+
export class BadRequest extends Error {}
|
|
14
|
+
|
|
15
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 读取并解析 JSON 请求体。
|
|
19
|
+
* @param {AsyncIterable<Buffer|string>} req
|
|
20
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
21
|
+
*/
|
|
22
|
+
export async function readJson(req) {
|
|
23
|
+
const chunks = []
|
|
24
|
+
let size = 0
|
|
25
|
+
for await (const chunk of req) {
|
|
26
|
+
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
|
|
27
|
+
size += buffer.length
|
|
28
|
+
if (size > MAX_BODY_BYTES) throw new BadRequest('请求体过大')
|
|
29
|
+
chunks.push(buffer)
|
|
30
|
+
}
|
|
31
|
+
if (size === 0) throw new BadRequest('请求体为空')
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
34
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
35
|
+
throw new BadRequest('请求体必须是 JSON 对象')
|
|
36
|
+
}
|
|
37
|
+
return parsed
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error instanceof BadRequest) throw error
|
|
40
|
+
throw new BadRequest(`JSON 解析失败:${error instanceof Error ? error.message : String(error)}`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 取第一个非回环 IPv4 的 CIDR 作为局域网白名单;探测不到时给常见默认值。 */
|
|
45
|
+
export function detectLanCidr() {
|
|
46
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
47
|
+
for (const entry of entries ?? []) {
|
|
48
|
+
if (entry.family === 'IPv4' && !entry.internal && entry.cidr !== null) return entry.cidr
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return '192.168.1.0/24'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 端口是否可绑定(127.0.0.1 上试听一次)。 */
|
|
55
|
+
export function isPortFree(port) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const server = createServer()
|
|
58
|
+
server.once('error', () => resolve(false))
|
|
59
|
+
server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)))
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 从 start 起找第一个空闲端口,最多试 tries 个。 */
|
|
64
|
+
export async function findFreePort(start = 8710, tries = 50) {
|
|
65
|
+
for (let offset = 0; offset < tries; offset += 1) {
|
|
66
|
+
const candidate = start + offset
|
|
67
|
+
if (candidate > 65535) break
|
|
68
|
+
if (await isPortFree(candidate)) return candidate
|
|
69
|
+
}
|
|
70
|
+
throw new BadRequest(`从 ${start} 起连续 ${tries} 个端口都被占用,请手动指定`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 生成符合服务端校验的随机口令(base64url,去掉 =)。 */
|
|
74
|
+
export function suggestToken(length = 24) {
|
|
75
|
+
return randomBytes(48).toString('base64url').replace(/=+$/, '').slice(0, length)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 向导打开时的默认值。 */
|
|
79
|
+
export async function defaults(version) {
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
version,
|
|
83
|
+
user: userInfo().username,
|
|
84
|
+
home: homedir(),
|
|
85
|
+
port: await findFreePort(8710),
|
|
86
|
+
upstreamPort: 3080,
|
|
87
|
+
lanCidr: detectLanCidr(),
|
|
88
|
+
etVersion: '2.6.4',
|
|
89
|
+
accessKey: suggestToken(24),
|
|
90
|
+
networkSecret: suggestToken(32),
|
|
91
|
+
networkName: 'dsh-remote',
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 渲染向导结果。
|
|
97
|
+
* @param {Record<string, unknown>} body
|
|
98
|
+
* @param {string} version
|
|
99
|
+
*/
|
|
100
|
+
export function render(body, version) {
|
|
101
|
+
try {
|
|
102
|
+
const result = renderDeploy({ ...body, version })
|
|
103
|
+
return { ok: true, ...result }
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new BadRequest(error instanceof Error ? error.message : String(error))
|
|
106
|
+
}
|
|
107
|
+
}
|