@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.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/bin/index.js +171 -0
- package/package.json +51 -0
- package/scripts/postinstall.js +18 -0
- package/src/OSystem.js +131 -0
- package/src/ProxyManager.js +435 -0
- package/src/SshClient.js +132 -0
- package/src/config.js +104 -0
- package/src/config.json5 +15 -0
- package/src/contains.json5 +33 -0
- package/src/index.js +4 -0
- package/src/init.js +141 -0
- package/src/web/public/index.html +302 -0
- package/src/web/server.js +126 -0
|
@@ -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
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
}
|