feat: initial release v1.0

This commit is contained in:
David Piccinini 2026-05-16 09:30:56 -03:00
commit 77de4481a7
7 changed files with 541 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
dist/

15
Dockerfile Normal file
View File

@ -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"]

193
README.md Normal file
View File

@ -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 <<EOF
[Unit]
Description=EasyBot Tunnel Client
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Restart=always
RestartSec=5
Environment=TUNNEL_SERVER=wss://runtime.example.com/ws/tunnel
Environment=TUNNEL_TOKEN=eb_tun_xxxxx
WorkingDirectory=$(pwd)
ExecStart=/usr/bin/node dist/index.js
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now easybot-tunnel
sudo journalctl -u easybot-tunnel -f
```
### Windows (Docker Desktop)
```powershell
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
```
### Windows (as a service, without Docker)
```powershell
# Install Node.js 20+ from https://nodejs.org
# Download and build
git clone https://repo.pyp.ar/public-pull/easybot-tunnel-client.git
cd easybot-tunnel-client
npm install --omit=dev
npm run build
# Install as Windows service using nssm (https://nssm.cc)
nssm install EasyBotTunnel "C:\Program Files\nodejs\node.exe" "C:\easybot-tunnel-client\dist\index.js"
nssm set EasyBotTunnel AppEnvironmentExtra TUNNEL_SERVER=wss://runtime.example.com/ws/tunnel TUNNEL_TOKEN=eb_tun_xxxxx
nssm set EasyBotTunnel AppRestartDelay 5000
nssm start EasyBotTunnel
```
### macOS (Docker)
```bash
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
```
### macOS (launchd, without Docker)
```bash
# Install Node.js
brew install node@22
# Download and build
git clone https://repo.pyp.ar/public-pull/easybot-tunnel-client.git
cd easybot-tunnel-client
npm install --omit=dev
npm run build
# Create launch agent
cat > ~/Library/LaunchAgents/com.easybot.tunnel.plist <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.easybot.tunnel</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/node</string>
<string>$(pwd)/dist/index.js</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>TUNNEL_SERVER</key><string>wss://runtime.example.com/ws/tunnel</string>
<key>TUNNEL_TOKEN</key><string>eb_tun_xxxxx</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
</dict>
</plist>
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
```

19
package.json Normal file
View File

@ -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"
}
}

95
src/connection-manager.ts Normal file
View File

@ -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<number, net.Socket>();
/** Open a connection to a target host:port for a given connection_id */
open(connId: number, targetHost: string, targetPort: number): Promise<void> {
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),
};
}

203
src/index.ts Normal file
View File

@ -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<typeof setTimeout> | null = null;
let shuttingDown = false;
// ── Logging ──────────────────────────────────────────────────────────────────
function log(level: string, msg: string, data?: Record<string, any>): 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<void> {
// 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<string, any>): 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();

14
tsconfig.json Normal file
View File

@ -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"]
}