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