v-tunnel
Version:
Manageable multi-tunnel and port forwarding system
1,425 lines (1,226 loc) • 73.2 kB
JavaScript
#!/usr/bin/env node
/**
* V-Tunnel - Lightweight Tunnel Routing Solution
*
* A 100% free and open-source alternative to commercial tunneling solutions
* like Ngrok, Cloudflare Tunnel, and others.
*
* @file client.js
* @description Enhanced Tunnel Routing Client with JWT Authentication
* @author Cengiz AKCAN <me@cengizakcan.com>
* @copyright Copyright (c) 2025, Cengiz AKCAN
* @license MIT
* @link https://github.com/wwwakcan/V-Tunnel
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
'use strict';
const net = require('net');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');
const readFileAsync = promisify(fs.readFile);
const writeFileAsync = promisify(fs.writeFile);
const mkdirAsync = promisify(fs.mkdir);
const child_process = require('child_process');
const os = require('os');
// Advanced CLI packages
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');
const inquirer = require('inquirer');
const Table = require('cli-table3');
const colors = require('colors/safe');
// Configuration
const CONFIG_DIR = path.join(__dirname, '.vtunnel-client');
const AUTH_FILE = path.join(CONFIG_DIR, 'auth.json');
const TUNNELS_FILE = path.join(CONFIG_DIR, 'tunnels.json');
const API_PID_FILE = path.join(CONFIG_DIR, 'api.pid');
const apiServer = require('./api');
// Constants
const HEARTBEAT_INTERVAL = 30000; // ms
const CONNECTION_TIMEOUT = 10000; // ms
const MAX_RECONNECTION_ATTEMPTS = 5;
const RECONNECTION_BASE_DELAY = 2000; // ms
// Color themes
colors.setTheme({
info: 'blue',
success: 'green',
warning: 'yellow',
error: 'red',
title: ['cyan', 'bold'],
highlight: ['yellow', 'bold'],
muted: 'grey'
});
// Logger
const logger = {
info: (message) => console.log(colors.info(`[INFO] ${new Date().toISOString()} - ${message}`)),
success: (message) => console.log(colors.success(`[SUCCESS] ${new Date().toISOString()} - ${message}`)),
warning: (message) => console.log(colors.warning(`[WARNING] ${new Date().toISOString()} - ${message}`)),
error: (message) => console.error(colors.error(`[ERROR] ${new Date().toISOString()} - ${message}`)),
debug: (message) => process.env.DEBUG && console.log(colors.muted(`[DEBUG] ${new Date().toISOString()} - ${message}`)),
plain: (message) => console.log(message)
};
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
}
// Ensure config directory exists
async function ensureConfigDir() {
try {
if (!fs.existsSync(CONFIG_DIR)) {
await mkdirAsync(CONFIG_DIR, { recursive: true });
}
return true;
} catch (err) {
logger.error('Could not create configuration directory: ' + err);
return false;
}
}
// Load credentials
async function loadAuth() {
try {
if (fs.existsSync(AUTH_FILE)) {
const data = await readFileAsync(AUTH_FILE, 'utf8');
return JSON.parse(data);
}
} catch (err) {
logger.error('Could not load credentials: ' + err);
}
return null;
}
// Save credentials
async function saveAuth(auth) {
try {
await ensureConfigDir();
await writeFileAsync(AUTH_FILE, JSON.stringify(auth, null, 2));
return true;
} catch (err) {
logger.error('Could not save credentials: ' + err);
return false;
}
}
// Load active tunnels
async function loadActiveTunnels() {
try {
if (fs.existsSync(TUNNELS_FILE)) {
const data = await readFileAsync(TUNNELS_FILE, 'utf8');
return JSON.parse(data);
}
} catch (err) {
logger.error('Could not load active tunnels: ' + err);
}
return { tunnels: [], active: [] };
}
// Save active tunnels
async function saveActiveTunnels(tunnelsData) {
try {
await ensureConfigDir();
await writeFileAsync(TUNNELS_FILE, JSON.stringify(tunnelsData, null, 2));
return true;
} catch (err) {
logger.error('Could not save active tunnels: ' + err);
return false;
}
}
// Check if the API server is running
async function isApiServerRunning() {
try {
if (fs.existsSync(API_PID_FILE)) {
const pidData = await readFileAsync(API_PID_FILE, 'utf8');
const pid = parseInt(pidData.trim(), 10);
if (!isNaN(pid)) {
return await isProcessRunning(pid);
}
}
return false;
} catch (err) {
logger.error('Error checking if API server is running: ' + err);
return false;
}
}
// Start the API server
async function startApiServer() {
try {
// Check if API server is already running
if (await isApiServerRunning()) {
logger.info('API server is already running');
return true;
}
// Check if user is logged in
const auth = await loadAuth();
if (!auth) {
logger.error('You must be logged in to start the API server');
logger.info('Please use "node client.js login" to authenticate first');
return false;
}
logger.info('Starting API server...');
// Start API server in a separate process
const apiProcess = child_process.spawn(process.execPath, [
path.join(__dirname, 'api.js')
], {
detached: true,
stdio: 'ignore'
});
// Detach child process from parent
apiProcess.unref();
logger.success(`API server started. PID: ${apiProcess.pid}`);
return true;
} catch (err) {
logger.error('Error starting API server: ' + err);
return false;
}
}
// Stop the API server
async function stopApiServer() {
try {
// Check if API server is running
if (!await isApiServerRunning()) {
logger.info('API server is not running');
return true;
}
// Get the API server PID
const pidData = await readFileAsync(API_PID_FILE, 'utf8');
const pid = parseInt(pidData.trim(), 10);
if (isNaN(pid)) {
logger.error('Invalid PID in API PID file');
return false;
}
// Kill the process
try {
process.kill(pid);
logger.success('API server stopped successfully');
// Remove the PID file
fs.unlinkSync(API_PID_FILE);
return true;
} catch (err) {
logger.error(`Could not stop API server: ${err.message}`);
return false;
}
} catch (err) {
logger.error('Error stopping API server: ' + err.message);
return false;
}
}
// Messaging helpers
function sendMessage(socket, message) {
if (!socket || socket.destroyed) return false;
return socket.write(encrypt(message) + "\n");
}
function encrypt(data) {
try {
const dataString = typeof data === 'string' ? data : JSON.stringify(data);
const iv = crypto.randomBytes(16);
const key = crypto.createHash('sha256').update("vtunnel").digest();
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(dataString, 'utf8', 'base64');
encrypted += cipher.final('base64');
return iv.toString('hex') + ':' + encrypted;
} catch (err) {
logger.error('Encryption error: ' + err.message);
return null;
}
}
function decrypt(text) {
try {
const parts = text.split(':');
if (parts.length !== 2) {
logger.debug('Invalid encrypted format (missing separator)');
return null;
}
const iv = Buffer.from(parts[0], 'hex');
const key = crypto.createHash('sha256').update("vtunnel").digest();
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(parts[1], 'base64', 'utf8');
decrypted += decipher.final('utf8');
return JSON.parse(decrypted);
} catch (err) {
logger.error('Decryption error: ' + err.message);
return null;
}
}
// Connect to server and send/receive messages
async function connectAndSend(message, timeoutMs = 10000) {
return new Promise(async (resolve, reject) => {
const auth = await loadAuth();
if (!auth && message.type !== 'login') {
reject(new Error('Authentication required. Please use the "login" command first.'));
return;
}
// Correctly convert port value
const SERVER_HOST = auth ? auth.server : message.server || 'localhost';
const SERVER_PORT = parseInt(auth ? auth.port : (message.port || '9012'), 10);
// Check port validity
if (isNaN(SERVER_PORT) || SERVER_PORT <= 0 || SERVER_PORT >= 65536) {
reject(new Error(`Invalid port number: ${SERVER_PORT}`));
return;
}
logger.debug(`Connecting to server: ${SERVER_HOST}:${SERVER_PORT}`);
const socket = new net.Socket();
let responseReceived = false;
let responseData = null;
let buffer = '';
let timeoutId;
socket.on('data', data => {
try {
buffer += data.toString();
let newlineIndex;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const messageStr = buffer.substring(0, newlineIndex);
buffer = buffer.substring(newlineIndex + 1);
const response = decrypt(messageStr);
if (!response) continue;
if (response.type === 'welcome') {
// Send actual message after welcome message
if (message.type === 'login') {
sendMessage(socket, message);
} else {
// Authentication with token
sendMessage(socket, {
...message,
token: auth.token
});
}
} else {
responseReceived = true;
responseData = response;
clearTimeout(timeoutId);
socket.end();
}
}
} catch (err) {
logger.error('Data processing error: ' + err);
reject(err);
socket.destroy();
}
});
socket.on('error', err => {
logger.error('Connection error: ' + err.message);
reject(err);
});
socket.on('close', () => {
if (!responseReceived) {
reject(new Error('Connection closed, no response received'));
} else {
resolve(responseData);
}
});
timeoutId = setTimeout(() => {
socket.destroy();
reject(new Error('Server did not respond (timeout)'));
}, timeoutMs);
socket.connect(SERVER_PORT, SERVER_HOST);
});
}
// Variables to track reconnection state
let reconnectionTimer = null;
// Enhanced tunnel client creation with improved flow control and better reconnection
async function createTunnelClient(tunnelName, localHost, localPort, options = {}) {
const auth = await loadAuth();
if (!auth) {
logger.error('Authentication information not found. Please use the "login" command first.');
return null;
}
logger.info(`Starting tunnel ${tunnelName}...`);
logger.info(`Server: ${auth.server}:${auth.port}`);
logger.info(`Target: ${localHost}:${localPort}`);
const tunnelState = {
reconnectionAttempts: 0,
isReconnecting: false,
isShuttingDown: false
};
// Default options
const tunnelOptions = {
maxReconnectionAttempts: options.maxReconnectionAttempts || MAX_RECONNECTION_ATTEMPTS,
heartbeatInterval: options.heartbeatInterval || HEARTBEAT_INTERVAL,
connectionTimeout: options.connectionTimeout || CONNECTION_TIMEOUT,
autoReconnect: options.autoReconnect !== false // Default to true
};
return new Promise((resolve, reject) => {
// Safely convert port value
const SERVER_HOST = auth.server;
const SERVER_PORT = parseInt(auth.port, 10);
if (isNaN(SERVER_PORT) || SERVER_PORT <= 0 || SERVER_PORT >= 65536) {
reject(new Error(`Invalid port number: ${auth.port}`));
return;
}
// Create connection and set up handlers
const controlSocket = createControlConnection(tunnelName, localHost, localPort, tunnelState, tunnelOptions, resolve, reject);
});
}
// Create a control connection to the server
function createControlConnection(tunnelName, localHost, localPort, tunnelState, options, resolvePromise, rejectPromise) {
const auth = loadAuth().catch(err => {
logger.error('Failed to load authentication information: ' + err.message);
return null;
});
// Start with no auth to avoid blocking
const SERVER_HOST = auth?.server || 'localhost';
const SERVER_PORT = parseInt(auth?.port || '9012', 10);
const controlSocket = new net.Socket();
let pingInterval;
let heartbeatInterval;
let registered = false;
let tunnelPort;
let buffer = '';
let isConnected = false;
const clients = {}; // Track client connections
let lastHeartbeatResponse = Date.now();
controlSocket.on('data', data => {
try {
buffer += data.toString();
let newlineIndex;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const messageStr = buffer.substring(0, newlineIndex);
buffer = buffer.substring(newlineIndex + 1);
const message = decrypt(messageStr);
if (!message) continue;
processMessage(controlSocket, message);
}
} catch (err) {
logger.error('Data processing error: ' + err);
controlSocket.destroy();
}
});
controlSocket.on('error', err => {
logger.error('Control connection error: ' + err.message);
// Don't reject here, let the close handler handle reconnection
});
controlSocket.on('close', () => {
logger.info('Connection to tunnel server closed');
clearInterval(pingInterval);
clearInterval(heartbeatInterval);
isConnected = false;
if (tunnelState.isShuttingDown) {
logger.info('Tunnel is shutting down, not attempting to reconnect.');
return;
}
if (registered && options.autoReconnect) {
// Only attempt to reconnect if we were previously registered
tunnelState.isReconnecting = true;
attemptReconnection(tunnelName, localHost, localPort, tunnelPort, tunnelState, options, resolvePromise);
} else if (!registered) {
rejectPromise(new Error('Failed to establish initial connection to the server.'));
}
});
// Set a timeout for the connection
controlSocket.setTimeout(options.connectionTimeout);
controlSocket.on('timeout', () => {
const timeSinceLastHeartbeat = Date.now() - lastHeartbeatResponse;
if (timeSinceLastHeartbeat > options.heartbeatInterval * 2) {
logger.warning('Connection timeout. No response from server.');
controlSocket.destroy();
}
});
controlSocket.connect(SERVER_PORT, SERVER_HOST, () => {
logger.info('Connected to tunnel server');
isConnected = true;
tunnelState.reconnectionAttempts = 0; // Reset counter on successful connection
// Start ping interval
pingInterval = setInterval(() => {
// Check connection status before sending ping
if (!controlSocket.destroyed) {
sendMessage(controlSocket, {
type: 'ping',
time: Date.now()
});
}
}, 30000); // Ping every 30 seconds
// Start heartbeat interval - more frequent than ping
heartbeatInterval = setInterval(() => {
if (!controlSocket.destroyed) {
sendMessage(controlSocket, {
type: 'heartbeat',
time: Date.now(),
tunnel_name: tunnelName,
stats: {
clientCount: Object.keys(clients).length
}
});
}
// Check if we haven't received a heartbeat response in a while
const timeSinceLastHeartbeat = Date.now() - lastHeartbeatResponse;
if (timeSinceLastHeartbeat > options.heartbeatInterval * 3) {
logger.warning(`No heartbeat response received for ${Math.round(timeSinceLastHeartbeat/1000)}s. Connection may be stale.`);
controlSocket.destroy(); // Force reconnection
}
}, options.heartbeatInterval);
});
// Process incoming messages
function processMessage(socket, message) {
switch(message.type) {
case 'welcome':
logger.debug(`Received server welcome, client_id: ${message.client_id}`);
// Load auth info (should be available now)
auth.then(authData => {
if (!authData) {
socket.destroy();
rejectPromise(new Error('Authentication information not found'));
return;
}
// Login with token
sendMessage(socket, {
type: 'login',
token: authData.token
});
});
break;
case 'login_response':
if (message.success) {
// Register tunnel
sendMessage(socket, {
type: 'register_tunnel',
tunnel_name: tunnelName,
description: `Local service at ${localHost}:${localPort}`
});
} else {
logger.error(`Authentication error: ${message.message}`);
socket.destroy();
rejectPromise(new Error(`Authentication error: ${message.message}`));
}
break;
case 'tunnel_registered':
registered = true;
tunnelState.reconnectionAttempts = 0; // Reset counter when successfully registered
tunnelPort = message.port;
logger.success(`Tunnel successfully registered! Your service is accessible at:`);
logger.success(` ${SERVER_HOST}:${message.port}`);
resolvePromise({
controlSocket,
tunnelName,
tunnelPort,
isConnected: true,
clients,
options
});
break;
case 'error':
logger.error(`Server error: ${message.message}`);
if (!registered) {
socket.destroy();
rejectPromise(new Error(`Server error: ${message.message}`));
}
break;
case 'connection':
handleNewConnection(socket, message.client_id, message.remote_address, message.remote_port, tunnelName);
break;
case 'data':
forwardDataToLocalService(message.client_id, message.data);
break;
case 'client_disconnected':
closeClientConnection(message.client_id);
break;
case 'pong':
case 'heartbeat_ack':
// Update last heartbeat response time
lastHeartbeatResponse = Date.now();
break;
default:
logger.debug(`Unknown message type: ${message.type}`);
break;
}
}
// Handler functions for client connections
function handleNewConnection(controlSocket, clientId, remoteAddress, remotePort, tunnelName) {
logger.info(`New connection from ${remoteAddress}:${remotePort} (ID: ${clientId})`);
// Create connection to local service
const localSocket = new net.Socket();
// Save client information with queue for data
clients[clientId] = {
socket: localSocket,
connected: false,
bytesReceived: 0,
bytesSent: 0,
queuedData: [],
remoteAddress,
remotePort,
connectionTime: Date.now()
};
// Correctly convert local port number
const parsedLocalPort = parseInt(localPort, 10);
if (isNaN(parsedLocalPort)) {
logger.error(`Invalid local port: ${localPort}`);
return;
}
// Set timeout for local connection
localSocket.setTimeout(options.connectionTimeout);
localSocket.on('timeout', () => {
logger.warning(`Local connection timeout for client ${clientId}`);
localSocket.destroy();
});
localSocket.connect(parsedLocalPort, localHost, () => {
logger.info(`Connected to local service for client ${clientId}`);
clients[clientId].connected = true;
// Tell the server we're ready to receive data
sendMessage(controlSocket, {
type: 'client_ready',
client_id: clientId,
tunnel_name: tunnelName
});
// Process queued data if any
if (clients[clientId].queuedData.length > 0) {
logger.debug(`Processing ${clients[clientId].queuedData.length} queued data chunks for client ${clientId}`);
while (clients[clientId].queuedData.length > 0) {
const data = clients[clientId].queuedData.shift();
localSocket.write(data);
clients[clientId].bytesReceived += data.length;
}
}
});
localSocket.on('data', data => {
// Forward data from local service to client
if (controlSocket && !controlSocket.destroyed) {
sendMessage(controlSocket, {
type: 'data',
client_id: clientId,
data: data.toString('base64')
});
clients[clientId].bytesSent += data.length;
}
});
localSocket.on('error', err => {
logger.error(`Local service error for client ${clientId}: ${err.message}`);
closeClientConnection(clientId);
});
localSocket.on('close', () => {
logger.info(`Local service closed connection for client ${clientId}`);
// Log transfer stats
if (clients[clientId]) {
logger.info(`Data transfer for client ${clientId}: Sent ${formatBytes(clients[clientId].bytesSent)}, Received ${formatBytes(clients[clientId].bytesReceived)}`);
}
closeClientConnection(clientId);
// Notify server
if (controlSocket && !controlSocket.destroyed) {
sendMessage(controlSocket, {
type: 'client_disconnected',
client_id: clientId
});
}
});
}
function forwardDataToLocalService(clientId, dataBase64) {
if (!clients[clientId]) {
logger.error(`Received data for unknown client ${clientId}`);
return;
}
try {
const data = Buffer.from(dataBase64, 'base64');
if (clients[clientId].connected) {
// Send data to local service
clients[clientId].socket.write(data);
clients[clientId].bytesReceived += data.length;
if (process.env.DEBUG) {
logger.debug(`Data forwarded to local service for client ${clientId} (${data.length} bytes)`);
}
} else {
// Queue data until connected
clients[clientId].queuedData.push(data);
logger.debug(`Data queued for client ${clientId} (${data.length} bytes), waiting for connection`);
}
} catch (err) {
logger.error(`Error forwarding data to local service for client ${clientId}: ${err}`);
}
}
function closeClientConnection(clientId) {
if (!clients[clientId]) return;
if (clients[clientId].socket && !clients[clientId].socket.destroyed) {
clients[clientId].socket.destroy();
}
if (clients[clientId].connected) {
logger.info(`Client ${clientId} disconnected. Transfer: ${formatBytes(clients[clientId].bytesSent)} sent, ${formatBytes(clients[clientId].bytesReceived)} received`);
}
delete clients[clientId];
}
return controlSocket;
}
// Function to handle reconnection attempts with exponential backoff
async function attemptReconnection(tunnelName, localHost, localPort, tunnelPort, tunnelState, options, resolveOriginalPromise) {
if (tunnelState.reconnectionAttempts >= options.maxReconnectionAttempts) {
logger.error(`Failed to reconnect after ${options.maxReconnectionAttempts} attempts. Stopping tunnel.`);
// Get tunnel data to find and stop the process
const tunnelsData = await loadActiveTunnels();
if (tunnelsData.active) {
const activeTunnel = tunnelsData.active.find(t => t.name === tunnelName);
if (activeTunnel) {
try {
process.kill(activeTunnel.pid);
logger.warning(`Tunnel ${tunnelName} stopped due to connection failures.`);
// Remove from active tunnels
tunnelsData.active = tunnelsData.active.filter(t => t.name !== tunnelName);
await saveActiveTunnels(tunnelsData);
} catch (err) {
logger.error(`Could not stop process: ${err.message}`);
}
}
}
tunnelState.isReconnecting = false;
return;
}
tunnelState.reconnectionAttempts++;
logger.warning(`Attempting to reconnect (${tunnelState.reconnectionAttempts}/${options.maxReconnectionAttempts})...`);
// Clear any existing reconnection timer
if (reconnectionTimer) {
clearTimeout(reconnectionTimer);
}
// Exponential backoff with jitter
const delay = Math.min(
RECONNECTION_BASE_DELAY * Math.pow(2, tunnelState.reconnectionAttempts - 1) * (0.5 + Math.random()),
30000 // Cap at 30 seconds
);
logger.info(`Reconnecting in ${Math.round(delay/1000)} seconds...`);
reconnectionTimer = setTimeout(async () => {
try {
logger.info(`Reconnecting to tunnel ${tunnelName}...`);
// Create new control connection
const controlSocket = createControlConnection(
tunnelName,
localHost,
localPort,
tunnelState,
options,
resolveOriginalPromise,
(err) => {
logger.error(`Reconnection attempt failed: ${err.message}`);
// Try again with the next attempt
attemptReconnection(tunnelName, localHost, localPort, tunnelPort, tunnelState, options, resolveOriginalPromise);
}
);
} catch (err) {
logger.error(`Error during reconnection attempt: ${err.message}`);
// Try again
attemptReconnection(tunnelName, localHost, localPort, tunnelPort, tunnelState, options, resolveOriginalPromise);
}
}, delay);
}
// Modify the runTunnel function to use our enhanced tunnel client
async function runTunnel(argv) {
try {
if (!argv.name || !argv.port) {
logger.error('Tunnel name and port are required');
console.log('Usage: node client.js run --name <tunnel_name> --host <local_host> --port <local_port>');
return;
}
// Safely convert port value
const localPort = parseInt(argv.port, 10);
if (isNaN(localPort)) {
logger.error(`Invalid port: ${argv.port}`);
return;
}
logger.info(`Starting tunnel ${argv.name} (${argv.host || 'localhost'}:${localPort})`);
const options = {
autoReconnect: argv.autoReconnect !== false,
maxReconnectionAttempts: argv.maxRetries || MAX_RECONNECTION_ATTEMPTS,
connectionTimeout: argv.timeout || CONNECTION_TIMEOUT,
heartbeatInterval: argv.heartbeatInterval || HEARTBEAT_INTERVAL
};
const tunnel = await createTunnelClient(argv.name, argv.host || 'localhost', localPort, options);
if (tunnel) {
logger.success(`Tunnel successfully created!`);
// Track stats
let lastStats = {
clientCount: 0,
bytesSent: 0,
bytesReceived: 0
};
// Set up connection health check and stats
const healthCheck = setInterval(() => {
if (!tunnel.controlSocket || tunnel.controlSocket.destroyed) {
logger.warning('Tunnel connection appears to be down.');
clearInterval(healthCheck);
return;
}
// Calculate current stats
let totalBytesSent = 0;
let totalBytesReceived = 0;
const clientCount = Object.keys(tunnel.clients).length;
Object.values(tunnel.clients).forEach(client => {
totalBytesSent += client.bytesSent || 0;
totalBytesReceived += client.bytesReceived || 0;
});
// Log stats if changed significantly
if (clientCount !== lastStats.clientCount ||
Math.abs(totalBytesSent - lastStats.bytesSent) > 1024*1024 ||
Math.abs(totalBytesReceived - lastStats.bytesReceived) > 1024*1024) {
logger.info(`Tunnel stats: ${clientCount} active clients, ${formatBytes(totalBytesSent)} sent, ${formatBytes(totalBytesReceived)} received`);
lastStats = {
clientCount,
bytesSent: totalBytesSent,
bytesReceived: totalBytesReceived
};
}
}, 60000); // Check every minute
// Catch Ctrl+C
process.on('SIGINT', () => {
logger.info('Closing tunnel...');
clearInterval(healthCheck);
if (tunnel.controlSocket && !tunnel.controlSocket.destroyed) {
tunnel.controlSocket.destroy();
}
process.exit(0);
});
// Keep process alive for tunnel to persist
setInterval(() => {}, 1000000);
}
} catch (err) {
logger.error('Error during tunnel run: ' + err.message);
process.exit(1);
}
}
// Add a new function to check all tunnels and restart any that have crashed
async function checkAndRestartTunnels() {
try {
const tunnelsData = await loadActiveTunnels();
if (!tunnelsData.active || tunnelsData.active.length === 0) {
logger.debug('No active tunnels to check.');
return;
}
logger.info('Checking status of all active tunnels...');
// Get current running tunnels
let needsUpdate = false;
for (const tunnel of tunnelsData.active) {
const isRunning = await isProcessRunning(tunnel.pid);
if (!isRunning) {
logger.warning(`Tunnel "${tunnel.name}" (PID: ${tunnel.pid}) is not running. Attempting to restart...`);
// Get tunnel configuration
const tunnelConfig = tunnelsData.tunnels.find(t => t.name === tunnel.name);
if (tunnelConfig) {
try {
// Start tunnel in background
const activeTunnel = await startTunnelInBackground({
name: tunnel.name,
localHost: tunnelConfig.localHost,
localPort: tunnelConfig.localPort
});
// Update tunnel in active list
const index = tunnelsData.active.findIndex(t => t.name === tunnel.name);
if (index !== -1) {
tunnelsData.active[index] = activeTunnel;
}
logger.success(`Restarted tunnel "${tunnel.name}" with new PID: ${activeTunnel.pid}`);
needsUpdate = true;
} catch (err) {
logger.error(`Failed to restart tunnel "${tunnel.name}": ${err.message}`);
}
} else {
logger.error(`Could not find configuration for tunnel "${tunnel.name}"`);
// Remove from active tunnels
tunnelsData.active = tunnelsData.active.filter(t => t.name !== tunnel.name);
needsUpdate = true;
}
} else {
logger.debug(`Tunnel "${tunnel.name}" (PID: ${tunnel.pid}) is running.`);
}
}
// Save updated tunnel status
if (needsUpdate) {
await saveActiveTunnels(tunnelsData);
}
} catch (err) {
logger.error('Error checking tunnels: ' + err.message);
}
}
// Add a helper function to add automatic tunnel checking capability
function enableTunnelMonitoring() {
// Check tunnels every 5 minutes
setInterval(checkAndRestartTunnels, 5 * 60 * 1000);
logger.info('Automatic tunnel monitoring enabled. Checks will run every 5 minutes.');
}
// Start tunnel in background
async function startTunnelInBackground(tunnelInfo) {
return new Promise((resolve, reject) => {
try {
// Check and convert local port value
const localPort = parseInt(tunnelInfo.localPort, 10);
if (isNaN(localPort)) {
reject(new Error(`Invalid local port: ${tunnelInfo.localPort}`));
return;
}
// Build the command-line arguments
const args = [__filename, 'run',
'--name', tunnelInfo.name,
'--host', tunnelInfo.localHost,
'--port', localPort.toString()
];
// Add additional options if specified
if (tunnelInfo.autoReconnect === false) {
args.push('--no-auto-reconnect');
}
if (tunnelInfo.maxRetries) {
args.push('--max-retries', tunnelInfo.maxRetries.toString());
}
if (tunnelInfo.debug) {
args.push('--debug');
}
// Start tunnel client in a separate process
const childProcess = child_process.spawn(process.execPath, args, {
detached: true,
stdio: 'ignore'
});
// Detach child process from parent
childProcess.unref();
// Keep track of PID
resolve({
pid: childProcess.pid,
name: tunnelInfo.name,
localHost: tunnelInfo.localHost,
localPort: localPort,
startedAt: new Date().toISOString()
});
} catch (err) {
logger.error('Could not start tunnel: ' + err);
reject(err);
}
});
}
// Check if process is running
async function isProcessRunning(pid) {
try {
process.kill(pid, 0);
return true;
} catch (err) {
return false;
}
}
// Command: Login
async function login(argv) {
try {
const auth = await loadAuth();
// Use inquirer to get login information
const answers = await inquirer.prompt([
{
type: 'input',
name: 'server',
message: 'Server address:',
default: argv.server || auth?.server || 'localhost'
},
{
type: 'input',
name: 'port',
message: 'Server port:',
default: (argv.port || auth?.port || 9012).toString(),
validate: (input) => {
const port = parseInt(input, 10);
return !isNaN(port) && port > 0 && port < 65536 ? true : 'Enter a valid port number (1-65535)';
}
},
{
type: 'input',
name: 'username',
message: 'Username:',
validate: (input) => input.trim() ? true : 'Username is required'
},
{
type: 'password',
name: 'password',
message: 'Password:',
mask: '*',
validate: (input) => input.trim() ? true : 'Password is required'
}
]);
// Safely convert port value
const port = parseInt(answers.port, 10);
if (isNaN(port)) {
logger.error(`Invalid port number: ${answers.port}`);
return;
}
// Connect to server and login
const response = await connectAndSend({
type: 'login',
username: answers.username,
password: answers.password,
server: answers.server,
port: port
});
if (response.success) {
// Save credentials
const auth = {
server: answers.server,
port: port,
token: response.token,
user: response.user,
loginTime: new Date().toISOString()
};
await saveAuth(auth);
logger.success(`Successfully logged in! Welcome, ${response.user.username}`);
// Auto-start API server after successful login
logger.info("Starting API server in the background...");
const apiStarted = await startApiServer();
if (apiStarted) {
logger.success("API server has been started successfully!");
logger.info(`API is accessible at: http://localhost:9011/api`);
} else {
logger.warning("Could not start API server automatically. You can start it manually with the 'api start' command.");
}
} else {
logger.error(`Login failed: ${response.message}`);
}
} catch (err) {
logger.error('Error during login: ' + err.message);
}
}
// Command: Logout
async function logout() {
try {
if (fs.existsSync(AUTH_FILE)) {
await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: 'Are you sure you want to log out?',
default: true
}
]).then(async (answers) => {
if (answers.confirm) {
fs.unlinkSync(AUTH_FILE);
logger.success('Successfully logged out');
await stopApiServer();
} else {
logger.info('Logout cancelled');
}
});
} else {
logger.info('Already logged out');
}
} catch (err) {
logger.error('Error during logout: ' + err.message);
}
}
// Command: Change password
async function changePassword() {
try {
// Check if session is active
const auth = await loadAuth();
if (!auth) {
logger.error('You must be logged in to change your password');
console.log('To login: node client.js login');
return;
}
// Get password information from user
const answers = await inquirer.prompt([
{
type: 'password',
name: 'currentPassword',
message: 'Current password:',
mask: '*',
validate: (input) => input.trim() ? true : 'Current password is required'
},
{
type: 'password',
name: 'newPassword',
message: 'New password:',
mask: '*',
validate: (input) => {
if (!input.trim()) return 'New password is required';
if (input.length < 6) return 'Password must be at least 6 characters';
return true;
}
},
{
type: 'password',
name: 'confirmPassword',
message: 'Confirm new password:',
mask: '*',
validate: (input, answers) => {
if (!input.trim()) return 'Password confirmation is required';
if (input !== answers.newPassword) return 'Passwords do not match';
return true;
}
}
]);
// Double check that passwords match
if (answers.newPassword !== answers.confirmPassword) {
logger.error('Passwords do not match');
return;
}
// Send password change request
const response = await connectAndSend({
type: 'change_password',
current_password: answers.currentPassword,
new_password: answers.newPassword
});
if (response.success) {
logger.success(response.message);
// Keep user info but ask to log in again
const { confirmLogout } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmLogout',
message: 'Your password has been changed. You need to log in again for changes to take effect. Do you want to log out now?',
default: true
}
]);
if (confirmLogout) {
// Delete auth.json file
if (fs.existsSync(AUTH_FILE)) {
fs.unlinkSync(AUTH_FILE);
logger.success('Logged out. Please log in again with your new password.');
console.log('To login: node client.js login');
}
} else {
logger.info('Please log in again later.');
}
} else {
logger.error(`Password change failed: ${response.message}`);
}
} catch (err) {
logger.error('Error during password change: ' + err.message);
}
}
// Command: Create tunnel
async function createTunnel(argv) {
try {
const answers = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Tunnel name:',
default: argv.name,
validate: (input) => input.trim() ? true : 'Tunnel name is required'
},
{
type: 'input',
name: 'description',
message: 'Description (optional):'
},
{
type: 'input',
name: 'localHost',
message: 'Local service address:',
default: argv.host || 'localhost'
},
{
type: 'input',
name: 'localPort',
message: 'Local service port:',
default: argv.port ? argv.port.toString() : '',
validate: (input) => {
const port = parseInt(input, 10);
return !isNaN(port) && port > 0 && port < 65536 ? true : 'Enter a valid port number (1-65535)';
}
}
]);
// Request tunnel creation from server
const response = await connectAndSend({
type: 'register_tunnel',
tunnel_name: answers.name,
description: answers.description
});
if (response.type === 'tunnel_registered') {
// Save tunnel information locally
const tunnelsData = await loadActiveTunnels();
// Check if a tunnel with the same name already exists
const existingIndex = tunnelsData.tunnels.findIndex(t => t.name === answers.name);
const tunnelInfo = {
name: answers.name,
description: answers.description,
localHost: answers.localHost,
localPort: parseInt(answers.localPort, 10),
serverPort: response.port,
createdAt: new Date().toISOString()
};
if (existingIndex !== -1) {
tunnelsData.tunnels[existingIndex] = tunnelInfo;
} else {
tunnelsData.tunnels.push(tunnelInfo);
}
await saveActiveTunnels(tunnelsData);
logger.success(`Tunnel successfully created: ${answers.name}`);
// Show summary with table
const table = new Table({
head: [
colors.title('Property'),
colors.title('Value')
]
});
table.push(
['Tunnel Name', colors.highlight(answers.name)],
['Description', answers.description || '-'],
['Local Service', `${answers.localHost}:${answers.localPort}`],
['In/Port', colors.highlight(`${response.port}`)],
['Out/Port', colors.highlight(`${response.port}`)]
);
console.log(table.toString());
// Ask if user wants to start the tunnel now
const startAnswers = await inquirer.prompt([
{
type: 'confirm',
name: 'startNow',
message: 'Would you like to start this tunnel now?',
default: true
}
]);
if (startAnswers.startNow) {
await startTunnelById(tunnelInfo);
}
} else {
logger.error(`Error creating tunnel: ${response.message || 'Unknown error'}`);
}
} catch (err) {
logger.error('Error during tunnel creation: ' + err.message);
}
}
// Command: List tunnels
async function listTunnels() {
try {
const response = await connectAndSend({
type: 'list_tunnels'
});
if (response.type === 'tunnels_list') {
if (response.tunnels.length === 0) {
logger.info('No tunnels yet');
return;
}
console.log(colors.title('\nTunnels:'));
const table = new Table({
head: [
colors.title('ID'),
colors.title('Name'),
colors.title('Out/Port'),
colors.title('Description'),
colors.title('Sent'),
colors.title('Received'),
colors.title('Connections'),
colors.title('Last Activity')
],
colWidths: [5, 20, 10, 30, 12, 12, 12, 25]
});
const tunnelsData = await loadActiveTunnels();
response.tunnels.forEach(tunnel => {
// Check if active
const isActive = tunnelsData.active && tunnelsData.active.some(t => t.name === tunnel.name);
// Highlight tunnel name if active
const tunnelName = isActive ? colors.highlight(tunnel.name) : tunnel.name;
table.push([
tunnel.id,
tunnelName,
tunnel.port,
tunnel.description || '-',
formatBytes(tunnel.bytes_sent || 0),
formatBytes(tunnel.bytes_received || 0),
tunnel.active_connections || 0,
tunnel.lastActiveAt ? new Date(tunnel.lastActiveAt).toLocaleString() : '-'
]);
});
console.log(table.toString());
console.log(`\nTotal: ${response.tunnels.length} tunnels\n`);
// Show extra info about active tunnels
const activeTunnels = tunnelsData.active && tunnelsData.active.filter(async t => await isProcessRunning(t.pid));
if (activeTunnels && activeTunnels.length > 0) {
console.log(colors.title('Note: Highlighted tunnels are currently active.\n'));
}
} else {
logger.error('Error listing tunnels');
}
} catch (err) {
logger.error('Error during tunnel listing: ' + err.message);
}
}
// Start a specific tunnel by ID
async function startTunnelById(tunnelInfo) {
try {
const tunnelsData = await loadActiveTunnels();
// Check if tunnel is already running
const existingActiveTunnel = tunnelsData