219 lines
6 KiB
TypeScript
219 lines
6 KiB
TypeScript
|
|
import net from 'node:net'
|
||
|
|
import type { Client } from 'ssh2'
|
||
|
|
import { db } from '../db/index.js'
|
||
|
|
import { loadSshHost, connectTarget } from '../ssh/connect.js'
|
||
|
|
import { readSocks5Target, sendSocks5Success, sendSocks5Failure } from './socks5.js'
|
||
|
|
|
||
|
|
export type TunnelMode = 'local' | 'remote' | 'dynamic'
|
||
|
|
export type TunnelStatus = 'stopped' | 'connecting' | 'connected' | 'retrying' | 'error'
|
||
|
|
|
||
|
|
export interface TunnelRow {
|
||
|
|
id: number
|
||
|
|
name: string
|
||
|
|
integration_id: number
|
||
|
|
mode: TunnelMode
|
||
|
|
source_port: number
|
||
|
|
endpoint_host: string
|
||
|
|
endpoint_port: number
|
||
|
|
auto_start: number
|
||
|
|
max_retries: number
|
||
|
|
retry_interval_ms: number
|
||
|
|
created_at: string
|
||
|
|
}
|
||
|
|
|
||
|
|
interface RuntimeState {
|
||
|
|
status: TunnelStatus
|
||
|
|
error: string | null
|
||
|
|
retryCount: number
|
||
|
|
client: Client | null
|
||
|
|
jumpConn: Client | null
|
||
|
|
server: net.Server | null
|
||
|
|
retryTimer: NodeJS.Timeout | null
|
||
|
|
stopRequested: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
const runtimes = new Map<number, RuntimeState>()
|
||
|
|
|
||
|
|
function emptyState(): RuntimeState {
|
||
|
|
return {
|
||
|
|
status: 'stopped',
|
||
|
|
error: null,
|
||
|
|
retryCount: 0,
|
||
|
|
client: null,
|
||
|
|
jumpConn: null,
|
||
|
|
server: null,
|
||
|
|
retryTimer: null,
|
||
|
|
stopRequested: false,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function getState(id: number): RuntimeState {
|
||
|
|
let state = runtimes.get(id)
|
||
|
|
if (!state) {
|
||
|
|
state = emptyState()
|
||
|
|
runtimes.set(id, state)
|
||
|
|
}
|
||
|
|
return state
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getTunnelRow(id: number): TunnelRow | null {
|
||
|
|
return (db.prepare('SELECT * FROM tunnels WHERE id = ?').get(id) as TunnelRow | undefined) ?? null
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getStatus(id: number) {
|
||
|
|
const state = getState(id)
|
||
|
|
return { status: state.status, error: state.error, retryCount: state.retryCount }
|
||
|
|
}
|
||
|
|
|
||
|
|
function teardownNetwork(state: RuntimeState) {
|
||
|
|
state.server?.close()
|
||
|
|
state.client?.end()
|
||
|
|
state.jumpConn?.end()
|
||
|
|
state.server = null
|
||
|
|
state.client = null
|
||
|
|
state.jumpConn = null
|
||
|
|
}
|
||
|
|
|
||
|
|
function scheduleRetry(id: number, tunnel: TunnelRow, state: RuntimeState) {
|
||
|
|
if (state.stopRequested) return
|
||
|
|
if (state.retryCount >= tunnel.max_retries) {
|
||
|
|
state.status = 'error'
|
||
|
|
return
|
||
|
|
}
|
||
|
|
state.status = 'retrying'
|
||
|
|
state.retryCount += 1
|
||
|
|
state.retryTimer = setTimeout(() => startTunnel(id), tunnel.retry_interval_ms)
|
||
|
|
}
|
||
|
|
|
||
|
|
function bindLocalForward(client: Client, tunnel: TunnelRow, state: RuntimeState, onFail: (message: string) => void) {
|
||
|
|
const server = net.createServer((socket) => {
|
||
|
|
client.forwardOut(
|
||
|
|
socket.remoteAddress ?? '127.0.0.1',
|
||
|
|
socket.remotePort ?? 0,
|
||
|
|
tunnel.endpoint_host,
|
||
|
|
tunnel.endpoint_port,
|
||
|
|
(err, stream) => {
|
||
|
|
if (err) {
|
||
|
|
socket.destroy()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
socket.pipe(stream).pipe(socket)
|
||
|
|
stream.on('close', () => socket.destroy())
|
||
|
|
socket.on('close', () => stream.end())
|
||
|
|
},
|
||
|
|
)
|
||
|
|
})
|
||
|
|
server.on('error', (err) => onFail(err.message))
|
||
|
|
server.listen(tunnel.source_port, '127.0.0.1')
|
||
|
|
state.server = server
|
||
|
|
}
|
||
|
|
|
||
|
|
function bindRemoteForward(client: Client, tunnel: TunnelRow, state: RuntimeState, onFail: (message: string) => void) {
|
||
|
|
client.forwardIn('0.0.0.0', tunnel.source_port, (err) => {
|
||
|
|
if (err) {
|
||
|
|
onFail(err.message)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
})
|
||
|
|
client.on('tcp connection', (info, accept, reject) => {
|
||
|
|
if (info.destPort !== tunnel.source_port) {
|
||
|
|
reject()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
const stream = accept()
|
||
|
|
const sock = net.connect(tunnel.endpoint_port, tunnel.endpoint_host)
|
||
|
|
sock.on('error', () => stream.end())
|
||
|
|
stream.on('error', () => sock.destroy())
|
||
|
|
sock.pipe(stream).pipe(sock)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
function bindDynamicForward(client: Client, tunnel: TunnelRow, state: RuntimeState, onFail: (message: string) => void) {
|
||
|
|
const server = net.createServer((socket) => {
|
||
|
|
readSocks5Target(socket)
|
||
|
|
.then((target) => {
|
||
|
|
client.forwardOut(socket.remoteAddress ?? '127.0.0.1', socket.remotePort ?? 0, target.host, target.port, (err, stream) => {
|
||
|
|
if (err) {
|
||
|
|
sendSocks5Failure(socket)
|
||
|
|
socket.destroy()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
sendSocks5Success(socket)
|
||
|
|
socket.pipe(stream).pipe(socket)
|
||
|
|
stream.on('close', () => socket.destroy())
|
||
|
|
socket.on('close', () => stream.end())
|
||
|
|
})
|
||
|
|
})
|
||
|
|
.catch(() => socket.destroy())
|
||
|
|
})
|
||
|
|
server.on('error', (err) => onFail(err.message))
|
||
|
|
server.listen(tunnel.source_port, '127.0.0.1')
|
||
|
|
state.server = server
|
||
|
|
}
|
||
|
|
|
||
|
|
export function startTunnel(id: number) {
|
||
|
|
const tunnel = getTunnelRow(id)
|
||
|
|
if (!tunnel) return
|
||
|
|
const state = getState(id)
|
||
|
|
state.stopRequested = false
|
||
|
|
state.status = 'connecting'
|
||
|
|
state.error = null
|
||
|
|
|
||
|
|
const target = loadSshHost(tunnel.integration_id)
|
||
|
|
if (!target) {
|
||
|
|
state.status = 'error'
|
||
|
|
state.error = 'SSH integration not found'
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
const onFail = (message: string) => {
|
||
|
|
if (state.stopRequested) return
|
||
|
|
state.error = message
|
||
|
|
teardownNetwork(state)
|
||
|
|
scheduleRetry(id, tunnel, state)
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = connectTarget(
|
||
|
|
target,
|
||
|
|
(client) => {
|
||
|
|
if (state.stopRequested) {
|
||
|
|
client.end()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
state.client = client
|
||
|
|
state.status = 'connected'
|
||
|
|
state.error = null
|
||
|
|
state.retryCount = 0
|
||
|
|
client.on('error', (err) => onFail(err.message))
|
||
|
|
client.on('close', () => onFail('SSH connection closed'))
|
||
|
|
|
||
|
|
if (tunnel.mode === 'local') bindLocalForward(client, tunnel, state, onFail)
|
||
|
|
else if (tunnel.mode === 'remote') bindRemoteForward(client, tunnel, state, onFail)
|
||
|
|
else bindDynamicForward(client, tunnel, state, onFail)
|
||
|
|
},
|
||
|
|
onFail,
|
||
|
|
)
|
||
|
|
state.jumpConn = result.jumpConn
|
||
|
|
}
|
||
|
|
|
||
|
|
export function stopTunnel(id: number) {
|
||
|
|
const state = getState(id)
|
||
|
|
state.stopRequested = true
|
||
|
|
if (state.retryTimer) clearTimeout(state.retryTimer)
|
||
|
|
state.retryTimer = null
|
||
|
|
state.retryCount = 0
|
||
|
|
state.status = 'stopped'
|
||
|
|
state.error = null
|
||
|
|
teardownNetwork(state)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function deleteTunnelRuntime(id: number) {
|
||
|
|
stopTunnel(id)
|
||
|
|
runtimes.delete(id)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function startAutoStartTunnels() {
|
||
|
|
const rows = db.prepare('SELECT * FROM tunnels WHERE auto_start = 1').all() as TunnelRow[]
|
||
|
|
for (const row of rows) startTunnel(row.id)
|
||
|
|
}
|