From 17091bcc953d059332bca1b058a424f73858aad2 Mon Sep 17 00:00:00 2001 From: ImBenji Date: Tue, 19 Aug 2025 20:30:43 +0100 Subject: [PATCH] Implement authentication middleware and rolling codes service for secure API access --- lib/services/rolling_codes_service.dart | 132 ++++++++++++++++++++++++ lib/services/server_service.dart | 82 ++++++++++----- lib/services/supabase_heartbeat.dart | 127 +++++++++++++++++++++-- lib/web/peer_routes.dart | 83 +++++++++++++-- 4 files changed, 380 insertions(+), 44 deletions(-) create mode 100644 lib/services/rolling_codes_service.dart diff --git a/lib/services/rolling_codes_service.dart b/lib/services/rolling_codes_service.dart new file mode 100644 index 0000000..9e86983 --- /dev/null +++ b/lib/services/rolling_codes_service.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:waylume_server/core/utils.dart'; + +import '../config/supabase_config.dart'; + +class RollingCodesService { + // Shared constants (these should match edge function constants) + static const String registrationSeed = String.fromEnvironment('REGISTRATION_SEED', defaultValue: 'default_registration_seed_change_in_production'); + static const String secretSalt = String.fromEnvironment('SECRET_SALT', defaultValue: 'default_secret_salt_change_in_production'); + static const int timeWindowSeconds = 300; // 5 minutes + + // Server configuration file path + static const String configFile = '/tmp/waylume_server_config.json'; + + static String? _operationalSeed; + static String? _serverId; + + /// Initialize service and load/generate configuration + static Future initialize() async { + _serverId = fromEnivronment('SERVER_ID'); + if (_serverId == null) { + throw Exception('SERVER_ID environment variable is required'); + } + + await _loadConfiguration(); + } + + /// Generate registration rolling code + static String generateRegistrationCode([DateTime? timestamp]) { + timestamp ??= DateTime.now(); + final timeWindow = timestamp.millisecondsSinceEpoch ~/ 1000 ~/ timeWindowSeconds; + return _generateHmacSha256(registrationSeed, timeWindow.toString()); + } + + /// Generate operational rolling code + static String generateOperationalCode([DateTime? timestamp]) { + if (_operationalSeed == null) { + throw Exception('Operational seed not set. Server needs to be registered first.'); + } + + timestamp ??= DateTime.now(); + final timeWindow = timestamp.millisecondsSinceEpoch ~/ 1000 ~/ timeWindowSeconds; + return _generateHmacSha256(_operationalSeed!, timeWindow.toString() + secretSalt); + } + + /// Validate rolling code (checks current and previous window) + static bool validateOperationalCode(String receivedCode, [DateTime? timestamp]) { + if (_operationalSeed == null) { + return false; + } + + timestamp ??= DateTime.now(); + final currentWindow = timestamp.millisecondsSinceEpoch ~/ 1000 ~/ timeWindowSeconds; + + // Check current window + final currentExpected = _generateHmacSha256(_operationalSeed!, currentWindow.toString() + secretSalt); + if (receivedCode == currentExpected) { + return true; + } + + // Check previous window (10-minute tolerance total) + final previousExpected = _generateHmacSha256(_operationalSeed!, (currentWindow - 1).toString() + secretSalt); + return receivedCode == previousExpected; + } + + /// Set operational seed after successful registration + static Future setOperationalSeed(String seed) async { + _operationalSeed = seed; + await _saveConfiguration(); + } + + /// Get current operational seed + static String? get operationalSeed => _operationalSeed; + + /// Get server ID + static String? get serverId => _serverId; + + /// Check if server is registered (has operational seed) + static bool get isRegistered => _operationalSeed != null; + + /// Generate HMAC-SHA256 hash + static String _generateHmacSha256(String key, String message) { + final keyBytes = utf8.encode(key); + final messageBytes = utf8.encode(key + message); + + final hmac = Hmac(sha256, keyBytes); + final digest = hmac.convert(messageBytes); + + return digest.toString(); + } + + /// Load configuration from persistent storage + static Future _loadConfiguration() async { + try { + final file = File(configFile); + if (await file.exists()) { + final content = await file.readAsString(); + final config = jsonDecode(content) as Map; + + if (config['server_id'] == _serverId) { + _operationalSeed = config['operational_seed']; + print('Loaded operational seed for server $_serverId'); + } else { + print('Configuration file exists but server_id does not match. Need to re-register.'); + } + } + } catch (e) { + print('Error loading configuration: $e'); + } + } + + /// Save configuration to persistent storage + static Future _saveConfiguration() async { + try { + final config = { + 'server_id': _serverId, + 'operational_seed': _operationalSeed, + 'last_updated': DateTime.now().toIso8601String(), + }; + + final file = File(configFile); + await file.writeAsString(jsonEncode(config)); + print('Configuration saved for server $_serverId'); + } catch (e) { + print('Error saving configuration: $e'); + throw Exception('Failed to save server configuration'); + } + } +} \ No newline at end of file diff --git a/lib/services/server_service.dart b/lib/services/server_service.dart index ea8a759..5cd8bd0 100644 --- a/lib/services/server_service.dart +++ b/lib/services/server_service.dart @@ -1,37 +1,67 @@ import 'dart:io'; +import 'dart:convert'; +import 'package:http/http.dart' as http; import 'package:waylume_server/config/supabase_config.dart'; import 'package:waylume_server/core/utils.dart'; +import 'package:waylume_server/services/rolling_codes_service.dart'; class ServerService { static Future registerServer() async { - + await RollingCodesService.initialize(); + + // If already registered and has operational seed, skip registration + if (RollingCodesService.isRegistered) { + print('Server already registered with operational seed'); + return; + } + GeolocationData geolocationData = await getGeolocationData(); - - String ip = "${geolocationData.ip}:${fromEnivronment("EXTERNAL_PORT") ?? "3000"}"; - - var existsCheck = await SUPABASE_CLIENT - .from("waylume_servers") - .select() - .eq("id", fromEnivronment('SERVER_ID')) - .eq("host_ip", ip); - - if (existsCheck.isEmpty) { - await SUPABASE_CLIENT - .from("waylume_servers") - .insert({ - "id": fromEnivronment('SERVER_ID')!, - "last_heartbeat": DateTime.now().toUtc().toIso8601String(), - "host_ip": ip, - "geolocation": { - "country": geolocationData.countryName, - "country_code": geolocationData.countryCode, - "city": geolocationData.city, - "coords": [ - geolocationData.latitude, - geolocationData.longitude - ], + + // Generate registration rolling code + String registrationAuth = RollingCodesService.generateRegistrationCode(); + + // Call server-manager registration endpoint + String serverManagerUrl = '${fromEnivronment("SUPABASE_URL")}/functions/v1/server-manager/register'; + + Map requestBody = { + 'server_id': fromEnivronment('SERVER_ID')!, + 'registration_auth': registrationAuth, + 'geolocation_data': { + "country": geolocationData.countryName, + "country_code": geolocationData.countryCode, + "city": geolocationData.city, + "coords": [ + geolocationData.latitude, + geolocationData.longitude + ], + } + }; + + try { + final response = await http.post( + Uri.parse(serverManagerUrl), + headers: { + 'Content-Type': 'application/json', + 'apikey': fromEnivronment('SUPABASE_ANON_KEY')!, + }, + body: jsonEncode(requestBody), + ); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + if (responseData['success']) { + // Store operational seed + await RollingCodesService.setOperationalSeed(responseData['operational_seed']); + print('Server registered successfully with server-manager'); + } else { + throw Exception('Registration failed: ${responseData['error']}'); } - }); + } else { + throw Exception('Registration request failed: ${response.statusCode}'); + } + } catch (e) { + print('Error registering server: $e'); + throw Exception('Failed to register server: $e'); } } diff --git a/lib/services/supabase_heartbeat.dart b/lib/services/supabase_heartbeat.dart index fb56744..0ff9c0c 100644 --- a/lib/services/supabase_heartbeat.dart +++ b/lib/services/supabase_heartbeat.dart @@ -4,9 +4,10 @@ import 'dart:io'; import 'dart:isolate'; import 'dart:math'; - -import 'package:supabase/supabase.dart'; +import 'dart:convert'; +import 'package:http/http.dart' as http; import 'package:waylume_server/config/supabase_config.dart'; +import 'package:waylume_server/services/rolling_codes_service.dart'; void initHeartbeat() { @@ -18,18 +19,122 @@ void initHeartbeat() { while (true) { DateTime now = DateTime.now().toUtc(); + + try { + // Wait until rolling codes service is initialized and registered + if (!RollingCodesService.isRegistered) { + print("Server not registered yet, skipping heartbeat"); + await Future.delayed(Duration(seconds: 30)); + continue; + } + + // Generate operational rolling code for heartbeat authentication + String authCode = RollingCodesService.generateOperationalCode(); + + // Get server status (CPU, memory, active connections) + Map serverStatus = await _getServerStatus(); + + // Send heartbeat to server-manager endpoint + String serverManagerUrl = '${fromEnivronment("SUPABASE_URL")}/functions/v1/server-manager/heartbeat'; + + Map heartbeatData = { + 'server_id': fromEnivronment("SERVER_ID")!, + 'timestamp': now.toIso8601String(), + 'auth_code': authCode, + 'status': serverStatus + }; + + final response = await http.post( + Uri.parse(serverManagerUrl), + headers: { + 'Content-Type': 'application/json', + 'apikey': fromEnivronment('SUPABASE_ANON_KEY')!, + }, + body: jsonEncode(heartbeatData), + ); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + if (responseData['success']) { + print("Heartbeat sent successfully at ${now.toIso8601String()}"); + } else { + print("Heartbeat failed: ${responseData['error']}"); + } + } else { + print("Heartbeat request failed: ${response.statusCode}"); + } + + } catch (e) { + print("Error sending heartbeat: $e"); + } - await SUPABASE_CLIENT - .from("waylume_servers") - .update({ "last_heartbeat": now.toIso8601String() }) - .eq("id", fromEnivronment("SERVER_ID")!); - - print("Heartbeat sent to Supabase at ${now.toIso8601String()}"); - - // Wait 30 seconds before sending the next heartbeat - await Future.delayed(Duration(seconds: 30)); + // Wait 120 seconds (2 minutes) before sending the next heartbeat + await Future.delayed(Duration(seconds: 120)); } }, null); +} + +Future> _getServerStatus() async { + // Get basic server status information + // This is a simplified implementation - could be enhanced with actual metrics + + int activeConnections = 0; + double cpuUsage = 0.0; + double memoryUsage = 0.0; + + try { + // Get active WireGuard connections count + ProcessResult wgShowResult = await Process.run('wg', ['show', 'wg0']); + if (wgShowResult.exitCode == 0) { + // Count peer entries (simplified - each peer has multiple lines) + String output = wgShowResult.stdout as String; + activeConnections = 'peer:'.allMatches(output).length; + } + } catch (e) { + print("Error getting WireGuard status: $e"); + } + + try { + // Get basic system info (simplified) + ProcessResult uptimeResult = await Process.run('uptime', []); + if (uptimeResult.exitCode == 0) { + String output = uptimeResult.stdout as String; + // Parse load average as a rough CPU usage indicator + RegExp loadRegex = RegExp(r'load average: (\d+\.?\d*),'); + Match? match = loadRegex.firstMatch(output); + if (match != null) { + cpuUsage = double.tryParse(match.group(1) ?? '0') ?? 0.0; + cpuUsage = (cpuUsage * 100).clamp(0, 100); // Convert to percentage + } + } + } catch (e) { + print("Error getting CPU usage: $e"); + } + + try { + // Get memory usage (simplified) + ProcessResult freeResult = await Process.run('free', ['-m']); + if (freeResult.exitCode == 0) { + String output = freeResult.stdout as String; + List lines = output.split('\n'); + if (lines.length > 1) { + List memLine = lines[1].split(RegExp(r'\s+')); + if (memLine.length > 2) { + int total = int.tryParse(memLine[1]) ?? 1; + int used = int.tryParse(memLine[2]) ?? 0; + memoryUsage = (used / total * 100).clamp(0, 100); + } + } + } + } catch (e) { + print("Error getting memory usage: $e"); + } + + return { + 'active_connections': activeConnections, + 'cpu_usage': cpuUsage, + 'memory_usage': memoryUsage, + }; } \ No newline at end of file diff --git a/lib/web/peer_routes.dart b/lib/web/peer_routes.dart index 44b71db..fa94233 100644 --- a/lib/web/peer_routes.dart +++ b/lib/web/peer_routes.dart @@ -7,22 +7,91 @@ import 'package:waylume_server/wireguard/utils.dart'; import 'package:waylume_server/core/utils.dart'; import 'package:waylume_server/services/vpn_session_service.dart'; import 'package:waylume_server/services/bandwidth_service.dart'; +import 'package:waylume_server/services/rolling_codes_service.dart'; class PeerRoutes { Router get router { final router = Router(); - router.get('/peers', _getPeers); - router.post('/peer', _createPeer); - router.delete('/peer/', _deletePeer); - router.get('/peer//config', _getPeerConfig); - router.patch('/peer//speed-limit', _setSpeedLimit); - router.patch('/peer//data-cap', _setDataCap); - router.get('/bandwidth-stats', _getBandwidthStats); + router.get('/peers', _authMiddleware(_getPeers)); + router.post('/peer', _authMiddleware(_createPeer)); + router.delete('/peer/', _authMiddleware(_deletePeer)); + router.get('/peer//config', _authMiddleware(_getPeerConfig)); + router.patch('/peer//speed-limit', _authMiddleware(_setSpeedLimit)); + router.patch('/peer//data-cap', _authMiddleware(_setDataCap)); + router.get('/bandwidth-stats', _authMiddleware(_getBandwidthStats)); return router; } + /// Authentication middleware for API endpoints + Handler _authMiddleware(Handler handler) { + return (Request request) async { + try { + // Check if server is registered + if (!RollingCodesService.isRegistered) { + return Response(401, + body: jsonEncode({ + 'success': false, + 'error': 'Server not registered - missing operational seed' + }), + headers: {'Content-Type': 'application/json'} + ); + } + + // Get authorization header + String? authHeader = request.headers['authorization']; + if (authHeader == null) { + return Response(401, + body: jsonEncode({ + 'success': false, + 'error': 'Authorization header required' + }), + headers: {'Content-Type': 'application/json'} + ); + } + + // Validate WaylumeAuth format + if (!authHeader.startsWith('WaylumeAuth ')) { + return Response(401, + body: jsonEncode({ + 'success': false, + 'error': 'Invalid authorization format - expected WaylumeAuth' + }), + headers: {'Content-Type': 'application/json'} + ); + } + + // Extract auth code + String authCode = authHeader.substring('WaylumeAuth '.length); + + // Validate rolling code + bool isValid = RollingCodesService.validateOperationalCode(authCode); + if (!isValid) { + return Response(401, + body: jsonEncode({ + 'success': false, + 'error': 'Invalid authentication code' + }), + headers: {'Content-Type': 'application/json'} + ); + } + + // Authentication successful, proceed to handler + return await handler(request); + + } catch (e) { + return Response(500, + body: jsonEncode({ + 'success': false, + 'error': 'Authentication error: $e' + }), + headers: {'Content-Type': 'application/json'} + ); + } + }; + } + Future _getPeers(Request request) async { try { final statusParam = request.url.queryParameters['status']?.toUpperCase();