feat: initial release v1.0
This commit is contained in:
commit
77de4481a7
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal 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
193
README.md
Normal 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
19
package.json
Normal 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
95
src/connection-manager.ts
Normal 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
203
src/index.ts
Normal 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
14
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user