Initial Commit
This commit is contained in:
commit
322909b8af
|
|
@ -0,0 +1,5 @@
|
|||
JWT_SECRET=your-shared-secret-here
|
||||
PORT=3002
|
||||
|
||||
# For local Electron agent dev (default: https://tunnel.rokojori.com)
|
||||
# TUNNEL_SERVER_URL=http://localhost:3002
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
.env
|
||||
storage/
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
DEPLOY_DIR="/opt/tunnel-rokojori"
|
||||
SERVICE="tunnel-rokojori"
|
||||
NGINX_CONF="/etc/nginx/sites-available/tunnel-rokojori.conf"
|
||||
|
||||
echo "=== Deploying Rokojori Tunnel Server ==="
|
||||
|
||||
cd "$DEPLOY_DIR"
|
||||
|
||||
echo "→ Pulling latest code…"
|
||||
git pull
|
||||
|
||||
echo "→ Installing dependencies…"
|
||||
npm install --omit=dev
|
||||
|
||||
echo "→ Restarting service…"
|
||||
sudo systemctl restart "$SERVICE"
|
||||
sudo systemctl status "$SERVICE" --no-pager -l
|
||||
|
||||
echo "=== Done ==="
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name tunnel.rokojori.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name tunnel.rokojori.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/tunnel.rokojori.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/tunnel.rokojori.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# WebSocket upgrade for agent connections — needs long timeout
|
||||
location /api/agent/ {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# HTTP proxy + API routes
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
client_max_body_size 100m;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
[Unit]
|
||||
Description=Rokojori Tunnel Server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/tunnel-rokojori
|
||||
ExecStart=/usr/bin/npx ts-node --project tsconfig.ts-node.json source/server/index.ts
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=tunnel-rokojori
|
||||
EnvironmentFile=/opt/tunnel-rokojori/.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import WebSocket from 'ws';
|
||||
import http from 'http';
|
||||
|
||||
export interface TunnelAgentConfig
|
||||
{
|
||||
tunnelId: string;
|
||||
token: string;
|
||||
localPort: number;
|
||||
serverUrl: string; // e.g. https://tunnel.rokojori.com
|
||||
}
|
||||
|
||||
interface RelayRequest
|
||||
{
|
||||
reqId: string;
|
||||
method: string;
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: string; // base64
|
||||
}
|
||||
|
||||
interface RelayResponse
|
||||
{
|
||||
reqId: string;
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: string; // base64
|
||||
}
|
||||
|
||||
export class TunnelAgent
|
||||
{
|
||||
config: TunnelAgentConfig;
|
||||
ws: WebSocket | null = null;
|
||||
_active = false;
|
||||
_shouldRun = false;
|
||||
_reconnDelay = 2000;
|
||||
_maxDelay = 30_000;
|
||||
|
||||
onStatus: ( active: boolean ) => void = () => {};
|
||||
|
||||
constructor( config: TunnelAgentConfig ) { this.config = config; }
|
||||
|
||||
start(): void { this._shouldRun = true; this._connect(); }
|
||||
stop(): void { this._shouldRun = false; this.ws?.close(); this.ws = null; this._setActive( false ); }
|
||||
isActive(): boolean { return this._active; }
|
||||
|
||||
private _setActive( v: boolean ): void
|
||||
{
|
||||
if ( this._active === v ) return;
|
||||
this._active = v;
|
||||
this.onStatus( v );
|
||||
}
|
||||
|
||||
private _connect(): void
|
||||
{
|
||||
if ( !this._shouldRun ) return;
|
||||
|
||||
const { serverUrl, tunnelId, token } = this.config;
|
||||
const wsBase = serverUrl.replace( /^http/, 'ws' );
|
||||
const url = `${ wsBase }/api/agent/${ tunnelId }?token=${ token }`;
|
||||
|
||||
this.ws = new WebSocket( url );
|
||||
|
||||
this.ws.on( 'open', () =>
|
||||
{
|
||||
this._reconnDelay = 2000;
|
||||
this._setActive( true );
|
||||
console.log( `[agent ${ tunnelId.slice( 0, 8 ) }] connected → :${ this.config.localPort }` );
|
||||
} );
|
||||
|
||||
this.ws.on( 'message', data => this._forward( data as Buffer ) );
|
||||
|
||||
this.ws.on( 'close', () =>
|
||||
{
|
||||
this._setActive( false );
|
||||
if ( this._shouldRun )
|
||||
{
|
||||
setTimeout( () => this._connect(), this._reconnDelay );
|
||||
this._reconnDelay = Math.min( this._reconnDelay * 2, this._maxDelay );
|
||||
}
|
||||
} );
|
||||
|
||||
this.ws.on( 'error', err =>
|
||||
console.error( `[agent ${ tunnelId.slice( 0, 8 ) }] error: ${ err.message }` )
|
||||
);
|
||||
}
|
||||
|
||||
private _forward( data: Buffer ): void
|
||||
{
|
||||
let req: RelayRequest;
|
||||
try { req = JSON.parse( data.toString() ) as RelayRequest; }
|
||||
catch { return; }
|
||||
|
||||
const bodyBuf = Buffer.from( req.body ?? '', 'base64' );
|
||||
|
||||
const opts: http.RequestOptions = {
|
||||
hostname: 'localhost',
|
||||
port: this.config.localPort,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
headers: { ...req.headers, 'content-length': String( bodyBuf.length ) },
|
||||
};
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
const localReq = http.request( opts, localRes =>
|
||||
{
|
||||
localRes.on( 'data', ( chunk: Buffer ) => chunks.push( Buffer.from( chunk ) ) );
|
||||
localRes.on( 'end', () =>
|
||||
{
|
||||
const respHeaders: Record<string, string> = {};
|
||||
for ( const [ k, v ] of Object.entries( localRes.headers ) )
|
||||
{
|
||||
if ( typeof v === 'string' ) respHeaders[ k ] = v;
|
||||
else if ( Array.isArray( v ) ) respHeaders[ k ] = v.join( ', ' );
|
||||
}
|
||||
|
||||
const resp: RelayResponse = {
|
||||
reqId: req.reqId,
|
||||
status: localRes.statusCode ?? 200,
|
||||
headers: respHeaders,
|
||||
body: Buffer.concat( chunks ).toString( 'base64' ),
|
||||
};
|
||||
this.ws?.send( JSON.stringify( resp ) );
|
||||
} );
|
||||
} );
|
||||
|
||||
localReq.on( 'error', err =>
|
||||
{
|
||||
const errResp: RelayResponse = {
|
||||
reqId: req.reqId,
|
||||
status: 502,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: Buffer.from( JSON.stringify( { error: 'Local service error', detail: err.message } ) )
|
||||
.toString( 'base64' ),
|
||||
};
|
||||
this.ws?.send( JSON.stringify( errResp ) );
|
||||
} );
|
||||
|
||||
if ( bodyBuf.length > 0 ) localReq.write( bodyBuf );
|
||||
localReq.end();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Tunnel Agent — Sign in</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #0f0f0f;
|
||||
color: #e8e8e8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card { width: 340px; padding: 2.5rem 2rem; }
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.2rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.subtitle { font-size: 0.83rem; color: #666; margin-bottom: 2rem; }
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
margin-bottom: 0.35rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
color: #e8e8e8;
|
||||
font-size: 0.92rem;
|
||||
outline: none;
|
||||
margin-bottom: 1.1rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input:focus { border-color: #555; }
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.68rem;
|
||||
background: #e8e8e8;
|
||||
color: #0f0f0f;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
button:hover { background: #fff; }
|
||||
button:disabled { background: #333; color: #666; cursor: default; }
|
||||
|
||||
.error {
|
||||
font-size: 0.8rem;
|
||||
color: #e05555;
|
||||
margin-top: 0.8rem;
|
||||
min-height: 1.1em;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Tunnel Agent</h1>
|
||||
<p class="subtitle">Sign in with your rokojori account</p>
|
||||
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" placeholder="you@example.com" autocomplete="email">
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" placeholder="••••••••" autocomplete="current-password">
|
||||
|
||||
<button id="btn">Sign in</button>
|
||||
<p class="error" id="error"></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const emailEl = document.getElementById( 'email' );
|
||||
const passwordEl = document.getElementById( 'password' );
|
||||
const btn = document.getElementById( 'btn' );
|
||||
const errorEl = document.getElementById( 'error' );
|
||||
|
||||
async function login()
|
||||
{
|
||||
const email = emailEl.value.trim();
|
||||
const password = passwordEl.value;
|
||||
if ( !email || !password ) { errorEl.textContent = 'Please enter email and password.'; return; }
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Signing in…';
|
||||
errorEl.textContent = '';
|
||||
|
||||
const result = await window.tunnelAPI.login( email, password );
|
||||
|
||||
if ( result.ok )
|
||||
{
|
||||
window.tunnelAPI.loginSuccess();
|
||||
}
|
||||
else
|
||||
{
|
||||
errorEl.textContent = result.error ?? 'Sign in failed.';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Sign in';
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener( 'click', login );
|
||||
document.addEventListener( 'keydown', e => { if ( e.key === 'Enter' ) login(); } );
|
||||
emailEl.focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
import { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage } from 'electron';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import https from 'https';
|
||||
import { TunnelAgent } from './agent/TunnelAgent';
|
||||
|
||||
function loadEnv(): void
|
||||
{
|
||||
const envPath = path.join( __dirname, '..', '..', '.env' );
|
||||
try
|
||||
{
|
||||
const lines = fs.readFileSync( envPath, 'utf-8' ).split( /\r?\n/ );
|
||||
for ( const line of lines )
|
||||
{
|
||||
const trimmed = line.trim();
|
||||
if ( !trimmed || trimmed.startsWith( '#' ) ) continue;
|
||||
const eq = trimmed.indexOf( '=' );
|
||||
if ( eq === -1 ) continue;
|
||||
const key = trimmed.slice( 0, eq ).trim();
|
||||
const val = trimmed.slice( eq + 1 ).trim().replace( /^["']|["']$/g, '' );
|
||||
if ( key && !( key in process.env ) ) process.env[ key ] = val;
|
||||
}
|
||||
}
|
||||
catch { /* no .env — rely on inherited env */ }
|
||||
}
|
||||
|
||||
loadEnv();
|
||||
|
||||
const AUTH_HOST = 'https://account.rokojori.com';
|
||||
const TUNNEL_SERVER_URL = process.env.TUNNEL_SERVER_URL ?? 'https://tunnel.rokojori.com';
|
||||
|
||||
// ── Tokens ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Tokens { accessToken: string; refreshToken: string; }
|
||||
|
||||
function tokenFile(): string { return path.join( app.getPath( 'userData' ), 'tokens.json' ); }
|
||||
|
||||
function loadTokens(): Tokens | null
|
||||
{
|
||||
try { return JSON.parse( fs.readFileSync( tokenFile(), 'utf-8' ) ) as Tokens; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
function saveTokens( t: Tokens ): void { fs.writeFileSync( tokenFile(), JSON.stringify( t ), 'utf-8' ); }
|
||||
function clearTokens(): void { try { fs.unlinkSync( tokenFile() ); } catch { /* already gone */ } }
|
||||
|
||||
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function postJson( url: string, body: unknown ): Promise<unknown>
|
||||
{
|
||||
return new Promise( ( resolve, reject ) =>
|
||||
{
|
||||
const data = JSON.stringify( body );
|
||||
const parsed = new URL( url );
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
path: parsed.pathname,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength( data ) },
|
||||
},
|
||||
res =>
|
||||
{
|
||||
let raw = '';
|
||||
res.on( 'data', ( chunk: string ) => { raw += chunk; } );
|
||||
res.on( 'end', () => { try { resolve( JSON.parse( raw ) ); } catch { reject( new Error( raw ) ); } } );
|
||||
}
|
||||
);
|
||||
req.on( 'error', reject );
|
||||
req.write( data );
|
||||
req.end();
|
||||
} );
|
||||
}
|
||||
|
||||
async function apiFetch( apiPath: string, options: RequestInit = {} ): Promise<Response>
|
||||
{
|
||||
const url = `${ TUNNEL_SERVER_URL }${ apiPath }`;
|
||||
return fetch( url,
|
||||
{
|
||||
...options,
|
||||
headers:
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${ currentTokens?.accessToken ?? '' }`,
|
||||
...( options.headers ?? {} ),
|
||||
},
|
||||
} );
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let currentTokens: Tokens | null = null;
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let loginWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
const agents = new Map<string, TunnelAgent>();
|
||||
|
||||
// ── Tray ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildTray(): void
|
||||
{
|
||||
const iconPath = path.join( __dirname, '..', '..', 'assets', 'tray-icon.png' );
|
||||
const icon = fs.existsSync( iconPath )
|
||||
? nativeImage.createFromPath( iconPath )
|
||||
: nativeImage.createEmpty();
|
||||
|
||||
tray = new Tray( icon );
|
||||
tray.setToolTip( 'Rokojori Tunnel Agent' );
|
||||
|
||||
const menu = Menu.buildFromTemplate(
|
||||
[
|
||||
{ label: 'Show Window', click: showMainWindow },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Disconnect All',
|
||||
click: () =>
|
||||
{
|
||||
for ( const [ id, agent ] of agents )
|
||||
{
|
||||
agent.stop();
|
||||
mainWindow?.webContents.send( 'tunnel:status', id, false );
|
||||
}
|
||||
agents.clear();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Quit', role: 'quit' },
|
||||
] );
|
||||
|
||||
tray.setContextMenu( menu );
|
||||
tray.on( 'double-click', showMainWindow );
|
||||
tray.on( 'click', showMainWindow ); // Windows single-click
|
||||
}
|
||||
|
||||
function showMainWindow(): void
|
||||
{
|
||||
if ( mainWindow ) { mainWindow.show(); mainWindow.focus(); }
|
||||
else createMainWindow();
|
||||
}
|
||||
|
||||
// ── Windows ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function createLoginWindow(): void
|
||||
{
|
||||
loginWindow = new BrowserWindow(
|
||||
{
|
||||
width: 420, height: 520, resizable: false,
|
||||
webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join( __dirname, 'preload.js' ) },
|
||||
title: 'Tunnel Agent — Sign in',
|
||||
} );
|
||||
loginWindow.loadFile( path.join( __dirname, 'login.html' ) );
|
||||
loginWindow.on( 'closed', () => { loginWindow = null; } );
|
||||
}
|
||||
|
||||
function createMainWindow(): void
|
||||
{
|
||||
mainWindow = new BrowserWindow(
|
||||
{
|
||||
width: 400, height: 560, minWidth: 320, minHeight: 400,
|
||||
webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join( __dirname, 'preload.js' ) },
|
||||
title: 'Tunnel Agent',
|
||||
} );
|
||||
mainWindow.loadFile( path.join( __dirname, 'window.html' ) );
|
||||
mainWindow.on( 'closed', () => { mainWindow = null; } );
|
||||
}
|
||||
|
||||
// ── IPC ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function registerIPC(): void
|
||||
{
|
||||
// Login
|
||||
ipcMain.handle( 'auth:login', async ( _e, email: string, password: string ) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const result = await postJson( `${ AUTH_HOST }/api/auth/login`, { email, password } ) as Record<string, unknown>;
|
||||
if ( result.accessToken && result.refreshToken )
|
||||
{
|
||||
currentTokens = { accessToken: result.accessToken as string, refreshToken: result.refreshToken as string };
|
||||
saveTokens( currentTokens );
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, error: ( result.error as string ) ?? 'Login failed' };
|
||||
}
|
||||
catch ( err ) { return { ok: false, error: String( err ) }; }
|
||||
} );
|
||||
|
||||
ipcMain.on( 'auth:login-success', () =>
|
||||
{
|
||||
loginWindow?.close();
|
||||
createMainWindow();
|
||||
if ( !tray ) buildTray();
|
||||
} );
|
||||
|
||||
// List tunnels
|
||||
ipcMain.handle( 'tunnels:list', async () =>
|
||||
{
|
||||
const res = await apiFetch( '/api/tunnels' );
|
||||
const tunnels = await res.json() as Array<Record<string, unknown>>;
|
||||
return tunnels.map( t => ( { ...t, agentActive: agents.get( t.id as string )?.isActive() ?? false } ) );
|
||||
} );
|
||||
|
||||
// Connect
|
||||
ipcMain.handle( 'tunnel:connect', async ( _e, tunnelId: string ) =>
|
||||
{
|
||||
if ( agents.has( tunnelId ) ) return;
|
||||
|
||||
const res = await apiFetch( `/api/tunnels/${ tunnelId }` );
|
||||
const tunnel = await res.json() as { localPort: number };
|
||||
|
||||
const agent = new TunnelAgent(
|
||||
{
|
||||
tunnelId,
|
||||
token: currentTokens!.accessToken,
|
||||
localPort: tunnel.localPort,
|
||||
serverUrl: TUNNEL_SERVER_URL,
|
||||
} );
|
||||
|
||||
agent.onStatus = ( active ) =>
|
||||
mainWindow?.webContents.send( 'tunnel:status', tunnelId, active );
|
||||
|
||||
agents.set( tunnelId, agent );
|
||||
agent.start();
|
||||
} );
|
||||
|
||||
// Disconnect
|
||||
ipcMain.handle( 'tunnel:disconnect', ( _e, tunnelId: string ) =>
|
||||
{
|
||||
const agent = agents.get( tunnelId );
|
||||
agent?.stop();
|
||||
agents.delete( tunnelId );
|
||||
mainWindow?.webContents.send( 'tunnel:status', tunnelId, false );
|
||||
} );
|
||||
|
||||
// Add tunnel
|
||||
ipcMain.handle( 'tunnel:add', async ( _e, data: object ) =>
|
||||
{
|
||||
const res = await apiFetch( '/api/tunnels', { method: 'POST', body: JSON.stringify( data ) } );
|
||||
if ( !res.ok )
|
||||
{
|
||||
const err = await res.json() as { error: string };
|
||||
throw new Error( err.error ?? 'Failed to create tunnel' );
|
||||
}
|
||||
return res.json();
|
||||
} );
|
||||
|
||||
// Delete tunnel
|
||||
ipcMain.handle( 'tunnel:delete', async ( _e, tunnelId: string ) =>
|
||||
{
|
||||
agents.get( tunnelId )?.stop();
|
||||
agents.delete( tunnelId );
|
||||
await apiFetch( `/api/tunnels/${ tunnelId }`, { method: 'DELETE' } );
|
||||
} );
|
||||
}
|
||||
|
||||
// ── Startup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.whenReady().then( () =>
|
||||
{
|
||||
registerIPC();
|
||||
|
||||
currentTokens = loadTokens();
|
||||
|
||||
if ( currentTokens )
|
||||
{
|
||||
createMainWindow();
|
||||
buildTray();
|
||||
}
|
||||
else
|
||||
{
|
||||
createLoginWindow();
|
||||
}
|
||||
|
||||
app.on( 'activate', () =>
|
||||
{
|
||||
if ( BrowserWindow.getAllWindows().length === 0 )
|
||||
{
|
||||
if ( currentTokens ) createMainWindow();
|
||||
else createLoginWindow();
|
||||
}
|
||||
} );
|
||||
} );
|
||||
|
||||
// Stay alive in tray when all windows are closed
|
||||
app.on( 'window-all-closed', () => { /* tray keeps the app alive */ } );
|
||||
|
||||
app.on( 'before-quit', () =>
|
||||
{
|
||||
for ( const agent of agents.values() ) agent.stop();
|
||||
agents.clear();
|
||||
} );
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
contextBridge.exposeInMainWorld( 'tunnelAPI', {
|
||||
login: ( email: string, password: string ) => ipcRenderer.invoke( 'auth:login', email, password ),
|
||||
loginSuccess: () => ipcRenderer.send( 'auth:login-success' ),
|
||||
|
||||
listTunnels: () => ipcRenderer.invoke( 'tunnels:list' ),
|
||||
connectTunnel: ( id: string ) => ipcRenderer.invoke( 'tunnel:connect', id ),
|
||||
disconnectTunnel:( id: string ) => ipcRenderer.invoke( 'tunnel:disconnect', id ),
|
||||
addTunnel: ( data: object )=> ipcRenderer.invoke( 'tunnel:add', data ),
|
||||
deleteTunnel: ( id: string ) => ipcRenderer.invoke( 'tunnel:delete', id ),
|
||||
|
||||
onStatusChange: ( cb: ( id: string, active: boolean ) => void ) =>
|
||||
ipcRenderer.on( 'tunnel:status', ( _e, id, active ) => cb( id, active ) ),
|
||||
} );
|
||||
|
|
@ -0,0 +1,397 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Tunnel Agent</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #0f0f0f;
|
||||
color: #d8d8d8;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #1e1e1e;
|
||||
background: #111;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-title { flex: 1; font-size: 13px; font-weight: 600; color: #ccc; }
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.icon-btn:hover { color: #ccc; background: #1e1e1e; }
|
||||
|
||||
/* ── Tunnel list ── */
|
||||
.tunnels {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #444;
|
||||
font-size: 12px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tunnel-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 11px;
|
||||
border-radius: 7px;
|
||||
background: #161616;
|
||||
border: 1px solid #1e1e1e;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: #2a2a2a;
|
||||
transition: background 0.25s, box-shadow 0.25s;
|
||||
}
|
||||
.dot.active {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 5px #22c55e99;
|
||||
}
|
||||
|
||||
.t-info { flex: 1; min-width: 0; }
|
||||
.t-name { font-weight: 600; color: #ccc; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.t-meta { font-size: 11px; color: #555; margin-top: 2px; }
|
||||
|
||||
.t-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 3px 9px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border: 1px solid;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.t-btn.conn { background: #0e2a52; border-color: #1a5fd4; color: #7aaeff; }
|
||||
.t-btn.conn:hover { background: #1a3a70; }
|
||||
.t-btn.disc { background: #3a1212; border-color: #802020; color: #f88; }
|
||||
.t-btn.disc:hover { background: #4a1a1a; }
|
||||
|
||||
.del-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
border: 1px solid #232323;
|
||||
background: none;
|
||||
color: #444;
|
||||
margin-left: 2px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.del-btn:hover { color: #e05555; border-color: #e05555; }
|
||||
|
||||
/* ── Footer ── */
|
||||
.footer {
|
||||
flex-shrink: 0;
|
||||
padding: 8px;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
}
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
padding: 7px;
|
||||
background: #141414;
|
||||
border: 1px dashed #272727;
|
||||
color: #555;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.add-btn:hover { border-color: #444; color: #888; }
|
||||
|
||||
/* ── Add form ── */
|
||||
.add-form {
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
background: #0d0d0d;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.f-row { display: flex; flex-direction: column; gap: 3px; }
|
||||
.f-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #555;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.f-input {
|
||||
background: #181818;
|
||||
border: 1px solid #252525;
|
||||
border-radius: 5px;
|
||||
color: #ddd;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.f-input:focus { border-color: #2a4a8a; }
|
||||
select.f-input { cursor: pointer; appearance: none; }
|
||||
|
||||
.f-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
|
||||
.f-actions { display: flex; gap: 6px; margin-top: 2px; }
|
||||
.f-submit {
|
||||
flex: 1;
|
||||
padding: 6px;
|
||||
background: #1a4a8a;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: #aacfff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.f-submit:hover { background: #1a5fd4; }
|
||||
.f-cancel {
|
||||
padding: 6px 12px;
|
||||
background: #181818;
|
||||
border: 1px solid #252525;
|
||||
border-radius: 5px;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.f-cancel:hover { color: #888; }
|
||||
|
||||
.form-error { font-size: 11px; color: #e05555; min-height: 1em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<span class="header-title">Tunnel Agent</span>
|
||||
<button class="icon-btn" id="refresh-btn" title="Refresh list">↻</button>
|
||||
</div>
|
||||
|
||||
<div class="tunnels" id="tunnel-list">
|
||||
<div class="empty">Loading…</div>
|
||||
</div>
|
||||
|
||||
<div class="footer" id="footer">
|
||||
<button class="add-btn" id="show-add-btn">+ Add Tunnel</button>
|
||||
</div>
|
||||
|
||||
<div class="add-form" id="add-form" style="display:none">
|
||||
<div class="f-row">
|
||||
<span class="f-label">Name</span>
|
||||
<input class="f-input" id="f-name" placeholder="My LLM Server">
|
||||
</div>
|
||||
<div class="f-grid">
|
||||
<div class="f-row">
|
||||
<span class="f-label">Purpose</span>
|
||||
<select class="f-input" id="f-purpose">
|
||||
<option value="llm-openai-compatible">LLM (OpenAI compat.)</option>
|
||||
<option value="stable-diffusion">Stable Diffusion</option>
|
||||
<option value="general">General</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="f-row">
|
||||
<span class="f-label">Local Port</span>
|
||||
<input class="f-input" id="f-port" type="number" placeholder="11434" min="1" max="65535">
|
||||
</div>
|
||||
</div>
|
||||
<div class="f-row">
|
||||
<span class="f-label">Description (optional)</span>
|
||||
<input class="f-input" id="f-desc" placeholder="e.g. Ollama on desktop">
|
||||
</div>
|
||||
<div class="f-row">
|
||||
<span class="f-label">Access</span>
|
||||
<select class="f-input" id="f-access">
|
||||
<option value="private">Private — only me</option>
|
||||
<option value="public">Public — all users</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="form-error" id="form-error"></p>
|
||||
<div class="f-actions">
|
||||
<button class="f-submit" id="create-btn">Create Tunnel</button>
|
||||
<button class="f-cancel" id="cancel-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const api = window.tunnelAPI;
|
||||
let tunnels = [];
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
|
||||
function esc( s )
|
||||
{
|
||||
return String( s )
|
||||
.replace( /&/g, '&' )
|
||||
.replace( /</g, '<' )
|
||||
.replace( />/g, '>' );
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────
|
||||
|
||||
function render()
|
||||
{
|
||||
const list = document.getElementById( 'tunnel-list' );
|
||||
|
||||
if ( !tunnels.length )
|
||||
{
|
||||
list.innerHTML = '<div class="empty">No tunnels yet.<br>Add one below.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = tunnels.map( t => `
|
||||
<div class="tunnel-item">
|
||||
<div class="dot ${ t.agentActive ? 'active' : '' }"></div>
|
||||
<div class="t-info">
|
||||
<div class="t-name">${ esc( t.name ) }</div>
|
||||
<div class="t-meta">${ esc( t.purpose ) } · :${ t.localPort }</div>
|
||||
</div>
|
||||
${ t.agentActive
|
||||
? `<button class="t-btn disc" data-action="disconnect" data-id="${ t.id }">Disconnect</button>`
|
||||
: `<button class="t-btn conn" data-action="connect" data-id="${ t.id }">Connect</button>`
|
||||
}
|
||||
<button class="del-btn" data-action="delete" data-id="${ t.id }" title="Delete tunnel">✕</button>
|
||||
</div>
|
||||
` ).join( '' );
|
||||
}
|
||||
|
||||
// ── Load ─────────────────────────────────────────────────
|
||||
|
||||
async function refresh()
|
||||
{
|
||||
try
|
||||
{
|
||||
tunnels = await api.listTunnels();
|
||||
render();
|
||||
}
|
||||
catch
|
||||
{
|
||||
document.getElementById( 'tunnel-list' ).innerHTML =
|
||||
'<div class="empty">Could not reach tunnel server.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────
|
||||
|
||||
document.getElementById( 'tunnel-list' ).addEventListener( 'click', async e =>
|
||||
{
|
||||
const btn = e.target.closest( '[data-action]' );
|
||||
if ( !btn ) return;
|
||||
|
||||
const action = btn.dataset.action;
|
||||
const id = btn.dataset.id;
|
||||
|
||||
if ( action === 'connect' )
|
||||
{
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
await api.connectTunnel( id );
|
||||
}
|
||||
else if ( action === 'disconnect' )
|
||||
{
|
||||
await api.disconnectTunnel( id );
|
||||
}
|
||||
else if ( action === 'delete' )
|
||||
{
|
||||
const t = tunnels.find( t => t.id === id );
|
||||
if ( !confirm( `Delete "${ t?.name ?? id }"? This cannot be undone.` ) ) return;
|
||||
await api.deleteTunnel( id );
|
||||
await refresh();
|
||||
}
|
||||
} );
|
||||
|
||||
// Status pushed from main process
|
||||
api.onStatusChange( ( id, active ) =>
|
||||
{
|
||||
const t = tunnels.find( t => t.id === id );
|
||||
if ( t ) { t.agentActive = active; render(); }
|
||||
} );
|
||||
|
||||
// ── Add form ─────────────────────────────────────────────
|
||||
|
||||
document.getElementById( 'show-add-btn' ).addEventListener( 'click', () =>
|
||||
{
|
||||
document.getElementById( 'add-form' ).style.display = '';
|
||||
document.getElementById( 'footer' ).style.display = 'none';
|
||||
} );
|
||||
|
||||
function hideForm()
|
||||
{
|
||||
document.getElementById( 'add-form' ).style.display = 'none';
|
||||
document.getElementById( 'footer' ).style.display = '';
|
||||
document.getElementById( 'form-error' ).textContent = '';
|
||||
}
|
||||
|
||||
document.getElementById( 'cancel-btn' ).addEventListener( 'click', hideForm );
|
||||
|
||||
document.getElementById( 'create-btn' ).addEventListener( 'click', async () =>
|
||||
{
|
||||
const name = document.getElementById( 'f-name' ).value.trim();
|
||||
const port = parseInt( document.getElementById( 'f-port' ).value );
|
||||
const purpose = document.getElementById( 'f-purpose' ).value;
|
||||
const desc = document.getElementById( 'f-desc' ).value.trim();
|
||||
const access = document.getElementById( 'f-access' ).value;
|
||||
|
||||
if ( !name ) { document.getElementById( 'form-error' ).textContent = 'Name is required.'; return; }
|
||||
if ( !port || port < 1 || port > 65535 )
|
||||
{
|
||||
document.getElementById( 'form-error' ).textContent = 'Enter a valid local port (1–65535).';
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await api.addTunnel( { name, localPort: port, purpose, description: desc, access } );
|
||||
document.getElementById( 'f-name' ).value = '';
|
||||
document.getElementById( 'f-port' ).value = '';
|
||||
document.getElementById( 'f-desc' ).value = '';
|
||||
hideForm();
|
||||
await refresh();
|
||||
}
|
||||
catch ( err )
|
||||
{
|
||||
document.getElementById( 'form-error' ).textContent = String( err );
|
||||
}
|
||||
} );
|
||||
|
||||
document.getElementById( 'refresh-btn' ).addEventListener( 'click', refresh );
|
||||
|
||||
refresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"name": "rokojori-tunnel",
|
||||
"version": "1.0.0",
|
||||
"main": "build/electron/main.js",
|
||||
"scripts": {
|
||||
"start": "ts-node --project tsconfig.ts-node.json source/server/index.ts",
|
||||
"dev": "ts-node --project tsconfig.ts-node.json source/server/index.ts",
|
||||
"agent": "ts-node --project tsconfig.ts-node.json scripts/test-agent.ts",
|
||||
"electron:build": "tsc --project tsconfig.electron.json && node scripts/copy-electron-assets.js",
|
||||
"electron:dev": "npm run electron:build && electron .",
|
||||
"electron:dist": "npm run electron:build && electron-builder"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie-parser": "^1.4.6",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"ws": "^8.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/ws": "^8.5.10",
|
||||
"electron": "^35.7.5",
|
||||
"electron-builder": "^26.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.rokojori.tunnel-agent",
|
||||
"productName": "Rokojori Tunnel Agent",
|
||||
"directories": {
|
||||
"buildResources": "assets",
|
||||
"output": "dist-electron"
|
||||
},
|
||||
"files": [
|
||||
"build/electron/**/*"
|
||||
],
|
||||
"win": { "target": "portable" },
|
||||
"mac": { "target": "dmg" },
|
||||
"linux": { "target": "AppImage" }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
const fs = require( 'fs' );
|
||||
const path = require( 'path' );
|
||||
|
||||
const src = path.join( __dirname, '..', 'electron-agent' );
|
||||
const dest = path.join( __dirname, '..', 'build', 'electron' );
|
||||
|
||||
fs.mkdirSync( dest, { recursive: true } );
|
||||
|
||||
for ( const file of [ 'login.html', 'window.html' ] )
|
||||
{
|
||||
const from = path.join( src, file );
|
||||
const to = path.join( dest, file );
|
||||
if ( fs.existsSync( from ) ) fs.copyFileSync( from, to );
|
||||
}
|
||||
|
||||
console.log( 'Electron assets copied.' );
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* Standalone test agent — connects to the tunnel server and forwards
|
||||
* inbound requests to a local port. Use this to validate Phase 1
|
||||
* before the Electron app exists.
|
||||
*
|
||||
* Usage:
|
||||
* TUNNEL_SERVER=ws://localhost:3002 \
|
||||
* TUNNEL_ID=<uuid> \
|
||||
* TOKEN=<accessToken> \
|
||||
* LOCAL_PORT=11434 \
|
||||
* npm run agent
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import WebSocket from 'ws';
|
||||
import http from 'http';
|
||||
|
||||
const SERVER = process.env.TUNNEL_SERVER ?? 'ws://localhost:3002';
|
||||
const TUNNEL_ID = process.env.TUNNEL_ID ?? '';
|
||||
const TOKEN = process.env.TOKEN ?? '';
|
||||
const LOCAL_PORT = Number( process.env.LOCAL_PORT ?? 11434 );
|
||||
const RECONNECT_DELAY_MS = 3_000;
|
||||
|
||||
if ( !TUNNEL_ID || !TOKEN )
|
||||
{
|
||||
console.error( 'TUNNEL_ID and TOKEN env vars are required' );
|
||||
process.exit( 1 );
|
||||
}
|
||||
|
||||
interface RelayRequest
|
||||
{
|
||||
reqId: string;
|
||||
method: string;
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: string; // base64
|
||||
}
|
||||
|
||||
interface RelayResponse
|
||||
{
|
||||
reqId: string;
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: string; // base64
|
||||
}
|
||||
|
||||
function forward( req: RelayRequest, ws: WebSocket ): void
|
||||
{
|
||||
const bodyBuf = Buffer.from( req.body, 'base64' );
|
||||
|
||||
const options: http.RequestOptions = {
|
||||
hostname: 'localhost',
|
||||
port: LOCAL_PORT,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
headers: {
|
||||
...req.headers,
|
||||
'content-length': String( bodyBuf.length ),
|
||||
},
|
||||
};
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
const localReq = http.request( options, ( localRes ) =>
|
||||
{
|
||||
localRes.on( 'data', chunk => chunks.push( Buffer.from( chunk ) ) );
|
||||
localRes.on( 'end', () =>
|
||||
{
|
||||
const respHeaders: Record<string, string> = {};
|
||||
for ( const [ k, v ] of Object.entries( localRes.headers ) )
|
||||
{
|
||||
if ( typeof v === 'string' ) respHeaders[ k ] = v;
|
||||
else if ( Array.isArray( v ) ) respHeaders[ k ] = v.join( ', ' );
|
||||
}
|
||||
|
||||
const resp: RelayResponse = {
|
||||
reqId: req.reqId,
|
||||
status: localRes.statusCode ?? 200,
|
||||
headers: respHeaders,
|
||||
body: Buffer.concat( chunks ).toString( 'base64' ),
|
||||
};
|
||||
ws.send( JSON.stringify( resp ) );
|
||||
} );
|
||||
} );
|
||||
|
||||
localReq.on( 'error', ( err ) =>
|
||||
{
|
||||
console.error( `[agent] local request failed: ${ err.message }` );
|
||||
const errResp: RelayResponse = {
|
||||
reqId: req.reqId,
|
||||
status: 502,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: Buffer.from( JSON.stringify( { error: 'Local service error', detail: err.message } ) )
|
||||
.toString( 'base64' ),
|
||||
};
|
||||
ws.send( JSON.stringify( errResp ) );
|
||||
} );
|
||||
|
||||
if ( bodyBuf.length > 0 ) localReq.write( bodyBuf );
|
||||
localReq.end();
|
||||
}
|
||||
|
||||
function connect(): void
|
||||
{
|
||||
const url = `${ SERVER }/api/agent/${ TUNNEL_ID }?token=${ TOKEN }`;
|
||||
const ws = new WebSocket( url );
|
||||
|
||||
ws.on( 'open', () =>
|
||||
console.log( `[agent] connected → forwarding to localhost:${ LOCAL_PORT }` )
|
||||
);
|
||||
|
||||
ws.on( 'message', ( data ) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const req: RelayRequest = JSON.parse( data.toString() );
|
||||
console.log( `[agent] → ${ req.method } ${ req.path }` );
|
||||
forward( req, ws );
|
||||
}
|
||||
catch ( err )
|
||||
{
|
||||
console.error( '[agent] failed to parse message:', err );
|
||||
}
|
||||
} );
|
||||
|
||||
ws.on( 'close', ( code, reason ) =>
|
||||
{
|
||||
console.log( `[agent] disconnected (${ code }: ${ reason }). Reconnecting in ${ RECONNECT_DELAY_MS / 1000 }s...` );
|
||||
setTimeout( connect, RECONNECT_DELAY_MS );
|
||||
} );
|
||||
|
||||
ws.on( 'error', ( err ) => console.error( '[agent] error:', err.message ) );
|
||||
}
|
||||
|
||||
connect();
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export type AccessMode = 'private' | 'public';
|
||||
|
||||
export interface TunnelConfig
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
purpose: string;
|
||||
ownerId: string;
|
||||
access: AccessMode;
|
||||
allowedUserIds: string[];
|
||||
localPort: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const DB_PATH = path.join( __dirname, '..', '..', '..', 'build', 'data', 'db', 'tunnels.json' );
|
||||
|
||||
function ensureDir(): void
|
||||
{
|
||||
fs.mkdirSync( path.dirname( DB_PATH ), { recursive: true } );
|
||||
}
|
||||
|
||||
function readAll(): TunnelConfig[]
|
||||
{
|
||||
try
|
||||
{
|
||||
return JSON.parse( fs.readFileSync( DB_PATH, 'utf-8' ) );
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeAll( tunnels: TunnelConfig[] ): void
|
||||
{
|
||||
ensureDir();
|
||||
fs.writeFileSync( DB_PATH, JSON.stringify( tunnels, null, 2 ) );
|
||||
}
|
||||
|
||||
export function createTunnel( data: Omit<TunnelConfig, 'id' | 'createdAt'> ): TunnelConfig
|
||||
{
|
||||
const tunnel: TunnelConfig = {
|
||||
...data,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const all = readAll();
|
||||
all.push( tunnel );
|
||||
writeAll( all );
|
||||
return tunnel;
|
||||
}
|
||||
|
||||
export function getTunnel( id: string ): TunnelConfig | undefined
|
||||
{
|
||||
return readAll().find( t => t.id === id );
|
||||
}
|
||||
|
||||
export function getTunnelsByOwner( ownerId: string ): TunnelConfig[]
|
||||
{
|
||||
return readAll().filter( t => t.ownerId === ownerId );
|
||||
}
|
||||
|
||||
export function getAvailableTunnels( userId: string, purpose?: string ): TunnelConfig[]
|
||||
{
|
||||
let tunnels = readAll().filter( t =>
|
||||
t.ownerId === userId ||
|
||||
t.access === 'public' ||
|
||||
t.allowedUserIds.includes( userId )
|
||||
);
|
||||
if ( purpose ) tunnels = tunnels.filter( t => t.purpose === purpose );
|
||||
return tunnels;
|
||||
}
|
||||
|
||||
export function updateTunnel(
|
||||
id: string,
|
||||
patch: Partial<Pick<TunnelConfig, 'name' | 'description' | 'access' | 'allowedUserIds' | 'purpose'>>
|
||||
): TunnelConfig | undefined
|
||||
{
|
||||
const all = readAll();
|
||||
const idx = all.findIndex( t => t.id === id );
|
||||
if ( idx === -1 ) return undefined;
|
||||
all[ idx ] = { ...all[ idx ], ...patch };
|
||||
writeAll( all );
|
||||
return all[ idx ];
|
||||
}
|
||||
|
||||
export function deleteTunnel( id: string ): boolean
|
||||
{
|
||||
const all = readAll();
|
||||
const filtered = all.filter( t => t.id !== id );
|
||||
if ( filtered.length === all.length ) return false;
|
||||
writeAll( filtered );
|
||||
return true;
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import http from 'http';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import tunnelsRouter from './routes/tunnels';
|
||||
import proxyRouter from './routes/proxy';
|
||||
import { handleAgentUpgrade } from './routes/agent';
|
||||
|
||||
const app = express();
|
||||
app.set( 'trust proxy', 1 );
|
||||
app.use( cookieParser() );
|
||||
|
||||
// Apply JSON parsing only to API routes — proxy routes need raw body streams
|
||||
app.use( '/api/tunnels', express.json(), tunnelsRouter );
|
||||
app.use( '/t', proxyRouter );
|
||||
|
||||
const server = http.createServer( app );
|
||||
const wss = new WebSocketServer( { noServer: true } );
|
||||
|
||||
server.on( 'upgrade', ( req, socket, head ) =>
|
||||
{
|
||||
if ( req.url?.startsWith( '/api/agent/' ) )
|
||||
{
|
||||
wss.handleUpgrade( req, socket, head, ws => handleAgentUpgrade( req, ws ) );
|
||||
}
|
||||
else
|
||||
{
|
||||
socket.destroy();
|
||||
}
|
||||
} );
|
||||
|
||||
const PORT = process.env.PORT ? Number( process.env.PORT ) : 3002;
|
||||
server.listen( PORT, () => console.log( `rokojori-tunnel running on http://localhost:${ PORT }` ) );
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export interface AuthPayload
|
||||
{
|
||||
userId: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
products: string[];
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
declare global
|
||||
{
|
||||
namespace Express
|
||||
{
|
||||
interface Request
|
||||
{
|
||||
auth?: AuthPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAuth( req: Request, res: Response, next: NextFunction ): void
|
||||
{
|
||||
const token = req.cookies?.accessToken ?? extractBearer( req );
|
||||
if ( !token )
|
||||
{
|
||||
res.status( 401 ).json( { error: 'Not authenticated' } );
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
req.auth = jwt.verify( token, process.env.JWT_SECRET ?? '' ) as AuthPayload;
|
||||
next();
|
||||
}
|
||||
catch
|
||||
{
|
||||
res.status( 401 ).json( { error: 'Invalid token' } );
|
||||
}
|
||||
}
|
||||
|
||||
export function extractBearer( req: Request ): string | undefined
|
||||
{
|
||||
const auth = req.headers.authorization;
|
||||
if ( auth?.startsWith( 'Bearer ' ) ) return auth.slice( 7 );
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { WebSocket } from 'ws';
|
||||
|
||||
class TunnelRegistry
|
||||
{
|
||||
private sockets = new Map<string, WebSocket>();
|
||||
|
||||
register( tunnelId: string, ws: WebSocket ): void
|
||||
{
|
||||
this.sockets.set( tunnelId, ws );
|
||||
}
|
||||
|
||||
unregister( tunnelId: string ): void
|
||||
{
|
||||
this.sockets.delete( tunnelId );
|
||||
}
|
||||
|
||||
get( tunnelId: string ): WebSocket | undefined
|
||||
{
|
||||
return this.sockets.get( tunnelId );
|
||||
}
|
||||
|
||||
isActive( tunnelId: string ): boolean
|
||||
{
|
||||
return this.sockets.has( tunnelId );
|
||||
}
|
||||
}
|
||||
|
||||
export const registry = new TunnelRegistry();
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
export interface RelayResponse
|
||||
{
|
||||
reqId: string;
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: string; // base64-encoded
|
||||
}
|
||||
|
||||
const callbacks = new Map<string, ( r: RelayResponse ) => void>();
|
||||
|
||||
export function addPending( reqId: string, cb: ( r: RelayResponse ) => void ): void
|
||||
{
|
||||
callbacks.set( reqId, cb );
|
||||
}
|
||||
|
||||
export function resolvePending( resp: RelayResponse ): void
|
||||
{
|
||||
const cb = callbacks.get( resp.reqId );
|
||||
if ( !cb ) return;
|
||||
callbacks.delete( resp.reqId );
|
||||
cb( resp );
|
||||
}
|
||||
|
||||
export function removePending( reqId: string ): void
|
||||
{
|
||||
callbacks.delete( reqId );
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { IncomingMessage } from 'http';
|
||||
import { WebSocket } from 'ws';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { AuthPayload } from '../middleware/requireAuth';
|
||||
import { getTunnel } from '../db';
|
||||
import { registry } from '../relay/TunnelRegistry';
|
||||
import { resolvePending } from '../relay/pending';
|
||||
|
||||
export function handleAgentUpgrade( req: IncomingMessage, ws: WebSocket ): void
|
||||
{
|
||||
const match = req.url?.match( /^\/api\/agent\/([^/?]+)/ );
|
||||
if ( !match ) { ws.close( 1008, 'Bad URL' ); return; }
|
||||
const tunnelId = match[ 1 ];
|
||||
|
||||
const url = new URL( req.url!, 'http://localhost' );
|
||||
const token = url.searchParams.get( 'token' );
|
||||
if ( !token ) { ws.close( 1008, 'Missing token' ); return; }
|
||||
|
||||
let payload: AuthPayload;
|
||||
try
|
||||
{
|
||||
payload = jwt.verify( token, process.env.JWT_SECRET ?? '' ) as AuthPayload;
|
||||
}
|
||||
catch
|
||||
{
|
||||
ws.close( 1008, 'Invalid token' );
|
||||
return;
|
||||
}
|
||||
|
||||
const tunnel = getTunnel( tunnelId );
|
||||
if ( !tunnel ) { ws.close( 1008, 'Tunnel not found' ); return; }
|
||||
if ( tunnel.ownerId !== payload.userId ) { ws.close( 1008, 'Forbidden' ); return; }
|
||||
|
||||
registry.register( tunnelId, ws );
|
||||
console.log( `[agent] connected: "${tunnel.name}" (${tunnelId})` );
|
||||
|
||||
ws.on( 'message', ( data ) =>
|
||||
{
|
||||
try { resolvePending( JSON.parse( data.toString() ) ); }
|
||||
catch { /* ignore malformed messages */ }
|
||||
} );
|
||||
|
||||
ws.on( 'close', () =>
|
||||
{
|
||||
registry.unregister( tunnelId );
|
||||
console.log( `[agent] disconnected: "${tunnel.name}" (${tunnelId})` );
|
||||
} );
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import { Router, Request } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import { getTunnel, TunnelConfig } from '../db';
|
||||
import { registry } from '../relay/TunnelRegistry';
|
||||
import { AuthPayload, extractBearer } from '../middleware/requireAuth';
|
||||
import { addPending, removePending, RelayResponse } from '../relay/pending';
|
||||
|
||||
export interface RelayRequest
|
||||
{
|
||||
reqId: string;
|
||||
method: string;
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: string; // base64-encoded
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
function softAuth( req: Request ): void
|
||||
{
|
||||
const token = req.cookies?.accessToken ?? extractBearer( req );
|
||||
if ( !token ) return;
|
||||
try
|
||||
{
|
||||
req.auth = jwt.verify( token, process.env.JWT_SECRET ?? '' ) as AuthPayload;
|
||||
}
|
||||
catch { /* leave req.auth undefined */ }
|
||||
}
|
||||
|
||||
function canAccess( tunnel: TunnelConfig, req: Request ): boolean
|
||||
{
|
||||
if ( tunnel.access === 'public' ) return true;
|
||||
if ( !req.auth ) return false;
|
||||
return tunnel.ownerId === req.auth.userId || tunnel.allowedUserIds.includes( req.auth.userId );
|
||||
}
|
||||
|
||||
// Matches /:tunnelId and /:tunnelId/any/path
|
||||
router.all( [ '/:tunnelId', '/:tunnelId/*' ], async ( req, res ) =>
|
||||
{
|
||||
softAuth( req );
|
||||
|
||||
const tunnel = getTunnel( req.params.tunnelId );
|
||||
if ( !tunnel ) { res.status( 404 ).json( { error: 'Tunnel not found' } ); return; }
|
||||
if ( !canAccess( tunnel, req ) ) { res.status( 401 ).json( { error: 'Not authenticated' } ); return; }
|
||||
|
||||
const ws = registry.get( tunnel.id );
|
||||
if ( !ws ) { res.status( 503 ).json( { error: 'Tunnel agent not connected' } ); return; }
|
||||
|
||||
// Strip the /:tunnelId prefix to get the path to forward
|
||||
const prefix = '/' + req.params.tunnelId;
|
||||
const forwardPath = req.url.slice( prefix.length ) || '/';
|
||||
|
||||
// Buffer the raw request body (no JSON middleware on /t routes)
|
||||
const chunks: Buffer[] = [];
|
||||
await new Promise<void>( ( resolve, reject ) =>
|
||||
{
|
||||
req.on( 'data', chunk => chunks.push( Buffer.from( chunk ) ) );
|
||||
req.on( 'end', resolve );
|
||||
req.on( 'error', reject );
|
||||
} );
|
||||
const bodyBuf = Buffer.concat( chunks );
|
||||
|
||||
// Forward all headers except host and content-length
|
||||
const headers: Record<string, string> = {};
|
||||
for ( const [ key, val ] of Object.entries( req.headers ) )
|
||||
{
|
||||
if ( key === 'host' || key === 'content-length' ) continue;
|
||||
if ( typeof val === 'string' ) headers[ key ] = val;
|
||||
else if ( Array.isArray( val ) ) headers[ key ] = val.join( ', ' );
|
||||
}
|
||||
|
||||
const reqId = crypto.randomUUID();
|
||||
|
||||
const relayReq: RelayRequest = {
|
||||
reqId,
|
||||
method: req.method,
|
||||
path: forwardPath,
|
||||
headers,
|
||||
body: bodyBuf.toString( 'base64' ),
|
||||
};
|
||||
|
||||
const TIMEOUT_MS = 30_000;
|
||||
const response = await new Promise<RelayResponse | null>( ( resolve ) =>
|
||||
{
|
||||
const timer = setTimeout( () =>
|
||||
{
|
||||
removePending( reqId );
|
||||
resolve( null );
|
||||
}, TIMEOUT_MS );
|
||||
|
||||
addPending( reqId, ( resp ) =>
|
||||
{
|
||||
clearTimeout( timer );
|
||||
resolve( resp );
|
||||
} );
|
||||
|
||||
ws.send( JSON.stringify( relayReq ) );
|
||||
} );
|
||||
|
||||
if ( !response ) { res.status( 504 ).json( { error: 'Agent timed out' } ); return; }
|
||||
|
||||
res.status( response.status );
|
||||
for ( const [ key, val ] of Object.entries( response.headers ) )
|
||||
{
|
||||
if ( key.toLowerCase() === 'transfer-encoding' ) continue;
|
||||
res.setHeader( key, val );
|
||||
}
|
||||
res.end( Buffer.from( response.body, 'base64' ) );
|
||||
} );
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { Router } from 'express';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import * as db from '../db';
|
||||
import { registry } from '../relay/TunnelRegistry';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post( '/', requireAuth, ( req, res ) =>
|
||||
{
|
||||
const { name, description, purpose, access, allowedUserIds, localPort } = req.body;
|
||||
if ( !name || !purpose || !localPort )
|
||||
{
|
||||
res.status( 400 ).json( { error: 'name, purpose, and localPort are required' } );
|
||||
return;
|
||||
}
|
||||
const tunnel = db.createTunnel( {
|
||||
name,
|
||||
description: description ?? '',
|
||||
purpose,
|
||||
ownerId: req.auth!.userId,
|
||||
access: access === 'public' ? 'public' : 'private',
|
||||
allowedUserIds: Array.isArray( allowedUserIds ) ? allowedUserIds : [],
|
||||
localPort: Number( localPort ),
|
||||
} );
|
||||
res.status( 201 ).json( tunnel );
|
||||
} );
|
||||
|
||||
router.get( '/', requireAuth, ( _req, res ) =>
|
||||
{
|
||||
const tunnels = db.getTunnelsByOwner( _req.auth!.userId ).map( t => ( {
|
||||
...t,
|
||||
active: registry.isActive( t.id ),
|
||||
} ) );
|
||||
res.json( tunnels );
|
||||
} );
|
||||
|
||||
router.get( '/available', requireAuth, ( req, res ) =>
|
||||
{
|
||||
const purpose = typeof req.query.purpose === 'string' ? req.query.purpose : undefined;
|
||||
const tunnels = db.getAvailableTunnels( req.auth!.userId, purpose ).map( t => ( {
|
||||
...t,
|
||||
active: registry.isActive( t.id ),
|
||||
} ) );
|
||||
res.json( tunnels );
|
||||
} );
|
||||
|
||||
router.get( '/:id', requireAuth, ( req, res ) =>
|
||||
{
|
||||
const tunnel = db.getTunnel( req.params.id );
|
||||
if ( !tunnel ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
|
||||
const userId = req.auth!.userId;
|
||||
if ( tunnel.ownerId !== userId && !tunnel.allowedUserIds.includes( userId ) )
|
||||
{
|
||||
res.status( 403 ).json( { error: 'Forbidden' } ); return;
|
||||
}
|
||||
res.json( { ...tunnel, active: registry.isActive( tunnel.id ) } );
|
||||
} );
|
||||
|
||||
router.patch( '/:id', requireAuth, ( req, res ) =>
|
||||
{
|
||||
const tunnel = db.getTunnel( req.params.id );
|
||||
if ( !tunnel ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
|
||||
if ( tunnel.ownerId !== req.auth!.userId ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; }
|
||||
const { name, description, access, allowedUserIds, purpose } = req.body;
|
||||
const updated = db.updateTunnel( req.params.id, { name, description, access, allowedUserIds, purpose } );
|
||||
res.json( updated );
|
||||
} );
|
||||
|
||||
router.delete( '/:id', requireAuth, ( req, res ) =>
|
||||
{
|
||||
const tunnel = db.getTunnel( req.params.id );
|
||||
if ( !tunnel ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
|
||||
if ( tunnel.ownerId !== req.auth!.userId ) { res.status( 403 ).json( { error: 'Forbidden' } ); return; }
|
||||
db.deleteTunnel( req.params.id );
|
||||
res.status( 204 ).end();
|
||||
} );
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "build/electron",
|
||||
"rootDir": "electron-agent"
|
||||
},
|
||||
"include": ["electron-agent/**/*"]
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["source/server/**/*", "scripts/**/*"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": false
|
||||
},
|
||||
"include": ["source/server/**/*", "scripts/**/*"]
|
||||
}
|
||||
Loading…
Reference in New Issue