@wwkit/sshproxy 1.0.1

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,33 @@
1
+ {
2
+ paths: {
3
+ stateDir: '~/.local/share/sshproxy',
4
+ pidFile: 'proxy.pid',
5
+ chromePidFile: 'proxy-chrome.pid',
6
+ webPidFile: 'proxy-web.pid',
7
+ sshDir: '~/.ssh',
8
+ sshConfigFile: 'config',
9
+ },
10
+ ssh: {
11
+ keyCandidates: ['id_ed25519', 'id_rsa', 'id_ecdsa'],
12
+ defaultKeyName: 'id_ed25519',
13
+ defaultPort: 22,
14
+ keygenComment: 'sshproxy',
15
+ keepAliveInterval: 30,
16
+ keepAliveCountMax: 3,
17
+ aliasPrefix: 'client-proxy',
18
+ },
19
+ tunnel: {
20
+ autosshArgs: ['-M', '0', '-N', '-f'],
21
+ },
22
+ os: {
23
+ macNetworkServices: ['Wi-Fi', 'Ethernet'],
24
+ macNetworkDefault: 'Wi-Fi',
25
+ },
26
+ debug: {
27
+ curlUrl: 'https://www.google.com',
28
+ curlTimeoutMs: 15000,
29
+ },
30
+ web: {
31
+ pollIntervalMs: 3000,
32
+ },
33
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { ProxyManager } from './ProxyManager.js'
2
+ export { SshClient } from './SshClient.js'
3
+ export { loadConfig, defaultConfig, getConfigPath, getConfigDir, getProxyConfig, clientAddr, sshAlias } from './config.js'
4
+ export { initClient } from './init.js'
package/src/init.js ADDED
@@ -0,0 +1,141 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { spawnSync } from 'node:child_process'
4
+
5
+ import { clientAddr, getResolvedStaticConfig, loadConfig, sshAlias } from './config.js'
6
+ import { getOsType, which } from './OSystem.js'
7
+ import { SshClient } from './SshClient.js'
8
+
9
+ function ensureLocalKey() {
10
+ const s = getResolvedStaticConfig()
11
+ const sshDir = s.paths.sshDir
12
+ for (const name of s.ssh.keyCandidates) {
13
+ const pubPath = path.join(sshDir, `${name}.pub`)
14
+ if (fs.existsSync(pubPath)) {
15
+ return fs.readFileSync(pubPath, 'utf-8').trim()
16
+ }
17
+ }
18
+ const keyPath = path.join(sshDir, s.ssh.defaultKeyName)
19
+ fs.mkdirSync(sshDir, { recursive: true })
20
+ const result = spawnSync('ssh-keygen', ['-t', 'ed25519', '-C', s.ssh.keygenComment, '-N', '', '-f', keyPath], { encoding: 'utf-8' })
21
+ if (result.status !== 0) return null
22
+ const pubPath = `${keyPath}.pub`
23
+ if (fs.existsSync(pubPath)) {
24
+ return fs.readFileSync(pubPath, 'utf-8').trim()
25
+ }
26
+ return null
27
+ }
28
+
29
+ function writeSshConfig(host, user, port, alias, localPort) {
30
+ const s = getResolvedStaticConfig()
31
+ const configPath = s.paths.sshConfigFile
32
+ const portLine = port && Number(port) !== 22 ? `\n Port ${port}` : ''
33
+ const entry = `
34
+ Host ${alias}
35
+ HostName ${host}
36
+ User ${user}${portLine}
37
+ DynamicForward ${localPort}
38
+ ServerAliveInterval ${s.ssh.keepAliveInterval}
39
+ ServerAliveCountMax ${s.ssh.keepAliveCountMax}
40
+ `
41
+ let content = ''
42
+ if (fs.existsSync(configPath)) {
43
+ content = fs.readFileSync(configPath, 'utf-8')
44
+ }
45
+ if (content.includes(`Host ${alias}`)) {
46
+ return true
47
+ }
48
+ fs.appendFileSync(configPath, entry, 'utf-8')
49
+ return true
50
+ }
51
+
52
+ function checkAutossh() {
53
+ return which('autossh') !== null
54
+ }
55
+
56
+ function installAutossh() {
57
+ const osType = getOsType()
58
+ let installCmd = null
59
+ if (osType === 'mac') {
60
+ installCmd = ['brew', 'install', 'autossh']
61
+ } else if (osType === 'linux') {
62
+ if (which('apt-get') !== null) {
63
+ installCmd = ['apt-get', 'install', '-y', 'autossh']
64
+ } else if (which('dnf') !== null) {
65
+ installCmd = ['dnf', 'install', '-y', 'autossh']
66
+ }
67
+ }
68
+ if (!installCmd) return '当前系统不支持自动安装 autossh,请手动安装'
69
+ const result = spawnSync(installCmd[0], installCmd.slice(1), { encoding: 'utf-8' })
70
+ if (result.status !== 0) {
71
+ return `安装失败: ${(result.stderr ?? '').trim()},请手动执行: ${installCmd.join(' ')}`
72
+ }
73
+ return 'autossh 已安装'
74
+ }
75
+
76
+ export async function initClient() {
77
+ const lines = []
78
+ const [localHost, localPort] = clientAddr()
79
+ const alias = sshAlias(localPort)
80
+ const sshCfg = loadConfig()
81
+
82
+ lines.push('[1/5] 检查 SSH 密钥...')
83
+ const pubKey = ensureLocalKey()
84
+ if (!pubKey) {
85
+ lines.push(' ✗ 无法生成密钥,请手动执行: ssh-keygen -t ed25519')
86
+ return lines
87
+ }
88
+ lines.push(' ✓ 密钥就绪')
89
+
90
+ lines.push('[2/5] 检查 SSH 连接配置...')
91
+ const host = (sshCfg.host ?? '').trim()
92
+ const user = (sshCfg.user ?? '').trim()
93
+ const password = sshCfg.password ?? ''
94
+ const port = sshCfg.port
95
+ const missing = []
96
+ if (!host) missing.push('host')
97
+ if (!user) missing.push('user')
98
+ if (!password) missing.push('password')
99
+ if (port === undefined || port === null || port === '' || Number.isNaN(Number(port))) missing.push('port')
100
+ if (missing.length > 0) {
101
+ lines.push(` ✗ 配置缺失: ${missing.join(', ')}`)
102
+ lines.push(' 请先在 ~/.config/sshproxy/config.json5 补全连接信息')
103
+ return lines
104
+ }
105
+ const client = new SshClient()
106
+ if (!(await client.checkConnection())) {
107
+ lines.push(' ✗ SSH 连接失败,请检查网络或服务器状态')
108
+ return lines
109
+ }
110
+ lines.push(` ✓ SSH 连接正常 (${host})`)
111
+
112
+ lines.push('[3/5] 安装公钥到远程服务器...')
113
+ const command = (
114
+ `mkdir -p ~/.ssh && chmod 700 ~/.ssh && `
115
+ + `grep -qF '${pubKey}' ~/.ssh/authorized_keys 2>/dev/null || `
116
+ + `echo '${pubKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`
117
+ )
118
+ const [code, , err] = await client.execute(command)
119
+ if (code !== 0) {
120
+ lines.push(` ✗ 安装公钥失败: ${(err ?? '').trim()}`)
121
+ return lines
122
+ }
123
+ lines.push(' ✓ 公钥已安装')
124
+
125
+ lines.push('[4/5] 配置 ~/.ssh/config...')
126
+ writeSshConfig(host, user, port, alias, localPort)
127
+ lines.push(` ✓ ${alias} 配置已写入`)
128
+
129
+ lines.push('[5/5] 检查 autossh...')
130
+ if (checkAutossh()) {
131
+ lines.push(' ✓ autossh 就绪')
132
+ } else {
133
+ lines.push(' autossh 未安装,正在安装...')
134
+ const msg = installAutossh()
135
+ lines.push(` ${msg.startsWith('✓') || msg === 'autossh 已安装' ? '✓' : '✗'} ${msg}`)
136
+ }
137
+
138
+ lines.push('')
139
+ lines.push(`初始化完成!执行 sshproxy tunnel on 启动隧道 (SOCKS5 ${localHost}:${localPort})`)
140
+ return lines
141
+ }
@@ -0,0 +1,302 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>sshproxy 管理页</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f5f7fa;
10
+ --surface: #ffffff;
11
+ --border: #e4e7ed;
12
+ --text: #303133;
13
+ --text-secondary: #909399;
14
+ --primary: #409eff;
15
+ --success: #67c23a;
16
+ --danger: #f56c6c;
17
+ --info: #909399;
18
+ --warning: #e6a23c;
19
+ --radius: 8px;
20
+ }
21
+ * { box-sizing: border-box; margin: 0; padding: 0; }
22
+ body {
23
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
24
+ background: var(--bg);
25
+ color: var(--text);
26
+ font-size: 14px;
27
+ line-height: 1.6;
28
+ }
29
+ .page { max-width: 1280px; margin: 0 auto; padding: 24px 16px 48px; }
30
+ header { margin-bottom: 20px; }
31
+ header h1 { font-size: 22px; font-weight: 600; }
32
+ header p { color: var(--text-secondary); font-size: 13px; margin-top: 4px; }
33
+ .card {
34
+ background: var(--surface);
35
+ border: 1px solid var(--border);
36
+ border-radius: var(--radius);
37
+ padding: 20px 24px;
38
+ margin-bottom: 16px;
39
+ }
40
+ .row { display: flex; align-items: center; gap: 12px; padding: 6px 0; }
41
+ .label { flex-shrink: 0; width: 110px; color: var(--text-secondary); font-size: 13px; }
42
+ .value { font-size: 14px; word-break: break-all; }
43
+ .mono { font-family: "SF Mono", "Menlo", "Consolas", monospace; }
44
+ .tag {
45
+ display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 12px; line-height: 20px;
46
+ color: #fff; border: none;
47
+ }
48
+ .tag.primary { background: var(--primary); }
49
+ .tag.success { background: var(--success); }
50
+ .tag.info { background: var(--info); }
51
+ .tag.warning { background: var(--warning); }
52
+ .actions { display: flex; gap: 16px; flex-wrap: wrap; }
53
+ .action-group { flex: 1; min-width: 150px; }
54
+ .action-group h3 { font-size: 13px; font-weight: 600; margin-bottom: 10px; }
55
+ .btn {
56
+ display: inline-flex; align-items: center; gap: 6px;
57
+ padding: 8px 16px; border-radius: 4px; border: 1px solid transparent;
58
+ font-size: 14px; cursor: pointer; color: #fff; margin-right: 8px;
59
+ white-space: nowrap;
60
+ transition: opacity .2s;
61
+ }
62
+ .btn:hover { opacity: .85; }
63
+ .btn:disabled { opacity: .5; cursor: not-allowed; }
64
+ .btn.primary { background: var(--primary); }
65
+ .btn.danger { background: var(--danger); }
66
+ .btn.ghost { background: var(--surface); color: var(--primary); border-color: var(--primary); }
67
+ .btn.loading { opacity: .6; pointer-events: none; }
68
+ .toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
69
+ .debug-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
70
+ .debug-toolbar h3 { margin: 0; }
71
+ .debug-row { display: flex; align-items: center; gap: 10px; padding: 6px 10px; background: var(--bg); border-radius: 4px; margin-bottom: 6px; }
72
+ .debug-mark { flex-shrink: 0; width: 18px; font-weight: 700; text-align: center; font-family: "SF Mono", Menlo, monospace; }
73
+ .debug-mark.ok { color: var(--success); }
74
+ .debug-mark.fail { color: var(--danger); }
75
+ .debug-mark.skipped { color: var(--info); }
76
+ .debug-name { flex-shrink: 0; font-size: 13px; }
77
+ .debug-detail { flex: 1; font-size: 12px; color: var(--text-secondary); word-break: break-all; }
78
+ .note { margin-top: 12px; font-size: 12px; color: var(--text-secondary); font-style: italic; }
79
+ #msg { margin-top: 12px; font-size: 13px; }
80
+ #msg.ok { color: var(--success); }
81
+ #msg.err { color: var(--danger); }
82
+ .refresh-hint { font-size: 12px; color: var(--text-secondary); }
83
+ </style>
84
+ </head>
85
+ <body>
86
+ <div class="page">
87
+ <header>
88
+ <h1>sshproxy 管理页</h1>
89
+ <p>SOCKS5 代理管理(本地 SSH 隧道)</p>
90
+ </header>
91
+
92
+ <div class="card">
93
+ <div class="toolbar">
94
+ <button class="btn ghost" id="refreshBtn">刷新</button>
95
+ <span class="refresh-hint" id="refreshHint"></span>
96
+ </div>
97
+ <div id="statusGrid"></div>
98
+ </div>
99
+
100
+ <div class="card">
101
+ <h3 style="margin-bottom:12px;">操作</h3>
102
+ <div class="actions">
103
+ <div class="action-group">
104
+ <h3>隧道</h3>
105
+ <button class="btn primary" id="tunnelOnBtn" disabled>开启隧道</button>
106
+ <button class="btn danger" id="tunnelOffBtn" disabled>关闭隧道</button>
107
+ </div>
108
+ <div class="action-group">
109
+ <h3>系统代理</h3>
110
+ <button class="btn primary" id="proxyOnBtn" disabled>开启代理</button>
111
+ <button class="btn danger" id="proxyOffBtn" disabled>关闭代理</button>
112
+ </div>
113
+ <div class="action-group">
114
+ <h3>代理 Chrome</h3>
115
+ <button class="btn primary" id="chromeOnBtn" disabled>启动 Chrome</button>
116
+ <button class="btn danger" id="chromeOffBtn" disabled>关闭 Chrome</button>
117
+ </div>
118
+ <div class="action-group">
119
+ <h3>一键</h3>
120
+ <button class="btn primary" id="startBtn" disabled>全部开启</button>
121
+ <button class="btn danger" id="stopBtn" disabled>全部关闭</button>
122
+ </div>
123
+ </div>
124
+ <div id="msg"></div>
125
+ </div>
126
+
127
+ <div class="card">
128
+ <div class="debug-toolbar">
129
+ <h3>debug 检查</h3>
130
+ <button class="btn ghost" id="debugBtn">全链路检查</button>
131
+ </div>
132
+ <div id="debugList"><p style="color:var(--text-secondary);font-size:13px;">点击"全链路检查"开始验证</p></div>
133
+ </div>
134
+ </div>
135
+
136
+ <script type="module">
137
+ const $ = (id) => document.getElementById(id)
138
+ let status = null
139
+ let acting = null
140
+ let pollIntervalMs = 3000
141
+
142
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
143
+
144
+ async function api(method, path, body) {
145
+ const resp = await fetch(path, {
146
+ method,
147
+ headers: { 'Content-Type': 'application/json' },
148
+ body: body ? JSON.stringify(body) : undefined,
149
+ })
150
+ return resp.json()
151
+ }
152
+
153
+ async function refreshStatus() {
154
+ try {
155
+ const resp = await api('GET', '/api/status')
156
+ if (resp.code !== 200) throw new Error(resp.message)
157
+ status = resp.data
158
+ renderStatus()
159
+ renderButtons()
160
+ } catch (err) {
161
+ setMsg(err.message || String(err), true)
162
+ }
163
+ }
164
+
165
+ function renderStatus() {
166
+ const s = status
167
+ if (!s) return
168
+ const rows = []
169
+ rows.push(['地址', `<span class="value mono">${esc(s.host)}:${s.port}</span>`])
170
+ rows.push(['代理隧道', s.tunnel && s.tunnel.running
171
+ ? `<span class="tag success">运行中${s.tunnel.pid ? ` (PID=${s.tunnel.pid})` : ''}</span>`
172
+ : '<span class="tag info">未运行</span>'])
173
+ rows.push(['系统代理', s.system_proxy && s.system_proxy.enabled
174
+ ? '<span class="tag success">已开启</span>'
175
+ : '<span class="tag info">已关闭</span>'])
176
+ rows.push(['代理 Chrome', s.chrome && s.chrome.running
177
+ ? `<span class="tag success">运行中${s.chrome.pid ? ` (PID=${s.chrome.pid})` : ''}</span>`
178
+ : '<span class="tag info">未运行</span>'])
179
+ $('statusGrid').innerHTML = rows.map(([label, html]) =>
180
+ `<div class="row"><span class="label">${esc(label)}</span>${html}</div>`).join('')
181
+ }
182
+
183
+ function renderButtons() {
184
+ if (!status) return
185
+ const tunnelRunning = status.tunnel && status.tunnel.running
186
+ const proxyRunning = status.system_proxy && status.system_proxy.enabled
187
+ const chromeRunning = status.chrome && status.chrome.running
188
+ $('tunnelOnBtn').disabled = tunnelRunning
189
+ $('tunnelOffBtn').disabled = !tunnelRunning
190
+ $('proxyOnBtn').disabled = proxyRunning
191
+ $('proxyOffBtn').disabled = !proxyRunning
192
+ $('chromeOnBtn').disabled = chromeRunning
193
+ $('chromeOffBtn').disabled = !chromeRunning
194
+ $('startBtn').disabled = proxyRunning
195
+ $('stopBtn').disabled = !(proxyRunning || tunnelRunning || chromeRunning)
196
+ }
197
+
198
+ function setMsg(text, isErr) {
199
+ const el = $('msg')
200
+ el.textContent = text
201
+ el.className = isErr ? 'err' : 'ok'
202
+ if (text) setTimeout(() => { el.textContent = '' }, 4000)
203
+ }
204
+
205
+ async function doAction(action, btnId) {
206
+ if (acting) return
207
+ acting = action
208
+ const btn = $(btnId)
209
+ btn.classList.add('loading')
210
+ btn.disabled = true
211
+ try {
212
+ let resp
213
+ if (action === 'start') resp = await api('POST', '/api/start')
214
+ else if (action === 'stop') resp = await api('POST', '/api/stop')
215
+ else if (action === 'tunnelOn') resp = await api('POST', '/api/tunnel', { action: 'on' })
216
+ else if (action === 'tunnelOff') resp = await api('POST', '/api/tunnel', { action: 'off' })
217
+ else if (action === 'proxyOn') resp = await api('POST', '/api/proxy', { action: 'on' })
218
+ else if (action === 'proxyOff') resp = await api('POST', '/api/proxy', { action: 'off' })
219
+ else if (action === 'chromeOn') resp = await api('POST', '/api/chrome', { action: 'on' })
220
+ else if (action === 'chromeOff') resp = await api('POST', '/api/chrome', { action: 'off' })
221
+ if (resp.code === 200) setMsg(resp.message, false)
222
+ else setMsg(resp.message, true)
223
+ } catch (err) {
224
+ setMsg(err.message || String(err), true)
225
+ } finally {
226
+ acting = null
227
+ btn.classList.remove('loading')
228
+ await refreshStatus()
229
+ }
230
+ }
231
+
232
+ async function onDebug() {
233
+ $('debugBtn').classList.add('loading')
234
+ $('debugBtn').disabled = true
235
+ try {
236
+ const resp = await api('POST', '/api/debug')
237
+ const checks = resp.data ?? []
238
+ $('debugList').innerHTML = checks.map((c) => {
239
+ const mark = c.ok === null ? '—' : (c.ok ? '✓' : '✗')
240
+ const cls = c.ok === null ? 'skipped' : (c.ok ? 'ok' : 'fail')
241
+ return `<div class="debug-row"><span class="debug-mark ${cls}">${mark}</span>` +
242
+ `<span class="debug-name">${esc(c.name)}</span>` +
243
+ (c.detail ? `<span class="debug-detail">${esc(c.detail)}</span>` : '') + `</div>`
244
+ }).join('')
245
+ await refreshStatus()
246
+ } catch (err) {
247
+ setMsg(err.message || String(err), true)
248
+ } finally {
249
+ $('debugBtn').classList.remove('loading')
250
+ $('debugBtn').disabled = false
251
+ }
252
+ }
253
+
254
+ async function loadConfig() {
255
+ try {
256
+ const resp = await api('GET', '/api/config')
257
+ if (resp.code === 200 && resp.data && resp.data.web) {
258
+ pollIntervalMs = resp.data.web.pollIntervalMs ?? 3000
259
+ }
260
+ } catch (err) {
261
+ setMsg(err.message || String(err), true)
262
+ }
263
+ }
264
+
265
+ let autoTimer = null
266
+ function startAutoRefresh() {
267
+ stopAutoRefresh()
268
+ autoTimer = setInterval(() => {
269
+ api('GET', '/api/status').then((resp) => {
270
+ if (resp.code === 200) {
271
+ status = resp.data
272
+ renderStatus()
273
+ renderButtons()
274
+ }
275
+ }).catch(() => {})
276
+ }, pollIntervalMs)
277
+ }
278
+ function stopAutoRefresh() {
279
+ if (autoTimer) { clearInterval(autoTimer); autoTimer = null }
280
+ }
281
+ document.addEventListener('visibilitychange', () => {
282
+ if (document.hidden) stopAutoRefresh()
283
+ else startAutoRefresh()
284
+ })
285
+
286
+ $('refreshBtn').addEventListener('click', refreshStatus)
287
+ $('tunnelOnBtn').addEventListener('click', () => doAction('tunnelOn', 'tunnelOnBtn'))
288
+ $('tunnelOffBtn').addEventListener('click', () => doAction('tunnelOff', 'tunnelOffBtn'))
289
+ $('proxyOnBtn').addEventListener('click', () => doAction('proxyOn', 'proxyOnBtn'))
290
+ $('proxyOffBtn').addEventListener('click', () => doAction('proxyOff', 'proxyOffBtn'))
291
+ $('chromeOnBtn').addEventListener('click', () => doAction('chromeOn', 'chromeOnBtn'))
292
+ $('chromeOffBtn').addEventListener('click', () => doAction('chromeOff', 'chromeOffBtn'))
293
+ $('startBtn').addEventListener('click', () => doAction('start', 'startBtn'))
294
+ $('stopBtn').addEventListener('click', () => doAction('stop', 'stopBtn'))
295
+ $('debugBtn').addEventListener('click', onDebug)
296
+
297
+ refreshStatus()
298
+ loadConfig()
299
+ startAutoRefresh()
300
+ </script>
301
+ </body>
302
+ </html>
@@ -0,0 +1,126 @@
1
+ import fs from 'node:fs'
2
+ import http from 'node:http'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ import { ProxyManager } from '../ProxyManager.js'
7
+ import { getResolvedStaticConfig, loadConfig } from '../config.js'
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
10
+ const PUBLIC_DIR = path.join(__dirname, 'public')
11
+ const MIME = {
12
+ '.html': 'text/html; charset=utf-8',
13
+ '.js': 'text/javascript; charset=utf-8',
14
+ '.css': 'text/css; charset=utf-8',
15
+ '.json': 'application/json; charset=utf-8',
16
+ '.svg': 'image/svg+xml',
17
+ '.png': 'image/png',
18
+ '.ico': 'image/x-icon',
19
+ }
20
+
21
+ function json(res, statusCode, data) {
22
+ const body = JSON.stringify(data)
23
+ res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' })
24
+ res.end(body)
25
+ }
26
+
27
+ function sendFile(res, filePath) {
28
+ const ext = path.extname(filePath).toLowerCase()
29
+ res.writeHead(200, { 'Content-Type': MIME[ext] ?? 'application/octet-stream' })
30
+ fs.createReadStream(filePath).pipe(res)
31
+ }
32
+
33
+ function readBody(req) {
34
+ return new Promise((resolve) => {
35
+ let data = ''
36
+ req.on('data', (chunk) => { data += chunk })
37
+ req.on('end', () => {
38
+ if (!data) return resolve({})
39
+ try {
40
+ resolve(JSON.parse(data))
41
+ } catch {
42
+ resolve({})
43
+ }
44
+ })
45
+ })
46
+ }
47
+
48
+ export async function startServer({ host, port } = {}) {
49
+ const cfg = loadConfig().web ?? {}
50
+ const listenHost = host ?? cfg.host ?? '127.0.0.1'
51
+ const listenPort = port ?? cfg.port ?? 1081
52
+ const manager = new ProxyManager()
53
+
54
+ const server = http.createServer(async (req, res) => {
55
+ const url = new URL(req.url, `http://${req.headers.host}`)
56
+ const pathname = url.pathname
57
+
58
+ try {
59
+ if (req.method === 'GET' && pathname === '/api/status') {
60
+ return json(res, 200, { code: 200, data: manager.getStatus() })
61
+ }
62
+ if (req.method === 'GET' && pathname === '/api/config') {
63
+ return json(res, 200, { code: 200, data: getResolvedStaticConfig() })
64
+ }
65
+ if (req.method === 'POST' && pathname === '/api/tunnel') {
66
+ const body = await readBody(req)
67
+ const result = body.action === 'off' ? await manager.tunnelStop() : await manager.tunnelStart()
68
+ return json(res, 200, { code: result.ok ? 200 : 500, message: result.message })
69
+ }
70
+ if (req.method === 'POST' && pathname === '/api/proxy') {
71
+ const body = await readBody(req)
72
+ const result = body.action === 'off' ? await manager.proxyOff() : await manager.proxyOn()
73
+ return json(res, 200, { code: result.ok ? 200 : 500, message: result.message })
74
+ }
75
+ if (req.method === 'POST' && pathname === '/api/chrome') {
76
+ const body = await readBody(req)
77
+ const result = body.action === 'off' ? await manager.chromeStop() : await manager.chromeStart()
78
+ return json(res, 200, { code: result.ok ? 200 : 500, message: result.message, pid: result.pid })
79
+ }
80
+ if (req.method === 'POST' && pathname === '/api/start') {
81
+ const result = await manager.start()
82
+ if (!result.ok) return json(res, 200, { code: 500, message: result.message })
83
+ return json(res, 200, { code: 200, message: result.message })
84
+ }
85
+ if (req.method === 'POST' && pathname === '/api/stop') {
86
+ const result = await manager.stop()
87
+ return json(res, 200, { code: 200, message: result.message })
88
+ }
89
+ if (req.method === 'POST' && pathname === '/api/debug') {
90
+ const data = await manager.debug()
91
+ return json(res, 200, { code: 200, data })
92
+ }
93
+
94
+ if (req.method === 'GET' && (pathname === '/' || pathname === '/index.html')) {
95
+ return sendFile(res, path.join(PUBLIC_DIR, 'index.html'))
96
+ }
97
+ if (req.method === 'GET' && pathname.startsWith('/assets/')) {
98
+ const safe = path.normalize(pathname.replace(/^\/+/, ''))
99
+ const filePath = path.join(PUBLIC_DIR, safe)
100
+ if (fs.existsSync(filePath) && filePath.startsWith(PUBLIC_DIR)) {
101
+ return sendFile(res, filePath)
102
+ }
103
+ }
104
+
105
+ json(res, 404, { code: 404, message: 'Not Found' })
106
+ } catch (err) {
107
+ json(res, 500, { code: 500, message: String(err?.message ?? err) })
108
+ }
109
+ })
110
+
111
+ await new Promise((resolve, reject) => {
112
+ server.once('error', reject)
113
+ server.listen(listenPort, listenHost, () => resolve())
114
+ })
115
+
116
+ console.log(`sshproxy web 管理页: http://${listenHost}:${listenPort}`)
117
+ return server
118
+ }
119
+
120
+ const isMain = process.argv[1] && process.argv[1].endsWith('server.js')
121
+ if (isMain) {
122
+ startServer().catch((err) => {
123
+ console.error(err?.message ?? err)
124
+ process.exit(1)
125
+ })
126
+ }