diff --git a/backend/auth/permissions.ts b/backend/auth/permissions.ts index 9e520047..139a75e0 100644 --- a/backend/auth/permissions.ts +++ b/backend/auth/permissions.ts @@ -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' ]); /** diff --git a/backend/tunnel/tunnel-access-log.test.ts b/backend/tunnel/tunnel-access-log.test.ts new file mode 100644 index 00000000..e99333c7 --- /dev/null +++ b/backend/tunnel/tunnel-access-log.test.ts @@ -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); + }); +}); diff --git a/backend/tunnel/tunnel-access-log.ts b/backend/tunnel/tunnel-access-log.ts new file mode 100644 index 00000000..d6f9b359 --- /dev/null +++ b/backend/tunnel/tunnel-access-log.ts @@ -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; +} + +class TunnelAccessLogger { + private logs: TunnelAccessLogEntry[] = []; + private readonly MAX_LOGS = 1000; // Keep last 1000 entries in memory + + /** + * Log a tunnel access event + */ + log(entry: Omit): 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; + byUser: Record; + } { + const stats = { + totalCreated: 0, + totalActive: 0, + byType: {} as Record, + byUser: {} as Record + }; + + // 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(); + 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(); diff --git a/backend/tunnel/tunnel-audit-logger.test.ts b/backend/tunnel/tunnel-audit-logger.test.ts new file mode 100644 index 00000000..066543fb --- /dev/null +++ b/backend/tunnel/tunnel-audit-logger.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +const mockLogEvent = mock(() => true); +const mockDebugLog = mock(() => {}); +const mockDebugError = mock(() => {}); + +mock.module('$backend/database/queries/audit-log-queries', () => ({ + auditLogQueries: { + logEvent: mockLogEvent + } +})); + +mock.module('$shared/utils/logger', () => ({ + debug: { + log: mockDebugLog, + error: mockDebugError + } +})); + +const { tunnelAuditLogger } = await import('./tunnel-audit-logger'); + +describe('tunnelAuditLogger', () => { + beforeEach(() => { + mockLogEvent.mockClear(); + mockDebugLog.mockClear(); + mockDebugError.mockClear(); + }); + + test('persists quick tunnel start events through the audit log query layer', () => { + tunnelAuditLogger.logQuickTunnelStart('user-1', 3000, { + ipAddress: '203.0.113.10', + autoStopMinutes: 30 + }); + + expect(mockLogEvent).toHaveBeenCalledTimes(1); + + const calls = mockLogEvent.mock.calls as unknown[][]; + const entry = calls[0]?.[0] as Record; + expect(entry).toMatchObject({ + userId: 'user-1', + actorUserId: 'user-1', + eventType: 'tunnel:quick:start', + ipAddress: '203.0.113.10' + }); + expect(JSON.parse(entry.eventDetails as string)).toEqual({ + tunnelType: 'quick', + port: 3000, + autoStopMinutes: 30, + restarted: false + }); + }); + + test('persists quick tunnel stop events', () => { + tunnelAuditLogger.logQuickTunnelStop('user-1', 3000, { + ipAddress: '203.0.113.10' + }); + + expect(mockLogEvent).toHaveBeenCalledTimes(1); + const calls = mockLogEvent.mock.calls as unknown[][]; + const entry = calls[0]?.[0] as Record; + expect(entry.eventType).toBe('tunnel:quick:stop'); + expect(JSON.parse(entry.eventDetails as string)).toEqual({ + tunnelType: 'quick', + port: 3000 + }); + }); + + test('persists quick tunnel restart events', () => { + tunnelAuditLogger.logQuickTunnelRestart('user-1', 3000, { + ipAddress: '203.0.113.10', + autoStopMinutes: 60 + }); + + expect(mockLogEvent).toHaveBeenCalledTimes(1); + const calls = mockLogEvent.mock.calls as unknown[][]; + const entry = calls[0]?.[0] as Record; + expect(entry.eventType).toBe('tunnel:quick:restart'); + expect(JSON.parse(entry.eventDetails as string)).toEqual({ + tunnelType: 'quick', + port: 3000, + autoStopMinutes: 60, + restarted: true + }); + }); + + test('persists remote tunnel start events', () => { + tunnelAuditLogger.logRemoteTunnelStart('user-1', 'config-1', 'My Tunnel', { + ipAddress: '203.0.113.10' + }); + + expect(mockLogEvent).toHaveBeenCalledTimes(1); + const calls = mockLogEvent.mock.calls as unknown[][]; + const entry = calls[0]?.[0] as Record; + expect(entry.eventType).toBe('tunnel:remote:start'); + expect(JSON.parse(entry.eventDetails as string)).toEqual({ + tunnelType: 'remote', + configId: 'config-1', + label: 'My Tunnel' + }); + }); + + test('persists local tunnel start events', () => { + tunnelAuditLogger.logLocalTunnelStart('user-1', 'local-1', 'dev-tunnel', { + ipAddress: '203.0.113.10' + }); + + expect(mockLogEvent).toHaveBeenCalledTimes(1); + const calls = mockLogEvent.mock.calls as unknown[][]; + const entry = calls[0]?.[0] as Record; + expect(entry.eventType).toBe('tunnel:local:start'); + expect(JSON.parse(entry.eventDetails as string)).toEqual({ + tunnelType: 'local', + configId: 'local-1', + name: 'dev-tunnel' + }); + }); + + test('does not throw when audit log write fails', () => { + mockLogEvent.mockImplementationOnce(() => { + throw new Error('DB write failed'); + }); + + expect(() => { + tunnelAuditLogger.logQuickTunnelStart('user-1', 3000, {}); + }).not.toThrow(); + + expect(mockDebugError).toHaveBeenCalled(); + }); +}); diff --git a/backend/tunnel/tunnel-audit-logger.ts b/backend/tunnel/tunnel-audit-logger.ts new file mode 100644 index 00000000..06e44e68 --- /dev/null +++ b/backend/tunnel/tunnel-audit-logger.ts @@ -0,0 +1,202 @@ +/** + * Tunnel Audit Logger + * + * Centralized logging of all tunnel operations to the persistent audit log. + * Integrates with auditLogQueries.logEvent() for database storage. + * + * Event types follow the pattern: tunnel: + * - tunnel:quick:start + * - tunnel:quick:stop + * - tunnel:quick:restart + * - tunnel:remote:start + * - tunnel:remote:stop + * - tunnel:local:start + * - tunnel:local:stop + */ + +import { auditLogQueries } from '$backend/database/queries/audit-log-queries'; +import { debug } from '$shared/utils/logger'; + +/** + * Metadata for tunnel operations + */ +export interface TunnelMetadata { + /** IP address of the client making the request */ + ipAddress?: string; + /** Auto-stop duration in minutes (for quick tunnels) */ + autoStopMinutes?: number; + /** Whether this is a restart operation */ + restarted?: boolean; +} + +/** + * Event details stored in the audit log + */ +interface QuickTunnelDetails { + tunnelType: 'quick'; + port: number; + autoStopMinutes?: number; + restarted?: boolean; +} + +interface RemoteTunnelDetails { + tunnelType: 'remote'; + configId: string; + label: string; +} + +interface LocalTunnelDetails { + tunnelType: 'local'; + configId: string; + name: string; +} + +type TunnelEventDetails = QuickTunnelDetails | RemoteTunnelDetails | LocalTunnelDetails; + +/** + * Tunnel Audit Logger + * + * Logs all tunnel operations to the persistent audit log table. + */ +export class TunnelAuditLogger { + /** + * Log an event to the audit log + */ + private logEvent( + userId: string, + eventType: string, + eventDetails: TunnelEventDetails, + metadata: TunnelMetadata + ): void { + try { + auditLogQueries.logEvent({ + userId, + actorUserId: userId, + eventType, + eventDetails: JSON.stringify(eventDetails), + ipAddress: metadata.ipAddress + }); + debug.log('tunnel', `Audit log: ${eventType} for user ${userId}`); + } catch (error) { + debug.error('tunnel', `Failed to log audit event ${eventType}:`, error); + } + } + + /** + * Log quick tunnel start + */ + logQuickTunnelStart(userId: string, port: number, metadata: TunnelMetadata): void { + this.logEvent( + userId, + 'tunnel:quick:start', + { + tunnelType: 'quick', + port, + autoStopMinutes: metadata.autoStopMinutes, + restarted: false + }, + metadata + ); + } + + /** + * Log quick tunnel stop + */ + logQuickTunnelStop(userId: string, port: number, metadata: TunnelMetadata): void { + this.logEvent( + userId, + 'tunnel:quick:stop', + { + tunnelType: 'quick', + port + }, + metadata + ); + } + + /** + * Log quick tunnel restart + */ + logQuickTunnelRestart(userId: string, port: number, metadata: TunnelMetadata): void { + this.logEvent( + userId, + 'tunnel:quick:restart', + { + tunnelType: 'quick', + port, + autoStopMinutes: metadata.autoStopMinutes, + restarted: true + }, + metadata + ); + } + + /** + * Log remote tunnel start + */ + logRemoteTunnelStart(userId: string, configId: string, label: string, metadata: TunnelMetadata): void { + this.logEvent( + userId, + 'tunnel:remote:start', + { + tunnelType: 'remote', + configId, + label + }, + metadata + ); + } + + /** + * Log remote tunnel stop + */ + logRemoteTunnelStop(userId: string, configId: string, label: string, metadata: TunnelMetadata): void { + this.logEvent( + userId, + 'tunnel:remote:stop', + { + tunnelType: 'remote', + configId, + label + }, + metadata + ); + } + + /** + * Log local tunnel start + */ + logLocalTunnelStart(userId: string, configId: string, name: string, metadata: TunnelMetadata): void { + this.logEvent( + userId, + 'tunnel:local:start', + { + tunnelType: 'local', + configId, + name + }, + metadata + ); + } + + /** + * Log local tunnel stop + */ + logLocalTunnelStop(userId: string, configId: string, name: string, metadata: TunnelMetadata): void { + this.logEvent( + userId, + 'tunnel:local:stop', + { + tunnelType: 'local', + configId, + name + }, + metadata + ); + } +} + +/** + * Singleton instance of the tunnel audit logger + */ +export const tunnelAuditLogger = new TunnelAuditLogger(); diff --git a/backend/tunnel/tunnel-rate-limiter.test.ts b/backend/tunnel/tunnel-rate-limiter.test.ts new file mode 100644 index 00000000..b2cc50ab --- /dev/null +++ b/backend/tunnel/tunnel-rate-limiter.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test, beforeEach, mock } from 'bun:test'; + +mock.module('$shared/utils/logger', () => ({ + debug: { + log: mock(() => {}), + warn: mock(() => {}), + error: mock(() => {}) + } +})); + +import { tunnelRateLimiter } from './tunnel-rate-limiter'; + +describe('Tunnel Rate Limiter', () => { + beforeEach(() => { + // Clear all rate limits before each test + tunnelRateLimiter.clearAll(); + }); + + test('should allow tunnel creation when under limit', () => { + const userId = 'user-123'; + const result = tunnelRateLimiter.canCreateTunnel(userId); + expect(result.allowed).toBe(true); + expect(result.retryAfter).toBeUndefined(); + }); + + test('should track tunnel creation count', () => { + const userId = 'user-123'; + + // Create 3 tunnels + for (let i = 0; i < 3; i++) { + tunnelRateLimiter.recordTunnelCreation(userId); + } + + const status = tunnelRateLimiter.getStatus(userId); + expect(status.count).toBe(3); + expect(status.limit).toBe(10); + expect(status.resetAt).toBeGreaterThan(Date.now()); + }); + + test('should block tunnel creation when limit exceeded', () => { + const userId = 'user-123'; + + // Create 10 tunnels (max limit) + for (let i = 0; i < 10; i++) { + tunnelRateLimiter.recordTunnelCreation(userId); + } + + // 11th tunnel should be blocked + const result = tunnelRateLimiter.canCreateTunnel(userId); + expect(result.allowed).toBe(false); + expect(result.retryAfter).toBeGreaterThan(0); + }); + + test('should reset limit after window expires', () => { + const userId = 'user-123'; + + // Create 10 tunnels + for (let i = 0; i < 10; i++) { + tunnelRateLimiter.recordTunnelCreation(userId); + } + + // Should be blocked + expect(tunnelRateLimiter.canCreateTunnel(userId).allowed).toBe(false); + + // Manually reset (simulating window expiry) + tunnelRateLimiter.resetUser(userId); + + // Should be allowed again + expect(tunnelRateLimiter.canCreateTunnel(userId).allowed).toBe(true); + }); + + test('should track different users independently', () => { + const user1 = 'user-1'; + const user2 = 'user-2'; + + // User 1 creates 5 tunnels + for (let i = 0; i < 5; i++) { + tunnelRateLimiter.recordTunnelCreation(user1); + } + + // User 2 creates 3 tunnels + for (let i = 0; i < 3; i++) { + tunnelRateLimiter.recordTunnelCreation(user2); + } + + const status1 = tunnelRateLimiter.getStatus(user1); + const status2 = tunnelRateLimiter.getStatus(user2); + + expect(status1.count).toBe(5); + expect(status2.count).toBe(3); + }); + + test('should return zero count for new user', () => { + const userId = 'new-user'; + const status = tunnelRateLimiter.getStatus(userId); + + expect(status.count).toBe(0); + expect(status.limit).toBe(10); + expect(status.resetAt).toBeNull(); + }); + + test('should clear all limits', () => { + const user1 = 'user-1'; + const user2 = 'user-2'; + + tunnelRateLimiter.recordTunnelCreation(user1); + tunnelRateLimiter.recordTunnelCreation(user2); + + tunnelRateLimiter.clearAll(); + + expect(tunnelRateLimiter.getStatus(user1).count).toBe(0); + expect(tunnelRateLimiter.getStatus(user2).count).toBe(0); + }); +}); diff --git a/backend/tunnel/tunnel-rate-limiter.ts b/backend/tunnel/tunnel-rate-limiter.ts new file mode 100644 index 00000000..32716e6e --- /dev/null +++ b/backend/tunnel/tunnel-rate-limiter.ts @@ -0,0 +1,129 @@ +/** + * Tunnel Rate Limiter + * + * Prevents abuse by limiting tunnel creation rate per user. + */ + +import { debug } from '$shared/utils/logger'; + +interface RateLimitEntry { + count: number; + resetAt: number; +} + +class TunnelRateLimiter { + private limits = new Map(); + private cleanupTimer: ReturnType | null = null; + private readonly WINDOW_MS = 60 * 60 * 1000; // 1 hour + private readonly MAX_TUNNELS_PER_HOUR = 10; // Max 10 tunnels per hour per user + private readonly CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + + constructor() { + this.cleanupTimer = setInterval(() => this.cleanup(), this.CLEANUP_INTERVAL_MS); + this.cleanupTimer.unref?.(); + } + + /** + * Check if user can create a new tunnel + */ + canCreateTunnel(userId: string): { allowed: boolean; retryAfter?: number } { + const now = Date.now(); + const entry = this.limits.get(userId); + + // No entry or expired window - allow + if (!entry || now >= entry.resetAt) { + return { allowed: true }; + } + + // Check if under limit + if (entry.count < this.MAX_TUNNELS_PER_HOUR) { + return { allowed: true }; + } + + // Rate limited + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + debug.warn('tunnel-rate-limit', `User ${userId} rate limited. Retry after ${retryAfter}s`); + return { allowed: false, retryAfter }; + } + + /** + * Record a tunnel creation + */ + recordTunnelCreation(userId: string): void { + const now = Date.now(); + const entry = this.limits.get(userId); + + if (!entry || now >= entry.resetAt) { + // New window + this.limits.set(userId, { + count: 1, + resetAt: now + this.WINDOW_MS + }); + } else { + // Increment count + entry.count++; + } + } + + /** + * Get current rate limit status for a user + */ + getStatus(userId: string): { count: number; limit: number; resetAt: number | null } { + const entry = this.limits.get(userId); + const now = Date.now(); + + if (!entry || now >= entry.resetAt) { + return { + count: 0, + limit: this.MAX_TUNNELS_PER_HOUR, + resetAt: null + }; + } + + return { + count: entry.count, + limit: this.MAX_TUNNELS_PER_HOUR, + resetAt: entry.resetAt + }; + } + + /** + * Reset rate limit for a user (admin only) + */ + resetUser(userId: string): void { + this.limits.delete(userId); + debug.log('tunnel-rate-limit', `Rate limit reset for user ${userId}`); + } + + /** + * Clear all rate limits (admin only) + */ + clearAll(): void { + this.limits.clear(); + debug.log('tunnel-rate-limit', 'All rate limits cleared'); + } + + /** + * Cleanup expired entries periodically + */ + cleanup(): void { + const now = Date.now(); + for (const [userId, entry] of this.limits.entries()) { + if (now >= entry.resetAt) { + this.limits.delete(userId); + } + } + } + + /** + * Dispose of the cleanup timer (for testing) + */ + dispose(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + } +} + +export const tunnelRateLimiter = new TunnelRateLimiter(); diff --git a/backend/ws/tunnel/operations.test.ts b/backend/ws/tunnel/operations.test.ts new file mode 100644 index 00000000..f93eaaab --- /dev/null +++ b/backend/ws/tunnel/operations.test.ts @@ -0,0 +1,266 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +const mockStartQuickTunnel = mock(async (_port: number, _autoStopMinutes: number) => ({ + publicUrl: 'https://quick.example.com', + timings: {}, + restarted: false +})); +const mockSetRemoteIngressUpdateCallback = mock(() => {}); +const mockSetStatusChangedCallback = mock(() => {}); + +mock.module('../../tunnel/global-tunnel-manager', () => ({ + globalTunnelManager: { + setRemoteIngressUpdateCallback: mockSetRemoteIngressUpdateCallback, + setStatusChangedCallback: mockSetStatusChangedCallback, + startQuickTunnel: mockStartQuickTunnel + } +})); + +mock.module('../../tunnel/tunnel-config', () => ({ + getRemoteTunnelConfigById: mock(() => null), + getLocalTunnelConfigById: mock(() => null), + addLocalTunnelConfig: mock(() => null), + removeLocalTunnelConfig: mock(() => null), + addLocalTunnelIngress: mock(() => null), + removeLocalTunnelIngress: mock(() => null), + getAuthorizedZone: mock(() => null), + setAuthorizedZone: mock(() => {}), + clearAuthorizedZone: mock(() => {}) +})); + +const mockGetUserId = mock(() => 'user-1'); +const mockGetRemoteAddress = mock(() => '203.0.113.10'); +const mockGetRole = mock(() => 'member'); + +mock.module('$backend/utils/ws', () => ({ + ws: { + getUserId: mockGetUserId, + getRemoteAddress: mockGetRemoteAddress, + getRole: mockGetRole, + emit: { + global: mock(() => {}), + user: mock(() => {}) + } + } +})); + +const mockCanCreateTunnel = mock(() => ({ allowed: true })); +const mockRecordTunnelCreation = mock(() => {}); +const mockGetStatus = mock(() => ({ + count: 0, + limit: 10, + resetAt: null +})); + +mock.module('../../tunnel/tunnel-rate-limiter', () => ({ + tunnelRateLimiter: { + canCreateTunnel: mockCanCreateTunnel, + recordTunnelCreation: mockRecordTunnelCreation, + getStatus: mockGetStatus + } +})); + +const mockLogQuickTunnelStart = mock(() => {}); +const mockLogQuickTunnelStop = mock(() => {}); +const mockLogQuickTunnelRestart = mock(() => {}); + +mock.module('../../tunnel/tunnel-audit-logger', () => ({ + tunnelAuditLogger: { + logQuickTunnelStart: mockLogQuickTunnelStart, + logQuickTunnelStop: mockLogQuickTunnelStop, + logQuickTunnelRestart: mockLogQuickTunnelRestart, + logRemoteTunnelStart: mock(() => {}), + logRemoteTunnelStop: mock(() => {}), + logLocalTunnelStart: mock(() => {}), + logLocalTunnelStop: mock(() => {}) + } +})); + +const mockGetRecentLogs = mock(() => []); +const mockGetEventsByType = mock(() => []); + +mock.module('../../database/queries/audit-log-queries', () => ({ + auditLogQueries: { + getRecentLogs: mockGetRecentLogs, + getEventsByType: mockGetEventsByType + } +})); + +mock.module('$shared/utils/logger', () => ({ + debug: { + log: mock(() => {}), + warn: mock(() => {}), + error: mock(() => {}) + } +})); + +const { operationsHandler } = await import('./operations'); + +function createConnection() { + const sent: Array<{ action: string; payload: any }> = []; + + return { + conn: { + readyState: 1, + send(message: string) { + sent.push(JSON.parse(message)); + }, + close() {} + }, + sent + }; +} + +describe('operationsHandler quick tunnel routes', () => { + beforeEach(() => { + mockStartQuickTunnel.mockReset(); + mockStartQuickTunnel.mockImplementation(async () => ({ + publicUrl: 'https://quick.example.com', + timings: {}, + restarted: false + })); + mockCanCreateTunnel.mockReset(); + mockCanCreateTunnel.mockImplementation(() => ({ allowed: true })); + mockRecordTunnelCreation.mockClear(); + mockLogQuickTunnelStart.mockClear(); + mockLogQuickTunnelStop.mockClear(); + }); + + test('rejects tunnel:quick:start before launching a tunnel when the user is over limit', async () => { + mockCanCreateTunnel.mockImplementation(() => ({ + allowed: false, + retryAfter: 60 + })); + + const { conn, sent } = createConnection(); + + await (operationsHandler as any).handleMessage(conn, JSON.stringify({ + action: 'tunnel:quick:start', + payload: { + requestId: 'req-1', + data: { port: 3000, autoStopMinutes: 30 } + } + })); + + expect(mockStartQuickTunnel).not.toHaveBeenCalled(); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + action: 'tunnel:quick:start:response', + payload: { + success: false, + requestId: 'req-1' + } + }); + expect(sent[0].payload.error).toContain('Rate limit exceeded'); + }); + + test('allows tunnel creation and logs audit event when under limit', async () => { + const { conn, sent } = createConnection(); + + await (operationsHandler as any).handleMessage(conn, JSON.stringify({ + action: 'tunnel:quick:start', + payload: { + requestId: 'req-2', + data: { port: 3000 } + } + })); + + expect(mockStartQuickTunnel).toHaveBeenCalled(); + expect(mockRecordTunnelCreation).toHaveBeenCalledWith('user-1'); + expect(mockLogQuickTunnelStart).toHaveBeenCalledWith('user-1', 3000, { + ipAddress: '203.0.113.10', + autoStopMinutes: 60 + }); + expect(sent[0]).toMatchObject({ + action: 'tunnel:quick:start:response', + payload: { + success: true, + requestId: 'req-2' + } + }); + }); +}); + +describe('operationsHandler monitoring routes', () => { + beforeEach(() => { + mockGetRecentLogs.mockReset(); + mockGetRecentLogs.mockImplementation(() => []); + mockGetEventsByType.mockReset(); + mockGetEventsByType.mockImplementation(() => []); + mockGetRole.mockReset(); + mockGetRole.mockImplementation(() => 'member'); + mockGetUserId.mockReset(); + mockGetUserId.mockImplementation(() => 'user-1'); + }); + + test('tunnel:monitoring:rate-limit-status allows members to check their own status', async () => { + const { conn, sent } = createConnection(); + + await (operationsHandler as any).handleMessage(conn, JSON.stringify({ + action: 'tunnel:monitoring:rate-limit-status', + payload: { + requestId: 'req-1', + data: {} + } + })); + + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + action: 'tunnel:monitoring:rate-limit-status:response', + payload: { + success: true, + requestId: 'req-1' + } + }); + expect(sent[0].payload.data).toMatchObject({ + userId: 'user-1' + }); + }); + + test('tunnel:monitoring:rate-limit-status rejects member checking another user', async () => { + const { conn, sent } = createConnection(); + + await (operationsHandler as any).handleMessage(conn, JSON.stringify({ + action: 'tunnel:monitoring:rate-limit-status', + payload: { + requestId: 'req-2', + data: { userId: 'user-2' } + } + })); + + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + action: 'tunnel:monitoring:rate-limit-status:response', + payload: { + success: false, + error: 'Admin access required to check other users rate limits', + requestId: 'req-2' + } + }); + }); + + test('tunnel:monitoring:rate-limit-status allows admin to check any user', async () => { + mockGetRole.mockImplementation(() => 'admin'); + const { conn, sent } = createConnection(); + + await (operationsHandler as any).handleMessage(conn, JSON.stringify({ + action: 'tunnel:monitoring:rate-limit-status', + payload: { + requestId: 'req-3', + data: { userId: 'user-2' } + } + })); + + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + action: 'tunnel:monitoring:rate-limit-status:response', + payload: { + success: true, + requestId: 'req-3' + } + }); + expect(sent[0].payload.data).toMatchObject({ + userId: 'user-2' + }); + }); +}); diff --git a/backend/ws/tunnel/operations.ts b/backend/ws/tunnel/operations.ts index 1998edcd..e0bbfd77 100644 --- a/backend/ws/tunnel/operations.ts +++ b/backend/ws/tunnel/operations.ts @@ -21,6 +21,9 @@ import { setAuthorizedZone, clearAuthorizedZone } from '../../tunnel/tunnel-config'; +import { tunnelAuditLogger } from '../../tunnel/tunnel-audit-logger'; +import { tunnelRateLimiter } from '../../tunnel/tunnel-rate-limiter'; +import { auditLogQueries } from '../../database/queries/audit-log-queries'; import { debug } from '$shared/utils/logger'; import { ws } from '$backend/utils/ws'; @@ -47,11 +50,28 @@ export const operationsHandler = createRouter() autoStopMinutes: t.Optional(t.Number({ minimum: 0 })) }), response: t.Any() - }, async ({ data }) => { + }, async ({ data, conn }) => { const { port, autoStopMinutes = 60 } = data; - debug.log('tunnel', `[WS] Quick tunnel start: port=${port}, autoStop=${autoStopMinutes}`); + const userId = ws.getUserId(conn); + const ipAddress = ws.getRemoteAddress(conn); + + // Check rate limit + const rateLimit = tunnelRateLimiter.canCreateTunnel(userId); + if (!rateLimit.allowed) { + throw new Error(`Rate limit exceeded. Please try again in ${rateLimit.retryAfter} seconds.`); + } + + debug.log('tunnel', `[WS] Quick tunnel start: port=${port}, autoStop=${autoStopMinutes}, user=${userId}`); const result = await globalTunnelManager.startQuickTunnel(port, autoStopMinutes); + + // Record rate limit and audit log + tunnelRateLimiter.recordTunnelCreation(userId); + tunnelAuditLogger.logQuickTunnelStart(userId, port, { + ipAddress, + autoStopMinutes + }); + return result; }) @@ -60,8 +80,15 @@ export const operationsHandler = createRouter() port: t.Number() }), response: t.Object({ stopped: t.Boolean() }) - }, async ({ data }) => { + }, async ({ data, conn }) => { + const userId = ws.getUserId(conn); + const ipAddress = ws.getRemoteAddress(conn); + await globalTunnelManager.stopQuickTunnel(data.port); + + // Log audit event + tunnelAuditLogger.logQuickTunnelStop(userId, data.port, { ipAddress }); + return { stopped: true }; }) @@ -74,12 +101,19 @@ export const operationsHandler = createRouter() configId: t.String({ minLength: 1 }) }), response: t.Any() - }, async ({ data }) => { + }, async ({ data, conn }) => { const config = getRemoteTunnelConfigById(data.configId); if (!config) throw new Error('Remote tunnel config not found'); + + const userId = ws.getUserId(conn); + const ipAddress = ws.getRemoteAddress(conn); - debug.log('tunnel', `[WS] Remote tunnel start: ${config.label}`); + debug.log('tunnel', `[WS] Remote tunnel start: ${config.label}, user=${userId}`); const result = await globalTunnelManager.startRemoteTunnel(config); + + // Log audit event + tunnelAuditLogger.logRemoteTunnelStart(userId, data.configId, config.label, { ipAddress }); + return result; }) @@ -88,8 +122,16 @@ export const operationsHandler = createRouter() configId: t.String({ minLength: 1 }) }), response: t.Object({ stopped: t.Boolean() }) - }, async ({ data }) => { + }, async ({ data, conn }) => { + const userId = ws.getUserId(conn); + const ipAddress = ws.getRemoteAddress(conn); + const config = getRemoteTunnelConfigById(data.configId); + await globalTunnelManager.stopRemoteTunnel(data.configId); + + // Log audit event + tunnelAuditLogger.logRemoteTunnelStop(userId, data.configId, config?.label ?? data.configId, { ipAddress }); + return { stopped: true }; }) @@ -279,12 +321,19 @@ export const operationsHandler = createRouter() id: t.String({ minLength: 1 }) }), response: t.Any() - }, async ({ data }) => { + }, async ({ data, conn }) => { const config = getLocalTunnelConfigById(data.id); if (!config) throw new Error('Local tunnel config not found'); + const userId = ws.getUserId(conn); + const ipAddress = ws.getRemoteAddress(conn); + debug.log('tunnel', `[WS] Starting local tunnel: ${config.name}`); const result = await globalTunnelManager.startLocalTunnel(config); + + // Log audit event + tunnelAuditLogger.logLocalTunnelStart(userId, data.id, config.name, { ipAddress }); + return result; }) @@ -293,8 +342,16 @@ export const operationsHandler = createRouter() id: t.String({ minLength: 1 }) }), response: t.Object({ stopped: t.Boolean() }) - }, async ({ data }) => { + }, async ({ data, conn }) => { + const config = getLocalTunnelConfigById(data.id); + const userId = ws.getUserId(conn); + const ipAddress = ws.getRemoteAddress(conn); + await globalTunnelManager.stopLocalTunnel(data.id); + + // Log audit event + tunnelAuditLogger.logLocalTunnelStop(userId, data.id, config?.name ?? data.id, { ipAddress }); + return { stopped: true }; }) @@ -311,6 +368,96 @@ export const operationsHandler = createRouter() return { tunnels: globalTunnelManager.getActiveTunnels() }; }) + // ═══════════════════════════════════════ + // Monitoring (admin-only, gated via ADMIN_ONLY_ROUTES) + // ═══════════════════════════════════════ + + .http('tunnel:monitoring:access-logs', { + data: t.Object({ + limit: t.Optional(t.Number({ minimum: 1, maximum: 500 })) + }), + response: t.Object({ + logs: t.Array(t.Object({ + id: t.String(), + userId: t.String(), + eventType: t.String(), + eventDetails: t.Nullable(t.String()), + ipAddress: t.Nullable(t.String()), + userAgent: t.Nullable(t.String()), + createdAt: t.String() + })) + }) + }, async ({ data }) => { + const { limit = 100 } = data; + const allLogs = auditLogQueries.getRecentLogs(limit); + const tunnelLogs = allLogs + .filter((log) => log.event_type.startsWith('tunnel:')) + .map((log) => ({ + id: log.id, + userId: log.user_id, + eventType: log.event_type, + eventDetails: log.event_details, + ipAddress: log.ip_address, + userAgent: log.user_agent, + createdAt: log.created_at + })); + return { logs: tunnelLogs }; + }) + + .http('tunnel:monitoring:statistics', { + data: t.Object({}), + response: t.Object({ + totalCreated: t.Number(), + byType: t.Record(t.String(), t.Number()), + byUser: t.Record(t.String(), t.Number()) + }) + }, async () => { + const tunnelEvents = auditLogQueries.getEventsByType('tunnel:quick:start', 1000) + .concat(auditLogQueries.getEventsByType('tunnel:remote:start', 1000)) + .concat(auditLogQueries.getEventsByType('tunnel:local:start', 1000)); + + const byType: Record = {}; + const byUser: Record = {}; + + for (const event of tunnelEvents) { + const details = event.event_details ? JSON.parse(event.event_details) : null; + const tunnelType = details?.tunnelType ?? 'unknown'; + byType[tunnelType] = (byType[tunnelType] ?? 0) + 1; + byUser[event.user_id] = (byUser[event.user_id] ?? 0) + 1; + } + + return { + totalCreated: tunnelEvents.length, + byType, + byUser + }; + }) + + .http('tunnel:monitoring:rate-limit-status', { + data: t.Object({ + userId: t.Optional(t.String()) + }), + response: t.Object({ + userId: t.String(), + count: t.Number(), + limit: t.Number(), + resetAt: t.Nullable(t.Number()) + }) + }, async ({ data, conn }) => { + const targetUserId = data.userId ?? ws.getUserId(conn); + const callerUserId = ws.getUserId(conn); + const callerRole = ws.getRole(conn); + + if (targetUserId !== callerUserId && callerRole !== 'admin') { + throw new Error('Admin access required to check other users rate limits'); + } + + return { + userId: targetUserId, + ...tunnelRateLimiter.getStatus(targetUserId) + }; + }) + // ═══════════════════════════════════════ // Event declarations (Server → Client) // ═══════════════════════════════════════ diff --git a/shared/utils/logger.ts b/shared/utils/logger.ts index e4c7ab30..25950f95 100644 --- a/shared/utils/logger.ts +++ b/shared/utils/logger.ts @@ -36,6 +36,8 @@ export type LogLabel = | 'settings' | 'engine' | 'tunnel' + | 'tunnel-access' + | 'tunnel-rate-limit' | 'db-client' // User