Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/auth/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,10 @@ export const ADMIN_ONLY_ROUTES = new Set([
'tunnel:local:ingress:add',
'tunnel:local:ingress:remove',
'tunnel:local:start',
'tunnel:local:stop'
'tunnel:local:stop',
// Tunnel monitoring — admin-only observability endpoints.
'tunnel:monitoring:access-logs',
'tunnel:monitoring:statistics'
]);

/**
Expand Down
209 changes: 209 additions & 0 deletions backend/tunnel/tunnel-access-log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { describe, expect, test, beforeEach } from 'bun:test';
import { tunnelAccessLogger } from './tunnel-access-log';

describe('Tunnel Access Logger', () => {
beforeEach(() => {
// Clear logs before each test
tunnelAccessLogger.clearLogs();
});

test('should log tunnel creation', () => {
tunnelAccessLogger.log({
tunnelType: 'quick',
tunnelId: 'quick-3000',
action: 'created',
userId: 'user-123',
port: 3000,
publicUrl: 'https://test.trycloudflare.com'
});

const logs = tunnelAccessLogger.getRecentLogs(10);
expect(logs).toHaveLength(1);
expect(logs[0].tunnelType).toBe('quick');
expect(logs[0].action).toBe('created');
expect(logs[0].userId).toBe('user-123');
expect(logs[0].port).toBe(3000);
});

test('should log tunnel stop', () => {
tunnelAccessLogger.log({
tunnelType: 'quick',
tunnelId: 'quick-3000',
action: 'stopped',
userId: 'user-123',
port: 3000
});

const logs = tunnelAccessLogger.getRecentLogs(10);
expect(logs).toHaveLength(1);
expect(logs[0].action).toBe('stopped');
});

test('should get logs for specific tunnel', () => {
tunnelAccessLogger.log({
tunnelType: 'quick',
tunnelId: 'tunnel-1',
action: 'created',
userId: 'user-123'
});

tunnelAccessLogger.log({
tunnelType: 'quick',
tunnelId: 'tunnel-2',
action: 'created',
userId: 'user-123'
});

tunnelAccessLogger.log({
tunnelType: 'quick',
tunnelId: 'tunnel-1',
action: 'stopped',
userId: 'user-123'
});

const tunnel1Logs = tunnelAccessLogger.getLogsForTunnel('tunnel-1');
expect(tunnel1Logs).toHaveLength(2);
expect(tunnel1Logs[0].action).toBe('created');
expect(tunnel1Logs[1].action).toBe('stopped');
});

test('should get logs for specific user', () => {
tunnelAccessLogger.log({
tunnelType: 'quick',
tunnelId: 'tunnel-1',
action: 'created',
userId: 'user-1'
});

tunnelAccessLogger.log({
tunnelType: 'quick',
tunnelId: 'tunnel-2',
action: 'created',
userId: 'user-2'
});

tunnelAccessLogger.log({
tunnelType: 'quick',
tunnelId: 'tunnel-3',
action: 'created',
userId: 'user-1'
});

const user1Logs = tunnelAccessLogger.getLogsForUser('user-1');
expect(user1Logs).toHaveLength(2);
expect(user1Logs.every(log => log.userId === 'user-1')).toBe(true);
});

test('should calculate statistics correctly', () => {
// Create 3 quick tunnels
for (let i = 0; i < 3; i++) {
tunnelAccessLogger.log({
tunnelType: 'quick',
tunnelId: `quick-${i}`,
action: 'created',
userId: 'user-1'
});
}

// Create 2 remote tunnels
for (let i = 0; i < 2; i++) {
tunnelAccessLogger.log({
tunnelType: 'remote',
tunnelId: `remote-${i}`,
action: 'created',
userId: 'user-2'
});
}

const stats = tunnelAccessLogger.getStatistics();
expect(stats.totalCreated).toBe(5);
expect(stats.byType.quick).toBe(3);
expect(stats.byType.remote).toBe(2);
expect(stats.byUser['user-1']).toBe(3);
expect(stats.byUser['user-2']).toBe(2);
});

test('should track active tunnels correctly', () => {
// Create and start a tunnel
tunnelAccessLogger.log({
tunnelType: 'quick',
tunnelId: 'tunnel-1',
action: 'created',
userId: 'user-1'
});

// Create another tunnel
tunnelAccessLogger.log({
tunnelType: 'quick',
tunnelId: 'tunnel-2',
action: 'created',
userId: 'user-1'
});

// Stop first tunnel
tunnelAccessLogger.log({
tunnelType: 'quick',
tunnelId: 'tunnel-1',
action: 'stopped',
userId: 'user-1'
});

const stats = tunnelAccessLogger.getStatistics();
expect(stats.totalCreated).toBe(2);
expect(stats.totalActive).toBe(1); // Only tunnel-2 is active
});

test('should limit log entries to MAX_LOGS', () => {
// Create 1100 log entries (more than MAX_LOGS of 1000)
// Suppress debug output for this test to avoid timeout
const originalLog = console.log;
console.log = () => {};

for (let i = 0; i < 1100; i++) {
tunnelAccessLogger.log({
tunnelType: 'quick',
tunnelId: `tunnel-${i}`,
action: 'created',
userId: 'user-1'
});
}

console.log = originalLog;

const logs = tunnelAccessLogger.getRecentLogs(2000);
expect(logs.length).toBeLessThanOrEqual(1000);
});

test('should include timestamp in logs', () => {
const beforeLog = Date.now();

tunnelAccessLogger.log({
tunnelType: 'quick',
tunnelId: 'tunnel-1',
action: 'created',
userId: 'user-1'
});

const afterLog = Date.now();
const logs = tunnelAccessLogger.getRecentLogs(1);

expect(logs[0].timestamp).toBeDefined();
const logTime = new Date(logs[0].timestamp).getTime();
expect(logTime).toBeGreaterThanOrEqual(beforeLog);
expect(logTime).toBeLessThanOrEqual(afterLog);
});

test('should clear all logs', () => {
tunnelAccessLogger.log({
tunnelType: 'quick',
tunnelId: 'tunnel-1',
action: 'created',
userId: 'user-1'
});

tunnelAccessLogger.clearLogs();

const logs = tunnelAccessLogger.getRecentLogs(10);
expect(logs).toHaveLength(0);
});
});
124 changes: 124 additions & 0 deletions backend/tunnel/tunnel-access-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Tunnel Access Logging
*
* Tracks tunnel creation, access, and usage for security monitoring.
*/

