- backend/src/ssh/connect.ts: extracted shared SSH-connect logic (jump-host chaining, TOFU host-key verification) out of terminal.ts so tunnels can reuse it. - backend/src/tunnels/manager.ts + socks5.ts: in-memory tunnel runtime manager supporting local forward (forwardOut), remote forward (forwardIn), and dynamic SOCKS5 proxying, with automatic reconnect/retry and an auto-start-on-boot option. New `tunnels` table persists configs as the saved presets. - backend/src/routes/tunnels.ts: REST CRUD + connect/disconnect. - src/pages/Tunnels.tsx: new /tunnels page (sidebar entry added) to create, start/stop, and delete tunnels with live status polling. - Verified end-to-end against a real ssh2 test server handling real forwardOut/forwardIn requests and a real upstream TCP echo server - all three tunnel modes moved real data, and disconnect correctly tore down the local listener. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Plus, Play, Square, Trash2, ArrowRightLeft, ArrowLeftRight, Shuffle } from 'lucide-react'
|
|
import { api, type Tunnel, type Integration } from '../lib/api'
|
|
|
|
const TEXT_PRIMARY = '#E8E6E0'
|
|
const TEXT_SECONDARY = '#7A7D85'
|
|
const GOLD = '#C8A434'
|
|
|
|
const cardBase: React.CSSProperties = {
|
|
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
|
border: '1px solid rgba(200, 164, 52, 0.08)',
|
|
borderRadius: '12px',
|
|
padding: '20px',
|
|
boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)',
|
|
}
|
|
|
|
const MODE_LABEL: Record<Tunnel['mode'], string> = {
|
|
local: 'Local Forward',
|
|
remote: 'Remote Forward',
|
|
dynamic: 'Dynamic (SOCKS5)',
|
|
}
|
|
|
|
const MODE_ICON: Record<Tunnel['mode'], React.ComponentType<{ size?: number; color?: string }>> = {
|
|
local: ArrowRightLeft,
|
|
remote: ArrowLeftRight,
|
|
dynamic: Shuffle,
|
|
}
|
|
|
|
const STATUS_COLOR: Record<Tunnel['status'], string> = {
|
|
stopped: TEXT_SECONDARY,
|
|
connecting: GOLD,
|
|
retrying: '#E0A030',
|
|
connected: '#2ECC71',
|
|
error: '#E74C3C',
|
|
}
|
|
|
|
function inputStyle(): React.CSSProperties {
|
|
return {
|
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
|
border: '1px solid rgba(255,255,255,0.08)',
|
|
borderRadius: '6px',
|
|
padding: '6px 10px',
|
|
color: TEXT_PRIMARY,
|
|
fontSize: '13px',
|
|
width: '100%',
|
|
}
|
|
}
|
|
|
|
export default function Tunnels() {
|
|
const [tunnels, setTunnels] = useState<Tunnel[]>([])
|
|
const [hosts, setHosts] = useState<Integration[]>([])
|
|
const [showForm, setShowForm] = useState(false)
|
|
const [busyId, setBusyId] = useState<number | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const [name, setName] = useState('')
|
|
const [integrationId, setIntegrationId] = useState<number | ''>('')
|
|
const [mode, setMode] = useState<Tunnel['mode']>('local')
|
|
const [sourcePort, setSourcePort] = useState('')
|
|
const [endpointHost, setEndpointHost] = useState('')
|
|
const [endpointPort, setEndpointPort] = useState('')
|
|
const [autoStart, setAutoStart] = useState(false)
|
|
|
|
function refresh() {
|
|
api.listTunnels().then(({ tunnels }) => setTunnels(tunnels))
|
|
}
|
|
|
|
useEffect(() => {
|
|
refresh()
|
|
api.listIntegrations().then(({ integrations }) => setHosts(integrations.filter((i) => i.type === 'ssh')))
|
|
const interval = setInterval(refresh, 3000)
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
async function handleCreate() {
|
|
if (!name.trim() || !integrationId || !sourcePort) {
|
|
setError('Name, SSH host, and source port are required')
|
|
return
|
|
}
|
|
setError(null)
|
|
try {
|
|
await api.createTunnel({
|
|
name: name.trim(),
|
|
integrationId: Number(integrationId),
|
|
mode,
|
|
sourcePort: Number(sourcePort),
|
|
endpointHost: mode === 'dynamic' ? '' : endpointHost,
|
|
endpointPort: mode === 'dynamic' ? 0 : Number(endpointPort) || 0,
|
|
autoStart,
|
|
})
|
|
setName('')
|
|
setIntegrationId('')
|
|
setSourcePort('')
|
|
setEndpointHost('')
|
|
setEndpointPort('')
|
|
setAutoStart(false)
|
|
setShowForm(false)
|
|
refresh()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to create tunnel')
|
|
}
|
|
}
|
|
|
|
async function handleConnect(id: number) {
|
|
setBusyId(id)
|
|
try {
|
|
await api.connectTunnel(id)
|
|
refresh()
|
|
} finally {
|
|
setBusyId(null)
|
|
}
|
|
}
|
|
|
|
async function handleDisconnect(id: number) {
|
|
setBusyId(id)
|
|
try {
|
|
await api.disconnectTunnel(id)
|
|
refresh()
|
|
} finally {
|
|
setBusyId(null)
|
|
}
|
|
}
|
|
|
|
async function handleDelete(id: number) {
|
|
setBusyId(id)
|
|
try {
|
|
await api.deleteTunnel(id)
|
|
refresh()
|
|
} finally {
|
|
setBusyId(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-semibold" style={{ color: TEXT_PRIMARY }}>
|
|
SSH Tunnels
|
|
</h1>
|
|
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>
|
|
Local / remote / dynamic SOCKS5 port forwarding through your configured SSH hosts.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowForm((s) => !s)}
|
|
className="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium"
|
|
style={{ backgroundColor: GOLD, color: '#0A0A0C' }}
|
|
>
|
|
<Plus size={16} />
|
|
New Tunnel
|
|
</button>
|
|
</div>
|
|
|
|
{showForm && (
|
|
<div style={cardBase} className="space-y-3">
|
|
{error && <div style={{ color: '#E74C3C', fontSize: '13px' }}>{error}</div>}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>Name</label>
|
|
<input style={inputStyle()} value={name} onChange={(e) => setName(e.target.value)} placeholder="my-tunnel" />
|
|
</div>
|
|
<div>
|
|
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>SSH Host</label>
|
|
<select
|
|
style={inputStyle()}
|
|
value={integrationId}
|
|
onChange={(e) => setIntegrationId(e.target.value ? Number(e.target.value) : '')}
|
|
>
|
|
<option value="">Select host…</option>
|
|
{hosts.map((h) => (
|
|
<option key={h.id} value={h.id}>
|
|
{h.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>Mode</label>
|
|
<select style={inputStyle()} value={mode} onChange={(e) => setMode(e.target.value as Tunnel['mode'])}>
|
|
<option value="local">Local Forward</option>
|
|
<option value="remote">Remote Forward</option>
|
|
<option value="dynamic">Dynamic (SOCKS5)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>
|
|
{mode === 'dynamic' ? 'SOCKS5 Listen Port' : 'Source Port'}
|
|
</label>
|
|
<input style={inputStyle()} value={sourcePort} onChange={(e) => setSourcePort(e.target.value)} placeholder="8080" />
|
|
</div>
|
|
{mode !== 'dynamic' && (
|
|
<>
|
|
<div>
|
|
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>Endpoint Host</label>
|
|
<input style={inputStyle()} value={endpointHost} onChange={(e) => setEndpointHost(e.target.value)} placeholder="127.0.0.1" />
|
|
</div>
|
|
<div>
|
|
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>Endpoint Port</label>
|
|
<input style={inputStyle()} value={endpointPort} onChange={(e) => setEndpointPort(e.target.value)} placeholder="80" />
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<label className="flex items-center gap-2 text-xs" style={{ color: TEXT_PRIMARY }}>
|
|
<input type="checkbox" checked={autoStart} onChange={(e) => setAutoStart(e.target.checked)} />
|
|
Auto-start when the server boots
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<button onClick={handleCreate} className="rounded-md px-3 py-1.5 text-sm font-medium" style={{ backgroundColor: GOLD, color: '#0A0A0C' }}>
|
|
Create
|
|
</button>
|
|
<button onClick={() => setShowForm(false)} className="rounded-md px-3 py-1.5 text-sm" style={{ color: TEXT_SECONDARY }}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{tunnels.map((t) => {
|
|
const Icon = MODE_ICON[t.mode]
|
|
const host = hosts.find((h) => h.id === t.integrationId)
|
|
return (
|
|
<div key={t.id} style={cardBase} className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Icon size={16} color={GOLD} />
|
|
<span className="font-medium" style={{ color: TEXT_PRIMARY }}>
|
|
{t.name}
|
|
</span>
|
|
</div>
|
|
<span
|
|
className="text-xs rounded-full px-2 py-0.5"
|
|
style={{ color: STATUS_COLOR[t.status], border: `1px solid ${STATUS_COLOR[t.status]}33` }}
|
|
>
|
|
{t.status}
|
|
{t.status === 'retrying' ? ` (${t.retryCount}/${t.maxRetries})` : ''}
|
|
</span>
|
|
</div>
|
|
<div style={{ color: TEXT_SECONDARY, fontSize: '12px' }} className="space-y-1">
|
|
<div>{MODE_LABEL[t.mode]}</div>
|
|
<div>via {host?.name ?? `integration #${t.integrationId}`}</div>
|
|
<div>
|
|
localhost:{t.sourcePort} {t.mode === 'dynamic' ? '(SOCKS5 proxy)' : `→ ${t.endpointHost}:${t.endpointPort}`}
|
|
</div>
|
|
{t.error && <div style={{ color: '#E74C3C' }}>{t.error}</div>}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{t.status === 'connected' || t.status === 'connecting' || t.status === 'retrying' ? (
|
|
<button
|
|
disabled={busyId === t.id}
|
|
onClick={() => handleDisconnect(t.id)}
|
|
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs"
|
|
style={{ border: '1px solid rgba(255,255,255,0.1)', color: TEXT_PRIMARY }}
|
|
>
|
|
<Square size={12} /> Stop
|
|
</button>
|
|
) : (
|
|
<button
|
|
disabled={busyId === t.id}
|
|
onClick={() => handleConnect(t.id)}
|
|
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs"
|
|
style={{ backgroundColor: GOLD, color: '#0A0A0C' }}
|
|
>
|
|
<Play size={12} /> Start
|
|
</button>
|
|
)}
|
|
<button
|
|
disabled={busyId === t.id}
|
|
onClick={() => handleDelete(t.id)}
|
|
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs"
|
|
style={{ border: '1px solid rgba(231,76,60,0.3)', color: '#E74C3C' }}
|
|
>
|
|
<Trash2 size={12} /> Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
{tunnels.length === 0 && !showForm && (
|
|
<div style={{ color: TEXT_SECONDARY, fontSize: '13px' }}>No tunnels configured yet.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|