tunnel/source/server/routes/proxy.ts

115 lines
3.4 KiB
TypeScript
Raw Permalink Normal View History

2026-07-16 12:17:00 +00:00
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 } from '../relay/pending';
2026-07-16 12:17:00 +00:00
export interface RelayRequest
{
reqId: string;
method: string;
path: string;
2026-07-16 12:17:00 +00:00
headers: Record<string, string>;
body: string; // base64-encoded
2026-07-16 12:17:00 +00:00
}
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;
2026-07-16 12:17:00 +00:00
const forwardPath = req.url.slice( prefix.length ) || '/';
// Buffer the raw request body
2026-07-16 12:17:00 +00:00
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,
2026-07-16 12:17:00 +00:00
headers,
body: bodyBuf.toString( 'base64' ),
2026-07-16 12:17:00 +00:00
};
// 2-minute timeout for the first response chunk — reset once streaming starts
await new Promise<void>( ( resolve ) =>
2026-07-16 12:17:00 +00:00
{
const timer = setTimeout( () =>
{
removePending( reqId );
if ( !res.headersSent ) res.status( 504 ).json( { error: 'Agent timed out' } );
resolve();
}, 120_000 );
2026-07-16 12:17:00 +00:00
addPending( reqId,
2026-07-16 12:17:00 +00:00
{
onStart: ( status, respHeaders ) =>
{
clearTimeout( timer ); // streaming started — no more timeout
res.status( status );
for ( const [ key, val ] of Object.entries( respHeaders ) )
{
if ( key.toLowerCase() === 'transfer-encoding' ) continue;
res.setHeader( key, val );
}
res.flushHeaders();
},
onData: ( chunk ) => res.write( chunk ),
onEnd: () => { res.end(); resolve(); },
2026-07-16 12:17:00 +00:00
} );
ws.send( JSON.stringify( relayReq ) );
} );
} );
export default router;