@linxin666/dsh-ssh 0.1.14 → 0.1.16

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,125 @@
1
+ /**
2
+ * Local port-forward tunnels: one loopback-only listener per tunnel, pinned
3
+ * to a pooled connection so an idle sweep never closes it, with per-tunnel
4
+ * socket tracking and stop (single / alias-scoped / all).
5
+ */
6
+
7
+ import { createServer, type Server as NetServer, type Socket } from 'node:net'
8
+ import type { TunnelInfo } from '../protocol.ts'
9
+ import { acquire, disposeRecord, type PoolEngine } from './connection-pool.ts'
10
+
11
+ /** One active tunnel record (server + pinned client + live sockets). */
12
+ export interface TunnelRecord {
13
+ info: TunnelInfo
14
+ server: NetServer
15
+ alias: string
16
+ sockets: Set<Socket>
17
+ }
18
+
19
+ /** The engine slice the tunnel module needs (adds the tunnel registry). */
20
+ export interface TunnelEngine extends PoolEngine {
21
+ readonly tunnels: Map<string, TunnelRecord>
22
+ nextTunnelId: number
23
+ }
24
+
25
+ /** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
26
+ export async function startTunnel(
27
+ engine: TunnelEngine,
28
+ alias: string,
29
+ options: { remotePort: number; remoteHost?: string; localPort?: number },
30
+ ): Promise<TunnelInfo> {
31
+ if (!Number.isInteger(options.remotePort) || options.remotePort < 1 || options.remotePort > 65535) {
32
+ throw new Error('remotePort must be an integer in 1..65535')
33
+ }
34
+ if (options.localPort !== undefined && (!Number.isInteger(options.localPort) || options.localPort < 1 || options.localPort > 65535)) {
35
+ throw new Error('localPort must be an integer in 1..65535')
36
+ }
37
+ const entry = engine.store.find(alias)
38
+ if (entry === undefined) throw new Error('alias \'' + alias + '\' not found — add it first')
39
+ const remoteHost = options.remoteHost ?? '127.0.0.1'
40
+ const id = 'tun-' + engine.nextTunnelId
41
+ engine.nextTunnelId += 1
42
+ const info: TunnelInfo = {
43
+ id,
44
+ alias,
45
+ localPort: 0,
46
+ remoteHost,
47
+ remotePort: options.remotePort,
48
+ state: 'connecting',
49
+ startedAt: Date.now(),
50
+ }
51
+ const record = await acquire(engine, alias)
52
+ const client = record.client
53
+ const sockets = new Set<Socket>()
54
+ const server = createServer((socket) => {
55
+ sockets.add(socket)
56
+ socket.on('close', () => { sockets.delete(socket) })
57
+ client.forwardOut('127.0.0.1', 0, remoteHost, options.remotePort, (error, stream) => {
58
+ if (error !== undefined) {
59
+ socket.destroy()
60
+ return
61
+ }
62
+ // Both ends of the pipe can die independently; destroy the pair so an
63
+ // unhandled 'error' event can never crash the host process.
64
+ const destroy = (): void => {
65
+ try { socket.destroy() } catch { /* gone */ }
66
+ try { stream.close() } catch { /* gone */ }
67
+ }
68
+ stream.on('error', destroy)
69
+ socket.on('error', destroy)
70
+ stream.on('close', destroy)
71
+ socket.on('close', destroy)
72
+ stream.pipe(socket).pipe(stream)
73
+ })
74
+ })
75
+ try {
76
+ await new Promise<void>((resolve, reject) => {
77
+ server.once('error', reject)
78
+ server.listen(options.localPort ?? 0, '127.0.0.1', () => {
79
+ server.removeListener('error', reject)
80
+ resolve()
81
+ })
82
+ })
83
+ } catch (error) {
84
+ // Roll back: never leave an unpinned orphan connection behind.
85
+ if (!record.pinned && record.inFlight === 0) disposeRecord(engine, alias, record)
86
+ throw error
87
+ }
88
+ record.pinned = true
89
+ const address = server.address()
90
+ info.localPort = typeof address === 'object' && address !== null ? address.port : 0
91
+ info.state = 'forwarding'
92
+ engine.tunnels.set(id, { info, server, alias, sockets })
93
+ return info
94
+ }
95
+
96
+ /** All active tunnels. */
97
+ export function listTunnels(engine: TunnelEngine): TunnelInfo[] {
98
+ return [...engine.tunnels.values()].map(tunnel => ({ ...tunnel.info }))
99
+ }
100
+
101
+ /** Stop one tunnel (closes the listener, live sockets, and the pinned connection). */
102
+ export function stopTunnel(engine: TunnelEngine, id: string): boolean {
103
+ const tunnel = engine.tunnels.get(id)
104
+ if (tunnel === undefined) return false
105
+ engine.tunnels.delete(id)
106
+ try { tunnel.server.close() } catch { /* already closed */ }
107
+ for (const socket of tunnel.sockets) {
108
+ try { socket.destroy() } catch { /* already closed */ }
109
+ }
110
+ tunnel.sockets.clear()
111
+ disposeRecord(engine, tunnel.alias)
112
+ return true
113
+ }
114
+
115
+ /** Stop all tunnels (optionally for one alias). */
116
+ export function stopAllTunnels(engine: TunnelEngine, alias?: string): number {
117
+ let count = 0
118
+ for (const [id, tunnel] of [...engine.tunnels]) {
119
+ if (alias === undefined || tunnel.alias === alias) {
120
+ stopTunnel(engine, id)
121
+ count += 1
122
+ }
123
+ }
124
+ return count
125
+ }