import { debug } from '$shared/utils/logger';

export interface TunnelAccessLogEntry {
timestamp: string;
tunnelType: 'quick' | 'remote' | 'local';
tunnelId: string;
action: 'created' | 'started' | 'stopped' | 'accessed' | 'deleted';
userId: string | null;
port?: number;
publicUrl?: string;
metadata?: Record<string, unknown>;
}

class TunnelAccessLogger {
private logs: TunnelAccessLogEntry[] = [];
private readonly MAX_LOGS = 1000; // Keep last 1000 entries in memory

/**
* Log a tunnel access event
*/
log(entry: Omit<TunnelAccessLogEntry, 'timestamp'>): void {
const logEntry: TunnelAccessLogEntry = {
...entry,
timestamp: new Date().toISOString()
};

this.logs.push(logEntry);

// Trim old logs if exceeding max
if (this.logs.length > this.MAX_LOGS) {
this.logs = this.logs.slice(-this.MAX_LOGS);
}

// Also log to debug for immediate visibility
debug.log('tunnel-access', `[${entry.action}] ${entry.tunnelType} tunnel ${entry.tunnelId}`, {
userId: entry.userId,
port: entry.port,
publicUrl: entry.publicUrl
});
}

/**
* Get recent tunnel access logs
*/
getRecentLogs(limit: number = 100): TunnelAccessLogEntry[] {
return this.logs.slice(-limit);
}

/**
* Get logs for a specific tunnel
*/
getLogsForTunnel(tunnelId: string): TunnelAccessLogEntry[] {
return this.logs.filter(log => log.tunnelId === tunnelId);
}

/**
* Get logs for a specific user
*/
getLogsForUser(userId: string): TunnelAccessLogEntry[] {
return this.logs.filter(log => log.userId === userId);
}

/**
* Get tunnel creation statistics
*/
getStatistics(): {
totalCreated: number;
totalActive: number;
byType: Record<string, number>;
byUser: Record<string, number>;
} {
const stats = {
totalCreated: 0,
totalActive: 0,
byType: {} as Record<string, number>,
byUser: {} as Record<string, number>
};

// Count created tunnels
const createdLogs = this.logs.filter(log => log.action === 'created');
stats.totalCreated = createdLogs.length;

// Count by type
for (const log of createdLogs) {
stats.byType[log.tunnelType] = (stats.byType[log.tunnelType] || 0) + 1;
}

// Count by user
for (const log of createdLogs) {
if (log.userId) {
stats.byUser[log.userId] = (stats.byUser[log.userId] || 0) + 1;
}
}

// Calculate currently active tunnels (created but not stopped)
const tunnelStates = new Map<string, 'active' | 'stopped'>();
for (const log of this.logs) {
if (log.action === 'created' || log.action === 'started') {
tunnelStates.set(log.tunnelId, 'active');
} else if (log.action === 'stopped' || log.action === 'deleted') {
tunnelStates.set(log.tunnelId, 'stopped');
}
}
stats.totalActive = Array.from(tunnelStates.values()).filter(state => state === 'active').length;

return stats;
}

/**
* Clear all logs (admin only)
*/
clearLogs(): void {
this.logs = [];
debug.log('tunnel-access', 'Access logs cleared');
}
}

export const tunnelAccessLogger = new TunnelAccessLogger();
Loading
Loading