Initial Commit

This commit is contained in:
Rokojori 2026-07-16 14:17:00 +02:00
commit 322909b8af
25 changed files with 7267 additions and 0 deletions

5
.env.example Normal file
View File

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

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
build/
dist/
.env
storage/

22
deploy/deploy.sh Normal file
View File

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

43
deploy/nginx-tunnel.conf Normal file
View File

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

View File

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

View File

@ -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();
}
}

131
electron-agent/login.html Normal file
View File

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

291
electron-agent/main.ts Normal file
View File

@ -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();
} );

15
electron-agent/preload.ts Normal file
View File

@ -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 ) ),
} );

397
electron-agent/window.html Normal file
View File

@ -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, '&amp;' )
.replace( /</g, '&lt;' )
.replace( />/g, '&gt;' );
}
// ── 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 (165535).';
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>

5495
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

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

View File

@ -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.' );

135
scripts/test-agent.ts Normal file
View File

@ -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();

99
source/server/db.ts Normal file
View File

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

34
source/server/index.ts Normal file
View File

@ -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 }` ) );

View File

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

View File

@ -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();

View File

@ -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 );
}

View File

@ -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})` );
} );
}

View File

@ -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;

View File

@ -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;

13
tsconfig.electron.json Normal file
View File

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

12
tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["source/server/**/*", "scripts/**/*"]
}

7
tsconfig.ts-node.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strictNullChecks": false
},
"include": ["source/server/**/*", "scripts/**/*"]
}