@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,435 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import net from 'node:net'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
5
|
+
|
|
6
|
+
import { execCapture, getChromePath, getSocksProxyStatus, killProcess, setSocksProxy, unsetSocksProxy, which } from './OSystem.js'
|
|
7
|
+
import { clientAddr, getProxyConfig, getResolvedStaticConfig, sshAlias } from './config.js'
|
|
8
|
+
import { initClient } from './init.js'
|
|
9
|
+
|
|
10
|
+
const _chromePidFile = () => getResolvedStaticConfig().paths.chromePidFile
|
|
11
|
+
const _webPidFile = () => getResolvedStaticConfig().paths.webPidFile
|
|
12
|
+
const _pidFile = () => getResolvedStaticConfig().paths.pidFile
|
|
13
|
+
const _sshDir = () => getResolvedStaticConfig().paths.sshDir
|
|
14
|
+
const _sshConfigFile = () => getResolvedStaticConfig().paths.sshConfigFile
|
|
15
|
+
const _keyCandidates = () => getResolvedStaticConfig().ssh.keyCandidates
|
|
16
|
+
const _autosshArgs = () => getResolvedStaticConfig().tunnel.autosshArgs
|
|
17
|
+
const _curlUrl = () => getResolvedStaticConfig().debug.curlUrl
|
|
18
|
+
const _curlTimeoutMs = () => getResolvedStaticConfig().debug.curlTimeoutMs
|
|
19
|
+
|
|
20
|
+
const _lock = (() => {
|
|
21
|
+
let chain = Promise.resolve()
|
|
22
|
+
return {
|
|
23
|
+
async run(fn) {
|
|
24
|
+
let release
|
|
25
|
+
const gate = new Promise((resolve) => { release = resolve })
|
|
26
|
+
const prev = chain
|
|
27
|
+
chain = chain.then(() => gate)
|
|
28
|
+
await prev
|
|
29
|
+
try {
|
|
30
|
+
return await fn()
|
|
31
|
+
} finally {
|
|
32
|
+
release()
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
})()
|
|
37
|
+
|
|
38
|
+
export class ProxyManager {
|
|
39
|
+
config() {
|
|
40
|
+
const cfg = getProxyConfig()
|
|
41
|
+
const [host, port] = clientAddr(cfg)
|
|
42
|
+
return { host, port }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getStatus() {
|
|
46
|
+
const cfg = this.config()
|
|
47
|
+
const pid = this._readPid()
|
|
48
|
+
const tunnel = { running: pid != null && this._isAlive(pid), pid }
|
|
49
|
+
const sys = getSocksProxyStatus()
|
|
50
|
+
return {
|
|
51
|
+
host: cfg.host,
|
|
52
|
+
port: cfg.port,
|
|
53
|
+
tunnel,
|
|
54
|
+
system_proxy: { enabled: sys.enabled, detail: sys.detail },
|
|
55
|
+
chrome: this.chromeStatus(),
|
|
56
|
+
web: this.webStatus(),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async tunnelStart() {
|
|
61
|
+
return _lock.run(() => this._doTunnelStart())
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async tunnelStop() {
|
|
65
|
+
return _lock.run(() => this._doTunnelStop())
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async proxyOn() {
|
|
69
|
+
return _lock.run(() => this._doProxyOn())
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async proxyOff() {
|
|
73
|
+
return _lock.run(() => this._doProxyOff())
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async start() {
|
|
77
|
+
return _lock.run(async () => {
|
|
78
|
+
const tunnel = await this._doTunnelStart()
|
|
79
|
+
if (!tunnel.ok) return tunnel
|
|
80
|
+
const proxy = await this._doProxyOn()
|
|
81
|
+
if (!proxy.ok) return proxy
|
|
82
|
+
return { ok: true, message: '代理已全部开启(隧道 + 系统代理)' }
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async stop() {
|
|
87
|
+
return _lock.run(() => {
|
|
88
|
+
const result = { ok: true, message: '' }
|
|
89
|
+
const tunnel = this._doTunnelStop()
|
|
90
|
+
if (tunnel.message) result.message += tunnel.message + ' '
|
|
91
|
+
const sys = getSocksProxyStatus()
|
|
92
|
+
if (sys.enabled) {
|
|
93
|
+
unsetSocksProxy()
|
|
94
|
+
result.message += '系统代理已关闭 '
|
|
95
|
+
}
|
|
96
|
+
const chromeMsg = this._stopChrome().message
|
|
97
|
+
if (chromeMsg) result.message += chromeMsg
|
|
98
|
+
if (!result.message.trim()) result.message = '代理未在运行'
|
|
99
|
+
return result
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------- 原子内部实现(不含锁,供组合命令复用) ----------
|
|
104
|
+
|
|
105
|
+
async _doTunnelStart() {
|
|
106
|
+
const cfg = this.config()
|
|
107
|
+
const ok = await this._startTunnel(cfg.host, cfg.port)
|
|
108
|
+
if (!ok) return { ok: false, message: '隧道启动失败' }
|
|
109
|
+
return { ok: true, message: `隧道已启动 (SOCKS5 ${cfg.host}:${cfg.port})` }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_doTunnelStop() {
|
|
113
|
+
const result = { ok: true, message: '' }
|
|
114
|
+
const pid = this._readPid()
|
|
115
|
+
if (pid && this._isAlive(pid)) {
|
|
116
|
+
try {
|
|
117
|
+
process.kill(pid, 'SIGTERM')
|
|
118
|
+
result.message = `隧道已停止 (PID=${pid})`
|
|
119
|
+
} catch {
|
|
120
|
+
result.message = '隧道停止失败'
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
this._cleanupPid()
|
|
124
|
+
if (!result.message) result.message = '隧道未在运行'
|
|
125
|
+
return result
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async _doProxyOn() {
|
|
129
|
+
const cfg = this.config()
|
|
130
|
+
if (!this._isProxyRunning()) {
|
|
131
|
+
const ok = await this._startTunnel(cfg.host, cfg.port)
|
|
132
|
+
if (!ok) return { ok: false, message: '隧道未运行,代理未开启' }
|
|
133
|
+
}
|
|
134
|
+
setSocksProxy(cfg.host, cfg.port)
|
|
135
|
+
return { ok: true, message: `系统代理已开启 (${cfg.host}:${cfg.port})` }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_doProxyOff() {
|
|
139
|
+
const sys = getSocksProxyStatus()
|
|
140
|
+
if (!sys.enabled) return { ok: false, message: '系统代理未开启' }
|
|
141
|
+
unsetSocksProxy()
|
|
142
|
+
return { ok: true, message: '系统代理已关闭' }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async debug() {
|
|
146
|
+
const cfg = this.config()
|
|
147
|
+
const host = cfg.host
|
|
148
|
+
const port = cfg.port
|
|
149
|
+
const alias = sshAlias(port)
|
|
150
|
+
const checks = [
|
|
151
|
+
{ name: 'autossh 已安装', fn: () => this._checkAutossh() },
|
|
152
|
+
{ name: `SSH config 有 ${alias}`, fn: () => this._checkSshConfig(alias) },
|
|
153
|
+
{ name: 'SSH 密钥存在', fn: () => this._checkSshKey() },
|
|
154
|
+
{ name: '代理进程在运行', fn: () => this._checkProxyRunning() },
|
|
155
|
+
{ name: `${port} 端口在监听`, fn: () => this._checkPort(port) },
|
|
156
|
+
{ name: '网络连通性测试', fn: () => this._checkConnectivity(host, port) },
|
|
157
|
+
]
|
|
158
|
+
const results = []
|
|
159
|
+
let criticalOk = true
|
|
160
|
+
for (const { name, fn } of checks) {
|
|
161
|
+
if (!criticalOk && name === '网络连通性测试') {
|
|
162
|
+
results.push({ name, ok: null, detail: '前置检查未通过,跳过连通性测试' })
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
const [ok, detail] = await fn()
|
|
166
|
+
results.push({ name, ok: Boolean(ok), detail })
|
|
167
|
+
if (!ok) criticalOk = false
|
|
168
|
+
}
|
|
169
|
+
return results
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
chromeStatus() {
|
|
173
|
+
const pid = this._readPid(_chromePidFile())
|
|
174
|
+
if (pid && this._isAlive(pid)) {
|
|
175
|
+
return { running: true, pid }
|
|
176
|
+
}
|
|
177
|
+
const { host, port } = this.config()
|
|
178
|
+
const pids = this._findPgrepPids(`chrome.*--proxy-server=socks5://${host}:${port}`)
|
|
179
|
+
if (pids.length > 0) {
|
|
180
|
+
return { running: true, pid: pids[0] }
|
|
181
|
+
}
|
|
182
|
+
this._cleanupPid(_chromePidFile())
|
|
183
|
+
return { running: false, pid: null }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async chromeStart() {
|
|
187
|
+
return _lock.run(async () => {
|
|
188
|
+
const cfg = this.config()
|
|
189
|
+
if (!this._isProxyRunning()) {
|
|
190
|
+
const ok = await this._startTunnel(cfg.host, cfg.port)
|
|
191
|
+
if (!ok) {
|
|
192
|
+
return { ok: false, message: '代理隧道启动失败' }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const chromePath = getChromePath()
|
|
196
|
+
if (!chromePath) {
|
|
197
|
+
return { ok: false, message: '未找到 Chrome,请确认安装路径' }
|
|
198
|
+
}
|
|
199
|
+
killProcess('Google Chrome')
|
|
200
|
+
const cmd = [
|
|
201
|
+
chromePath,
|
|
202
|
+
`--proxy-server=socks5://${cfg.host}:${cfg.port}`,
|
|
203
|
+
'--disable-features=SecureDns',
|
|
204
|
+
]
|
|
205
|
+
const proc = this._spawnDetached(cmd[0], cmd.slice(1))
|
|
206
|
+
this._writePid(proc.pid ?? 0, _chromePidFile())
|
|
207
|
+
return { ok: true, message: 'Chrome 已以代理模式启动', pid: proc.pid }
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
_spawnDetached(command, args) {
|
|
212
|
+
const proc = spawn(command, args, {
|
|
213
|
+
stdio: 'ignore',
|
|
214
|
+
detached: true,
|
|
215
|
+
})
|
|
216
|
+
proc.unref()
|
|
217
|
+
return proc
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async chromeStop() {
|
|
221
|
+
return _lock.run(() => this._stopChrome())
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
_stopChrome() {
|
|
225
|
+
const result = { ok: true, message: '' }
|
|
226
|
+
const pid = this._readPid(_chromePidFile())
|
|
227
|
+
let killed = false
|
|
228
|
+
if (pid && this._isAlive(pid)) {
|
|
229
|
+
try {
|
|
230
|
+
process.kill(pid, 'SIGTERM')
|
|
231
|
+
killed = true
|
|
232
|
+
} catch {
|
|
233
|
+
/* ignore */
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const { host, port } = this.config()
|
|
237
|
+
for (const p of this._findPgrepPids(`chrome.*--proxy-server=socks5://${host}:${port}`)) {
|
|
238
|
+
if (p === pid) continue
|
|
239
|
+
try {
|
|
240
|
+
process.kill(p, 'SIGTERM')
|
|
241
|
+
killed = true
|
|
242
|
+
} catch {
|
|
243
|
+
/* ignore */
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
this._cleanupPid(_chromePidFile())
|
|
247
|
+
result.message = killed ? '代理 Chrome 已关闭' : '代理 Chrome 未在运行'
|
|
248
|
+
return result
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------- Web 管理页 ----------
|
|
252
|
+
|
|
253
|
+
_webServerPath() {
|
|
254
|
+
return path.join(path.dirname(new URL(import.meta.url).pathname), 'web', 'server.js')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async webStart() {
|
|
258
|
+
return _lock.run(() => {
|
|
259
|
+
const pid = this._readPid(_webPidFile())
|
|
260
|
+
if (pid && this._isAlive(pid)) {
|
|
261
|
+
return { ok: false, message: 'Web 管理页已在运行' }
|
|
262
|
+
}
|
|
263
|
+
const proc = spawn(process.execPath, [this._webServerPath()], {
|
|
264
|
+
stdio: 'ignore',
|
|
265
|
+
detached: true,
|
|
266
|
+
env: { ...process.env },
|
|
267
|
+
})
|
|
268
|
+
proc.unref()
|
|
269
|
+
this._writePid(proc.pid ?? 0, _webPidFile())
|
|
270
|
+
return { ok: true, message: 'Web 管理页已启动(后台)', pid: proc.pid }
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async webStop() {
|
|
275
|
+
return _lock.run(() => {
|
|
276
|
+
const result = { ok: true, message: '' }
|
|
277
|
+
const pid = this._readPid(_webPidFile())
|
|
278
|
+
if (pid && this._isAlive(pid)) {
|
|
279
|
+
try {
|
|
280
|
+
process.kill(pid, 'SIGTERM')
|
|
281
|
+
result.message = 'Web 管理页已关闭'
|
|
282
|
+
} catch {
|
|
283
|
+
result.message = 'Web 管理页关闭失败'
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
this._cleanupPid(_webPidFile())
|
|
287
|
+
if (!result.message) result.message = 'Web 管理页未在运行'
|
|
288
|
+
return result
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
webStatus() {
|
|
293
|
+
const pid = this._readPid(_webPidFile())
|
|
294
|
+
if (pid && this._isAlive(pid)) {
|
|
295
|
+
return { running: true, pid }
|
|
296
|
+
}
|
|
297
|
+
this._cleanupPid(_webPidFile())
|
|
298
|
+
return { running: false, pid: null }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------- 隧道 ----------
|
|
302
|
+
|
|
303
|
+
async _startTunnel(host, port) {
|
|
304
|
+
const pid = this._readPid()
|
|
305
|
+
if (pid && this._isAlive(pid)) return true
|
|
306
|
+
if (await this._isPortBusy(port)) return false
|
|
307
|
+
const initLines = await initClient()
|
|
308
|
+
if (initLines.some((l) => l.includes('✗'))) return false
|
|
309
|
+
const alias = sshAlias(port)
|
|
310
|
+
const proc = spawn('autossh', [..._autosshArgs(), alias], {
|
|
311
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
312
|
+
})
|
|
313
|
+
const stderr = []
|
|
314
|
+
proc.stderr.on('data', (chunk) => stderr.push(String(chunk)))
|
|
315
|
+
const code = await new Promise((resolve) => {
|
|
316
|
+
proc.on('error', () => resolve(1))
|
|
317
|
+
proc.on('exit', (c) => resolve(c ?? 1))
|
|
318
|
+
})
|
|
319
|
+
if (code !== 0) return false
|
|
320
|
+
const proxyPid = this._findProxyPid(port)
|
|
321
|
+
if (!proxyPid) return false
|
|
322
|
+
this._writePid(proxyPid)
|
|
323
|
+
return true
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
_isProxyRunning() {
|
|
327
|
+
const pid = this._readPid()
|
|
328
|
+
return pid != null && this._isAlive(pid)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------- 检查原语 ----------
|
|
332
|
+
|
|
333
|
+
_checkAutossh() {
|
|
334
|
+
const autossh = which('autossh')
|
|
335
|
+
if (autossh) {
|
|
336
|
+
return [true, autossh]
|
|
337
|
+
}
|
|
338
|
+
return [false, '未安装,请执行: brew install autossh']
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
_checkSshConfig(alias) {
|
|
342
|
+
const configPath = _sshConfigFile()
|
|
343
|
+
if (!fs.existsSync(configPath)) return [false, `${configPath} 不存在`]
|
|
344
|
+
const content = fs.readFileSync(configPath, 'utf-8')
|
|
345
|
+
if (!content.includes(`Host ${alias}`)) {
|
|
346
|
+
return [false, `缺少 ${alias} 配置,请执行 sshproxy tunnel on`]
|
|
347
|
+
}
|
|
348
|
+
return [true, '']
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
_checkSshKey() {
|
|
352
|
+
const sshDir = _sshDir()
|
|
353
|
+
for (const name of _keyCandidates()) {
|
|
354
|
+
if (fs.existsSync(path.join(sshDir, name))) return [true, '']
|
|
355
|
+
}
|
|
356
|
+
return [false, `${sshDir}/ 下无密钥文件,请执行: ssh-keygen -t ed25519`]
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
_checkProxyRunning() {
|
|
360
|
+
const pid = this._readPid()
|
|
361
|
+
if (pid && this._isAlive(pid)) return [true, `PID=${pid}`]
|
|
362
|
+
return [false, '代理未运行,请执行: sshproxy tunnel on']
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async _checkPort(port) {
|
|
366
|
+
if (await this._isPortBusy(port)) return [true, `127.0.0.1:${port}`]
|
|
367
|
+
return [false, `${port} 端口未监听,代理可能未正确启动`]
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async _checkConnectivity(host, port) {
|
|
371
|
+
const result = await execCapture('curl', ['--socks5-hostname', `${host}:${port}`, _curlUrl(), '-I'], { timeout: _curlTimeoutMs() })
|
|
372
|
+
if (result.code === 0) return [true, '']
|
|
373
|
+
return [false, `请求失败 (exit=${result.code})`]
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---------- PID 文件 ----------
|
|
377
|
+
|
|
378
|
+
_readPid(filePath = _pidFile()) {
|
|
379
|
+
try {
|
|
380
|
+
return parseInt(fs.readFileSync(filePath, 'utf-8').trim(), 10)
|
|
381
|
+
} catch {
|
|
382
|
+
return null
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
_writePid(pid, filePath = _pidFile()) {
|
|
387
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
388
|
+
fs.writeFileSync(filePath, String(pid))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
_cleanupPid(filePath = _pidFile()) {
|
|
392
|
+
try {
|
|
393
|
+
fs.unlinkSync(filePath)
|
|
394
|
+
} catch {
|
|
395
|
+
/* ignore */
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
_isAlive(pid) {
|
|
400
|
+
try {
|
|
401
|
+
process.kill(pid, 0)
|
|
402
|
+
return true
|
|
403
|
+
} catch {
|
|
404
|
+
return false
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
_isPortBusy(port) {
|
|
409
|
+
return new Promise((resolve) => {
|
|
410
|
+
const s = net.createServer()
|
|
411
|
+
s.once('error', () => resolve(true))
|
|
412
|
+
s.once('listening', () => s.close(() => resolve(false)))
|
|
413
|
+
s.listen(port, '127.0.0.1')
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
_findProxyPid(port = null) {
|
|
418
|
+
const alias = port ? sshAlias(port) : 'client-proxy'
|
|
419
|
+
let pids = this._findPgrepPids(`autossh.*-N.*-f.*${alias}`)
|
|
420
|
+
if (pids.length === 0) pids = this._findPgrepPids(`ssh.*-N.*${alias}`)
|
|
421
|
+
return pids.length > 0 ? pids[0] : null
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
_findPgrepPids(pattern) {
|
|
425
|
+
const result = spawnSync('pgrep', ['-f', pattern])
|
|
426
|
+
if (result.status !== 0) return []
|
|
427
|
+
const pids = []
|
|
428
|
+
for (const line of String(result.stdout).trim().split('\n')) {
|
|
429
|
+
const pid = parseInt(line.trim(), 10)
|
|
430
|
+
if (Number.isNaN(pid)) continue
|
|
431
|
+
if (pid !== process.pid) pids.push(pid)
|
|
432
|
+
}
|
|
433
|
+
return pids
|
|
434
|
+
}
|
|
435
|
+
}
|
package/src/SshClient.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Client } from 'ssh2'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { loadConfig } from './config.js'
|
|
5
|
+
|
|
6
|
+
export class SshClient {
|
|
7
|
+
constructor() {
|
|
8
|
+
const cfg = loadConfig()
|
|
9
|
+
this.host = cfg.host ?? ''
|
|
10
|
+
this.port = Number(cfg.port ?? 22)
|
|
11
|
+
this.user = cfg.user ?? ''
|
|
12
|
+
this.password = cfg.password ?? ''
|
|
13
|
+
this.timeout = Number(cfg.timeout ?? 30)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
connect() {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const client = new Client()
|
|
19
|
+
const conn = {
|
|
20
|
+
host: this.host,
|
|
21
|
+
port: this.port,
|
|
22
|
+
username: this.user,
|
|
23
|
+
timeout: this.timeout * 1000,
|
|
24
|
+
readyTimeout: this.timeout * 1000,
|
|
25
|
+
}
|
|
26
|
+
if (this.password) conn.password = this.password
|
|
27
|
+
client.on('ready', () => resolve(client))
|
|
28
|
+
client.on('error', (err) => reject(err))
|
|
29
|
+
client.connect(conn)
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async checkConnection() {
|
|
34
|
+
try {
|
|
35
|
+
const client = await this.connect()
|
|
36
|
+
try {
|
|
37
|
+
await this.execOnClient(client, 'echo ok')
|
|
38
|
+
return true
|
|
39
|
+
} finally {
|
|
40
|
+
client.end()
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async execute(command) {
|
|
48
|
+
let client
|
|
49
|
+
try {
|
|
50
|
+
client = await this.connect()
|
|
51
|
+
const result = await this.execOnClient(client, command)
|
|
52
|
+
return [result.code, result.stdout, result.stderr]
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return [1, '', String(err)]
|
|
55
|
+
} finally {
|
|
56
|
+
if (client) client.end()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
execOnClient(client, command) {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
client.exec(command, (err, stream) => {
|
|
63
|
+
if (err) return reject(err)
|
|
64
|
+
let stdout = ''
|
|
65
|
+
let stderr = ''
|
|
66
|
+
stream.on('data', (chunk) => { stdout += String(chunk) })
|
|
67
|
+
stream.stderr.on('data', (chunk) => { stderr += String(chunk) })
|
|
68
|
+
stream.on('close', (code) => {
|
|
69
|
+
resolve({ code: code ?? 0, stdout, stderr })
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async upload(localPath, remotePath) {
|
|
76
|
+
let client
|
|
77
|
+
try {
|
|
78
|
+
client = await this.connect()
|
|
79
|
+
const sftp = await new Promise((resolve, reject) => {
|
|
80
|
+
client.sftp((err, sftp) => (err ? reject(err) : resolve(sftp)))
|
|
81
|
+
})
|
|
82
|
+
await this.mkdirs(sftp, path.posix.dirname(remotePath))
|
|
83
|
+
await new Promise((resolve, reject) => {
|
|
84
|
+
sftp.fastPut(localPath, remotePath, (err) => (err ? reject(err) : resolve()))
|
|
85
|
+
})
|
|
86
|
+
return true
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
} finally {
|
|
90
|
+
if (client) client.end()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async download(remotePath, localPath) {
|
|
95
|
+
let client
|
|
96
|
+
try {
|
|
97
|
+
client = await this.connect()
|
|
98
|
+
const sftp = await new Promise((resolve, reject) => {
|
|
99
|
+
client.sftp((err, sftp) => (err ? reject(err) : resolve(sftp)))
|
|
100
|
+
})
|
|
101
|
+
await new Promise((resolve, reject) => {
|
|
102
|
+
sftp.fastGet(remotePath, localPath, (err) => (err ? reject(err) : resolve()))
|
|
103
|
+
})
|
|
104
|
+
return true
|
|
105
|
+
} catch {
|
|
106
|
+
return false
|
|
107
|
+
} finally {
|
|
108
|
+
if (client) client.end()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
mkdirs(sftp, remoteDir) {
|
|
113
|
+
const dirs = remoteDir.split('/').filter(Boolean)
|
|
114
|
+
let p = ''
|
|
115
|
+
return dirs.reduce((chain, d) => {
|
|
116
|
+
p += `/${d}`
|
|
117
|
+
return chain.then(() => this.statOrMkdir(sftp, p))
|
|
118
|
+
}, Promise.resolve())
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
statOrMkdir(sftp, dirPath) {
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
sftp.stat(dirPath, (err) => {
|
|
124
|
+
if (err) {
|
|
125
|
+
sftp.mkdir(dirPath, (mkdirErr) => resolve(mkdirErr ? Promise.reject(mkdirErr) : resolve()))
|
|
126
|
+
} else {
|
|
127
|
+
resolve()
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import JSON5 from 'json5'
|
|
6
|
+
import dotenv from 'dotenv'
|
|
7
|
+
import { createConfigLoader, getXdgConfigDir } from '@wwkit/shared'
|
|
8
|
+
|
|
9
|
+
// ============================================================
|
|
10
|
+
// 业务配置(用户可调整;默认值存于 src/config.json5)
|
|
11
|
+
// ============================================================
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
dotenv.config({ path: path.join(__dirname, '..', '.env'), quiet: true })
|
|
15
|
+
const DEFAULT_CONFIG_PATH = path.join(__dirname, 'config.json5')
|
|
16
|
+
|
|
17
|
+
export function defaultConfig() {
|
|
18
|
+
const raw = fs.readFileSync(DEFAULT_CONFIG_PATH, 'utf-8')
|
|
19
|
+
return JSON5.parse(raw)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getConfigDir() {
|
|
23
|
+
const envDir = process.env.SSHPROXY_CONFIG_DIR
|
|
24
|
+
if (envDir) return envDir
|
|
25
|
+
return getXdgConfigDir('sshproxy')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getConfigPath() {
|
|
29
|
+
return path.join(getConfigDir(), 'config.json5')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const _loader = createConfigLoader({
|
|
33
|
+
builtinConfigPath: DEFAULT_CONFIG_PATH,
|
|
34
|
+
userConfigDir: getConfigDir(),
|
|
35
|
+
envContentVar: 'SSHPROXY_CONFIG_CONTENT',
|
|
36
|
+
envDirVar: 'SSHPROXY_CONFIG_DIR',
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export function loadConfig() {
|
|
40
|
+
return _loader.getConfig()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ============================================================
|
|
44
|
+
// 静态配置(系统内置常量,存于 src/contains.json5,用户不可改)
|
|
45
|
+
// ============================================================
|
|
46
|
+
|
|
47
|
+
const DEFAULT_STATIC_PATH = path.join(__dirname, 'contains.json5')
|
|
48
|
+
|
|
49
|
+
export function defaultStaticConfig() {
|
|
50
|
+
const raw = fs.readFileSync(DEFAULT_STATIC_PATH, 'utf-8')
|
|
51
|
+
return JSON5.parse(raw)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getStaticConfig() {
|
|
55
|
+
return defaultStaticConfig()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ============================================================
|
|
59
|
+
// 通用(由 @wwkit/shared 提供)
|
|
60
|
+
// ============================================================
|
|
61
|
+
|
|
62
|
+
export { deepMerge, resolveEnvVars } from '@wwkit/shared'
|
|
63
|
+
|
|
64
|
+
export function getProxyConfig() {
|
|
65
|
+
const cfg = loadConfig()
|
|
66
|
+
return cfg.proxy ?? cfg
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function clientAddr(cfg = getProxyConfig()) {
|
|
70
|
+
const c = cfg.socks5 ?? {}
|
|
71
|
+
return [c.host ?? '127.0.0.1', c.port ?? 1080]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function sshAlias(port) {
|
|
75
|
+
const s = getStaticConfig()
|
|
76
|
+
return `${s.ssh.aliasPrefix}-${port}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================================
|
|
80
|
+
// 静态配置派生(封装默认值与路径展开)
|
|
81
|
+
// ============================================================
|
|
82
|
+
|
|
83
|
+
function expandHome(p) {
|
|
84
|
+
return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getResolvedStaticConfig() {
|
|
88
|
+
const s = getStaticConfig()
|
|
89
|
+
return {
|
|
90
|
+
paths: {
|
|
91
|
+
stateDir: expandHome(s.paths.stateDir),
|
|
92
|
+
pidFile: path.join(expandHome(s.paths.stateDir), s.paths.pidFile),
|
|
93
|
+
chromePidFile: path.join(expandHome(s.paths.stateDir), s.paths.chromePidFile),
|
|
94
|
+
webPidFile: path.join(expandHome(s.paths.stateDir), s.paths.webPidFile),
|
|
95
|
+
sshDir: expandHome(s.paths.sshDir),
|
|
96
|
+
sshConfigFile: path.join(expandHome(s.paths.sshDir), s.paths.sshConfigFile),
|
|
97
|
+
},
|
|
98
|
+
ssh: { ...s.ssh },
|
|
99
|
+
tunnel: { ...s.tunnel },
|
|
100
|
+
os: { ...s.os },
|
|
101
|
+
debug: { ...s.debug },
|
|
102
|
+
web: { ...s.web },
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/config.json5
ADDED