commit 77de4481a7a28cf3050388b1a65b0fd3f79c3ab8 Author: David Piccinini Date: Sat May 16 09:30:56 2026 -0300 feat: initial release v1.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..12c81b8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY tsconfig.json ./ +COPY src/ ./src/ +RUN npx tsc + +FROM node:22-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev && npm cache clean --force +COPY --from=builder /app/dist/ ./dist/ +USER node +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe0a8ad --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# EasyBot Tunnel Client + +Lightweight reverse-tunnel client that connects private services (databases, APIs, etc.) behind firewalls to your EasyBot runtime via a secure WebSocket connection. + +The client connects **outbound** to your EasyBot server — no inbound ports, firewall rules, or public IPs needed on the client side. + +## Prerequisites + +1. An EasyBot instance with the Tunnels feature enabled +2. A tunnel created from the EasyBot dashboard (Settings > Tunnels) +3. The **token** shown once at creation time (starts with `eb_tun_`) + +## Quick Start (Docker) + +```bash +docker run -d --restart always --name easybot-tunnel \ + -e TUNNEL_SERVER=wss://YOUR_RUNTIME_URL/ws/tunnel \ + -e TUNNEL_TOKEN=eb_tun_YOUR_TOKEN_HERE \ + repo.pyp.ar/public-pull/easybot-tunnel-client:1.0 +``` + +Replace: +- `YOUR_RUNTIME_URL` with your EasyBot runtime hostname (e.g. `runtime.example.com`) +- `eb_tun_YOUR_TOKEN_HERE` with the token from the dashboard + +## Installation by OS + +### Linux (Docker) + +```bash +# Pull and run +docker run -d --restart always --name easybot-tunnel \ + -e TUNNEL_SERVER=wss://runtime.example.com/ws/tunnel \ + -e TUNNEL_TOKEN=eb_tun_xxxxx \ + repo.pyp.ar/public-pull/easybot-tunnel-client:1.0 + +# Check logs +docker logs -f easybot-tunnel +``` + +### Linux (systemd, without Docker) + +```bash +# Install Node.js 20+ +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash - +sudo apt-get install -y nodejs + +# Download and install +git clone https://repo.pyp.ar/public-pull/easybot-tunnel-client.git +cd easybot-tunnel-client +npm install --omit=dev +npm run build + +# Create systemd service +sudo tee /etc/systemd/system/easybot-tunnel.service < ~/Library/LaunchAgents/com.easybot.tunnel.plist < + + + + Labelcom.easybot.tunnel + ProgramArguments + + /usr/local/bin/node + $(pwd)/dist/index.js + + EnvironmentVariables + + TUNNEL_SERVERwss://runtime.example.com/ws/tunnel + TUNNEL_TOKENeb_tun_xxxxx + + RunAtLoad + KeepAlive + + +EOF + +launchctl load ~/Library/LaunchAgents/com.easybot.tunnel.plist +``` + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `TUNNEL_SERVER` | Yes | WebSocket URL of your EasyBot runtime (e.g. `wss://runtime.example.com/ws/tunnel`) | +| `TUNNEL_TOKEN` | Yes | Authentication token from the dashboard (starts with `eb_tun_`) | + +## How It Works + +1. The client connects to your EasyBot runtime via WebSocket (outbound only) +2. The server sends a **config** with port mappings (e.g. "forward localhost:5432 on this tunnel") +3. When EasyBot needs to reach your private service, it opens a TCP connection through the tunnel +4. Traffic flows bidirectionally: EasyBot runtime <-> WebSocket <-> Tunnel Client <-> Your private service + +All port mappings are configured **server-side** in the EasyBot dashboard. The client only needs the server URL and token. + +## Verifying the Connection + +After starting the client, check the EasyBot dashboard (Tunnels section). Your tunnel should show: +- Status: **Connected** (green) +- Latency in ms +- Client IP + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| "Connection refused" | Check TUNNEL_SERVER URL is correct and reachable | +| "Authentication failed" | Verify TUNNEL_TOKEN is correct (not expired/regenerated) | +| Keeps reconnecting | Check firewall allows outbound WSS (port 443) | +| Target not reachable | Ensure the target service (e.g. PostgreSQL) is running and accessible from the machine running the tunnel client | + +## Building from Source + +```bash +git clone https://repo.pyp.ar/public-pull/easybot-tunnel-client.git +cd easybot-tunnel-client +npm install +npm run build +node dist/index.js +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..4486aab --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "@easybot/tunnel-client", + "version": "1.0.0", + "description": "Lightweight tunnel client for EasyBot — connects private services to EasyBot runtime via WebSocket", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx src/index.ts" + }, + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/ws": "^8.5.13", + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/src/connection-manager.ts b/src/connection-manager.ts new file mode 100644 index 0000000..1f6d93a --- /dev/null +++ b/src/connection-manager.ts @@ -0,0 +1,95 @@ +import net from 'net'; + +/** + * Manages multiplexed TCP connections over a single WebSocket. + * Each connection is identified by a 4-byte uint32 connection_id. + */ +export class ConnectionManager { + private connections = new Map(); + + /** Open a connection to a target host:port for a given connection_id */ + open(connId: number, targetHost: string, targetPort: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection(targetPort, targetHost, () => { + this.connections.set(connId, socket); + resolve(); + }); + + socket.on('error', (err) => { + this.connections.delete(connId); + reject(err); + }); + + // Timeout for connect phase + socket.setTimeout(10_000, () => { + socket.destroy(new Error('Connection timeout')); + }); + + // Clear timeout once connected + socket.once('connect', () => socket.setTimeout(0)); + }); + } + + /** Set up data forwarding from a target socket to a callback */ + onData(connId: number, callback: (data: Buffer) => void): void { + const socket = this.connections.get(connId); + if (!socket) return; + socket.on('data', callback); + } + + /** Set up close handler */ + onClose(connId: number, callback: () => void): void { + const socket = this.connections.get(connId); + if (!socket) return; + socket.on('close', callback); + socket.on('error', callback); + } + + /** Write data to a target socket */ + write(connId: number, data: Buffer): boolean { + const socket = this.connections.get(connId); + if (!socket || socket.destroyed) return false; + socket.write(data); + return true; + } + + /** Close a specific connection */ + close(connId: number): void { + const socket = this.connections.get(connId); + if (socket) { + socket.destroy(); + this.connections.delete(connId); + } + } + + /** Close all connections */ + closeAll(): void { + for (const [connId, socket] of this.connections) { + socket.destroy(); + } + this.connections.clear(); + } + + get size(): number { + return this.connections.size; + } +} + +// ── Binary Frame Helpers ───────────────────────────────────────────────────── + +/** Encode a data frame: [4-byte connId BE][payload] */ +export function encodeFrame(connId: number, payload: Buffer): Buffer { + const frame = Buffer.allocUnsafe(4 + payload.length); + frame.writeUInt32BE(connId, 0); + payload.copy(frame, 4); + return frame; +} + +/** Decode a data frame: returns { connId, payload } */ +export function decodeFrame(data: Buffer): { connId: number; payload: Buffer } | null { + if (data.length < 5) return null; + return { + connId: data.readUInt32BE(0), + payload: data.subarray(4), + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..501e1a3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,203 @@ +import WebSocket from 'ws'; +import { ConnectionManager, encodeFrame, decodeFrame } from './connection-manager.js'; + +// ── Configuration ──────────────────────────────────────────────────────────── + +const TUNNEL_SERVER = process.env.TUNNEL_SERVER; +const TUNNEL_TOKEN = process.env.TUNNEL_TOKEN; +const CLIENT_VERSION = '1.0.0'; + +if (!TUNNEL_SERVER || !TUNNEL_TOKEN) { + console.error('ERROR: TUNNEL_SERVER and TUNNEL_TOKEN environment variables are required'); + process.exit(1); +} + +// ── State ──────────────────────────────────────────────────────────────────── + +interface MappingConfig { + id: string; + target_host: string; + target_port: number; + label: string; +} + +let ws: WebSocket | null = null; +let mappings: MappingConfig[] = []; +const connManager = new ConnectionManager(); +let reconnectDelay = 1000; +let reconnectTimer: ReturnType | null = null; +let shuttingDown = false; + +// ── Logging ────────────────────────────────────────────────────────────────── + +function log(level: string, msg: string, data?: Record): void { + const ts = new Date().toISOString(); + const extra = data ? ' ' + JSON.stringify(data) : ''; + console.log(`${ts} [${level}] ${msg}${extra}`); +} + +// ── WebSocket Connection ───────────────────────────────────────────────────── + +function connect(): void { + if (shuttingDown) return; + + log('info', `Connecting to ${TUNNEL_SERVER}...`); + + ws = new WebSocket(TUNNEL_SERVER!, { + headers: { + 'Authorization': `Bearer ${TUNNEL_TOKEN}`, + }, + }); + + ws.on('open', () => { + log('info', 'Connected to tunnel server'); + reconnectDelay = 1000; // Reset backoff on successful connection + + // Send client info + sendControl({ type: 'client_info', version: CLIENT_VERSION }); + }); + + ws.on('message', (data, isBinary) => { + if (isBinary) { + handleBinaryMessage(data as Buffer); + } else { + handleControlMessage(data.toString()); + } + }); + + ws.on('close', (code, reason) => { + log('info', `Disconnected from server`, { code, reason: reason.toString() }); + cleanup(); + scheduleReconnect(); + }); + + ws.on('error', (err) => { + log('error', `WebSocket error: ${err.message}`); + // 'close' event will fire after this, triggering reconnect + }); +} + +function cleanup(): void { + connManager.closeAll(); + ws = null; +} + +function scheduleReconnect(): void { + if (shuttingDown) return; + if (reconnectTimer) return; + + log('info', `Reconnecting in ${reconnectDelay}ms...`); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, reconnectDelay); + + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s + reconnectDelay = Math.min(reconnectDelay * 2, 30_000); +} + +// ── Control Message Handling ───────────────────────────────────────────────── + +function handleControlMessage(raw: string): void { + let msg: any; + try { msg = JSON.parse(raw); } catch { return; } + + switch (msg.type) { + case 'config': + mappings = msg.tunnels || []; + log('info', `Received config: ${mappings.length} mapping(s)`, { + mappings: mappings.map(m => `${m.label}: ${m.target_host}:${m.target_port}`), + }); + break; + + case 'open': + handleOpen(msg.connection_id, msg.tunnel_id); + break; + + case 'close': { + connManager.close(msg.connection_id); + break; + } + + case 'ping': + sendControl({ type: 'pong' }); + break; + } +} + +async function handleOpen(connId: number, mappingId: string): Promise { + // Find the mapping config + const mapping = mappings.find(m => m.id === mappingId); + if (!mapping) { + sendControl({ type: 'open_err', connection_id: connId, error: 'Unknown mapping' }); + return; + } + + try { + await connManager.open(connId, mapping.target_host, mapping.target_port); + sendControl({ type: 'open_ok', connection_id: connId }); + + // Forward data from target back to server + connManager.onData(connId, (data) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(encodeFrame(connId, data)); + } + }); + + // Handle target close + connManager.onClose(connId, () => { + sendControl({ type: 'close', connection_id: connId }); + }); + + log('info', `Connection ${connId} opened to ${mapping.target_host}:${mapping.target_port}`); + } catch (err: any) { + sendControl({ type: 'open_err', connection_id: connId, error: err.message }); + log('error', `Connection ${connId} failed: ${err.message}`); + } +} + +// ── Binary Message Handling ────────────────────────────────────────────────── + +function handleBinaryMessage(data: Buffer): void { + const frame = decodeFrame(data); + if (!frame) return; + + if (!connManager.write(frame.connId, frame.payload)) { + // Target socket gone — notify server + sendControl({ type: 'close', connection_id: frame.connId }); + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function sendControl(msg: Record): void { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(msg)); + } +} + +// ── Graceful Shutdown ──────────────────────────────────────────────────────── + +function shutdown(signal: string): void { + log('info', `Received ${signal} — shutting down`); + shuttingDown = true; + + if (reconnectTimer) clearTimeout(reconnectTimer); + connManager.closeAll(); + + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(1000, 'Client shutting down'); + } + + // Give a moment for close frame to be sent + setTimeout(() => process.exit(0), 500); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +// ── Start ──────────────────────────────────────────────────────────────────── + +log('info', `EasyBot Tunnel Client v${CLIENT_VERSION}`); +log('info', `Server: ${TUNNEL_SERVER}`); +connect(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4963332 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src"] +}