diff --git a/.gitignore b/.gitignore index e147207..ea33d90 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -repeater \ No newline at end of file +internal/api/routes.gen.go +internal/api/types.gen.go +/repeater \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2d57547 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: build run clean install test + +BINARY_NAME=repeater +CMD_PATH=./cmd/repeater +BUILD_DIR=. + +build: + go build -v -o $(BUILD_DIR)/$(BINARY_NAME) $(CMD_PATH) + +run: build + sudo ./$(BINARY_NAME) + +clean: + go clean + rm -f $(BUILD_DIR)/$(BINARY_NAME) + +install: build + sudo install -m 755 $(BUILD_DIR)/$(BINARY_NAME) /usr/local/bin/ + +test: + go test -v ./... + +tidy: + go mod tidy + +fmt: + go fmt ./... + +vet: + go vet ./... + +all: fmt vet build diff --git a/README.md b/README.md new file mode 100644 index 0000000..54691f9 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Travel Router Control + +A Go application for controlling a mini travel router with dual WiFi interfaces and Ethernet connectivity. The router can operate as a WiFi repeater, connecting to upstream networks while providing a hotspot for client devices. + +## Features + +- WiFi network scanning and connection management +- Hotspot (access point) configuration and control +- Connected device monitoring +- Real-time system logs via WebSocket +- RESTful API following OpenAPI 3.0 specification +- Web interface for easy management + +## Architecture + +The application follows a clean architecture pattern: + +``` +. +├── cmd/ +│ └── repeater/ # Application entry point +│ ├── main.go +│ └── static/ # Embedded web assets +├── internal/ +│ ├── api/ # HTTP API layer +│ │ ├── router.go # Gin router setup +│ │ └── handlers/ # HTTP handlers +│ ├── app/ # Application logic & lifecycle +│ ├── device/ # Device management +│ ├── hotspot/ # Hotspot control +│ ├── logging/ # Logging system +│ ├── models/ # Data structures +│ └── wifi/ # WiFi operations (wpa_supplicant via D-Bus) +├── openapi.yaml # API specification +└── go.mod +``` + +## Building + +```bash +go build -o repeater ./cmd/repeater +``` + +## Running + +```bash +sudo ./repeater +``` + +The application requires root privileges to: +- Access D-Bus system bus for wpa_supplicant +- Control systemd services (hostapd) +- Read DHCP leases and ARP tables + +The server will start on port 8080. + +## API Endpoints + +### WiFi Operations +- `GET /api/wifi/scan` - Scan for available networks +- `POST /api/wifi/connect` - Connect to a network +- `POST /api/wifi/disconnect` - Disconnect from current network + +### Hotspot Operations +- `POST /api/hotspot/config` - Configure hotspot settings +- `POST /api/hotspot/toggle` - Enable/disable hotspot + +### Device Management +- `GET /api/devices` - Get connected devices + +### System +- `GET /api/status` - Get system status +- `GET /api/logs` - Get system logs +- `DELETE /api/logs` - Clear logs + +### WebSocket +- `GET /ws/logs` - Real-time log streaming + +See `openapi.yaml` for complete API documentation. + +## Configuration + +The application uses the following system resources: + +- **WiFi Interface**: `wlan0` (for upstream connection) +- **AP Interface**: `wlan1` (for hotspot) +- **Hostapd Config**: `/etc/hostapd/hostapd.conf` +- **WPA Supplicant Config**: `/etc/wpa_supplicant/wpa_supplicant.conf` + +These can be modified in the respective package constants. + +## Dependencies + +- **Gin**: HTTP web framework +- **godbus**: D-Bus client for wpa_supplicant control +- **gorilla/websocket**: WebSocket support +- **wpa_supplicant**: WiFi connection management +- **hostapd**: Hotspot functionality + +## License + +MIT diff --git a/cmd/repeater/main.go b/cmd/repeater/main.go new file mode 100644 index 0000000..3fd1e66 --- /dev/null +++ b/cmd/repeater/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "embed" + "log" + "os" + "os/signal" + "syscall" + + "github.com/nemunaire/repeater/internal/app" + "github.com/nemunaire/repeater/internal/config" +) + +//go:embed all:static +var assets embed.FS + +func main() { + // Load and parse options + cfg, err := config.ConsolidateConfig() + if err != nil { + log.Fatal(err) + } + + // Create application instance + application := app.New(assets) + + // Initialize the application + if err := application.Initialize(cfg); err != nil { + log.Fatalf("Failed to initialize application: %v", err) + } + defer application.Shutdown() + + // Handle graceful shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigChan + log.Println("Shutting down gracefully...") + application.Shutdown() + os.Exit(0) + }() + + // Start the server + if err := application.Run(cfg.Bind); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/cmd/repeater/static/app.js b/cmd/repeater/static/app.js new file mode 100644 index 0000000..a7c367d --- /dev/null +++ b/cmd/repeater/static/app.js @@ -0,0 +1,791 @@ +// Application state +const appState = { + selectedWifi: null, + hotspotEnabled: true, + autoScrollLogs: true, + ws: null, + wifiWs: null, + reconnectAttempts: 0, + wifiReconnectAttempts: 0, + maxReconnectAttempts: 5, + connectedSSID: null, + connectionState: 'disconnected', + networks: [], + uptime: 0, + uptimeInterval: null +}; + +// Initialize the application +document.addEventListener('DOMContentLoaded', function() { + initializeApp(); +}); + +async function initializeApp() { + console.log('Initializing Travel Router Control Panel...'); + + // Load initial data + await Promise.all([ + loadStatus(), + scanWifi(), + loadDevices(), + loadLogs() + ]); + + // Set up WebSockets for real-time updates + connectWebSocket(); + connectWifiWebSocket(); + + // Start periodic updates + startPeriodicUpdates(); + + // Start uptime counter + startUptimeCounter(); +} + +// ===== API Functions ===== + +async function loadStatus() { + try { + const response = await fetch('/api/status'); + const status = await response.json(); + + updateStatusDisplay(status); + return status; + } catch (error) { + console.error('Error loading status:', error); + showToast('error', 'Erreur', 'Impossible de charger le statut'); + } +} + +async function scanWifi() { + const wifiList = document.getElementById('wifiList'); + const scanBtn = document.querySelector('[onclick="scanWifi()"]'); + + if (scanBtn) { + scanBtn.disabled = true; + } + + wifiList.innerHTML = '
Recherche des réseaux disponibles...
'; + + try { + const response = await fetch('/api/wifi/scan'); + const networks = await response.json(); + + appState.networks = networks; + displayWifiNetworks(networks); + showToast('success', 'Scan WiFi', `${networks.length} réseau(x) trouvé(s)`); + } catch (error) { + console.error('Error scanning WiFi:', error); + + // Fallback to cached networks + try { + const fallbackResponse = await fetch('/api/wifi/networks'); + const cachedNetworks = await fallbackResponse.json(); + + appState.networks = cachedNetworks; + displayWifiNetworks(cachedNetworks); + showToast('warning', 'Scan échoué', `Affichage des réseaux en cache (${cachedNetworks.length})`); + } catch (fallbackError) { + console.error('Error loading cached networks:', fallbackError); + wifiList.innerHTML = '
Erreur lors du scan
'; + showToast('error', 'Erreur', 'Échec du scan WiFi'); + } + } finally { + if (scanBtn) { + scanBtn.disabled = false; + } + } +} + +async function connectToWifi() { + if (!appState.selectedWifi) { + showToast('warning', 'Attention', 'Veuillez sélectionner un réseau WiFi'); + return; + } + + const password = document.getElementById('wifiPassword').value; + + // Password requirement disabled + // if (appState.selectedWifi.security !== 'Open' && !password) { + // showToast('warning', 'Attention', 'Mot de passe requis pour ce réseau'); + // return; + // } + + showLoading(true); + + try { + const response = await fetch('/api/wifi/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ssid: appState.selectedWifi.ssid, + password: password + }) + }); + + const result = await response.json(); + + if (response.ok) { + showToast('success', 'Connecté', `Connexion établie avec ${appState.selectedWifi.ssid}`); + document.getElementById('wifiPassword').value = ''; + await loadStatus(); + } else { + throw new Error(result.error || 'Connection failed'); + } + } catch (error) { + console.error('Error connecting to WiFi:', error); + showToast('error', 'Erreur', 'Échec de la connexion: ' + error.message); + } finally { + showLoading(false); + } +} + +async function disconnectWifi() { + showLoading(true); + + try { + const response = await fetch('/api/wifi/disconnect', { + method: 'POST' + }); + + if (response.ok) { + showToast('success', 'Déconnecté', 'Déconnexion WiFi réussie'); + await loadStatus(); + // Force refresh WiFi list to remove green highlighting + if (appState.networks.length > 0) { + displayWifiNetworks(appState.networks); + } + } else { + throw new Error('Disconnection failed'); + } + } catch (error) { + console.error('Error disconnecting WiFi:', error); + showToast('error', 'Erreur', 'Échec de la déconnexion'); + } finally { + showLoading(false); + } +} + +async function toggleHotspot() { + const toggle = document.getElementById('hotspotToggle'); + const enabled = toggle.checked; + + showLoading(true); + + try { + const response = await fetch('/api/hotspot/toggle', { + method: 'POST' + }); + + const result = await response.json(); + + if (response.ok) { + appState.hotspotEnabled = result.enabled; + toggle.checked = result.enabled; + showToast('success', 'Hotspot', result.enabled ? 'Hotspot activé' : 'Hotspot désactivé'); + await loadStatus(); + } else { + toggle.checked = !enabled; + throw new Error('Toggle failed'); + } + } catch (error) { + console.error('Error toggling hotspot:', error); + toggle.checked = !enabled; + showToast('error', 'Erreur', 'Échec du basculement'); + } finally { + showLoading(false); + } +} + +async function loadDevices() { + try { + const response = await fetch('/api/devices'); + const devices = await response.json(); + + displayDevices(devices); + document.getElementById('deviceCount').textContent = devices.length; + } catch (error) { + console.error('Error loading devices:', error); + const devicesList = document.getElementById('devicesList'); + devicesList.innerHTML = '

Erreur de chargement

'; + } +} + +async function loadLogs() { + try { + const response = await fetch('/api/logs'); + const logs = await response.json(); + + const logContainer = document.getElementById('logContainer'); + logContainer.innerHTML = ''; + + if (logs.length === 0) { + logContainer.innerHTML = '

Aucun log disponible

'; + } else { + logs.forEach(log => addLogEntry(log)); + } + } catch (error) { + console.error('Error loading logs:', error); + } +} + +async function clearLogs() { + try { + const response = await fetch('/api/logs', { method: 'DELETE' }); + + if (response.ok) { + const logContainer = document.getElementById('logContainer'); + logContainer.innerHTML = '

Aucun log disponible

'; + showToast('success', 'Logs', 'Logs effacés'); + } + } catch (error) { + console.error('Error clearing logs:', error); + showToast('error', 'Erreur', 'Échec de la suppression des logs'); + } +} + +// ===== Display Functions ===== + +function updateStatusDisplay(status) { + // Update WiFi status badge + const wifiStatus = document.getElementById('wifiStatus'); + const wifiDot = wifiStatus.querySelector('.status-dot'); + const wifiText = wifiStatus.querySelector('.status-text'); + + // Use connectionState for more detailed status + const state = status.connectionState || (status.connected ? 'connected' : 'disconnected'); + appState.connectionState = state; + + switch (state) { + case 'connected': + wifiDot.className = 'status-dot active'; + wifiText.textContent = `Connecté: ${status.connectedSSID}`; + break; + case 'connecting': + wifiDot.className = 'status-dot connecting'; + wifiText.textContent = status.connectedSSID ? `Connexion à ${status.connectedSSID}...` : 'Connexion...'; + break; + case 'disconnecting': + wifiDot.className = 'status-dot disconnecting'; + wifiText.textContent = 'Déconnexion...'; + break; + case 'roaming': + wifiDot.className = 'status-dot active'; + wifiText.textContent = `Roaming: ${status.connectedSSID}`; + break; + case 'disconnected': + default: + wifiDot.className = 'status-dot offline'; + wifiText.textContent = 'Déconnecté'; + break; + } + + // Update hotspot status badge + const hotspotStatus = document.getElementById('hotspotStatus'); + const hotspotDot = hotspotStatus.querySelector('.status-dot'); + const hotspotText = hotspotStatus.querySelector('.status-text'); + const hotspotToggle = document.getElementById('hotspotToggle'); + + const isHotspotEnabled = status.hotspotStatus && status.hotspotStatus.state === 'ENABLED'; + + if (isHotspotEnabled) { + hotspotDot.className = 'status-dot active'; + const numStations = status.hotspotStatus.numStations || 0; + hotspotText.textContent = `Hotspot actif (${numStations} client${numStations > 1 ? 's' : ''})`; + hotspotToggle.checked = true; + } else { + hotspotDot.className = 'status-dot offline'; + hotspotText.textContent = 'Hotspot inactif'; + hotspotToggle.checked = false; + } + + appState.hotspotEnabled = isHotspotEnabled; + + // Update hotspot details if available + updateHotspotDetails(status.hotspotStatus); + + // Check if connectedSSID or state changed and refresh WiFi list if needed + const prevSSID = appState.connectedSSID; + const prevState = appState.connectionState; + appState.connectedSSID = status.connectedSSID; + + const connectedChanged = prevSSID !== status.connectedSSID || prevState !== state; + + if (connectedChanged && appState.networks.length > 0) { + displayWifiNetworks(appState.networks); + } + + // Update stats + document.getElementById('connectedDevices').textContent = status.connectedCount; + document.getElementById('dataUsage').textContent = `${status.dataUsage.toFixed(1)} MB`; + + // Update uptime in state (will be incremented by the interval) + appState.uptime = status.uptime; + document.getElementById('uptime').textContent = formatUptime(status.uptime); + + document.getElementById('currentNetwork').textContent = status.connectedSSID || '-'; +} + +function displayWifiNetworks(networks) { + const wifiList = document.getElementById('wifiList'); + wifiList.innerHTML = ''; + + if (networks.length === 0) { + wifiList.innerHTML = '
Aucun réseau trouvé
'; + return; + } + + networks.forEach(network => { + const wifiItem = document.createElement('div'); + wifiItem.className = 'wifi-item'; + + // Mark the network based on connection state + if (appState.connectedSSID && network.ssid === appState.connectedSSID) { + switch (appState.connectionState) { + case 'connected': + case 'roaming': + wifiItem.classList.add('connected'); + break; + case 'connecting': + wifiItem.classList.add('connecting'); + break; + case 'disconnecting': + wifiItem.classList.add('disconnecting'); + break; + } + } + + wifiItem.onclick = () => selectWifi(network, wifiItem); + + wifiItem.innerHTML = ` +
+
${escapeHtml(network.ssid)}
+
+ ${escapeHtml(network.security)} + Canal ${escapeHtml(String(network.channel))} + ${escapeHtml(network.bssid)} +
+
+
+ ${generateSignalBars(network.signal)} +
+ `; + + wifiList.appendChild(wifiItem); + }); +} + +function selectWifi(network, element) { + document.querySelectorAll('.wifi-item').forEach(item => item.classList.remove('selected')); + element.classList.add('selected'); + appState.selectedWifi = network; +} + +function displayDevices(devices) { + const devicesList = document.getElementById('devicesList'); + devicesList.innerHTML = ''; + + if (devices.length === 0) { + devicesList.innerHTML = ` +
+ + + +

Aucun appareil connecté

+
+ `; + return; + } + + devices.forEach(device => { + const deviceCard = document.createElement('div'); + deviceCard.className = 'device-card'; + + deviceCard.innerHTML = ` + ${getDeviceIcon(device.type)} +
${escapeHtml(device.name)}
+
${device.type}
+
+
${device.ip}
+
${device.mac}
+
+ `; + + devicesList.appendChild(deviceCard); + }); +} + +function addLogEntry(log) { + const logContainer = document.getElementById('logContainer'); + + // Remove placeholder if it exists + const placeholder = logContainer.querySelector('.log-placeholder'); + if (placeholder) { + placeholder.remove(); + } + + const logEntry = document.createElement('div'); + logEntry.className = 'log-entry'; + + const timestamp = new Date(log.timestamp).toLocaleTimeString('fr-FR'); + + const timestampSpan = document.createElement('span'); + timestampSpan.className = 'log-timestamp'; + timestampSpan.textContent = timestamp; + + const sourceSpan = document.createElement('span'); + sourceSpan.className = 'log-source'; + sourceSpan.textContent = `[${log.source}]`; + + const messageSpan = document.createElement('span'); + messageSpan.className = 'log-message'; + messageSpan.textContent = log.message; + + logEntry.appendChild(timestampSpan); + logEntry.appendChild(sourceSpan); + logEntry.appendChild(messageSpan); + + logContainer.appendChild(logEntry); + + if (appState.autoScrollLogs) { + logContainer.scrollTop = logContainer.scrollHeight; + } +} + +function createHotspotInfoItem(label, value) { + const item = document.createElement('div'); + item.className = 'hotspot-info-item'; + + const labelSpan = document.createElement('span'); + labelSpan.className = 'info-label'; + labelSpan.textContent = label + ':'; + + const valueSpan = document.createElement('span'); + valueSpan.className = 'info-value'; + valueSpan.textContent = value; + + item.appendChild(labelSpan); + item.appendChild(valueSpan); + + return item; +} + +function updateHotspotDetails(hotspotStatus) { + const detailsContainer = document.getElementById('hotspotDetails'); + if (!detailsContainer) return; + + // Clear existing content + detailsContainer.innerHTML = ''; + + if (!hotspotStatus || hotspotStatus.state !== 'ENABLED') { + detailsContainer.appendChild(createHotspotInfoItem('État', 'Inactif')); + return; + } + + detailsContainer.appendChild(createHotspotInfoItem('État', hotspotStatus.state)); + + if (hotspotStatus.ssid) { + detailsContainer.appendChild(createHotspotInfoItem('SSID', hotspotStatus.ssid)); + } + + if (hotspotStatus.channel) { + detailsContainer.appendChild(createHotspotInfoItem('Canal', `${hotspotStatus.channel} (${hotspotStatus.frequency} MHz)`)); + } + + if (hotspotStatus.numStations !== undefined) { + detailsContainer.appendChild(createHotspotInfoItem('Clients connectés', hotspotStatus.numStations.toString())); + } + + if (hotspotStatus.bssid) { + detailsContainer.appendChild(createHotspotInfoItem('BSSID', hotspotStatus.bssid)); + } +} + +// ===== WebSocket Functions ===== + +function connectWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws/logs`; + + try { + appState.ws = new WebSocket(wsUrl); + + appState.ws.onopen = function() { + console.log('WebSocket connected'); + appState.reconnectAttempts = 0; + }; + + appState.ws.onmessage = function(event) { + try { + const log = JSON.parse(event.data); + addLogEntry(log); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + appState.ws.onerror = function(error) { + console.error('WebSocket error:', error); + }; + + appState.ws.onclose = function() { + console.log('WebSocket disconnected'); + + // Attempt to reconnect + if (appState.reconnectAttempts < appState.maxReconnectAttempts) { + appState.reconnectAttempts++; + setTimeout(connectWebSocket, 5000); + } + }; + } catch (error) { + console.error('Error creating WebSocket:', error); + } +} + +function connectWifiWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws/wifi`; + + try { + appState.wifiWs = new WebSocket(wsUrl); + + appState.wifiWs.onopen = function() { + console.log('WiFi WebSocket connected'); + appState.wifiReconnectAttempts = 0; + }; + + appState.wifiWs.onmessage = function(event) { + try { + const msg = JSON.parse(event.data); + handleWifiEvent(msg); + } catch (error) { + console.error('Error parsing WiFi WebSocket message:', error); + } + }; + + appState.wifiWs.onerror = function(error) { + console.error('WiFi WebSocket error:', error); + }; + + appState.wifiWs.onclose = function() { + console.log('WiFi WebSocket disconnected'); + + // Attempt to reconnect + if (appState.wifiReconnectAttempts < appState.maxReconnectAttempts) { + appState.wifiReconnectAttempts++; + setTimeout(connectWifiWebSocket, 5000); + } + }; + } catch (error) { + console.error('Error creating WiFi WebSocket:', error); + } +} + +function handleWifiEvent(event) { + console.log('WiFi event received:', event.type, event); + + switch (event.type) { + case 'scan_update': + handleScanUpdate(event.data); + break; + case 'state_change': + handleStateChange(event.data); + break; + case 'signal_update': + handleSignalUpdate(event.data); + break; + default: + console.warn('Unknown WiFi event type:', event.type); + } +} + +function handleScanUpdate(data) { + // Update the network list in real-time + appState.networks = data.networks; + displayWifiNetworks(data.networks); + console.log(`Scan update: ${data.networks.length} network(s) found`); +} + +function handleStateChange(data) { + // Update WiFi status badge + const wifiStatus = document.getElementById('wifiStatus'); + const wifiDot = wifiStatus.querySelector('.status-dot'); + const wifiText = wifiStatus.querySelector('.status-text'); + + // Update state in appState + appState.connectionState = data.state; + + switch (data.state) { + case 'connected': + wifiDot.className = 'status-dot active'; + wifiText.textContent = `Connecté: ${data.ssid}`; + appState.connectedSSID = data.ssid; + + // Refresh network list to show connected network + if (appState.networks.length > 0) { + displayWifiNetworks(appState.networks); + } + break; + + case 'connecting': + wifiDot.className = 'status-dot connecting'; + wifiText.textContent = data.ssid ? `Connexion à ${data.ssid}...` : 'Connexion...'; + + // Refresh network list to show connecting state + if (appState.networks.length > 0) { + displayWifiNetworks(appState.networks); + } + break; + + case 'disconnecting': + wifiDot.className = 'status-dot disconnecting'; + wifiText.textContent = 'Déconnexion...'; + + // Refresh network list to show disconnecting state + if (appState.networks.length > 0) { + displayWifiNetworks(appState.networks); + } + break; + + case 'roaming': + wifiDot.className = 'status-dot active'; + wifiText.textContent = `Roaming: ${data.ssid}`; + appState.connectedSSID = data.ssid; + break; + + case 'disconnected': + wifiDot.className = 'status-dot offline'; + wifiText.textContent = 'Déconnecté'; + appState.connectedSSID = null; + + // Refresh network list to remove connected highlighting + if (appState.networks.length > 0) { + displayWifiNetworks(appState.networks); + } + break; + + default: + console.warn('Unknown WiFi state:', data.state); + break; + } + + console.log(`WiFi state changed: ${data.previous_state} → ${data.state}`, data.ssid); +} + +function handleSignalUpdate(data) { + // Update signal strength display if needed + console.log(`Signal update for ${data.ssid}: ${data.signal}/5 (${data.dbm} dBm)`); + // Could update the network list to reflect new signal strength +} + +// ===== Utility Functions ===== + +function generateSignalBars(strength) { + const bars = []; + for (let i = 1; i <= 4; i++) { + const active = i <= strength ? 'active' : ''; + bars.push(`
`); + } + return `
${bars.join('')}
`; +} + +function getDeviceIcon(type) { + const icons = { + mobile: ``, + laptop: ``, + tablet: ``, + desktop: ``, + unknown: `` + }; + return icons[type] || icons.unknown; +} + +function formatUptime(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function showToast(type, title, message) { + const toast = document.getElementById('toast'); + const toastIcon = document.getElementById('toastIcon'); + const toastTitle = document.getElementById('toastTitle'); + const toastMessage = document.getElementById('toastMessage'); + + const icons = { + success: '', + error: '', + warning: '', + info: '' + }; + + toast.className = `toast ${type}`; + toastIcon.innerHTML = icons[type]; + toastTitle.textContent = title; + toastMessage.textContent = message; + + setTimeout(() => toast.classList.add('show'), 10); + + setTimeout(() => toast.classList.remove('show'), 5000); +} + +function hideToast() { + document.getElementById('toast').classList.remove('show'); +} + +function showLoading(show) { + const overlay = document.getElementById('loadingOverlay'); + if (show) { + overlay.classList.add('show'); + } else { + overlay.classList.remove('show'); + } +} + +function toggleAutoScroll() { + appState.autoScrollLogs = !appState.autoScrollLogs; + const btn = document.getElementById('autoScrollBtn'); + + if (appState.autoScrollLogs) { + btn.style.opacity = '1'; + showToast('info', 'Auto-scroll', 'Auto-scroll activé'); + } else { + btn.style.opacity = '0.5'; + showToast('info', 'Auto-scroll', 'Auto-scroll désactivé'); + } +} + +// ===== Periodic Updates ===== + +function startPeriodicUpdates() { + // Update status every 5 seconds + setInterval(() => { + loadStatus(); + }, 5000); + + // Update devices every 10 seconds + setInterval(() => { + loadDevices(); + }, 10000); +} + +function startUptimeCounter() { + // Clear any existing interval + if (appState.uptimeInterval) { + clearInterval(appState.uptimeInterval); + } + + // Increment uptime every second + appState.uptimeInterval = setInterval(() => { + appState.uptime++; + document.getElementById('uptime').textContent = formatUptime(appState.uptime); + }, 1000); +} diff --git a/cmd/repeater/static/index.html b/cmd/repeater/static/index.html new file mode 100644 index 0000000..d53a645 --- /dev/null +++ b/cmd/repeater/static/index.html @@ -0,0 +1,231 @@ + + + + + + Travel Router Control Panel + + + +
+
+
+

+ + + + Travel Router Control +

+
+
+ + Déconnecté +
+
+ + Hotspot actif +
+
+
+
+ +
+
+
+ + + +
+
+
0
+
Appareils connectés
+
+
+
+
+ + + +
+
+
0 MB
+
Données utilisées
+
+
+
+
+ + + +
+
+
00:00:00
+
Temps de fonctionnement
+
+
+
+
+ + + +
+
+
-
+
Réseau actuel
+
+
+
+ +
+
+
+

+ + + + Connexion WiFi Upstream +

+ +
+ +
+ +
+
+ Chargement des réseaux... +
+
+
+ +
+ + +
+ +
+ + +
+
+ +
+
+

+ + + + Hotspot Status +

+
+ + +
+
+ +
+
+ État: + Chargement... +
+
+
+
+ +
+
+
+

+ + + + Appareils connectés +

+ 0 +
+ +
+
+ + + +

Aucun appareil connecté

+
+
+
+ +
+
+

+ + + + Logs système +

+
+ + +
+
+ +
+
+ + + +

Aucun log disponible

+
+
+
+
+
+ + +
+
+
+
+
+
+ +
+ + +
+
+

Chargement...

+
+ + + + diff --git a/cmd/repeater/static/style.css b/cmd/repeater/static/style.css new file mode 100644 index 0000000..b518696 --- /dev/null +++ b/cmd/repeater/static/style.css @@ -0,0 +1,878 @@ +/* CSS Variables for theming */ +:root { + --primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --secondary-gradient: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + --success-color: #10b981; + --danger-color: #ef4444; + --warning-color: #f59e0b; + --info-color: #3b82f6; + --background: #f3f4f6; + --card-background: #ffffff; + --text-primary: #1f2937; + --text-secondary: #6b7280; + --border-color: #e5e7eb; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--primary-gradient); + min-height: 100vh; + padding: 1.5rem; + color: var(--text-primary); +} + +.container { + max-width: 1400px; + margin: 0 auto; + background: rgba(255, 255, 255, 0.98); + backdrop-filter: blur(20px); + border-radius: var(--radius-xl); + padding: 2rem; + box-shadow: var(--shadow-xl); +} + +/* Header Styles */ +.header { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 2px solid var(--border-color); +} + +.header-content { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; +} + +.header h1 { + color: var(--text-primary); + font-size: 2rem; + font-weight: 700; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.logo-icon { + color: #667eea; +} + +.connection-info { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 500; + background: var(--background); + border: 1px solid var(--border-color); +} + +.status-dot { + width: 0.625rem; + height: 0.625rem; + border-radius: 50%; + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.status-dot.active { + background: var(--success-color); +} + +.status-dot.offline { + background: var(--text-secondary); + animation: none; +} + +.status-dot.connecting { + background: var(--warning-color); + animation: blink 1s ease-in-out infinite; +} + +.status-dot.disconnecting { + background: var(--warning-color); + animation: blink 1s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--card-background); + border-radius: var(--radius-lg); + padding: 1.5rem; + box-shadow: var(--shadow-md); + border: 1px solid var(--border-color); + display: flex; + gap: 1rem; + align-items: center; + transition: all 0.3s ease; + max-width: 100%; +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.stat-icon { + width: 3rem; + height: 3rem; + border-radius: var(--radius-md); + background: var(--primary-gradient); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.stat-icon svg { + color: white; +} + +.stat-content { + flex: 1; + min-width: 0; +} + +.stat-value { + font-size: 1.875rem; + font-weight: 700; + color: var(--text-primary); + line-height: 1; + margin-bottom: 0.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.stat-label { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 500; +} + +/* Grid Layout */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +/* Card Styles */ +.card { + background: var(--card-background); + border-radius: var(--radius-lg); + padding: 1.5rem; + box-shadow: var(--shadow-md); + border: 1px solid var(--border-color); + transition: all 0.3s ease; +} + +.card:hover { + box-shadow: var(--shadow-lg); +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.card-header h2 { + color: var(--text-primary); + font-size: 1.25rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; +} + +.icon { + color: #667eea; +} + +/* Form Elements */ +.form-group { + margin-bottom: 1.25rem; +} + +.form-group label { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + color: var(--text-primary); + font-weight: 500; + font-size: 0.875rem; +} + +.input-icon { + color: var(--text-secondary); +} + +.form-group input, +.form-group select { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + font-size: 0.9375rem; + transition: all 0.2s ease; + background: var(--card-background); + color: var(--text-primary); +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--radius-md); + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + width: 100%; + color: white; +} + +.btn svg { + flex-shrink: 0; +} + +.btn-primary { + background: var(--primary-gradient); +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); +} + +.btn-secondary { + background: var(--text-secondary); +} + +.btn-secondary:hover { + background: #4b5563; + transform: translateY(-1px); +} + +.btn-danger { + background: var(--danger-color); +} + +.btn-danger:hover { + background: #dc2626; + transform: translateY(-1px); +} + +.btn:active { + transform: translateY(0); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-icon { + background: transparent; + border: none; + padding: 0.5rem; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-secondary); + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn-icon:hover { + background: var(--background); + color: var(--text-primary); +} + +.button-group { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +/* Toggle Switch */ +.toggle-switch { + position: relative; + display: inline-block; + width: 50px; + height: 26px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-switch label { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--text-secondary); + transition: 0.3s; + border-radius: 34px; +} + +.toggle-switch label:before { + position: absolute; + content: ""; + height: 20px; + width: 20px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.3s; + border-radius: 50%; +} + +.toggle-switch input:checked + label { + background: var(--success-color); +} + +.toggle-switch input:checked + label:before { + transform: translateX(24px); +} + +/* WiFi List */ +.wifi-list { + max-height: 320px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background); +} + +.wifi-item { + padding: 1rem; + border-bottom: 1px solid var(--border-color); + cursor: pointer; + transition: all 0.2s ease; + display: flex; + justify-content: space-between; + align-items: center; + background: var(--card-background); +} + +.wifi-item:hover { + background: var(--background); +} + +.wifi-item:last-child { + border-bottom: none; +} + +.wifi-item.selected { + background: #eff6ff; + border-left: 4px solid #667eea; +} + +.wifi-item.connected { + background: #d1fae5; + border-left-color: var(--success-color) !important; +} + +.wifi-item.connected .wifi-ssid { + color: var(--success-color); + font-weight: 700; +} + +.wifi-item.connecting { + background: #fef3c7; + border-left-color: var(--warning-color) !important; + animation: pulse-item 1.5s ease-in-out infinite; +} + +.wifi-item.connecting .wifi-ssid { + color: var(--warning-color); + font-weight: 700; +} + +.wifi-item.disconnecting { + background: #fee2e2; + border-left-color: #dc2626 !important; + animation: pulse-item 1.5s ease-in-out infinite; +} + +.wifi-item.disconnecting .wifi-ssid { + color: #dc2626; + font-weight: 700; +} + +@keyframes pulse-item { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +.wifi-item.loading { + justify-content: center; + color: var(--text-secondary); + cursor: default; +} + +.wifi-info { + flex: 1; +} + +.wifi-ssid { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.25rem; +} + +.wifi-details { + font-size: 0.8125rem; + color: var(--text-secondary); + display: flex; + gap: 1rem; +} + +.wifi-signal { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.signal-bars { + display: flex; + gap: 2px; + align-items: flex-end; + height: 16px; +} + +.signal-bar { + width: 3px; + background: var(--border-color); + border-radius: 2px; +} + +.signal-bar:nth-child(1) { height: 25%; } +.signal-bar:nth-child(2) { height: 50%; } +.signal-bar:nth-child(3) { height: 75%; } +.signal-bar:nth-child(4) { height: 100%; } + +.signal-bar.active { + background: var(--success-color); +} + +/* Devices */ +.devices-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; + min-height: 200px; +} + +.device-card { + background: var(--background); + border-radius: var(--radius-md); + padding: 1.25rem; + text-align: center; + border: 1px solid var(--border-color); + transition: all 0.2s ease; +} + +.device-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.device-icon { + width: 48px; + height: 48px; + margin: 0 auto 0.75rem; + color: #667eea; +} + +.device-name { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.25rem; + font-size: 0.9375rem; +} + +.device-type { + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.5rem; +} + +.device-info { + font-size: 0.8125rem; + color: var(--text-secondary); +} + +.device-placeholder { + grid-column: 1 / -1; + text-align: center; + padding: 3rem 1rem; + color: var(--text-secondary); +} + +.device-placeholder svg { + margin-bottom: 1rem; +} + +.device-count { + background: var(--primary-gradient); + color: white; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 600; +} + +/* Logs */ +.log-container { + background: #1f2937; + color: #10b981; + padding: 1rem; + border-radius: var(--radius-md); + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Consolas', 'Courier New', monospace; + font-size: 0.8125rem; + max-height: 400px; + overflow-y: auto; + line-height: 1.6; + min-height: 200px; +} + +.log-entry { + margin-bottom: 0.5rem; + display: flex; + gap: 0.75rem; +} + +.log-timestamp { + color: #6b7280; + flex-shrink: 0; +} + +.log-source { + color: #3b82f6; + font-weight: 600; + flex-shrink: 0; + min-width: 80px; +} + +.log-message { + color: #d1d5db; +} + +.log-controls { + display: flex; + gap: 0.5rem; +} + +.log-placeholder { + text-align: center; + padding: 3rem 1rem; + color: #6b7280; +} + +.log-placeholder svg { + margin-bottom: 1rem; +} + +/* Toast Notifications */ +.toast { + position: fixed; + top: 1.5rem; + right: 1.5rem; + max-width: 400px; + background: white; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + padding: 1rem; + display: flex; + gap: 0.75rem; + align-items: start; + transform: translateX(calc(100% + 2rem)); + transition: transform 0.3s ease; + z-index: 1000; + border: 1px solid var(--border-color); +} + +.toast.show { + transform: translateX(0); +} + +.toast-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.toast.success .toast-icon { + background: #d1fae5; + color: var(--success-color); +} + +.toast.error .toast-icon { + background: #fee2e2; + color: var(--danger-color); +} + +.toast.warning .toast-icon { + background: #fef3c7; + color: var(--warning-color); +} + +.toast.info .toast-icon { + background: #dbeafe; + color: var(--info-color); +} + +.toast-content { + flex: 1; +} + +.toast-title { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.25rem; +} + +.toast-message { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.toast-close { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 0.25rem; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.toast-close:hover { + background: var(--background); + color: var(--text-primary); +} + +/* Loading Overlay */ +.loading-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: none; + align-items: center; + justify-content: center; + z-index: 2000; + flex-direction: column; + gap: 1rem; +} + +.loading-overlay.show { + display: flex; +} + +.loading-overlay p { + color: white; + font-weight: 500; +} + +.spinner { + width: 48px; + height: 48px; + border: 4px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Scrollbar Styling */ +.wifi-list::-webkit-scrollbar, +.log-container::-webkit-scrollbar { + width: 8px; +} + +.wifi-list::-webkit-scrollbar-track, +.log-container::-webkit-scrollbar-track { + background: transparent; +} + +.wifi-list::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +.log-container::-webkit-scrollbar-thumb { + background: #374151; + border-radius: 4px; +} + +.wifi-list::-webkit-scrollbar-thumb:hover, +.log-container::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Hotspot Details */ +.hotspot-details { + margin-top: 1.5rem; + padding: 1rem; + background: var(--background); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); +} + +.hotspot-info-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border-color); +} + +.hotspot-info-item:last-child { + border-bottom: none; +} + +.info-label { + font-weight: 500; + color: var(--text-secondary); + font-size: 0.875rem; +} + +.info-value { + font-weight: 600; + color: var(--text-primary); + font-size: 0.875rem; +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + body { + padding: 1rem; + } + + .container { + padding: 1.5rem; + } + + .header h1 { + font-size: 1.5rem; + } + + .header-content { + flex-direction: column; + align-items: flex-start; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .grid { + grid-template-columns: 1fr; + } + + .button-group { + grid-template-columns: 1fr; + } + + .toast { + left: 1rem; + right: 1rem; + max-width: none; + } +} + +@media (max-width: 480px) { + .stat-card { + flex-direction: column; + text-align: center; + } + + .devices-grid { + grid-template-columns: 1fr; + } +} diff --git a/generate.go b/generate.go new file mode 100644 index 0000000..c01b94b --- /dev/null +++ b/generate.go @@ -0,0 +1,4 @@ +package main + +//go:generate go tool oapi-codegen -config oapi-types.cfg.yaml openapi.yaml +//go:generate go tool oapi-codegen -config oapi-gin.cfg.yaml openapi.yaml diff --git a/go.mod b/go.mod index 0f2fed9..5e17b30 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,62 @@ -module git.nemunai.re/nemunaire/repeater +module github.com/nemunaire/repeater go 1.24.4 require ( + github.com/getkin/kin-openapi v0.132.0 + github.com/gin-gonic/gin v1.11.0 github.com/godbus/dbus/v5 v5.1.0 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 ) + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/speakeasy-api/jsonpath v0.6.0 // indirect + github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.34.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen diff --git a/go.sum b/go.sum index 0546936..17956cd 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,239 @@ +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk= +github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU= +github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8= +github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= +github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= +github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go new file mode 100644 index 0000000..7c87a7c --- /dev/null +++ b/internal/api/handlers/handlers.go @@ -0,0 +1,148 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/nemunaire/repeater/internal/config" + "github.com/nemunaire/repeater/internal/hotspot" + "github.com/nemunaire/repeater/internal/logging" + "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/station" + "github.com/nemunaire/repeater/internal/wifi" +) + +// GetWiFiNetworks returns cached WiFi networks without scanning +func GetWiFiNetworks(c *gin.Context) { + networks, err := wifi.GetCachedNetworks() + if err != nil { + logging.AddLog("WiFi", "Erreur lors de la récupération des réseaux: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des réseaux"}) + return + } + + c.JSON(http.StatusOK, networks) +} + +// ScanWiFi handles WiFi network scanning +func ScanWiFi(c *gin.Context) { + networks, err := wifi.ScanNetworks() + if err != nil { + logging.AddLog("WiFi", "Erreur lors du scan: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du scan WiFi"}) + return + } + + logging.AddLog("WiFi", "Scan terminé - "+string(rune(len(networks)))+" réseaux trouvés") + c.JSON(http.StatusOK, networks) +} + +// ConnectWiFi handles WiFi connection requests +func ConnectWiFi(c *gin.Context) { + var req models.WiFiConnectRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"}) + return + } + + logging.AddLog("WiFi", "Tentative de connexion à "+req.SSID) + + err := wifi.Connect(req.SSID, req.Password) + if err != nil { + logging.AddLog("WiFi", "Échec de connexion: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Échec de connexion: " + err.Error()}) + return + } + + logging.AddLog("WiFi", "Connexion réussie à "+req.SSID) + c.JSON(http.StatusOK, gin.H{"status": "success"}) +} + +// DisconnectWiFi handles WiFi disconnection +func DisconnectWiFi(c *gin.Context) { + logging.AddLog("WiFi", "Tentative de déconnexion") + + err := wifi.Disconnect() + if err != nil { + logging.AddLog("WiFi", "Erreur de déconnexion: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de déconnexion: " + err.Error()}) + return + } + + logging.AddLog("WiFi", "Déconnexion réussie") + c.JSON(http.StatusOK, gin.H{"status": "success"}) +} + +// ConfigureHotspot handles hotspot configuration +func ConfigureHotspot(c *gin.Context) { + var config models.HotspotConfig + if err := c.ShouldBindJSON(&config); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"}) + return + } + + err := hotspot.Configure(config) + if err != nil { + logging.AddLog("Hotspot", "Erreur de configuration: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de configuration: " + err.Error()}) + return + } + + logging.AddLog("Hotspot", "Configuration mise à jour: "+config.SSID) + c.JSON(http.StatusOK, gin.H{"status": "success"}) +} + +// ToggleHotspot handles hotspot enable/disable +func ToggleHotspot(c *gin.Context, status *models.SystemStatus) { + // Determine current state + isEnabled := status.HotspotStatus != nil && status.HotspotStatus.State == "ENABLED" + + var err error + if !isEnabled { + err = hotspot.Start() + logging.AddLog("Hotspot", "Hotspot activé") + } else { + err = hotspot.Stop() + logging.AddLog("Hotspot", "Hotspot désactivé") + } + + if err != nil { + logging.AddLog("Hotspot", "Erreur: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur: " + err.Error()}) + return + } + + // Update status immediately + status.HotspotStatus = hotspot.GetDetailedStatus() + + c.JSON(http.StatusOK, gin.H{"enabled": !isEnabled}) +} + +// GetDevices returns connected devices +func GetDevices(c *gin.Context, cfg *config.Config) { + devices, err := station.GetStations() + if err != nil { + logging.AddLog("Système", "Erreur lors de la récupération des appareils: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des appareils"}) + return + } + + c.JSON(http.StatusOK, devices) +} + +// GetStatus returns system status +func GetStatus(c *gin.Context, status *models.SystemStatus) { + c.JSON(http.StatusOK, status) +} + +// GetLogs returns system logs +func GetLogs(c *gin.Context) { + logs := logging.GetLogs() + c.JSON(http.StatusOK, logs) +} + +// ClearLogs clears system logs +func ClearLogs(c *gin.Context) { + logging.ClearLogs() + c.JSON(http.StatusOK, gin.H{"status": "success"}) +} diff --git a/internal/api/handlers/websocket.go b/internal/api/handlers/websocket.go new file mode 100644 index 0000000..87d2823 --- /dev/null +++ b/internal/api/handlers/websocket.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "log" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/nemunaire/repeater/internal/logging" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +// WebSocketLogs handles WebSocket connections for real-time logs +func WebSocketLogs(c *gin.Context) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("Erreur WebSocket: %v", err) + return + } + defer conn.Close() + + // Register client + logging.RegisterWebSocketClient(conn) + defer logging.UnregisterWebSocketClient(conn) + + // Keep connection alive + for { + _, _, err := conn.ReadMessage() + if err != nil { + break + } + } +} diff --git a/internal/api/handlers/websocket_wifi.go b/internal/api/handlers/websocket_wifi.go new file mode 100644 index 0000000..ff8d132 --- /dev/null +++ b/internal/api/handlers/websocket_wifi.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "log" + + "github.com/gin-gonic/gin" + "github.com/nemunaire/repeater/internal/wifi" +) + +// WebSocketWifi handles WebSocket connections for real-time WiFi events +func WebSocketWifi(c *gin.Context) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("Erreur WebSocket WiFi: %v", err) + return + } + defer conn.Close() + + // Register client + wifi.RegisterWebSocketClient(conn) + defer wifi.UnregisterWebSocketClient(conn) + + // Keep connection alive + for { + _, _, err := conn.ReadMessage() + if err != nil { + break + } + } +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..956661e --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,69 @@ +package api + +import ( + "embed" + "io/fs" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/nemunaire/repeater/internal/api/handlers" + "github.com/nemunaire/repeater/internal/config" + "github.com/nemunaire/repeater/internal/models" +) + +// SetupRouter creates and configures the Gin router +func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.FS) *gin.Engine { + // Set Gin to release mode (can be overridden with GIN_MODE env var) + gin.SetMode(gin.ReleaseMode) + + r := gin.Default() + + // API routes + api := r.Group("/api") + { + // WiFi endpoints + wifi := api.Group("/wifi") + { + wifi.GET("/networks", handlers.GetWiFiNetworks) + wifi.GET("/scan", handlers.ScanWiFi) + wifi.POST("/connect", handlers.ConnectWiFi) + wifi.POST("/disconnect", handlers.DisconnectWiFi) + } + + // Hotspot endpoints + hotspot := api.Group("/hotspot") + { + hotspot.POST("/config", handlers.ConfigureHotspot) + hotspot.POST("/toggle", func(c *gin.Context) { + handlers.ToggleHotspot(c, status) + }) + } + + // Device endpoints + api.GET("/devices", func(c *gin.Context) { + handlers.GetDevices(c, cfg) + }) + + // Status endpoint + api.GET("/status", func(c *gin.Context) { + handlers.GetStatus(c, status) + }) + + // Log endpoints + api.GET("/logs", handlers.GetLogs) + api.DELETE("/logs", handlers.ClearLogs) + } + + // WebSocket endpoints + r.GET("/ws/logs", handlers.WebSocketLogs) + r.GET("/ws/wifi", handlers.WebSocketWifi) + + // Serve static files + sub, err := fs.Sub(assets, "static") + if err != nil { + panic("Unable to access static directory: " + err.Error()) + } + r.NoRoute(gin.WrapH(http.FileServer(http.FS(sub)))) + + return r +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..3e55b49 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,287 @@ +package app + +import ( + "embed" + "log" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/nemunaire/repeater/internal/api" + "github.com/nemunaire/repeater/internal/config" + "github.com/nemunaire/repeater/internal/hotspot" + "github.com/nemunaire/repeater/internal/logging" + "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/station" + "github.com/nemunaire/repeater/internal/station/backend" + "github.com/nemunaire/repeater/internal/syslog" + "github.com/nemunaire/repeater/internal/wifi" +) + +// App represents the application +type App struct { + Status models.SystemStatus + StatusMutex sync.RWMutex + StartTime time.Time + Assets embed.FS + Config *config.Config + SyslogTailer *syslog.SyslogTailer +} + +// New creates a new application instance +func New(assets embed.FS) *App { + return &App{ + Status: models.SystemStatus{ + Connected: false, + ConnectionState: "disconnected", + ConnectedSSID: "", + HotspotStatus: nil, + ConnectedCount: 0, + DataUsage: 0.0, + Uptime: 0, + }, + StartTime: time.Now(), + Assets: assets, + } +} + +// Initialize initializes the application +func (a *App) Initialize(cfg *config.Config) error { + // Store config reference + a.Config = cfg + + // Initialize WiFi backend + if err := wifi.Initialize(cfg.WifiInterface, cfg.WifiBackend); err != nil { + return err + } + + // Start WiFi event monitoring + if err := wifi.StartEventMonitoring(); err != nil { + log.Printf("Warning: WiFi event monitoring failed: %v", err) + // Don't fail - polling fallback still works + } + + // Initialize station backend + stationConfig := backend.BackendConfig{ + InterfaceName: cfg.HotspotInterface, + ARPTablePath: cfg.ARPTablePath, + DHCPLeasesPath: cfg.DHCPLeasesPath, + HostapdInterface: cfg.HotspotInterface, + } + if err := station.Initialize(cfg.StationBackend, stationConfig); err != nil { + log.Printf("Warning: Station backend initialization failed: %v", err) + // Don't fail - will continue without station discovery + } else { + // Start event monitoring for station events + if err := station.StartEventMonitoring(backend.EventCallbacks{ + OnStationConnected: a.handleStationConnected, + OnStationDisconnected: a.handleStationDisconnected, + OnStationUpdated: a.handleStationUpdated, + }); err != nil { + log.Printf("Warning: Station event monitoring failed: %v", err) + // Don't fail - polling fallback still works + } + } + + // Start syslog tailing if enabled + if cfg.SyslogEnabled { + a.SyslogTailer = syslog.NewSyslogTailer( + cfg.SyslogPath, + cfg.SyslogFilter, + cfg.SyslogSource, + ) + if err := a.SyslogTailer.Start(); err != nil { + log.Printf("Warning: Failed to start syslog tailing: %v", err) + // Don't fail - app continues without syslog + } + } + + // Start periodic tasks + go a.periodicStatusUpdate() + go a.periodicDeviceUpdate() + + logging.AddLog("Système", "Application initialisée") + return nil +} + +// Run starts the HTTP server +func (a *App) Run(addr string) error { + router := api.SetupRouter(&a.Status, a.Config, a.Assets) + + logging.AddLog("Système", "Serveur API démarré sur "+addr) + return router.Run(addr) +} + +// Shutdown gracefully shuts down the application +func (a *App) Shutdown() { + // Stop syslog tailing if running + if a.SyslogTailer != nil { + a.SyslogTailer.Stop() + } + + // Stop station monitoring and close backend + station.StopEventMonitoring() + station.Close() + + wifi.StopEventMonitoring() + wifi.Close() + logging.AddLog("Système", "Application arrêtée") +} + +// getSystemUptime reads system uptime from /proc/uptime +func getSystemUptime() int64 { + data, err := os.ReadFile("/proc/uptime") + if err != nil { + log.Printf("Error reading /proc/uptime: %v", err) + return 0 + } + + fields := strings.Fields(string(data)) + if len(fields) == 0 { + return 0 + } + + uptime, err := strconv.ParseFloat(fields[0], 64) + if err != nil { + log.Printf("Error parsing uptime: %v", err) + return 0 + } + + return int64(uptime) +} + +// getInterfaceBytes reads rx and tx bytes for a network interface +func getInterfaceBytes(interfaceName string) (rxBytes, txBytes int64) { + rxPath := "/sys/class/net/" + interfaceName + "/statistics/rx_bytes" + txPath := "/sys/class/net/" + interfaceName + "/statistics/tx_bytes" + + // Read RX bytes + rxData, err := os.ReadFile(rxPath) + if err != nil { + log.Printf("Error reading rx_bytes for %s: %v", interfaceName, err) + } else { + rxBytes, _ = strconv.ParseInt(strings.TrimSpace(string(rxData)), 10, 64) + } + + // Read TX bytes + txData, err := os.ReadFile(txPath) + if err != nil { + log.Printf("Error reading tx_bytes for %s: %v", interfaceName, err) + } else { + txBytes, _ = strconv.ParseInt(strings.TrimSpace(string(txData)), 10, 64) + } + + return rxBytes, txBytes +} + +// periodicStatusUpdate updates WiFi connection status periodically +func (a *App) periodicStatusUpdate() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for range ticker.C { + a.StatusMutex.Lock() + a.Status.Connected = wifi.IsConnected() + a.Status.ConnectionState = wifi.GetConnectionState() + a.Status.ConnectedSSID = wifi.GetConnectedSSID() + a.Status.Uptime = getSystemUptime() + + // Get detailed hotspot status + a.Status.HotspotStatus = hotspot.GetDetailedStatus() + + // Get network data usage for WiFi interface + if a.Config != nil { + rxBytes, txBytes := getInterfaceBytes(a.Config.WifiInterface) + // Convert to MB and sum rx + tx + a.Status.DataUsage = float64(rxBytes+txBytes) / (1024 * 1024) + } + + a.StatusMutex.Unlock() + } +} + +// periodicDeviceUpdate updates connected devices list periodically +func (a *App) periodicDeviceUpdate() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for range ticker.C { + devices, err := station.GetStations() + if err != nil { + log.Printf("Error getting connected devices: %v", err) + } + + a.StatusMutex.Lock() + a.Status.ConnectedDevices = devices + a.Status.ConnectedCount = len(devices) + a.StatusMutex.Unlock() + } +} + +// handleStationConnected handles station connection events +func (a *App) handleStationConnected(st backend.Station) { + a.StatusMutex.Lock() + defer a.StatusMutex.Unlock() + + // Convert backend.Station to models.ConnectedDevice + device := models.ConnectedDevice{ + Name: st.Hostname, + Type: st.Type, + MAC: st.MAC, + IP: st.IP, + } + + // Check if device already exists + found := false + for i, d := range a.Status.ConnectedDevices { + if d.MAC == device.MAC { + a.Status.ConnectedDevices[i] = device + found = true + break + } + } + + // Add new device if not found + if !found { + a.Status.ConnectedDevices = append(a.Status.ConnectedDevices, device) + a.Status.ConnectedCount = len(a.Status.ConnectedDevices) + logging.AddLog("Stations", "Device connected: "+device.MAC+" ("+device.IP+")") + } +} + +// handleStationDisconnected handles station disconnection events +func (a *App) handleStationDisconnected(mac string) { + a.StatusMutex.Lock() + defer a.StatusMutex.Unlock() + + // Remove device from list + for i, d := range a.Status.ConnectedDevices { + if d.MAC == mac { + a.Status.ConnectedDevices = append(a.Status.ConnectedDevices[:i], a.Status.ConnectedDevices[i+1:]...) + a.Status.ConnectedCount = len(a.Status.ConnectedDevices) + logging.AddLog("Stations", "Device disconnected: "+mac) + break + } + } +} + +// handleStationUpdated handles station update events +func (a *App) handleStationUpdated(st backend.Station) { + a.StatusMutex.Lock() + defer a.StatusMutex.Unlock() + + // Update existing device + for i, d := range a.Status.ConnectedDevices { + if d.MAC == st.MAC { + a.Status.ConnectedDevices[i] = models.ConnectedDevice{ + Name: st.Hostname, + Type: st.Type, + MAC: st.MAC, + IP: st.IP, + } + break + } + } +} diff --git a/internal/config/cli.go b/internal/config/cli.go new file mode 100644 index 0000000..49013b3 --- /dev/null +++ b/internal/config/cli.go @@ -0,0 +1,34 @@ +package config + +import ( + "flag" +) + +// declareFlags registers flags for the structure Options. +func declareFlags(o *Config) { + flag.StringVar(&o.Bind, "bind", ":8081", "Bind port/socket") + flag.StringVar(&o.WifiInterface, "wifi-interface", "wlan0", "WiFi interface name") + flag.StringVar(&o.HotspotInterface, "hotspot-interface", "wlan1", "Hotspot WiFi interface name") + flag.StringVar(&o.WifiBackend, "wifi-backend", "", "WiFi backend to use: 'iwd' or 'wpasupplicant' (required)") + flag.StringVar(&o.StationBackend, "station-backend", "hostapd", "Station discovery backend: 'arp', 'dhcp', or 'hostapd'") + flag.StringVar(&o.DHCPLeasesPath, "dhcp-leases-path", "/var/lib/dhcp/dhcpd.leases", "Path to DHCP leases file") + flag.StringVar(&o.ARPTablePath, "arp-table-path", "/proc/net/arp", "Path to ARP table file") + flag.BoolVar(&o.SyslogEnabled, "syslog-enabled", false, "Enable syslog tailing for iwd messages") + flag.StringVar(&o.SyslogPath, "syslog-path", "/var/log/messages", "Path to syslog file") + flag.Var(&StringArray{&o.SyslogFilter}, "daemon.info iwd:", "Filter string for syslog lines") + flag.StringVar(&o.SyslogSource, "syslog-source", "iwd", "Source name for syslog entries in logs") +} + +// parseCLI parse the flags and treats extra args as configuration filename. +func parseCLI(o *Config) error { + flag.Parse() + + for _, conf := range flag.Args() { + err := parseFile(o, conf) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3cd2fa7 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,106 @@ +package config + +import ( + "flag" + "log" + "os" + "path" + "strings" +) + +type Config struct { + Bind string + WifiInterface string + HotspotInterface string + WifiBackend string + StationBackend string // "arp", "dhcp", or "hostapd" + DHCPLeasesPath string + ARPTablePath string + SyslogEnabled bool + SyslogPath string + SyslogFilter []string + SyslogSource string +} + +// ConsolidateConfig fills an Options struct by reading configuration from +// config files, environment, then command line. +// +// Should be called only one time. +func ConsolidateConfig() (opts *Config, err error) { + // Define defaults options + opts = &Config{ + Bind: ":8080", + WifiInterface: "wlan0", + HotspotInterface: "wlan1", + DHCPLeasesPath: "/var/lib/dhcp/dhcpd.leases", + ARPTablePath: "/proc/net/arp", + SyslogEnabled: false, + SyslogPath: "/var/log/messages", + SyslogFilter: []string{"daemon.info wpa_supplicant:", "daemon.info iwd:", "daemon.info hostapd:"}, + SyslogSource: "iwd", + } + + declareFlags(opts) + + // Establish a list of possible configuration file locations + configLocations := []string{ + "repeater.conf", + } + + if home, err := os.UserConfigDir(); err == nil { + configLocations = append(configLocations, path.Join(home, "repeater", "repeater.conf")) + } + + configLocations = append(configLocations, path.Join("etc", "repeater.conf")) + + // If config file exists, read configuration from it + for _, filename := range configLocations { + if _, e := os.Stat(filename); !os.IsNotExist(e) { + log.Printf("Loading configuration from %s\n", filename) + err = parseFile(opts, filename) + if err != nil { + return + } + break + } + } + + // Then, overwrite that by what is present in the environment + err = parseEnvironmentVariables(opts) + if err != nil { + return + } + + // Finaly, command line takes precedence + err = parseCLI(opts) + if err != nil { + return + } + + // Validate configuration + if opts.WifiBackend != "iwd" && opts.WifiBackend != "wpasupplicant" { + log.Fatalf("wifi-backend must be set to 'iwd' or 'wpasupplicant' (got: '%s')", opts.WifiBackend) + } + + if opts.StationBackend != "arp" && opts.StationBackend != "dhcp" && opts.StationBackend != "hostapd" { + log.Fatalf("station-backend must be set to 'arp', 'dhcp', or 'hostapd' (got: '%s')", opts.StationBackend) + } + + return +} + +// parseLine treats a config line and place the read value in the variable +// declared to the corresponding flag. +func parseLine(o *Config, line string) (err error) { + fields := strings.SplitN(line, "=", 2) + orig_key := strings.TrimSpace(fields[0]) + value := strings.TrimSpace(fields[1]) + + key := strings.TrimPrefix(orig_key, "REPEATER_") + key = strings.Replace(key, "_", "-", -1) + key = strings.ToLower(key) + + err = flag.Set(key, value) + + return +} diff --git a/internal/config/custom.go b/internal/config/custom.go new file mode 100644 index 0000000..f038b7e --- /dev/null +++ b/internal/config/custom.go @@ -0,0 +1,45 @@ +package config + +import ( + "fmt" + "net/url" +) + +// StringArray is a custom type for handling multiple string values in flags. +type StringArray struct { + Array *[]string +} + +// String returns a string representation of the StringArray. +func (i *StringArray) String() string { + return fmt.Sprintf("%v", i.Array) +} + +// Set appends a new string value to the StringArray. +func (i *StringArray) Set(value string) error { + *i.Array = append(*i.Array, value) + + return nil +} + +type URL struct { + URL *url.URL +} + +func (i *URL) String() string { + if i.URL != nil { + return i.URL.String() + } else { + return "" + } +} + +func (i *URL) Set(value string) error { + u, err := url.Parse(value) + if err != nil { + return err + } + + *i.URL = *u + return nil +} diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..8888746 --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,21 @@ +package config + +import ( + "fmt" + "os" + "strings" +) + +// parseEnvironmentVariables analyzes all the environment variables to find +// each one starting by REPEATER_ +func parseEnvironmentVariables(o *Config) (err error) { + for _, line := range os.Environ() { + if strings.HasPrefix(line, "REPEATER_") { + err := parseLine(o, line) + if err != nil { + return fmt.Errorf("error in environment (%q): %w", line, err) + } + } + } + return +} diff --git a/internal/config/file.go b/internal/config/file.go new file mode 100644 index 0000000..8ba6a10 --- /dev/null +++ b/internal/config/file.go @@ -0,0 +1,33 @@ +package config + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// parseFile opens the file at the given filename path, then treat each line +// not starting with '#' as a configuration statement. +func parseFile(o *Config, filename string) error { + fp, err := os.Open(filename) + if err != nil { + return err + } + defer fp.Close() + + scanner := bufio.NewScanner(fp) + n := 0 + for scanner.Scan() { + n += 1 + line := strings.TrimSpace(scanner.Text()) + if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 { + err := parseLine(o, line) + if err != nil { + return fmt.Errorf("%v:%d: error in configuration: %w", filename, n, err) + } + } + } + + return nil +} diff --git a/internal/hotspot/hotspot.go b/internal/hotspot/hotspot.go new file mode 100644 index 0000000..793352f --- /dev/null +++ b/internal/hotspot/hotspot.go @@ -0,0 +1,111 @@ +package hotspot + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/nemunaire/repeater/internal/models" +) + +const ( + AP_INTERFACE = "wlan1" + HOSTAPD_CONF = "/etc/hostapd/hostapd.conf" +) + +// Configure updates the hotspot configuration +func Configure(config models.HotspotConfig) error { + hostapdConfig := fmt.Sprintf(`interface=%s +driver=nl80211 +ssid=%s +hw_mode=g +channel=%d +wmm_enabled=0 +macaddr_acl=0 +auth_algs=1 +ignore_broadcast_ssid=0 +wpa=2 +wpa_passphrase=%s +wpa_key_mgmt=WPA-PSK +wpa_pairwise=TKIP +rsn_pairwise=CCMP +`, AP_INTERFACE, config.SSID, config.Channel, config.Password) + + return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0644) +} + +// Start starts the hotspot +func Start() error { + cmd := exec.Command("/etc/init.d/hostapd", "start") + return cmd.Run() +} + +// Stop stops the hotspot +func Stop() error { + cmd := exec.Command("/etc/init.d/hostapd", "stop") + return cmd.Run() +} + +// Status checks if the hotspot is running. +// Returns nil if the service is running, or an error if it's stopped or crashed. +func Status() error { + cmd := exec.Command("/etc/init.d/hostapd", "status") + return cmd.Run() +} + +// GetDetailedStatus retrieves detailed status information from hostapd_cli. +// Returns nil if hostapd is not running or if there's an error. +func GetDetailedStatus() *models.HotspotStatus { + cmd := exec.Command("hostapd_cli", "status") + output, err := cmd.Output() + if err != nil { + return nil + } + + status := &models.HotspotStatus{} + lines := strings.Split(string(output), "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Selected interface") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + switch key { + case "state": + status.State = value + case "channel": + if ch, err := strconv.Atoi(value); err == nil { + status.Channel = ch + } + case "freq": + if freq, err := strconv.Atoi(value); err == nil { + status.Frequency = freq + } + case "ssid[0]": + status.SSID = value + case "bssid[0]": + status.BSSID = value + case "num_sta[0]": + if num, err := strconv.Atoi(value); err == nil { + status.NumStations = num + } + case "hw_mode": + status.HWMode = value + case "country_code": + status.CountryCode = value + } + } + + return status +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..064a813 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,94 @@ +package logging + +import ( + "log" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/nemunaire/repeater/internal/models" +) + +var ( + logEntries []models.LogEntry + logMutex sync.RWMutex + websocketClients = make(map[*websocket.Conn]bool) + clientsMutex sync.RWMutex +) + +// AddLog adds a new log entry +func AddLog(source, message string) { + logMutex.Lock() + entry := models.LogEntry{ + Timestamp: time.Now(), + Source: source, + Message: message, + } + logEntries = append(logEntries, entry) + + // Keep only the last 100 logs + if len(logEntries) > 100 { + logEntries = logEntries[len(logEntries)-100:] + } + logMutex.Unlock() + + // Broadcast to WebSocket clients + broadcastToWebSockets(entry) + + // Log to console + log.Printf("[%s] %s", source, message) +} + +// GetLogs returns all log entries +func GetLogs() []models.LogEntry { + logMutex.RLock() + defer logMutex.RUnlock() + + logs := make([]models.LogEntry, len(logEntries)) + copy(logs, logEntries) + return logs +} + +// ClearLogs clears all log entries +func ClearLogs() { + logMutex.Lock() + logEntries = []models.LogEntry{} + logMutex.Unlock() + + AddLog("Système", "Logs effacés") +} + +// RegisterWebSocketClient registers a new WebSocket client +func RegisterWebSocketClient(conn *websocket.Conn) { + clientsMutex.Lock() + websocketClients[conn] = true + clientsMutex.Unlock() + + // Send existing logs to the new client + logMutex.RLock() + for _, entry := range logEntries { + conn.WriteJSON(entry) + } + logMutex.RUnlock() +} + +// UnregisterWebSocketClient removes a WebSocket client +func UnregisterWebSocketClient(conn *websocket.Conn) { + clientsMutex.Lock() + delete(websocketClients, conn) + clientsMutex.Unlock() +} + +// broadcastToWebSockets sends a log entry to all connected WebSocket clients +func broadcastToWebSockets(entry models.LogEntry) { + clientsMutex.RLock() + defer clientsMutex.RUnlock() + + for client := range websocketClients { + err := client.WriteJSON(entry) + if err != nil { + client.Close() + delete(websocketClients, client) + } + } +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..64d5c79 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,71 @@ +package models + +import "time" + +// WiFiNetwork represents a discovered WiFi network +type WiFiNetwork struct { + SSID string `json:"ssid"` + Signal int `json:"signal"` + Security string `json:"security"` + Channel int `json:"channel"` + BSSID string `json:"bssid"` +} + +// ConnectedDevice represents a device connected to the hotspot +type ConnectedDevice struct { + Name string `json:"name"` + Type string `json:"type"` + MAC string `json:"mac"` + IP string `json:"ip"` +} + +// HotspotConfig represents hotspot configuration +type HotspotConfig struct { + SSID string `json:"ssid"` + Password string `json:"password"` + Channel int `json:"channel"` +} + +// HotspotStatus represents detailed hotspot status +type HotspotStatus struct { + State string `json:"state"` // ENABLED, DISABLED, etc. + SSID string `json:"ssid"` // Current SSID being broadcast + BSSID string `json:"bssid"` // MAC address of the AP + Channel int `json:"channel"` // Current channel + Frequency int `json:"frequency"` // Frequency in MHz + NumStations int `json:"numStations"` // Number of connected stations + HWMode string `json:"hwMode"` // Hardware mode (g, a, n, ac, etc.) + CountryCode string `json:"countryCode"` // Country code +} + +// SystemStatus represents overall system status +type SystemStatus struct { + Connected bool `json:"connected"` + ConnectionState string `json:"connectionState"` // Connection state: connected, disconnected, connecting, disconnecting, roaming + ConnectedSSID string `json:"connectedSSID"` + HotspotStatus *HotspotStatus `json:"hotspotStatus,omitempty"` // Detailed hotspot status + ConnectedCount int `json:"connectedCount"` + DataUsage float64 `json:"dataUsage"` + Uptime int64 `json:"uptime"` + ConnectedDevices []ConnectedDevice `json:"connectedDevices"` +} + +// WiFiConnectRequest represents a request to connect to WiFi +type WiFiConnectRequest struct { + SSID string `json:"ssid" binding:"required"` + Password string `json:"password"` +} + +// LogEntry represents a system log entry +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Source string `json:"source"` + Message string `json:"message"` +} + +// DHCPLease represents a DHCP lease entry +type DHCPLease struct { + IP string + MAC string + Hostname string +} diff --git a/internal/station/arp/backend.go b/internal/station/arp/backend.go new file mode 100644 index 0000000..5b4e975 --- /dev/null +++ b/internal/station/arp/backend.go @@ -0,0 +1,177 @@ +package arp + +import ( + "sync" + "time" + + "github.com/nemunaire/repeater/internal/station/backend" +) + +// Backend implements StationBackend using ARP table discovery +type Backend struct { + arpTablePath string + lastStations map[string]backend.Station // Key: MAC address + callbacks backend.EventCallbacks + stopChan chan struct{} + mu sync.RWMutex + running bool +} + +// NewBackend creates a new ARP backend +func NewBackend() *Backend { + return &Backend{ + lastStations: make(map[string]backend.Station), + stopChan: make(chan struct{}), + } +} + +// Initialize initializes the ARP backend +func (b *Backend) Initialize(config backend.BackendConfig) error { + b.mu.Lock() + defer b.mu.Unlock() + + b.arpTablePath = config.ARPTablePath + if b.arpTablePath == "" { + b.arpTablePath = "/proc/net/arp" + } + + return nil +} + +// Close cleans up backend resources +func (b *Backend) Close() error { + b.StopEventMonitoring() + return nil +} + +// GetStations returns all connected stations from ARP table +func (b *Backend) GetStations() ([]backend.Station, error) { + b.mu.RLock() + arpTablePath := b.arpTablePath + b.mu.RUnlock() + + arpEntries, err := parseARPTable(arpTablePath) + if err != nil { + return nil, err + } + + var stations []backend.Station + for _, entry := range arpEntries { + // Only include entries with valid flags (2 = COMPLETE, 6 = COMPLETE|PERM) + if entry.Flags == 2 || entry.Flags == 6 { + st := backend.Station{ + MAC: entry.HWAddress.String(), + IP: entry.IP.String(), + Hostname: "", // No hostname available from ARP + Type: backend.GuessDeviceType("", entry.HWAddress.String()), + Signal: 0, // Not available from ARP + RxBytes: 0, // Not available from ARP + TxBytes: 0, // Not available from ARP + ConnectedAt: time.Now(), + } + stations = append(stations, st) + } + } + + return stations, nil +} + +// StartEventMonitoring starts monitoring for station events via polling +func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error { + b.mu.Lock() + defer b.mu.Unlock() + + if b.running { + return nil + } + + b.callbacks = callbacks + b.running = true + + // Start polling goroutine + go b.pollLoop() + + return nil +} + +// StopEventMonitoring stops event monitoring +func (b *Backend) StopEventMonitoring() { + b.mu.Lock() + if !b.running { + b.mu.Unlock() + return + } + b.running = false + b.mu.Unlock() + + close(b.stopChan) +} + +// SupportsRealTimeEvents returns false (ARP is polling-based) +func (b *Backend) SupportsRealTimeEvents() bool { + return false +} + +// pollLoop polls the ARP table and simulates events +func (b *Backend) pollLoop() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + b.checkForChanges() + case <-b.stopChan: + return + } + } +} + +// checkForChanges compares current state with last state and triggers callbacks +func (b *Backend) checkForChanges() { + // Get current stations + current, err := b.GetStations() + if err != nil { + return + } + + // Build map of current stations + currentMap := make(map[string]backend.Station) + for _, station := range current { + currentMap[station.MAC] = station + } + + b.mu.Lock() + defer b.mu.Unlock() + + // Check for new stations (connected) + for mac, station := range currentMap { + if _, exists := b.lastStations[mac]; !exists { + // New station connected + if b.callbacks.OnStationConnected != nil { + go b.callbacks.OnStationConnected(station) + } + } else { + // Check for updates (IP change, etc.) + oldStation := b.lastStations[mac] + if oldStation.IP != station.IP || oldStation.Hostname != station.Hostname { + if b.callbacks.OnStationUpdated != nil { + go b.callbacks.OnStationUpdated(station) + } + } + } + } + + // Check for disconnected stations + for mac := range b.lastStations { + if _, exists := currentMap[mac]; !exists { + // Station disconnected + if b.callbacks.OnStationDisconnected != nil { + go b.callbacks.OnStationDisconnected(mac) + } + } + } + + // Update last state + b.lastStations = currentMap +} diff --git a/internal/station/arp/parser.go b/internal/station/arp/parser.go new file mode 100644 index 0000000..ca40f01 --- /dev/null +++ b/internal/station/arp/parser.go @@ -0,0 +1,64 @@ +package arp + +import ( + "fmt" + "net" + "os" + "strings" +) + +// ARPEntry represents an entry in the ARP table +type ARPEntry struct { + IP net.IP + HWType int + Flags int + HWAddress net.HardwareAddr + Mask string + Device string +} + +// parseARPTable reads and parses ARP table from /proc/net/arp format +func parseARPTable(path string) ([]ARPEntry, error) { + var entries []ARPEntry + + content, err := os.ReadFile(path) + if err != nil { + return entries, err + } + + for _, line := range strings.Split(string(content), "\n") { + fields := strings.Fields(line) + if len(fields) > 5 { + var entry ARPEntry + + // Parse HWType (hex format) + if _, err := fmt.Sscanf(fields[1], "0x%x", &entry.HWType); err != nil { + continue + } + + // Parse Flags (hex format) + if _, err := fmt.Sscanf(fields[2], "0x%x", &entry.Flags); err != nil { + continue + } + + // Parse IP address + entry.IP = net.ParseIP(fields[0]) + if entry.IP == nil { + continue + } + + // Parse MAC address + entry.HWAddress, err = net.ParseMAC(fields[3]) + if err != nil { + continue + } + + entry.Mask = fields[4] + entry.Device = fields[5] + + entries = append(entries, entry) + } + } + + return entries, nil +} diff --git a/internal/station/backend/types.go b/internal/station/backend/types.go new file mode 100644 index 0000000..9874d5c --- /dev/null +++ b/internal/station/backend/types.go @@ -0,0 +1,99 @@ +package backend + +import ( + "strings" + "time" +) + +// StationBackend defines the interface for station/device discovery backends. +// Implementations include ARP-based, DHCP-based, and Hostapd DBus-based discovery. +type StationBackend interface { + // Initialize initializes the backend with the given configuration + Initialize(config BackendConfig) error + + // Close cleans up backend resources + Close() error + + // GetStations returns all currently connected stations + GetStations() ([]Station, error) + + // StartEventMonitoring starts monitoring for station events + // Backends that don't support real-time events will poll and simulate events + StartEventMonitoring(callbacks EventCallbacks) error + + // StopEventMonitoring stops event monitoring + StopEventMonitoring() + + // SupportsRealTimeEvents returns true if backend supports real-time events (e.g., DBus) + // Returns false for polling-based backends (ARP, DHCP) + SupportsRealTimeEvents() bool +} + +// Station represents a connected device in a backend-agnostic format +type Station struct { + MAC string // Hardware MAC address (required, primary identifier) + IP string // IP address (may be empty for some backends initially) + Hostname string // Device hostname (may be empty) + Type string // Device type: "mobile", "laptop", "tablet", "unknown" + Signal int32 // Signal strength in dBm (0 if not available) + RxBytes uint64 // Received bytes (0 if not available) + TxBytes uint64 // Transmitted bytes (0 if not available) + ConnectedAt time.Time // When station connected (best effort) +} + +// EventCallbacks defines callback functions for station events. +// Backends call these when stations connect, disconnect, or update. +type EventCallbacks struct { + // OnStationConnected is called when a new station connects + OnStationConnected func(station Station) + + // OnStationDisconnected is called when a station disconnects + OnStationDisconnected func(mac string) + + // OnStationUpdated is called when station information changes + // (e.g., IP discovered, signal strength changed) + OnStationUpdated func(station Station) +} + +// BackendConfig provides configuration for backend initialization +type BackendConfig struct { + // Common + InterfaceName string // Network interface (e.g., "wlan1") + + // ARP-specific + ARPTablePath string // Path to /proc/net/arp (default: "/proc/net/arp") + + // DHCP-specific + DHCPLeasesPath string // Path to DHCP leases file + + // Hostapd-specific + HostapdInterface string // Hostapd interface name for DBus +} + +// GuessDeviceType attempts to guess device type from hostname and MAC address +func GuessDeviceType(hostname, mac string) string { + hostname = strings.ToLower(hostname) + + if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") { + return "mobile" + } else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") { + return "tablet" + } else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") { + return "laptop" + } + + // Guess by MAC prefix (OUI) + if len(mac) >= 8 { + macPrefix := strings.ToUpper(mac[:8]) + switch macPrefix { + case "00:50:56", "00:0C:29", "00:05:69": // VMware + return "laptop" + case "08:00:27": // VirtualBox + return "laptop" + default: + return "mobile" + } + } + + return "unknown" +} diff --git a/internal/station/dhcp/backend.go b/internal/station/dhcp/backend.go new file mode 100644 index 0000000..54abf95 --- /dev/null +++ b/internal/station/dhcp/backend.go @@ -0,0 +1,184 @@ +package dhcp + +import ( + "sync" + "time" + + "github.com/nemunaire/repeater/internal/station/backend" +) + +// Backend implements StationBackend using DHCP lease discovery +type Backend struct { + dhcpLeasesPath string + lastStations map[string]backend.Station // Key: MAC address + callbacks backend.EventCallbacks + stopChan chan struct{} + mu sync.RWMutex + running bool +} + +// NewBackend creates a new DHCP backend +func NewBackend() *Backend { + return &Backend{ + lastStations: make(map[string]backend.Station), + stopChan: make(chan struct{}), + } +} + +// Initialize initializes the DHCP backend +func (b *Backend) Initialize(config backend.BackendConfig) error { + b.mu.Lock() + defer b.mu.Unlock() + + b.dhcpLeasesPath = config.DHCPLeasesPath + if b.dhcpLeasesPath == "" { + b.dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases" + } + + return nil +} + +// Close cleans up backend resources +func (b *Backend) Close() error { + b.StopEventMonitoring() + return nil +} + +// GetStations returns all connected stations from DHCP leases validated by ARP +func (b *Backend) GetStations() ([]backend.Station, error) { + b.mu.RLock() + dhcpLeasesPath := b.dhcpLeasesPath + b.mu.RUnlock() + + // Read DHCP leases + leases, err := parseDHCPLeases(dhcpLeasesPath) + if err != nil { + return nil, err + } + + // Get ARP information for validation + arpInfo, err := getARPInfo() + if err != nil { + return nil, err + } + + var stations []backend.Station + for _, lease := range leases { + // Check if the device is still connected via ARP + if _, exists := arpInfo[lease.IP]; exists { + st := backend.Station{ + MAC: lease.MAC, + IP: lease.IP, + Hostname: lease.Hostname, + Type: backend.GuessDeviceType(lease.Hostname, lease.MAC), + Signal: 0, // Not available from DHCP + RxBytes: 0, // Not available from DHCP + TxBytes: 0, // Not available from DHCP + ConnectedAt: time.Now(), + } + stations = append(stations, st) + } + } + + return stations, nil +} + +// StartEventMonitoring starts monitoring for station events via polling +func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error { + b.mu.Lock() + defer b.mu.Unlock() + + if b.running { + return nil + } + + b.callbacks = callbacks + b.running = true + + // Start polling goroutine + go b.pollLoop() + + return nil +} + +// StopEventMonitoring stops event monitoring +func (b *Backend) StopEventMonitoring() { + b.mu.Lock() + if !b.running { + b.mu.Unlock() + return + } + b.running = false + b.mu.Unlock() + + close(b.stopChan) +} + +// SupportsRealTimeEvents returns false (DHCP is polling-based) +func (b *Backend) SupportsRealTimeEvents() bool { + return false +} + +// pollLoop polls DHCP leases and simulates events +func (b *Backend) pollLoop() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + b.checkForChanges() + case <-b.stopChan: + return + } + } +} + +// checkForChanges compares current state with last state and triggers callbacks +func (b *Backend) checkForChanges() { + // Get current stations + current, err := b.GetStations() + if err != nil { + return + } + + // Build map of current stations + currentMap := make(map[string]backend.Station) + for _, st := range current { + currentMap[st.MAC] = st + } + + b.mu.Lock() + defer b.mu.Unlock() + + // Check for new stations (connected) + for mac, st := range currentMap { + if _, exists := b.lastStations[mac]; !exists { + // New station connected + if b.callbacks.OnStationConnected != nil { + go b.callbacks.OnStationConnected(st) + } + } else { + // Check for updates (IP change, hostname change, etc.) + oldStation := b.lastStations[mac] + if oldStation.IP != st.IP || oldStation.Hostname != st.Hostname { + if b.callbacks.OnStationUpdated != nil { + go b.callbacks.OnStationUpdated(st) + } + } + } + } + + // Check for disconnected stations + for mac := range b.lastStations { + if _, exists := currentMap[mac]; !exists { + // Station disconnected + if b.callbacks.OnStationDisconnected != nil { + go b.callbacks.OnStationDisconnected(mac) + } + } + } + + // Update last state + b.lastStations = currentMap +} diff --git a/internal/station/dhcp/parser.go b/internal/station/dhcp/parser.go new file mode 100644 index 0000000..efcd583 --- /dev/null +++ b/internal/station/dhcp/parser.go @@ -0,0 +1,72 @@ +package dhcp + +import ( + "bufio" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/nemunaire/repeater/internal/models" +) + +// parseDHCPLeases reads and parses DHCP lease file +func parseDHCPLeases(path string) ([]models.DHCPLease, error) { + var leases []models.DHCPLease + + file, err := os.Open(path) + if err != nil { + return leases, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var currentLease models.DHCPLease + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if strings.HasPrefix(line, "lease ") { + ip := strings.Fields(line)[1] + currentLease = models.DHCPLease{IP: ip} + } else if strings.Contains(line, "hardware ethernet") { + mac := strings.Fields(line)[2] + mac = strings.TrimSuffix(mac, ";") + currentLease.MAC = mac + } else if strings.Contains(line, "client-hostname") { + hostname := strings.Fields(line)[1] + hostname = strings.Trim(hostname, `";`) + currentLease.Hostname = hostname + } else if line == "}" { + if currentLease.IP != "" && currentLease.MAC != "" { + leases = append(leases, currentLease) + } + currentLease = models.DHCPLease{} + } + } + + return leases, nil +} + +// getARPInfo retrieves ARP table information using arp command +// Returns a map of IP -> MAC address +func getARPInfo() (map[string]string, error) { + arpInfo := make(map[string]string) + + cmd := exec.Command("arp", "-a") + output, err := cmd.Output() + if err != nil { + return arpInfo, err + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 { + ip := matches[1] + mac := matches[2] + arpInfo[ip] = mac + } + } + + return arpInfo, nil +} diff --git a/internal/station/factory.go b/internal/station/factory.go new file mode 100644 index 0000000..94bfc2b --- /dev/null +++ b/internal/station/factory.go @@ -0,0 +1,24 @@ +package station + +import ( + "fmt" + + "github.com/nemunaire/repeater/internal/station/arp" + "github.com/nemunaire/repeater/internal/station/backend" + "github.com/nemunaire/repeater/internal/station/dhcp" + "github.com/nemunaire/repeater/internal/station/hostapd" +) + +// createBackend creates a station backend based on the backend name +func createBackend(backendName string) (backend.StationBackend, error) { + switch backendName { + case "arp": + return arp.NewBackend(), nil + case "dhcp": + return dhcp.NewBackend(), nil + case "hostapd": + return hostapd.NewBackend(), nil + default: + return nil, fmt.Errorf("invalid station backend: %s (must be 'arp', 'dhcp', or 'hostapd')", backendName) + } +} diff --git a/internal/station/hostapd/backend.go b/internal/station/hostapd/backend.go new file mode 100644 index 0000000..ce5708a --- /dev/null +++ b/internal/station/hostapd/backend.go @@ -0,0 +1,345 @@ +package hostapd + +import ( + "bufio" + "bytes" + "fmt" + "log" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "github.com/nemunaire/repeater/internal/station/backend" +) + +// Backend implements StationBackend using hostapd_cli +type Backend struct { + interfaceName string + hostapdCLI string // Path to hostapd_cli executable + + stations map[string]*HostapdStation // Key: MAC address + callbacks backend.EventCallbacks + + mu sync.RWMutex + running bool + stopCh chan struct{} + + // IP correlation - will be populated by periodic DHCP lease correlation + ipByMAC map[string]string // MAC -> IP mapping +} + +// NewBackend creates a new hostapd backend +func NewBackend() *Backend { + return &Backend{ + stations: make(map[string]*HostapdStation), + ipByMAC: make(map[string]string), + hostapdCLI: "hostapd_cli", + stopCh: make(chan struct{}), + } +} + +// Initialize initializes the hostapd backend +func (b *Backend) Initialize(config backend.BackendConfig) error { + b.mu.Lock() + defer b.mu.Unlock() + + b.interfaceName = config.InterfaceName + if b.interfaceName == "" { + b.interfaceName = "wlan1" // Default AP interface + } + + // Check if hostapd_cli is available + if _, err := exec.LookPath(b.hostapdCLI); err != nil { + return fmt.Errorf("hostapd_cli not found in PATH: %w", err) + } + + // Verify we can communicate with hostapd + if err := b.runCommand("ping"); err != nil { + return fmt.Errorf("failed to communicate with hostapd on interface %s: %w", b.interfaceName, err) + } + + log.Printf("Hostapd backend initialized for interface %s", b.interfaceName) + + // Load initial station list + if err := b.loadStations(); err != nil { + log.Printf("Warning: Failed to load initial stations: %v", err) + } + + return nil +} + +// Close cleans up backend resources +func (b *Backend) Close() error { + b.StopEventMonitoring() + return nil +} + +// runCommand executes a hostapd_cli command and returns the output +func (b *Backend) runCommand(args ...string) error { + cmdArgs := []string{"-i", b.interfaceName} + cmdArgs = append(cmdArgs, args...) + cmd := exec.Command(b.hostapdCLI, cmdArgs...) + return cmd.Run() +} + +// runCommandOutput executes a hostapd_cli command and returns the output +func (b *Backend) runCommandOutput(args ...string) (string, error) { + cmdArgs := []string{"-i", b.interfaceName} + cmdArgs = append(cmdArgs, args...) + cmd := exec.Command(b.hostapdCLI, cmdArgs...) + out, err := cmd.Output() + if err != nil { + return "", err + } + return string(out), nil +} + +// GetStations returns all connected stations +func (b *Backend) GetStations() ([]backend.Station, error) { + b.mu.RLock() + defer b.mu.RUnlock() + + stations := make([]backend.Station, 0, len(b.stations)) + for mac, hs := range b.stations { + station := b.convertStation(mac, hs) + stations = append(stations, station) + } + + return stations, nil +} + +// StartEventMonitoring starts monitoring for station events via polling +func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error { + b.mu.Lock() + defer b.mu.Unlock() + + if b.running { + return nil + } + + b.callbacks = callbacks + b.running = true + + // Start polling goroutine + go b.pollStations() + + log.Printf("Hostapd event monitoring started (polling mode)") + return nil +} + +// StopEventMonitoring stops event monitoring +func (b *Backend) StopEventMonitoring() { + b.mu.Lock() + if !b.running { + b.mu.Unlock() + return + } + b.running = false + b.mu.Unlock() + + close(b.stopCh) +} + +// SupportsRealTimeEvents returns false (hostapd_cli uses polling) +func (b *Backend) SupportsRealTimeEvents() bool { + return false +} + +// pollStations periodically polls for station changes +func (b *Backend) pollStations() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-b.stopCh: + return + case <-ticker.C: + if err := b.checkStationChanges(); err != nil { + log.Printf("Error polling stations: %v", err) + } + } + } +} + +// checkStationChanges checks for station connect/disconnect events +func (b *Backend) checkStationChanges() error { + // Get current stations from hostapd + currentStations, err := b.fetchStations() + if err != nil { + return err + } + + b.mu.Lock() + defer b.mu.Unlock() + + // Build a map of current MACs + currentMACs := make(map[string]bool) + for mac := range currentStations { + currentMACs[mac] = true + } + + // Check for new stations + for mac, station := range currentStations { + if _, exists := b.stations[mac]; !exists { + // New station connected + b.stations[mac] = station + if b.callbacks.OnStationConnected != nil { + st := b.convertStation(mac, station) + go b.callbacks.OnStationConnected(st) + } + log.Printf("Station connected: %s", mac) + } + } + + // Check for removed stations + for mac := range b.stations { + if !currentMACs[mac] { + // Station disconnected + delete(b.stations, mac) + delete(b.ipByMAC, mac) + if b.callbacks.OnStationDisconnected != nil { + go b.callbacks.OnStationDisconnected(mac) + } + log.Printf("Station disconnected: %s", mac) + } + } + + return nil +} + +// loadStations loads the initial list of stations from hostapd +func (b *Backend) loadStations() error { + stations, err := b.fetchStations() + if err != nil { + return err + } + + b.stations = stations + log.Printf("Loaded %d initial stations from hostapd", len(b.stations)) + return nil +} + +// fetchStations fetches all stations using hostapd_cli all_sta command +func (b *Backend) fetchStations() (map[string]*HostapdStation, error) { + output, err := b.runCommandOutput("all_sta") + if err != nil { + return nil, fmt.Errorf("failed to get stations: %w", err) + } + + return b.parseAllStaOutput(output), nil +} + +// parseAllStaOutput parses the output of "hostapd_cli all_sta" +func (b *Backend) parseAllStaOutput(output string) map[string]*HostapdStation { + stations := make(map[string]*HostapdStation) + scanner := bufio.NewScanner(bytes.NewBufferString(output)) + + var currentMAC string + var currentStation *HostapdStation + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + // Check if this is a MAC address line (starts the station block) + if !strings.Contains(line, "=") && len(line) == 17 && strings.Count(line, ":") == 5 { + // Save previous station if exists + if currentMAC != "" && currentStation != nil { + stations[currentMAC] = currentStation + } + // Start new station + currentMAC = strings.ToLower(line) + currentStation = &HostapdStation{} + continue + } + + // Parse key=value pairs + if currentStation != nil && strings.Contains(line, "=") { + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + switch key { + case "signal": + if v, err := strconv.Atoi(value); err == nil { + currentStation.Signal = int32(v) + } + case "rx_bytes": + if v, err := strconv.ParseUint(value, 10, 64); err == nil { + currentStation.RxBytes = v + } + case "tx_bytes": + if v, err := strconv.ParseUint(value, 10, 64); err == nil { + currentStation.TxBytes = v + } + } + } + } + + // Save last station + if currentMAC != "" && currentStation != nil { + stations[currentMAC] = currentStation + } + + return stations +} + +// convertStation converts HostapdStation to backend.Station +func (b *Backend) convertStation(mac string, hs *HostapdStation) backend.Station { + // Get IP address if available from correlation + ip := b.ipByMAC[mac] + + // Attempt hostname resolution if we have an IP + hostname := "" + // TODO: Could do reverse DNS lookup here if needed + + return backend.Station{ + MAC: mac, + IP: ip, + Hostname: hostname, + Type: backend.GuessDeviceType(hostname, mac), + Signal: hs.Signal, + RxBytes: hs.RxBytes, + TxBytes: hs.TxBytes, + ConnectedAt: time.Now(), // We don't have exact connection time + } +} + +// UpdateIPMapping updates the MAC -> IP mapping from external source (e.g., DHCP) +// This should be called periodically to correlate hostapd stations with IP addresses +func (b *Backend) UpdateIPMapping(macToIP map[string]string) { + b.mu.Lock() + defer b.mu.Unlock() + + // Track which stations got IP updates + updated := make(map[string]bool) + + for mac, ip := range macToIP { + if oldIP, exists := b.ipByMAC[mac]; exists && oldIP != ip { + // IP changed + updated[mac] = true + } else if !exists { + // New IP mapping + updated[mac] = true + } + b.ipByMAC[mac] = ip + } + + // Trigger update callbacks for stations that got new/changed IPs + for mac := range updated { + if station, exists := b.stations[mac]; exists { + if b.callbacks.OnStationUpdated != nil { + st := b.convertStation(mac, station) + go b.callbacks.OnStationUpdated(st) + } + } + } +} diff --git a/internal/station/hostapd/correlation.go b/internal/station/hostapd/correlation.go new file mode 100644 index 0000000..fe4361a --- /dev/null +++ b/internal/station/hostapd/correlation.go @@ -0,0 +1,130 @@ +package hostapd + +import ( + "bufio" + "log" + "os" + "strings" + "time" + + "github.com/nemunaire/repeater/internal/models" +) + +// DHCPCorrelator helps correlate hostapd stations with DHCP leases to discover IP addresses +type DHCPCorrelator struct { + backend *Backend + dhcpLeasesPath string + stopChan chan struct{} + running bool +} + +// NewDHCPCorrelator creates a new DHCP correlator +func NewDHCPCorrelator(backend *Backend, dhcpLeasesPath string) *DHCPCorrelator { + if dhcpLeasesPath == "" { + dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases" + } + + return &DHCPCorrelator{ + backend: backend, + dhcpLeasesPath: dhcpLeasesPath, + stopChan: make(chan struct{}), + } +} + +// Start begins periodic correlation of DHCP leases with hostapd stations +func (dc *DHCPCorrelator) Start() { + if dc.running { + return + } + + dc.running = true + go dc.correlationLoop() + log.Printf("DHCP correlation started for hostapd backend") +} + +// Stop stops the correlation loop +func (dc *DHCPCorrelator) Stop() { + if !dc.running { + return + } + + dc.running = false + close(dc.stopChan) + log.Printf("DHCP correlation stopped") +} + +// correlationLoop periodically correlates DHCP leases with stations +func (dc *DHCPCorrelator) correlationLoop() { + // Do an initial correlation immediately + dc.correlate() + + // Then correlate every 10 seconds + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + dc.correlate() + case <-dc.stopChan: + return + } + } +} + +// correlate performs one correlation cycle +func (dc *DHCPCorrelator) correlate() { + // Parse DHCP leases + leases, err := parseDHCPLeases(dc.dhcpLeasesPath) + if err != nil { + log.Printf("Warning: Failed to parse DHCP leases: %v", err) + return + } + + // Build MAC -> IP mapping + macToIP := make(map[string]string) + for _, lease := range leases { + macToIP[lease.MAC] = lease.IP + } + + // Update backend with IP mappings + dc.backend.UpdateIPMapping(macToIP) +} + +// parseDHCPLeases reads and parses DHCP lease file +func parseDHCPLeases(path string) ([]models.DHCPLease, error) { + var leases []models.DHCPLease + + file, err := os.Open(path) + if err != nil { + return leases, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var currentLease models.DHCPLease + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if strings.HasPrefix(line, "lease ") { + ip := strings.Fields(line)[1] + currentLease = models.DHCPLease{IP: ip} + } else if strings.Contains(line, "hardware ethernet") { + mac := strings.Fields(line)[2] + mac = strings.TrimSuffix(mac, ";") + currentLease.MAC = mac + } else if strings.Contains(line, "client-hostname") { + hostname := strings.Fields(line)[1] + hostname = strings.Trim(hostname, `";`) + currentLease.Hostname = hostname + } else if line == "}" { + if currentLease.IP != "" && currentLease.MAC != "" { + leases = append(leases, currentLease) + } + currentLease = models.DHCPLease{} + } + } + + return leases, scanner.Err() +} diff --git a/internal/station/hostapd/types.go b/internal/station/hostapd/types.go new file mode 100644 index 0000000..2521c1f --- /dev/null +++ b/internal/station/hostapd/types.go @@ -0,0 +1,10 @@ +package hostapd + +// HostapdStation represents station properties from hostapd_cli +type HostapdStation struct { + RxPackets uint64 + TxPackets uint64 + RxBytes uint64 + TxBytes uint64 + Signal int32 // Signal in dBm +} diff --git a/internal/station/station.go b/internal/station/station.go new file mode 100644 index 0000000..e6acaf1 --- /dev/null +++ b/internal/station/station.go @@ -0,0 +1,111 @@ +package station + +import ( + "sync" + + "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/station/backend" +) + +var ( + currentBackend backend.StationBackend + mu sync.RWMutex +) + +// Initialize initializes the station discovery backend +func Initialize(backendName string, config backend.BackendConfig) error { + mu.Lock() + defer mu.Unlock() + + // Close existing backend if any + if currentBackend != nil { + currentBackend.Close() + } + + // Create new backend + b, err := createBackend(backendName) + if err != nil { + return err + } + + // Initialize the backend + if err := b.Initialize(config); err != nil { + return err + } + + currentBackend = b + return nil +} + +// GetStations returns all connected stations as ConnectedDevice models +func GetStations() ([]models.ConnectedDevice, error) { + mu.RLock() + defer mu.RUnlock() + + if currentBackend == nil { + return nil, nil + } + + stations, err := currentBackend.GetStations() + if err != nil { + return nil, err + } + + // Convert backend.Station to models.ConnectedDevice + devices := make([]models.ConnectedDevice, len(stations)) + for i, s := range stations { + devices[i] = models.ConnectedDevice{ + Name: s.Hostname, + Type: s.Type, + MAC: s.MAC, + IP: s.IP, + } + } + + return devices, nil +} + +// StartEventMonitoring starts monitoring for station events +func StartEventMonitoring(callbacks backend.EventCallbacks) error { + mu.RLock() + defer mu.RUnlock() + + if currentBackend == nil { + return nil + } + + return currentBackend.StartEventMonitoring(callbacks) +} + +// StopEventMonitoring stops monitoring for station events +func StopEventMonitoring() { + mu.RLock() + defer mu.RUnlock() + + if currentBackend != nil { + currentBackend.StopEventMonitoring() + } +} + +// Close closes the current backend +func Close() { + mu.Lock() + defer mu.Unlock() + + if currentBackend != nil { + currentBackend.Close() + currentBackend = nil + } +} + +// SupportsRealTimeEvents returns true if the current backend supports real-time events +func SupportsRealTimeEvents() bool { + mu.RLock() + defer mu.RUnlock() + + if currentBackend == nil { + return false + } + + return currentBackend.SupportsRealTimeEvents() +} diff --git a/internal/syslog/parser.go b/internal/syslog/parser.go new file mode 100644 index 0000000..10c07b6 --- /dev/null +++ b/internal/syslog/parser.go @@ -0,0 +1,32 @@ +package syslog + +import ( + "strings" +) + +// ParseSyslogLine extracts the message content from a syslog line. +// It looks for the daemon prefix in the line and returns the message after it. +// +// Example input: "Dec 2 02:01:33 tyet daemon.info iwd: Error loading /var/lib/iwd//nemuphone.psk" +// Example output: "Error loading /var/lib/iwd//nemuphone.psk", true +// +// Returns the message and a boolean indicating if the line was successfully parsed. +func ParseSyslogLine(line, daemonPrefix string) (string, bool) { + // Find the daemon prefix in the line (e.g., "iwd:") + idx := strings.Index(line, daemonPrefix) + if idx == -1 { + return "", false + } + + // Extract everything after the daemon prefix + message := line[idx+len(daemonPrefix):] + + // Trim leading/trailing whitespace + message = strings.TrimSpace(message) + + if message == "" { + return "", false + } + + return message, true +} diff --git a/internal/syslog/syslog.go b/internal/syslog/syslog.go new file mode 100644 index 0000000..605a1fe --- /dev/null +++ b/internal/syslog/syslog.go @@ -0,0 +1,225 @@ +package syslog + +import ( + "bufio" + "io" + "log" + "os" + "strings" + "sync" + "time" + + "github.com/nemunaire/repeater/internal/logging" +) + +// SyslogTailer tails a syslog file and filters messages to the logging system. +type SyslogTailer struct { + path string + filters []string + source string + + file *os.File + done chan struct{} + wg sync.WaitGroup + mu sync.Mutex + running bool +} + +// NewSyslogTailer creates a new syslog tailer. +// path: Path to the syslog file (e.g., "/var/log/messages") +// filter: Filter string to match in lines (e.g., "daemon.info iwd:") +// source: Source name for logging (e.g., "iwd") +func NewSyslogTailer(path string, filters []string, source string) *SyslogTailer { + return &SyslogTailer{ + path: path, + filters: filters, + source: source, + done: make(chan struct{}), + } +} + +// Start opens the syslog file and begins tailing it. +func (t *SyslogTailer) Start() error { + t.mu.Lock() + defer t.mu.Unlock() + + if t.running { + return nil + } + + // Try to open the file + file, err := os.Open(t.path) + if err != nil { + // File might not exist yet, we'll retry in the goroutine + log.Printf("Warning: Cannot open syslog file %s: %v (will retry)", t.path, err) + } else { + // Seek to the end to only read new entries + _, err = file.Seek(0, io.SeekEnd) + if err != nil { + file.Close() + return err + } + t.file = file + } + + t.running = true + t.wg.Add(1) + go t.tail() + + return nil +} + +// Stop signals the tailer to stop and waits for it to finish. +func (t *SyslogTailer) Stop() { + t.mu.Lock() + if !t.running { + t.mu.Unlock() + return + } + t.mu.Unlock() + + close(t.done) + t.wg.Wait() + + t.mu.Lock() + if t.file != nil { + t.file.Close() + t.file = nil + } + t.running = false + t.mu.Unlock() +} + +// tail is the main loop that reads from the syslog file. +func (t *SyslogTailer) tail() { + defer t.wg.Done() + + retryDelay := 1 * time.Second + maxRetryDelay := 30 * time.Second + + for { + select { + case <-t.done: + return + default: + } + + // Check if we have a file open + t.mu.Lock() + file := t.file + t.mu.Unlock() + + if file == nil { + // Try to open the file + newFile, err := os.Open(t.path) + if err != nil { + // File doesn't exist or can't be opened, wait and retry + select { + case <-t.done: + return + case <-time.After(retryDelay): + // Exponential backoff + retryDelay *= 2 + if retryDelay > maxRetryDelay { + retryDelay = maxRetryDelay + } + continue + } + } + + // Seek to the end + _, err = newFile.Seek(0, io.SeekEnd) + if err != nil { + log.Printf("Error seeking syslog file: %v", err) + newFile.Close() + time.Sleep(retryDelay) + continue + } + + t.mu.Lock() + t.file = newFile + file = newFile + t.mu.Unlock() + + retryDelay = 1 * time.Second + log.Printf("Syslog tailer: opened %s", t.path) + } + + // Read lines from the file + if err := t.readLines(file); err != nil { + if err == io.EOF { + // End of file, wait a bit and try again + time.Sleep(100 * time.Millisecond) + continue + } + + // Other error, close the file and retry + log.Printf("Error reading syslog file: %v", err) + t.mu.Lock() + if t.file != nil { + t.file.Close() + t.file = nil + } + t.mu.Unlock() + + time.Sleep(retryDelay) + } + } +} + +// readLines reads and processes lines from the file. +func (t *SyslogTailer) readLines(file *os.File) error { + scanner := bufio.NewScanner(file) + + // Increase buffer size to handle long log lines + const maxCapacity = 512 * 1024 + buf := make([]byte, maxCapacity) + scanner.Buffer(buf, maxCapacity) + + for scanner.Scan() { + select { + case <-t.done: + return nil + default: + } + + line := scanner.Text() + + // Check if the line contains any of the filter strings + var matchedFilter string + for _, filter := range t.filters { + if strings.Contains(line, filter) { + matchedFilter = filter + break + } + } + if matchedFilter == "" { + continue + } + + // Parse the syslog line to extract the message + // We look for "iwd:" (or whatever comes after the filter) + // The filter is "daemon.info iwd:" so we want to extract text after "iwd:" + daemonPrefix := extractDaemonPrefix(matchedFilter) + message, ok := ParseSyslogLine(line, daemonPrefix) + if !ok { + // Couldn't parse the line, skip it + continue + } + + // Add to logging system + logging.AddLog(t.source, message) + } + + return scanner.Err() +} + +// extractDaemonPrefix extracts the daemon prefix from the filter string. +// For example, "daemon.info iwd:" returns "iwd:" +func extractDaemonPrefix(filter string) string { + parts := strings.Fields(filter) + if len(parts) > 0 { + return parts[len(parts)-1] + } + return filter +} diff --git a/internal/wifi/backend/types.go b/internal/wifi/backend/types.go new file mode 100644 index 0000000..1346e9b --- /dev/null +++ b/internal/wifi/backend/types.go @@ -0,0 +1,56 @@ +package backend + +// WiFiBackend is the interface that must be implemented by all WiFi backends (iwd, wpa_supplicant, etc.) +type WiFiBackend interface { + // Lifecycle Management + Initialize(interfaceName string) error + Close() error + + // Network Discovery + ScanNetworks() error + GetOrderedNetworks() ([]BackendNetwork, error) + IsScanning() (bool, error) + + // Connection Management + Connect(ssid, password string) error + Disconnect() error + GetConnectionState() (ConnectionState, error) + GetConnectedSSID() string + + // Event Monitoring + StartEventMonitoring(callbacks EventCallbacks) error + StopEventMonitoring() +} + +// BackendNetwork represents a WiFi network in a backend-agnostic format. +// Both iwd and wpa_supplicant backends convert their native representations to this type. +type BackendNetwork struct { + SSID string + SignalDBm int16 // Signal strength in dBm (-100 to 0) + SecurityType string // "open", "wep", "psk", "8021x" + BSSID string // MAC address of the access point + Frequency uint32 // Frequency in MHz (e.g., 2412 for channel 1, 5180 for channel 36) +} + +// ConnectionState represents the WiFi connection state in a backend-agnostic way. +type ConnectionState string + +const ( + StateConnected ConnectionState = "connected" + StateDisconnected ConnectionState = "disconnected" + StateConnecting ConnectionState = "connecting" + StateDisconnecting ConnectionState = "disconnecting" +) + +// EventCallbacks defines callback functions that backends use to notify the wifi package of events. +// This allows the wifi package to remain backend-agnostic while still receiving real-time updates. +type EventCallbacks struct { + // OnStateChange is called when the connection state changes + OnStateChange func(state ConnectionState, ssid string) + + // OnScanComplete is called when a network scan completes + OnScanComplete func() + + // OnSignalUpdate is called when signal strength changes for the connected network + OnSignalUpdate func(ssid string, signalDBm int16) +} diff --git a/internal/wifi/broadcaster.go b/internal/wifi/broadcaster.go new file mode 100644 index 0000000..931ad3e --- /dev/null +++ b/internal/wifi/broadcaster.go @@ -0,0 +1,140 @@ +package wifi + +import ( + "log" + "sync" + + "github.com/gorilla/websocket" + "github.com/nemunaire/repeater/internal/models" +) + +// WifiBroadcaster manages WebSocket clients and broadcasts WiFi events +type WifiBroadcaster struct { + clients map[*websocket.Conn]bool + clientsMu sync.RWMutex + + // State deduplication + lastState string + lastSSID string + lastNetworks []models.WiFiNetwork + stateMu sync.RWMutex +} + +// NewWifiBroadcaster creates a new WiFi broadcaster +func NewWifiBroadcaster() *WifiBroadcaster { + return &WifiBroadcaster{ + clients: make(map[*websocket.Conn]bool), + } +} + +// RegisterClient registers a new WebSocket client +func (wb *WifiBroadcaster) RegisterClient(conn *websocket.Conn) { + wb.clientsMu.Lock() + wb.clients[conn] = true + wb.clientsMu.Unlock() + + // Send initial state to the new client + wb.sendInitialState(conn) +} + +// UnregisterClient removes a WebSocket client +func (wb *WifiBroadcaster) UnregisterClient(conn *websocket.Conn) { + wb.clientsMu.Lock() + delete(wb.clients, conn) + wb.clientsMu.Unlock() +} + +// sendInitialState sends the current WiFi state to a newly connected client +func (wb *WifiBroadcaster) sendInitialState(conn *websocket.Conn) { + wb.stateMu.RLock() + lastState := wb.lastState + lastSSID := wb.lastSSID + lastNetworks := make([]models.WiFiNetwork, len(wb.lastNetworks)) + copy(lastNetworks, wb.lastNetworks) + wb.stateMu.RUnlock() + + // Send last known state if available + if lastState != "" { + event := NewStateChangeEvent(lastState, lastSSID, "") + conn.WriteJSON(event) + } + + // Send last known network list if available + if len(lastNetworks) > 0 { + event := NewScanUpdateEvent(lastNetworks) + conn.WriteJSON(event) + } +} + +// BroadcastScanUpdate broadcasts a scan update event to all clients +func (wb *WifiBroadcaster) BroadcastScanUpdate(networks []models.WiFiNetwork) { + // Check for changes to avoid duplicate broadcasts + wb.stateMu.Lock() + if networksEqual(wb.lastNetworks, networks) { + wb.stateMu.Unlock() + return + } + wb.lastNetworks = make([]models.WiFiNetwork, len(networks)) + copy(wb.lastNetworks, networks) + wb.stateMu.Unlock() + + event := NewScanUpdateEvent(networks) + wb.broadcast(event) +} + +// BroadcastStateChange broadcasts a state change event to all clients +func (wb *WifiBroadcaster) BroadcastStateChange(state, ssid string) { + // Check for changes to avoid duplicate broadcasts + wb.stateMu.Lock() + if wb.lastState == state && wb.lastSSID == ssid { + wb.stateMu.Unlock() + return + } + previousState := wb.lastState + wb.lastState = state + wb.lastSSID = ssid + wb.stateMu.Unlock() + + event := NewStateChangeEvent(state, ssid, previousState) + wb.broadcast(event) +} + +// BroadcastSignalUpdate broadcasts a signal update event to all clients +func (wb *WifiBroadcaster) BroadcastSignalUpdate(ssid string, signal, dbm int) { + event := NewSignalUpdateEvent(ssid, signal, dbm) + wb.broadcast(event) +} + +// broadcast sends an event to all connected clients +func (wb *WifiBroadcaster) broadcast(event WifiEvent) { + // Get list of clients with read lock + wb.clientsMu.RLock() + clients := make([]*websocket.Conn, 0, len(wb.clients)) + for client := range wb.clients { + clients = append(clients, client) + } + wb.clientsMu.RUnlock() + + // Broadcast to all clients + for _, client := range clients { + err := client.WriteJSON(event) + if err != nil { + log.Printf("Erreur lors de l'envoi WebSocket WiFi: %v", err) + client.Close() + wb.UnregisterClient(client) + } + } +} + +// networksEqual compares two network slices for equality +func networksEqual(a, b []models.WiFiNetwork) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].SSID != b[i].SSID || a[i].Signal != b[i].Signal { + return false + } + } + return true +} diff --git a/internal/wifi/events.go b/internal/wifi/events.go new file mode 100644 index 0000000..c8f3672 --- /dev/null +++ b/internal/wifi/events.go @@ -0,0 +1,70 @@ +package wifi + +import ( + "time" + + "github.com/nemunaire/repeater/internal/models" +) + +// WifiEvent represents a WiFi event to be sent over WebSocket +type WifiEvent struct { + Type string `json:"type"` + Timestamp time.Time `json:"timestamp"` + Data interface{} `json:"data"` +} + +// ScanUpdateData contains network list update information +type ScanUpdateData struct { + Networks []models.WiFiNetwork `json:"networks"` +} + +// StateChangeData contains connection state change information +type StateChangeData struct { + State string `json:"state"` + SSID string `json:"ssid,omitempty"` + PreviousState string `json:"previous_state,omitempty"` +} + +// SignalUpdateData contains signal strength update information +type SignalUpdateData struct { + SSID string `json:"ssid"` + Signal int `json:"signal"` // 1-5 scale + DBm int `json:"dbm"` // Raw dBm value +} + +// NewScanUpdateEvent creates a new scan update event +func NewScanUpdateEvent(networks []models.WiFiNetwork) WifiEvent { + return WifiEvent{ + Type: "scan_update", + Timestamp: time.Now(), + Data: ScanUpdateData{ + Networks: networks, + }, + } +} + +// NewStateChangeEvent creates a new state change event +func NewStateChangeEvent(state, ssid, previousState string) WifiEvent { + return WifiEvent{ + Type: "state_change", + Timestamp: time.Now(), + Data: StateChangeData{ + State: state, + SSID: ssid, + PreviousState: previousState, + }, + } +} + +// NewSignalUpdateEvent creates a new signal update event +func NewSignalUpdateEvent(ssid string, signal, dbm int) WifiEvent { + return WifiEvent{ + Type: "signal_update", + Timestamp: time.Now(), + Data: SignalUpdateData{ + SSID: ssid, + Signal: signal, + DBm: dbm, + }, + } +} diff --git a/internal/wifi/factory.go b/internal/wifi/factory.go new file mode 100644 index 0000000..fdb63cc --- /dev/null +++ b/internal/wifi/factory.go @@ -0,0 +1,21 @@ +package wifi + +import ( + "fmt" + + "github.com/nemunaire/repeater/internal/wifi/backend" + "github.com/nemunaire/repeater/internal/wifi/iwd" + "github.com/nemunaire/repeater/internal/wifi/wpasupplicant" +) + +// createBackend creates the appropriate WiFi backend based on the backend name +func createBackend(backendName string) (backend.WiFiBackend, error) { + switch backendName { + case "iwd": + return iwd.NewIWDBackend(), nil + case "wpasupplicant": + return wpasupplicant.NewWPABackend(), nil + default: + return nil, fmt.Errorf("invalid wifi backend: %s (must be 'iwd' or 'wpasupplicant')", backendName) + } +} diff --git a/internal/wifi/iwd/agent.go b/internal/wifi/iwd/agent.go new file mode 100644 index 0000000..3a53ba6 --- /dev/null +++ b/internal/wifi/iwd/agent.go @@ -0,0 +1,170 @@ +package iwd + +import ( + "fmt" + "sync" + + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" +) + +const agentIntrospectXML = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` + +// Agent implements the net.connman.iwd.Agent interface for credential callbacks +type Agent struct { + conn *dbus.Conn + path dbus.ObjectPath + passphraseStore map[string]string + mu sync.RWMutex +} + +// NewAgent creates a new Agent instance +func NewAgent(conn *dbus.Conn, path dbus.ObjectPath) *Agent { + return &Agent{ + conn: conn, + path: path, + passphraseStore: make(map[string]string), + } +} + +// SetPassphrase stores a passphrase for a given network SSID +func (a *Agent) SetPassphrase(ssid, passphrase string) { + a.mu.Lock() + defer a.mu.Unlock() + a.passphraseStore[ssid] = passphrase +} + +// ClearPassphrase removes the stored passphrase for a network +func (a *Agent) ClearPassphrase(ssid string) { + a.mu.Lock() + defer a.mu.Unlock() + delete(a.passphraseStore, ssid) +} + +// Export registers the agent object on D-Bus +func (a *Agent) Export() error { + err := a.conn.Export(a, a.path, AgentInterface) + if err != nil { + return fmt.Errorf("failed to export agent: %v", err) + } + + err = a.conn.Export(introspect.Introspectable(agentIntrospectXML), a.path, "org.freedesktop.DBus.Introspectable") + if err != nil { + a.conn.Export(nil, a.path, AgentInterface) + return fmt.Errorf("failed to export introspection: %v", err) + } + + return nil +} + +// Unexport unregisters the agent from D-Bus +func (a *Agent) Unexport() { + a.conn.Export(nil, a.path, AgentInterface) + a.conn.Export(nil, a.path, "org.freedesktop.DBus.Introspectable") +} + +// getNetworkSSID queries the network object to get its SSID (Name property) +func (a *Agent) getNetworkSSID(networkPath dbus.ObjectPath) (string, error) { + obj := a.conn.Object(Service, networkPath) + variant, err := obj.GetProperty(NetworkInterface + ".Name") + if err != nil { + return "", fmt.Errorf("failed to get network name: %v", err) + } + + name, ok := variant.Value().(string) + if !ok { + return "", fmt.Errorf("network name is not a string") + } + + return name, nil +} + +// RequestPassphrase is called by iwd when connecting to PSK networks +func (a *Agent) RequestPassphrase(network dbus.ObjectPath) (string, *dbus.Error) { + fmt.Printf("[Agent] RequestPassphrase called for network: %s\n", network) + + ssid, err := a.getNetworkSSID(network) + if err != nil { + fmt.Printf("[Agent] Failed to get SSID: %v\n", err) + return "", dbus.MakeFailedError(fmt.Errorf("failed to get network SSID: %v", err)) + } + + fmt.Printf("[Agent] Network SSID: %s\n", ssid) + + a.mu.RLock() + passphrase, ok := a.passphraseStore[ssid] + a.mu.RUnlock() + + if !ok { + fmt.Printf("[Agent] No passphrase stored for SSID: %s\n", ssid) + return "", dbus.MakeFailedError(fmt.Errorf("no passphrase stored for network '%s'", ssid)) + } + + fmt.Printf("[Agent] Returning passphrase for SSID: %s\n", ssid) + return passphrase, nil +} + +// RequestPrivateKeyPassphrase is called for encrypted private keys +func (a *Agent) RequestPrivateKeyPassphrase(network dbus.ObjectPath) (string, *dbus.Error) { + // Not implemented for now + return "", dbus.MakeFailedError(fmt.Errorf("RequestPrivateKeyPassphrase not implemented")) +} + +// RequestUserNameAndPassword is called for enterprise networks +func (a *Agent) RequestUserNameAndPassword(network dbus.ObjectPath) (string, string, *dbus.Error) { + // Not implemented for now + return "", "", dbus.MakeFailedError(fmt.Errorf("RequestUserNameAndPassword not implemented")) +} + +// RequestUserPassword is called for enterprise networks with known username +func (a *Agent) RequestUserPassword(network dbus.ObjectPath, user string) (string, *dbus.Error) { + // Not implemented for now + return "", dbus.MakeFailedError(fmt.Errorf("RequestUserPassword not implemented")) +} + +// Cancel is called when a request is canceled +func (a *Agent) Cancel(reason string) *dbus.Error { + // Nothing to do, just acknowledge + return nil +} + +// Release is called when the agent is unregistered +func (a *Agent) Release() *dbus.Error { + // Cleanup if needed + a.mu.Lock() + a.passphraseStore = make(map[string]string) + a.mu.Unlock() + return nil +} diff --git a/internal/wifi/iwd/agentmanager.go b/internal/wifi/iwd/agentmanager.go new file mode 100644 index 0000000..7ea6c77 --- /dev/null +++ b/internal/wifi/iwd/agentmanager.go @@ -0,0 +1,39 @@ +package iwd + +import ( + "fmt" + + "github.com/godbus/dbus/v5" +) + +// AgentManager handles agent registration with iwd +type AgentManager struct { + conn *dbus.Conn + obj dbus.BusObject +} + +// NewAgentManager creates a new AgentManager instance +func NewAgentManager(conn *dbus.Conn) *AgentManager { + return &AgentManager{ + conn: conn, + obj: conn.Object(Service, "/net/connman/iwd"), + } +} + +// RegisterAgent registers an agent with iwd +func (am *AgentManager) RegisterAgent(agentPath dbus.ObjectPath) error { + err := am.obj.Call(AgentManagerInterface+".RegisterAgent", 0, agentPath).Err + if err != nil { + return fmt.Errorf("failed to register agent: %v", err) + } + return nil +} + +// UnregisterAgent unregisters an agent from iwd +func (am *AgentManager) UnregisterAgent(agentPath dbus.ObjectPath) error { + err := am.obj.Call(AgentManagerInterface+".UnregisterAgent", 0, agentPath).Err + if err != nil { + return fmt.Errorf("failed to unregister agent: %v", err) + } + return nil +} diff --git a/internal/wifi/iwd/backend.go b/internal/wifi/iwd/backend.go new file mode 100644 index 0000000..615a280 --- /dev/null +++ b/internal/wifi/iwd/backend.go @@ -0,0 +1,255 @@ +package iwd + +import ( + "fmt" + + "github.com/godbus/dbus/v5" + "github.com/nemunaire/repeater/internal/wifi/backend" +) + +const ( + AgentPath = "/com/github/nemunaire/repeater/agent" +) + +// IWDBackend implements the WiFiBackend interface for iwd (Intel Wireless Daemon) +type IWDBackend struct { + conn *dbus.Conn + manager *Manager + station *Station + agent *Agent + agentManager *AgentManager + signalMonitor *SignalMonitor + interfaceName string + callbacks backend.EventCallbacks +} + +// NewIWDBackend creates a new IWD backend instance +func NewIWDBackend() *IWDBackend { + return &IWDBackend{} +} + +// Initialize initializes the iwd backend with the given interface name +func (b *IWDBackend) Initialize(interfaceName string) error { + b.interfaceName = interfaceName + var err error + + // Connect to D-Bus + b.conn, err = dbus.SystemBus() + if err != nil { + return fmt.Errorf("échec de connexion à D-Bus: %v", err) + } + + // Find station for interface + b.manager = NewManager(b.conn) + b.station, err = b.manager.FindStation(interfaceName) + if err != nil { + return fmt.Errorf("impossible de trouver la station pour %s: %v", interfaceName, err) + } + + // Create and register agent for credential callbacks + b.agent = NewAgent(b.conn, dbus.ObjectPath(AgentPath)) + if err := b.agent.Export(); err != nil { + return fmt.Errorf("échec de l'export de l'agent: %v", err) + } + + b.agentManager = NewAgentManager(b.conn) + if err := b.agentManager.RegisterAgent(dbus.ObjectPath(AgentPath)); err != nil { + b.agent.Unexport() + return fmt.Errorf("échec de l'enregistrement de l'agent: %v", err) + } + + return nil +} + +// Close closes the D-Bus connection and unregisters the agent +func (b *IWDBackend) Close() error { + if b.agentManager != nil && b.agent != nil { + b.agentManager.UnregisterAgent(dbus.ObjectPath(AgentPath)) + b.agent.Unexport() + } + if b.conn != nil { + b.conn.Close() + } + return nil +} + +// ScanNetworks triggers a network scan +func (b *IWDBackend) ScanNetworks() error { + err := b.station.Scan() + if err != nil { + return fmt.Errorf("erreur lors du scan: %v", err) + } + return nil +} + +// GetOrderedNetworks returns networks sorted by signal strength in backend-agnostic format +func (b *IWDBackend) GetOrderedNetworks() ([]backend.BackendNetwork, error) { + networkInfos, err := b.station.GetOrderedNetworks() + if err != nil { + return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err) + } + + var networks []backend.BackendNetwork + seenSSIDs := make(map[string]bool) + + for _, netInfo := range networkInfos { + network := NewNetwork(b.conn, netInfo.Path) + props, err := network.GetProperties() + if err != nil { + continue + } + + if props.Name == "" || seenSSIDs[props.Name] { + continue + } + seenSSIDs[props.Name] = true + + // Convert iwd network to backend-agnostic format + backendNet := backend.BackendNetwork{ + SSID: props.Name, + SignalDBm: netInfo.Signal / 100, // iwd provides 100*dBm, convert to dBm + SecurityType: props.Type, + BSSID: generateSyntheticBSSID(props.Name), // iwd doesn't expose BSSID + Frequency: 0, // iwd doesn't expose frequency in GetOrderedNetworks + } + + networks = append(networks, backendNet) + } + + return networks, nil +} + +// IsScanning checks if a scan is currently in progress +func (b *IWDBackend) IsScanning() (bool, error) { + return b.station.IsScanning() +} + +// Connect connects to a WiFi network +func (b *IWDBackend) Connect(ssid, password string) error { + // Store passphrase in agent for callback + if password != "" { + b.agent.SetPassphrase(ssid, password) + } + + // Ensure passphrase is cleared after connection attempt + defer func() { + if password != "" { + b.agent.ClearPassphrase(ssid) + } + }() + + // Get network object + network, err := b.station.GetNetwork(ssid) + if err != nil { + return fmt.Errorf("réseau '%s' non trouvé: %v", ssid, err) + } + + // Connect - iwd will call agent.RequestPassphrase() if needed + if err := network.Connect(); err != nil { + return fmt.Errorf("erreur lors de la connexion: %v", err) + } + + return nil +} + +// Disconnect disconnects from the current WiFi network +func (b *IWDBackend) Disconnect() error { + if err := b.station.Disconnect(); err != nil { + return fmt.Errorf("erreur lors de la déconnexion: %v", err) + } + return nil +} + +// GetConnectionState returns the current WiFi connection state +func (b *IWDBackend) GetConnectionState() (backend.ConnectionState, error) { + state, err := b.station.GetState() + if err != nil { + return backend.StateDisconnected, err + } + return mapIWDState(state), nil +} + +// GetConnectedSSID returns the SSID of the currently connected network +func (b *IWDBackend) GetConnectedSSID() string { + network, err := b.station.GetConnectedNetwork() + if err != nil { + return "" + } + + props, err := network.GetProperties() + if err != nil { + return "" + } + + return props.Name +} + +// StartEventMonitoring starts monitoring WiFi events +func (b *IWDBackend) StartEventMonitoring(callbacks backend.EventCallbacks) error { + b.callbacks = callbacks + + // Create signal monitor + b.signalMonitor = NewSignalMonitor(b.conn, b.station) + + // Register callbacks - wrap to convert iwd types to backend types + b.signalMonitor.OnStateChange(func(state StationState, ssid string) { + if b.callbacks.OnStateChange != nil { + b.callbacks.OnStateChange(mapIWDState(state), ssid) + } + }) + + b.signalMonitor.OnScanComplete(func() { + if b.callbacks.OnScanComplete != nil { + b.callbacks.OnScanComplete() + } + }) + + // Start monitoring + return b.signalMonitor.Start() +} + +// StopEventMonitoring stops monitoring WiFi events +func (b *IWDBackend) StopEventMonitoring() { + if b.signalMonitor != nil { + b.signalMonitor.Stop() + } +} + +// mapIWDState maps iwd-specific states to backend-agnostic states +func mapIWDState(state StationState) backend.ConnectionState { + switch state { + case StateConnected: + return backend.StateConnected + case StateConnecting: + return backend.StateConnecting + case StateDisconnecting: + return backend.StateDisconnecting + case StateDisconnected: + return backend.StateDisconnected + case StateRoaming: + // Map roaming to connected since we're still connected during roaming + return backend.StateConnected + default: + return backend.StateDisconnected + } +} + +// generateSyntheticBSSID generates a consistent fake BSSID from SSID +// (iwd doesn't expose real BSSID) +func generateSyntheticBSSID(ssid string) string { + // Use a simple hash approach - consistent per SSID + hash := 0 + for _, c := range ssid { + hash = ((hash << 5) - hash) + int(c) + } + + // Generate 6 bytes for MAC address + b1 := byte((hash >> 0) & 0xff) + b2 := byte((hash >> 8) & 0xff) + b3 := byte((hash >> 16) & 0xff) + b4 := byte((hash >> 24) & 0xff) + b5 := byte(len(ssid) & 0xff) + b6 := byte((len(ssid) >> 8) & 0xff) + + return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b1, b2, b3, b4, b5, b6) +} diff --git a/internal/wifi/iwd/manager.go b/internal/wifi/iwd/manager.go new file mode 100644 index 0000000..f636664 --- /dev/null +++ b/internal/wifi/iwd/manager.go @@ -0,0 +1,71 @@ +package iwd + +import ( + "fmt" + "strings" + + "github.com/godbus/dbus/v5" +) + +// Manager handles iwd object discovery via ObjectManager +type Manager struct { + conn *dbus.Conn + obj dbus.BusObject +} + +// NewManager creates a new Manager instance +func NewManager(conn *dbus.Conn) *Manager { + return &Manager{ + conn: conn, + obj: conn.Object(Service, dbus.ObjectPath(ManagerPath)), + } +} + +// GetManagedObjects returns all iwd managed objects +func (m *Manager) GetManagedObjects() (map[dbus.ObjectPath]map[string]map[string]dbus.Variant, error) { + var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant + err := m.obj.Call("org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&objects) + if err != nil { + return nil, fmt.Errorf("failed to get managed objects: %v", err) + } + return objects, nil +} + +// FindStation finds the Station object for the given interface name +func (m *Manager) FindStation(interfaceName string) (*Station, error) { + objects, err := m.GetManagedObjects() + if err != nil { + return nil, err + } + + // First, find the device with matching interface name + var devicePath dbus.ObjectPath + for path, interfaces := range objects { + if deviceProps, ok := interfaces[DeviceInterface]; ok { + if nameVariant, ok := deviceProps["Name"]; ok { + if name, ok := nameVariant.Value().(string); ok && name == interfaceName { + devicePath = path + break + } + } + } + } + + if devicePath == "" { + return nil, fmt.Errorf("device with interface '%s' not found", interfaceName) + } + + // Now find the station object under this device + // Station path is typically the same as device path or a child of it + for path, interfaces := range objects { + if _, ok := interfaces[StationInterface]; ok { + // Check if this station belongs to our device + // Station path should be the device path or start with it + if path == devicePath || strings.HasPrefix(string(path), string(devicePath)+"/") { + return NewStation(m.conn, path), nil + } + } + } + + return nil, fmt.Errorf("station for device '%s' not found", interfaceName) +} diff --git a/internal/wifi/iwd/network.go b/internal/wifi/iwd/network.go new file mode 100644 index 0000000..1563a95 --- /dev/null +++ b/internal/wifi/iwd/network.go @@ -0,0 +1,64 @@ +package iwd + +import ( + "fmt" + + "github.com/godbus/dbus/v5" +) + +// Network represents an iwd Network interface +type Network struct { + path dbus.ObjectPath + conn *dbus.Conn + obj dbus.BusObject +} + +// NewNetwork creates a new Network instance +func NewNetwork(conn *dbus.Conn, path dbus.ObjectPath) *Network { + return &Network{ + path: path, + conn: conn, + obj: conn.Object(Service, path), + } +} + +// GetProperties retrieves all network properties +func (n *Network) GetProperties() (*NetworkProperties, error) { + var props map[string]dbus.Variant + err := n.obj.Call("org.freedesktop.DBus.Properties.GetAll", 0, NetworkInterface).Store(&props) + if err != nil { + return nil, fmt.Errorf("failed to get network properties: %v", err) + } + + netProps := &NetworkProperties{} + + if nameVariant, ok := props["Name"]; ok { + if name, ok := nameVariant.Value().(string); ok { + netProps.Name = name + } + } + + if typeVariant, ok := props["Type"]; ok { + if netType, ok := typeVariant.Value().(string); ok { + netProps.Type = netType + } + } + + if connectedVariant, ok := props["Connected"]; ok { + if connected, ok := connectedVariant.Value().(bool); ok { + netProps.Connected = connected + } + } + + return netProps, nil +} + +// Connect initiates a connection to this network +// Credentials are provided via the registered agent's RequestPassphrase callback +func (n *Network) Connect() error { + err := n.obj.Call(NetworkInterface+".Connect", 0).Err + if err != nil { + return fmt.Errorf("connect failed: %v", err) + } + return nil +} diff --git a/internal/wifi/iwd/signals.go b/internal/wifi/iwd/signals.go new file mode 100644 index 0000000..90ed32e --- /dev/null +++ b/internal/wifi/iwd/signals.go @@ -0,0 +1,223 @@ +package iwd + +import ( + "log" + "sync" + + "github.com/godbus/dbus/v5" +) + +// SignalMonitor monitors D-Bus signals from iwd +type SignalMonitor struct { + conn *dbus.Conn + station *Station + + // Signal channel + signalChan chan *dbus.Signal + + // Callbacks + onStateChange func(state StationState, ssid string) + onScanComplete func() + + // Control + stopChan chan struct{} + mu sync.RWMutex + running bool + + // State tracking + lastScanning bool +} + +// NewSignalMonitor creates a new signal monitor +func NewSignalMonitor(conn *dbus.Conn, station *Station) *SignalMonitor { + return &SignalMonitor{ + conn: conn, + station: station, + signalChan: make(chan *dbus.Signal, 100), + stopChan: make(chan struct{}), + } +} + +// OnStateChange registers a callback for state changes +func (sm *SignalMonitor) OnStateChange(callback func(state StationState, ssid string)) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.onStateChange = callback +} + +// OnScanComplete registers a callback for scan completion +func (sm *SignalMonitor) OnScanComplete(callback func()) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.onScanComplete = callback +} + +// Start begins monitoring D-Bus signals +func (sm *SignalMonitor) Start() error { + sm.mu.Lock() + if sm.running { + sm.mu.Unlock() + return nil + } + sm.running = true + sm.mu.Unlock() + + // Subscribe to PropertiesChanged signals for Station interface + stationPath := sm.station.GetPath() + + // Add signal match for PropertiesChanged on Station interface + matchOptions := []dbus.MatchOption{ + dbus.WithMatchObjectPath(stationPath), + dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), + dbus.WithMatchMember("PropertiesChanged"), + } + + if err := sm.conn.AddMatchSignal(matchOptions...); err != nil { + sm.mu.Lock() + sm.running = false + sm.mu.Unlock() + return err + } + + // Register signal channel + sm.conn.Signal(sm.signalChan) + + // Get initial scanning state + scanning, err := sm.station.IsScanning() + if err == nil { + sm.lastScanning = scanning + } + + // Start monitoring goroutine + go sm.monitor() + + log.Printf("D-Bus signal monitoring started for station %s", stationPath) + return nil +} + +// Stop stops monitoring D-Bus signals +func (sm *SignalMonitor) Stop() { + sm.mu.Lock() + if !sm.running { + sm.mu.Unlock() + return + } + sm.running = false + sm.mu.Unlock() + + // Signal stop + close(sm.stopChan) + + // Remove signal channel + sm.conn.RemoveSignal(sm.signalChan) + + log.Printf("D-Bus signal monitoring stopped") +} + +// monitor is the main signal processing loop +func (sm *SignalMonitor) monitor() { + for { + select { + case sig := <-sm.signalChan: + sm.handleSignal(sig) + case <-sm.stopChan: + return + } + } +} + +// handleSignal processes a D-Bus signal +func (sm *SignalMonitor) handleSignal(sig *dbus.Signal) { + // Only process PropertiesChanged signals + if sig.Name != "org.freedesktop.DBus.Properties.PropertiesChanged" { + return + } + + // Verify signal is from Station interface + if len(sig.Body) < 2 { + return + } + + interfaceName, ok := sig.Body[0].(string) + if !ok || interfaceName != StationInterface { + return + } + + // Parse changed properties + changedProps, ok := sig.Body[1].(map[string]dbus.Variant) + if !ok { + return + } + + // Check for State property change + if stateVariant, ok := changedProps["State"]; ok { + if state, ok := stateVariant.Value().(string); ok { + sm.handleStateChange(StationState(state)) + } + } + + // Check for Scanning property change + if scanningVariant, ok := changedProps["Scanning"]; ok { + if scanning, ok := scanningVariant.Value().(bool); ok { + sm.handleScanningChange(scanning) + } + } + + // Check for ConnectedNetwork property change + if _, ok := changedProps["ConnectedNetwork"]; ok { + // Network connection changed, trigger state update + sm.handleConnectionChange() + } +} + +// handleStateChange processes a state change +func (sm *SignalMonitor) handleStateChange(state StationState) { + sm.mu.RLock() + callback := sm.onStateChange + sm.mu.RUnlock() + + if callback == nil { + return + } + + // Get connected SSID if connected + ssid := "" + if state == StateConnected { + network, err := sm.station.GetConnectedNetwork() + if err == nil { + props, err := network.GetProperties() + if err == nil { + ssid = props.Name + } + } + } + + callback(state, ssid) +} + +// handleScanningChange processes scanning state changes +func (sm *SignalMonitor) handleScanningChange(scanning bool) { + // Detect scan completion (transition from true to false) + if sm.lastScanning && !scanning { + sm.mu.RLock() + callback := sm.onScanComplete + sm.mu.RUnlock() + + if callback != nil { + callback() + } + } + + sm.lastScanning = scanning +} + +// handleConnectionChange processes connection changes +func (sm *SignalMonitor) handleConnectionChange() { + // Get current state and trigger state change callback + state, err := sm.station.GetState() + if err != nil { + return + } + + sm.handleStateChange(state) +} diff --git a/internal/wifi/iwd/station.go b/internal/wifi/iwd/station.go new file mode 100644 index 0000000..54014fd --- /dev/null +++ b/internal/wifi/iwd/station.go @@ -0,0 +1,142 @@ +package iwd + +import ( + "fmt" + + "github.com/godbus/dbus/v5" +) + +// Station represents an iwd Station interface +type Station struct { + path dbus.ObjectPath + conn *dbus.Conn + obj dbus.BusObject +} + +// NewStation creates a new Station instance +func NewStation(conn *dbus.Conn, path dbus.ObjectPath) *Station { + return &Station{ + path: path, + conn: conn, + obj: conn.Object(Service, path), + } +} + +// Scan triggers a network scan +func (s *Station) Scan() error { + err := s.obj.Call(StationInterface+".Scan", 0).Err + if err != nil { + return fmt.Errorf("scan failed: %v", err) + } + return nil +} + +// IsScanning checks if a scan is currently in progress +func (s *Station) IsScanning() (bool, error) { + prop, err := s.obj.GetProperty(StationInterface + ".Scanning") + if err != nil { + return false, fmt.Errorf("failed to get Scanning property: %v", err) + } + + scanning, ok := prop.Value().(bool) + if !ok { + return false, fmt.Errorf("Scanning property is not a boolean") + } + + return scanning, nil +} + +// GetOrderedNetworks returns networks sorted by signal strength +func (s *Station) GetOrderedNetworks() ([]NetworkInfo, error) { + var result []struct { + Path dbus.ObjectPath + Signal int16 + } + + err := s.obj.Call(StationInterface+".GetOrderedNetworks", 0).Store(&result) + if err != nil { + return nil, fmt.Errorf("failed to get ordered networks: %v", err) + } + + networks := make([]NetworkInfo, len(result)) + for i, r := range result { + networks[i] = NetworkInfo{ + Path: r.Path, + Signal: r.Signal, + } + } + + return networks, nil +} + +// GetState returns the current connection state +func (s *Station) GetState() (StationState, error) { + prop, err := s.obj.GetProperty(StationInterface + ".State") + if err != nil { + return "", fmt.Errorf("failed to get State property: %v", err) + } + + state, ok := prop.Value().(string) + if !ok { + return "", fmt.Errorf("State property is not a string") + } + + return StationState(state), nil +} + +// Disconnect disconnects from the current network +func (s *Station) Disconnect() error { + err := s.obj.Call(StationInterface+".Disconnect", 0).Err + if err != nil { + return fmt.Errorf("disconnect failed: %v", err) + } + return nil +} + +// GetNetwork finds and returns a Network object by SSID +func (s *Station) GetNetwork(ssid string) (*Network, error) { + networks, err := s.GetOrderedNetworks() + if err != nil { + return nil, err + } + + // Find the network with matching SSID + for _, netInfo := range networks { + network := NewNetwork(s.conn, netInfo.Path) + props, err := network.GetProperties() + if err != nil { + continue + } + + if props.Name == ssid { + return network, nil + } + } + + return nil, fmt.Errorf("network '%s' not found", ssid) +} + +// GetConnectedNetwork returns the currently connected network +func (s *Station) GetConnectedNetwork() (*Network, error) { + prop, err := s.obj.GetProperty(StationInterface + ".ConnectedNetwork") + if err != nil { + return nil, fmt.Errorf("failed to get ConnectedNetwork property: %v", err) + } + + path, ok := prop.Value().(dbus.ObjectPath) + if !ok { + return nil, fmt.Errorf("ConnectedNetwork property is not an ObjectPath") + } + + // Check if path is empty (not connected) + if path == "/" || path == "" { + return nil, fmt.Errorf("not connected to any network") + } + + return NewNetwork(s.conn, path), nil +} + +// GetPath returns the D-Bus object path for this station +func (s *Station) GetPath() dbus.ObjectPath { + return s.path +} diff --git a/internal/wifi/iwd/types.go b/internal/wifi/iwd/types.go new file mode 100644 index 0000000..76624c4 --- /dev/null +++ b/internal/wifi/iwd/types.go @@ -0,0 +1,38 @@ +package iwd + +import "github.com/godbus/dbus/v5" + +const ( + // D-Bus service and interfaces + Service = "net.connman.iwd" + ManagerPath = "/" + DeviceInterface = "net.connman.iwd.Device" + StationInterface = "net.connman.iwd.Station" + NetworkInterface = "net.connman.iwd.Network" + AgentInterface = "net.connman.iwd.Agent" + AgentManagerInterface = "net.connman.iwd.AgentManager" +) + +// NetworkInfo represents a network with its signal strength +type NetworkInfo struct { + Path dbus.ObjectPath + Signal int16 // 100 * dBm (0 to -10000) +} + +// NetworkProperties holds network properties +type NetworkProperties struct { + Name string // SSID + Type string // "open", "wep", "psk", "8021x" + Connected bool +} + +// StationState represents the connection state +type StationState string + +const ( + StateConnected StationState = "connected" + StateDisconnected StationState = "disconnected" + StateConnecting StationState = "connecting" + StateDisconnecting StationState = "disconnecting" + StateRoaming StationState = "roaming" +) diff --git a/internal/wifi/wifi.go b/internal/wifi/wifi.go new file mode 100644 index 0000000..dac04c3 --- /dev/null +++ b/internal/wifi/wifi.go @@ -0,0 +1,242 @@ +package wifi + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/gorilla/websocket" + "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/wifi/backend" +) + +var ( + wifiBackend backend.WiFiBackend + wifiBroadcaster *WifiBroadcaster +) + +// Initialize initializes the WiFi service with the specified backend +func Initialize(interfaceName string, backendName string) error { + // Create the appropriate backend using the factory + var err error + wifiBackend, err = createBackend(backendName) + if err != nil { + return err + } + + // Initialize the backend + return wifiBackend.Initialize(interfaceName) +} + +// Close closes the backend connection +func Close() { + if wifiBackend != nil { + wifiBackend.Close() + } +} + +// GetCachedNetworks returns previously discovered networks without triggering a scan +func GetCachedNetworks() ([]models.WiFiNetwork, error) { + // Get ordered networks from backend + backendNetworks, err := wifiBackend.GetOrderedNetworks() + if err != nil { + return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err) + } + + // Convert backend networks to models + networks := make([]models.WiFiNetwork, 0, len(backendNetworks)) + for _, backendNet := range backendNetworks { + wifiNet := models.WiFiNetwork{ + SSID: backendNet.SSID, + Signal: signalToStrength(int(backendNet.SignalDBm)), + Security: mapSecurityType(backendNet.SecurityType), + BSSID: backendNet.BSSID, + Channel: 0, // Not yet exposed by backends + } + networks = append(networks, wifiNet) + } + + // Sort by signal strength (descending) + sort.Slice(networks, func(i, j int) bool { + return networks[i].Signal > networks[j].Signal + }) + + return networks, nil +} + +// ScanNetworks scans for available WiFi networks +func ScanNetworks() ([]models.WiFiNetwork, error) { + // Check if already scanning + scanning, err := wifiBackend.IsScanning() + if err == nil && scanning { + time.Sleep(3 * time.Second) + } else { + // Trigger scan + err := wifiBackend.ScanNetworks() + if err != nil && !strings.Contains(err.Error(), "rejected") { + return nil, fmt.Errorf("erreur lors du scan: %v", err) + } + time.Sleep(2 * time.Second) + } + + // Get ordered networks from backend + backendNetworks, err := wifiBackend.GetOrderedNetworks() + if err != nil { + return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err) + } + + // Convert backend networks to models + networks := make([]models.WiFiNetwork, 0, len(backendNetworks)) + for _, backendNet := range backendNetworks { + wifiNet := models.WiFiNetwork{ + SSID: backendNet.SSID, + Signal: signalToStrength(int(backendNet.SignalDBm)), + Security: mapSecurityType(backendNet.SecurityType), + BSSID: backendNet.BSSID, + Channel: 0, // Not yet exposed by backends + } + networks = append(networks, wifiNet) + } + + // Sort by signal strength (descending) + sort.Slice(networks, func(i, j int) bool { + return networks[i].Signal > networks[j].Signal + }) + + // Broadcast to WebSocket clients if available + if wifiBroadcaster != nil { + wifiBroadcaster.BroadcastScanUpdate(networks) + } + + return networks, nil +} + +// Connect connects to a WiFi network +func Connect(ssid, password string) error { + // Use backend to connect + if err := wifiBackend.Connect(ssid, password); err != nil { + return err + } + + // Poll for connection + for i := 0; i < 20; i++ { + time.Sleep(500 * time.Millisecond) + if IsConnected() { + return nil + } + } + + return fmt.Errorf("timeout lors de la connexion") +} + +// Disconnect disconnects from the current WiFi network +func Disconnect() error { + return wifiBackend.Disconnect() +} + +// IsConnected checks if WiFi is connected +func IsConnected() bool { + state, err := wifiBackend.GetConnectionState() + if err != nil { + return false + } + return state == backend.StateConnected +} + +// GetConnectedSSID returns the SSID of the currently connected network +func GetConnectedSSID() string { + return wifiBackend.GetConnectedSSID() +} + +// GetConnectionState returns the current WiFi connection state +func GetConnectionState() string { + state, err := wifiBackend.GetConnectionState() + if err != nil { + return string(backend.StateDisconnected) + } + return string(state) +} + +// StartEventMonitoring initializes signal monitoring and WebSocket broadcasting +func StartEventMonitoring() error { + // Initialize broadcaster + wifiBroadcaster = NewWifiBroadcaster() + + // Set up callbacks + callbacks := backend.EventCallbacks{ + OnStateChange: handleStateChange, + OnScanComplete: handleScanComplete, + } + + // Start backend monitoring + return wifiBackend.StartEventMonitoring(callbacks) +} + +// StopEventMonitoring stops signal monitoring +func StopEventMonitoring() { + if wifiBackend != nil { + wifiBackend.StopEventMonitoring() + } +} + +// RegisterWebSocketClient registers a new WebSocket client for WiFi events +func RegisterWebSocketClient(conn *websocket.Conn) { + if wifiBroadcaster != nil { + wifiBroadcaster.RegisterClient(conn) + } +} + +// UnregisterWebSocketClient removes a WebSocket client +func UnregisterWebSocketClient(conn *websocket.Conn) { + if wifiBroadcaster != nil { + wifiBroadcaster.UnregisterClient(conn) + } +} + +// handleStateChange is called when WiFi connection state changes +func handleStateChange(newState backend.ConnectionState, connectedSSID string) { + if wifiBroadcaster != nil { + wifiBroadcaster.BroadcastStateChange(string(newState), connectedSSID) + } +} + +// handleScanComplete is called when a WiFi scan completes +func handleScanComplete() { + // Get updated network list + networks, err := GetCachedNetworks() + if err == nil && wifiBroadcaster != nil { + wifiBroadcaster.BroadcastScanUpdate(networks) + } +} + +// mapSecurityType maps backend security types to display format +func mapSecurityType(securityType string) string { + switch securityType { + case "open": + return "Open" + case "wep": + return "WEP" + case "psk": + return "WPA2" + case "8021x": + return "WPA2" + default: + return "WPA2" + } +} + +// signalToStrength converts signal level (dBm) to strength (1-5) +func signalToStrength(level int) int { + if level >= -30 { + return 5 + } else if level >= -50 { + return 4 + } else if level >= -60 { + return 3 + } else if level >= -70 { + return 2 + } else { + return 1 + } +} diff --git a/internal/wifi/wpasupplicant/backend.go b/internal/wifi/wpasupplicant/backend.go new file mode 100644 index 0000000..406b8f5 --- /dev/null +++ b/internal/wifi/wpasupplicant/backend.go @@ -0,0 +1,265 @@ +package wpasupplicant + +import ( + "fmt" + "time" + + "github.com/godbus/dbus/v5" + "github.com/nemunaire/repeater/internal/wifi/backend" +) + +// WPABackend implements the WiFiBackend interface for wpa_supplicant +type WPABackend struct { + conn *dbus.Conn + wpasupplicant dbus.BusObject + iface *WPAInterface + signalMonitor *SignalMonitor + interfaceName string + currentNetwork dbus.ObjectPath +} + +// NewWPABackend creates a new wpa_supplicant backend instance +func NewWPABackend() *WPABackend { + return &WPABackend{} +} + +// Initialize initializes the wpa_supplicant backend with the given interface name +func (b *WPABackend) Initialize(interfaceName string) error { + b.interfaceName = interfaceName + var err error + + // Connect to D-Bus + b.conn, err = dbus.SystemBus() + if err != nil { + return fmt.Errorf("failed to connect to D-Bus: %v", err) + } + + // Get wpa_supplicant root object + b.wpasupplicant = b.conn.Object(Service, dbus.ObjectPath(RootPath)) + + // Get interface path for the given interface name + interfacePath, err := b.getInterfacePath(interfaceName) + if err != nil { + return fmt.Errorf("failed to get interface for %s: %v", interfaceName, err) + } + + b.iface = NewWPAInterface(b.conn, interfacePath) + + return nil +} + +// getInterfacePath gets or creates the wpa_supplicant Interface object path +func (b *WPABackend) getInterfacePath(interfaceName string) (dbus.ObjectPath, error) { + var interfacePath dbus.ObjectPath + + // Try to get existing interface + err := b.wpasupplicant.Call(Service+".GetInterface", 0, interfaceName).Store(&interfacePath) + if err == nil { + return interfacePath, nil + } + + // Interface doesn't exist, create it + args := map[string]dbus.Variant{ + "Ifname": dbus.MakeVariant(interfaceName), + } + + err = b.wpasupplicant.Call(Service+".CreateInterface", 0, args).Store(&interfacePath) + if err != nil { + return "", fmt.Errorf("failed to create interface: %v", err) + } + + return interfacePath, nil +} + +// Close closes the D-Bus connection +func (b *WPABackend) Close() error { + if b.conn != nil { + b.conn.Close() + } + return nil +} + +// ScanNetworks triggers a network scan +func (b *WPABackend) ScanNetworks() error { + err := b.iface.Scan("active") + if err != nil { + return fmt.Errorf("failed to trigger scan: %v", err) + } + return nil +} + +// GetOrderedNetworks returns networks sorted by signal strength in backend-agnostic format +func (b *WPABackend) GetOrderedNetworks() ([]backend.BackendNetwork, error) { + // Get BSS list + bssPaths, err := b.iface.GetBSSs() + if err != nil { + return nil, fmt.Errorf("failed to get BSSs: %v", err) + } + + var networks []backend.BackendNetwork + seenSSIDs := make(map[string]bool) + + // Iterate through BSSs and collect network info + for _, bssPath := range bssPaths { + bss := NewBSS(b.conn, bssPath) + props, err := bss.GetProperties() + if err != nil { + continue + } + + ssid := string(props.SSID) + if ssid == "" || seenSSIDs[ssid] { + continue + } + seenSSIDs[ssid] = true + + // Get BSSID string + bssidStr, err := bss.GetBSSIDString() + if err != nil { + bssidStr = "" + } + + // Convert to backend-agnostic format + backendNet := backend.BackendNetwork{ + SSID: ssid, + SignalDBm: props.Signal, + SecurityType: props.DetermineSecurityType(), + BSSID: bssidStr, + Frequency: props.Frequency, + } + + networks = append(networks, backendNet) + } + + // Sort by signal strength (descending) + // Note: This is a simple bubble sort for demonstration + // In production, use sort.Slice + for i := 0; i < len(networks); i++ { + for j := i + 1; j < len(networks); j++ { + if networks[j].SignalDBm > networks[i].SignalDBm { + networks[i], networks[j] = networks[j], networks[i] + } + } + } + + return networks, nil +} + +// IsScanning checks if a scan is currently in progress +func (b *WPABackend) IsScanning() (bool, error) { + return b.iface.GetScanning() +} + +// Connect connects to a WiFi network +func (b *WPABackend) Connect(ssid, password string) error { + // Create network configuration + config := make(map[string]interface{}) + config["ssid"] = fmt.Sprintf("\"%s\"", ssid) // wpa_supplicant expects quoted SSID + + if password != "" { + // For WPA/WPA2-PSK networks + config["psk"] = fmt.Sprintf("\"%s\"", password) + } else { + // For open networks + config["key_mgmt"] = "NONE" + } + + // Add network + networkPath, err := b.iface.AddNetwork(config) + if err != nil { + return fmt.Errorf("failed to add network: %v", err) + } + + // Store current network path for cleanup + b.currentNetwork = networkPath + + // Select (connect to) the network + err = b.iface.SelectNetwork(networkPath) + if err != nil { + // Clean up network on failure + b.iface.RemoveNetwork(networkPath) + return fmt.Errorf("failed to select network: %v", err) + } + + return nil +} + +// Disconnect disconnects from the current WiFi network +func (b *WPABackend) Disconnect() error { + // Disconnect from current network + if err := b.iface.Disconnect(); err != nil { + return fmt.Errorf("failed to disconnect: %v", err) + } + + // Remove the network configuration if we have one + if b.currentNetwork != "" && b.currentNetwork != "/" { + b.iface.RemoveNetwork(b.currentNetwork) + b.currentNetwork = "" + } + + return nil +} + +// GetConnectionState returns the current WiFi connection state +func (b *WPABackend) GetConnectionState() (backend.ConnectionState, error) { + state, err := b.iface.GetState() + if err != nil { + return backend.StateDisconnected, err + } + return mapWPAState(state), nil +} + +// GetConnectedSSID returns the SSID of the currently connected network +func (b *WPABackend) GetConnectedSSID() string { + // Get current BSS + bssPath, err := b.iface.GetCurrentBSS() + if err != nil || bssPath == "/" { + return "" + } + + // Get BSS object + bss := NewBSS(b.conn, bssPath) + ssid, err := bss.GetSSIDString() + if err != nil { + return "" + } + + return ssid +} + +// StartEventMonitoring starts monitoring WiFi events +func (b *WPABackend) StartEventMonitoring(callbacks backend.EventCallbacks) error { + // Create signal monitor + b.signalMonitor = NewSignalMonitor(b.conn, b.iface) + + // Start monitoring + return b.signalMonitor.Start(callbacks) +} + +// StopEventMonitoring stops monitoring WiFi events +func (b *WPABackend) StopEventMonitoring() { + if b.signalMonitor != nil { + b.signalMonitor.Stop() + } +} + +// Wait for scan to complete (helper method) +func (b *WPABackend) waitForScanComplete(timeout time.Duration) error { + start := time.Now() + for { + if time.Since(start) > timeout { + return fmt.Errorf("scan timeout") + } + + scanning, err := b.iface.GetScanning() + if err != nil { + return err + } + + if !scanning { + return nil + } + + time.Sleep(100 * time.Millisecond) + } +} diff --git a/internal/wifi/wpasupplicant/bss.go b/internal/wifi/wpasupplicant/bss.go new file mode 100644 index 0000000..799e9f9 --- /dev/null +++ b/internal/wifi/wpasupplicant/bss.go @@ -0,0 +1,157 @@ +package wpasupplicant + +import ( + "fmt" + + "github.com/godbus/dbus/v5" +) + +// BSS represents a wpa_supplicant BSS (Basic Service Set) object +type BSS struct { + path dbus.ObjectPath + conn *dbus.Conn + obj dbus.BusObject +} + +// BSSProperties holds the properties of a BSS +type BSSProperties struct { + SSID []byte + BSSID []byte + Signal int16 // Signal strength in dBm + Frequency uint32 // Frequency in MHz + Privacy bool // Whether encryption is enabled + RSN map[string]dbus.Variant + WPA map[string]dbus.Variant +} + +// NewBSS creates a new BSS instance +func NewBSS(conn *dbus.Conn, path dbus.ObjectPath) *BSS { + return &BSS{ + path: path, + conn: conn, + obj: conn.Object(Service, path), + } +} + +// GetProperties returns all properties of the BSS +func (b *BSS) GetProperties() (*BSSProperties, error) { + props := &BSSProperties{} + + // Get SSID + if ssidProp, err := b.obj.GetProperty(BSSInterface + ".SSID"); err == nil { + if ssid, ok := ssidProp.Value().([]byte); ok { + props.SSID = ssid + } + } + + // Get BSSID + if bssidProp, err := b.obj.GetProperty(BSSInterface + ".BSSID"); err == nil { + if bssid, ok := bssidProp.Value().([]byte); ok { + props.BSSID = bssid + } + } + + // Get Signal + if signalProp, err := b.obj.GetProperty(BSSInterface + ".Signal"); err == nil { + if signal, ok := signalProp.Value().(int16); ok { + props.Signal = signal + } + } + + // Get Frequency + if freqProp, err := b.obj.GetProperty(BSSInterface + ".Frequency"); err == nil { + if freq, ok := freqProp.Value().(uint16); ok { + props.Frequency = uint32(freq) + } + } + + // Get Privacy + if privacyProp, err := b.obj.GetProperty(BSSInterface + ".Privacy"); err == nil { + if privacy, ok := privacyProp.Value().(bool); ok { + props.Privacy = privacy + } + } + + // Get RSN (WPA2) information + if rsnProp, err := b.obj.GetProperty(BSSInterface + ".RSN"); err == nil { + if rsn, ok := rsnProp.Value().(map[string]dbus.Variant); ok { + props.RSN = rsn + } + } + + // Get WPA information + if wpaProp, err := b.obj.GetProperty(BSSInterface + ".WPA"); err == nil { + if wpa, ok := wpaProp.Value().(map[string]dbus.Variant); ok { + props.WPA = wpa + } + } + + return props, nil +} + +// GetSSIDString returns the SSID as a string +func (b *BSS) GetSSIDString() (string, error) { + prop, err := b.obj.GetProperty(BSSInterface + ".SSID") + if err != nil { + return "", fmt.Errorf("failed to get SSID property: %v", err) + } + + ssid, ok := prop.Value().([]byte) + if !ok { + return "", fmt.Errorf("SSID property is not a byte array") + } + + return string(ssid), nil +} + +// GetBSSIDString returns the BSSID as a formatted MAC address string +func (b *BSS) GetBSSIDString() (string, error) { + prop, err := b.obj.GetProperty(BSSInterface + ".BSSID") + if err != nil { + return "", fmt.Errorf("failed to get BSSID property: %v", err) + } + + bssid, ok := prop.Value().([]byte) + if !ok || len(bssid) != 6 { + return "", fmt.Errorf("BSSID property is not a valid MAC address") + } + + return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", + bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]), nil +} + +// GetSignal returns the signal strength in dBm +func (b *BSS) GetSignal() (int16, error) { + prop, err := b.obj.GetProperty(BSSInterface + ".Signal") + if err != nil { + return 0, fmt.Errorf("failed to get Signal property: %v", err) + } + + signal, ok := prop.Value().(int16) + if !ok { + return 0, fmt.Errorf("Signal property is not an int16") + } + + return signal, nil +} + +// DetermineSecurityType determines the security type based on BSS properties +func (p *BSSProperties) DetermineSecurityType() string { + // Check for WPA2 (RSN) + if len(p.RSN) > 0 { + return "psk" + } + + // Check for WPA + if len(p.WPA) > 0 { + return "psk" + } + + // Check for WEP (privacy but no WPA/RSN) + if p.Privacy { + return "wep" + } + + // Open network + return "open" +} diff --git a/internal/wifi/wpasupplicant/interface.go b/internal/wifi/wpasupplicant/interface.go new file mode 100644 index 0000000..ecc9fbb --- /dev/null +++ b/internal/wifi/wpasupplicant/interface.go @@ -0,0 +1,146 @@ +package wpasupplicant + +import ( + "fmt" + + "github.com/godbus/dbus/v5" +) + +// WPAInterface represents a wpa_supplicant Interface object +type WPAInterface struct { + path dbus.ObjectPath + conn *dbus.Conn + obj dbus.BusObject +} + +// NewWPAInterface creates a new WPAInterface instance +func NewWPAInterface(conn *dbus.Conn, path dbus.ObjectPath) *WPAInterface { + return &WPAInterface{ + path: path, + conn: conn, + obj: conn.Object(Service, path), + } +} + +// Scan triggers a network scan +func (i *WPAInterface) Scan(scanType string) error { + args := map[string]interface{}{ + "Type": scanType, // "active" or "passive" + } + + err := i.obj.Call(InterfaceInterface+".Scan", 0, args).Err + if err != nil { + return fmt.Errorf("scan failed: %v", err) + } + return nil +} + +// GetBSSs returns a list of BSS (Basic Service Set) object paths +func (i *WPAInterface) GetBSSs() ([]dbus.ObjectPath, error) { + prop, err := i.obj.GetProperty(InterfaceInterface + ".BSSs") + if err != nil { + return nil, fmt.Errorf("failed to get BSSs property: %v", err) + } + + bsss, ok := prop.Value().([]dbus.ObjectPath) + if !ok { + return nil, fmt.Errorf("BSSs property is not an array of ObjectPath") + } + + return bsss, nil +} + +// GetState returns the current connection state +func (i *WPAInterface) GetState() (WPAState, error) { + prop, err := i.obj.GetProperty(InterfaceInterface + ".State") + if err != nil { + return "", fmt.Errorf("failed to get State property: %v", err) + } + + state, ok := prop.Value().(string) + if !ok { + return "", fmt.Errorf("State property is not a string") + } + + return WPAState(state), nil +} + +// GetCurrentBSS returns the currently connected BSS object path +func (i *WPAInterface) GetCurrentBSS() (dbus.ObjectPath, error) { + prop, err := i.obj.GetProperty(InterfaceInterface + ".CurrentBSS") + if err != nil { + return "", fmt.Errorf("failed to get CurrentBSS property: %v", err) + } + + bss, ok := prop.Value().(dbus.ObjectPath) + if !ok { + return "", fmt.Errorf("CurrentBSS property is not an ObjectPath") + } + + return bss, nil +} + +// AddNetwork creates a new network configuration +func (i *WPAInterface) AddNetwork(config map[string]interface{}) (dbus.ObjectPath, error) { + var networkPath dbus.ObjectPath + + // Convert config to proper DBus variant format + dbusConfig := make(map[string]dbus.Variant) + for key, value := range config { + dbusConfig[key] = dbus.MakeVariant(value) + } + + err := i.obj.Call(InterfaceInterface+".AddNetwork", 0, dbusConfig).Store(&networkPath) + if err != nil { + return "", fmt.Errorf("failed to add network: %v", err) + } + + return networkPath, nil +} + +// SelectNetwork connects to a network +func (i *WPAInterface) SelectNetwork(networkPath dbus.ObjectPath) error { + err := i.obj.Call(InterfaceInterface+".SelectNetwork", 0, networkPath).Err + if err != nil { + return fmt.Errorf("failed to select network: %v", err) + } + return nil +} + +// RemoveNetwork removes a network configuration +func (i *WPAInterface) RemoveNetwork(networkPath dbus.ObjectPath) error { + err := i.obj.Call(InterfaceInterface+".RemoveNetwork", 0, networkPath).Err + if err != nil { + return fmt.Errorf("failed to remove network: %v", err) + } + return nil +} + +// Disconnect disconnects from the current network +func (i *WPAInterface) Disconnect() error { + err := i.obj.Call(InterfaceInterface+".Disconnect", 0).Err + if err != nil { + return fmt.Errorf("disconnect failed: %v", err) + } + return nil +} + +// GetPath returns the D-Bus object path for this interface +func (i *WPAInterface) GetPath() dbus.ObjectPath { + return i.path +} + +// GetScanning returns whether a scan is currently in progress +func (i *WPAInterface) GetScanning() (bool, error) { + prop, err := i.obj.GetProperty(InterfaceInterface + ".Scanning") + if err != nil { + return false, fmt.Errorf("failed to get Scanning property: %v", err) + } + + scanning, ok := prop.Value().(bool) + if !ok { + return false, fmt.Errorf("Scanning property is not a boolean") + } + + return scanning, nil +} diff --git a/internal/wifi/wpasupplicant/network.go b/internal/wifi/wpasupplicant/network.go new file mode 100644 index 0000000..3152a6b --- /dev/null +++ b/internal/wifi/wpasupplicant/network.go @@ -0,0 +1,41 @@ +package wpasupplicant + +import ( + "github.com/godbus/dbus/v5" +) + +// Network represents a wpa_supplicant Network configuration object +type Network struct { + path dbus.ObjectPath + conn *dbus.Conn + obj dbus.BusObject +} + +// NewNetwork creates a new Network instance +func NewNetwork(conn *dbus.Conn, path dbus.ObjectPath) *Network { + return &Network{ + path: path, + conn: conn, + obj: conn.Object(Service, path), + } +} + +// GetPath returns the D-Bus object path for this network +func (n *Network) GetPath() dbus.ObjectPath { + return n.path +} + +// GetProperties returns properties of the network configuration +func (n *Network) GetProperties() (map[string]dbus.Variant, error) { + prop, err := n.obj.GetProperty(NetworkInterface + ".Properties") + if err != nil { + return nil, err + } + + props, ok := prop.Value().(map[string]dbus.Variant) + if !ok { + return nil, nil + } + + return props, nil +} diff --git a/internal/wifi/wpasupplicant/signals.go b/internal/wifi/wpasupplicant/signals.go new file mode 100644 index 0000000..a17a292 --- /dev/null +++ b/internal/wifi/wpasupplicant/signals.go @@ -0,0 +1,236 @@ +package wpasupplicant + +import ( + "log" + "sync" + + "github.com/godbus/dbus/v5" + "github.com/nemunaire/repeater/internal/wifi/backend" +) + +// SignalMonitor monitors D-Bus signals from wpa_supplicant +type SignalMonitor struct { + conn *dbus.Conn + iface *WPAInterface + callbacks backend.EventCallbacks + + // Signal channel + signalChan chan *dbus.Signal + + // Control + stopChan chan struct{} + mu sync.RWMutex + running bool + + // State tracking + lastState WPAState +} + +// NewSignalMonitor creates a new signal monitor +func NewSignalMonitor(conn *dbus.Conn, iface *WPAInterface) *SignalMonitor { + return &SignalMonitor{ + conn: conn, + iface: iface, + signalChan: make(chan *dbus.Signal, 100), + stopChan: make(chan struct{}), + } +} + +// Start begins monitoring D-Bus signals +func (sm *SignalMonitor) Start(callbacks backend.EventCallbacks) error { + sm.mu.Lock() + if sm.running { + sm.mu.Unlock() + return nil + } + sm.running = true + sm.callbacks = callbacks + sm.mu.Unlock() + + interfacePath := sm.iface.GetPath() + + // Add signal match for PropertiesChanged on Interface + matchOptions := []dbus.MatchOption{ + dbus.WithMatchObjectPath(interfacePath), + dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), + dbus.WithMatchMember("PropertiesChanged"), + } + + if err := sm.conn.AddMatchSignal(matchOptions...); err != nil { + sm.mu.Lock() + sm.running = false + sm.mu.Unlock() + return err + } + + // Add signal match for ScanDone + scanDoneOptions := []dbus.MatchOption{ + dbus.WithMatchObjectPath(interfacePath), + dbus.WithMatchInterface(InterfaceInterface), + dbus.WithMatchMember("ScanDone"), + } + + if err := sm.conn.AddMatchSignal(scanDoneOptions...); err != nil { + sm.mu.Lock() + sm.running = false + sm.mu.Unlock() + return err + } + + // Register signal channel + sm.conn.Signal(sm.signalChan) + + // Get initial state + state, err := sm.iface.GetState() + if err == nil { + sm.lastState = state + } + + // Start monitoring goroutine + go sm.monitor() + + log.Printf("D-Bus signal monitoring started for wpa_supplicant interface %s", interfacePath) + return nil +} + +// Stop stops monitoring D-Bus signals +func (sm *SignalMonitor) Stop() { + sm.mu.Lock() + if !sm.running { + sm.mu.Unlock() + return + } + sm.running = false + sm.mu.Unlock() + + // Signal stop + close(sm.stopChan) + + // Remove signal channel + sm.conn.RemoveSignal(sm.signalChan) + + log.Printf("D-Bus signal monitoring stopped for wpa_supplicant") +} + +// monitor is the main signal processing loop +func (sm *SignalMonitor) monitor() { + for { + select { + case sig := <-sm.signalChan: + sm.handleSignal(sig) + case <-sm.stopChan: + return + } + } +} + +// handleSignal processes a D-Bus signal +func (sm *SignalMonitor) handleSignal(sig *dbus.Signal) { + // Handle ScanDone signal + if sig.Name == InterfaceInterface+".ScanDone" { + sm.handleScanDone(sig) + return + } + + // Handle PropertiesChanged signals + if sig.Name != "org.freedesktop.DBus.Properties.PropertiesChanged" { + return + } + + // Verify signal is from Interface + if len(sig.Body) < 2 { + return + } + + interfaceName, ok := sig.Body[0].(string) + if !ok || interfaceName != InterfaceInterface { + return + } + + // Parse changed properties + changedProps, ok := sig.Body[1].(map[string]dbus.Variant) + if !ok { + return + } + + // Check for State property change + if stateVariant, ok := changedProps["State"]; ok { + if state, ok := stateVariant.Value().(string); ok { + sm.handleStateChange(WPAState(state)) + } + } + + // Check for CurrentBSS property change (connection status) + if _, ok := changedProps["CurrentBSS"]; ok { + // BSS changed, trigger state update + sm.handleConnectionChange() + } +} + +// handleStateChange processes a state change +func (sm *SignalMonitor) handleStateChange(state WPAState) { + sm.lastState = state + + sm.mu.RLock() + callback := sm.callbacks.OnStateChange + sm.mu.RUnlock() + + if callback == nil { + return + } + + // Map wpa_supplicant state to backend state + backendState := mapWPAState(state) + + // Get connected SSID if connected + ssid := "" + if backendState == backend.StateConnected { + if bssPath, err := sm.iface.GetCurrentBSS(); err == nil && bssPath != "/" { + bss := NewBSS(sm.conn, bssPath) + if ssidStr, err := bss.GetSSIDString(); err == nil { + ssid = ssidStr + } + } + } + + callback(backendState, ssid) +} + +// handleConnectionChange processes connection changes +func (sm *SignalMonitor) handleConnectionChange() { + // Get current state and trigger state change callback + state, err := sm.iface.GetState() + if err != nil { + return + } + + sm.handleStateChange(state) +} + +// handleScanDone processes scan completion +func (sm *SignalMonitor) handleScanDone(sig *dbus.Signal) { + sm.mu.RLock() + callback := sm.callbacks.OnScanComplete + sm.mu.RUnlock() + + if callback != nil { + callback() + } +} + +// mapWPAState maps wpa_supplicant states to backend-agnostic states +func mapWPAState(state WPAState) backend.ConnectionState { + switch state { + case StateCompleted: + return backend.StateConnected + case StateAuthenticating, StateAssociating, StateAssociated, State4WayHandshake, StateGroupHandshake: + return backend.StateConnecting + case StateDisconnected, StateInactive, StateInterfaceDisabled: + return backend.StateDisconnected + case StateScanning: + // Keep as disconnected if just scanning + return backend.StateDisconnected + default: + return backend.StateDisconnected + } +} diff --git a/internal/wifi/wpasupplicant/types.go b/internal/wifi/wpasupplicant/types.go new file mode 100644 index 0000000..85fec0f --- /dev/null +++ b/internal/wifi/wpasupplicant/types.go @@ -0,0 +1,27 @@ +package wpasupplicant + +const ( + // D-Bus service and interfaces + Service = "fi.w1.wpa_supplicant1" + RootPath = "/fi/w1/wpa_supplicant1" + InterfaceInterface = "fi.w1.wpa_supplicant1.Interface" + BSSInterface = "fi.w1.wpa_supplicant1.BSS" + NetworkInterface = "fi.w1.wpa_supplicant1.Network" +) + +// WPAState represents the wpa_supplicant connection state +type WPAState string + +const ( + // wpa_supplicant state strings + StateDisconnected WPAState = "disconnected" + StateInactive WPAState = "inactive" + StateScanning WPAState = "scanning" + StateAuthenticating WPAState = "authenticating" + StateAssociating WPAState = "associating" + StateAssociated WPAState = "associated" + State4WayHandshake WPAState = "4way_handshake" + StateGroupHandshake WPAState = "group_handshake" + StateCompleted WPAState = "completed" + StateInterfaceDisabled WPAState = "interface_disabled" +) diff --git a/main.go b/main.go deleted file mode 100644 index c8b9fe7..0000000 --- a/main.go +++ /dev/null @@ -1,826 +0,0 @@ -package main - -import ( - "bufio" - "embed" - "encoding/json" - "fmt" - "io/fs" - "log" - "net/http" - "os" - "os/exec" - "regexp" - "sort" - "strings" - "sync" - "time" - - "github.com/godbus/dbus/v5" - "github.com/gorilla/mux" - "github.com/gorilla/websocket" -) - -//go:embed all:static -var _assets embed.FS - -// Structures de données -type WiFiNetwork struct { - SSID string `json:"ssid"` - Signal int `json:"signal"` - Security string `json:"security"` - Channel int `json:"channel"` - BSSID string `json:"bssid"` -} - -type ConnectedDevice struct { - Name string `json:"name"` - Type string `json:"type"` - MAC string `json:"mac"` - IP string `json:"ip"` -} - -type HotspotConfig struct { - SSID string `json:"ssid"` - Password string `json:"password"` - Channel int `json:"channel"` -} - -type SystemStatus struct { - Connected bool `json:"connected"` - ConnectedSSID string `json:"connectedSSID"` - HotspotEnabled bool `json:"hotspotEnabled"` - ConnectedCount int `json:"connectedCount"` - DataUsage float64 `json:"dataUsage"` - Uptime int64 `json:"uptime"` - ConnectedDevices []ConnectedDevice `json:"connectedDevices"` -} - -type WiFiConnectRequest struct { - SSID string `json:"ssid"` - Password string `json:"password"` -} - -type LogEntry struct { - Timestamp time.Time `json:"timestamp"` - Source string `json:"source"` - Message string `json:"message"` -} - -// Variables globales -var ( - currentStatus SystemStatus - statusMutex sync.RWMutex - logEntries []LogEntry - logMutex sync.RWMutex - websocketClients = make(map[*websocket.Conn]bool) - clientsMutex sync.RWMutex - upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - return true - }, - } - startTime = time.Now() - dbusConn *dbus.Conn - wpaSupplicant dbus.BusObject -) - -const ( - WLAN_INTERFACE = "wlan0" - AP_INTERFACE = "wlan1" - HOSTAPD_CONF = "/etc/hostapd/hostapd.conf" - WPA_CONF = "/etc/wpa_supplicant/wpa_supplicant.conf" - - // D-Bus constantes pour wpa_supplicant - WPA_SUPPLICANT_SERVICE = "fi.w1.wpa_supplicant1" - WPA_SUPPLICANT_PATH = "/fi/w1/wpa_supplicant1" - WPA_SUPPLICANT_IFACE = "fi.w1.wpa_supplicant1" - WPA_INTERFACE_IFACE = "fi.w1.wpa_supplicant1.Interface" - WPA_BSS_IFACE = "fi.w1.wpa_supplicant1.BSS" - WPA_NETWORK_IFACE = "fi.w1.wpa_supplicant1.Network" -) - -func main() { - // Initialiser D-Bus - var err error - dbusConn, err = dbus.SystemBus() - if err != nil { - log.Fatalf("Erreur de connexion D-Bus: %v", err) - } - defer dbusConn.Close() - - wpaSupplicant = dbusConn.Object(WPA_SUPPLICANT_SERVICE, dbus.ObjectPath(WPA_SUPPLICANT_PATH)) - - // Initialiser le statut système - initializeStatus() - - // Démarrer les tâches périodiques - go periodicStatusUpdate() - go periodicDeviceUpdate() - - // Configuration du routeur - r := mux.NewRouter() - - // Routes API - api := r.PathPrefix("/api").Subrouter() - api.HandleFunc("/wifi/scan", scanWiFiHandler).Methods("GET") - api.HandleFunc("/wifi/connect", connectWiFiHandler).Methods("POST") - api.HandleFunc("/wifi/disconnect", disconnectWiFiHandler).Methods("POST") - api.HandleFunc("/hotspot/config", configureHotspotHandler).Methods("POST") - api.HandleFunc("/hotspot/toggle", toggleHotspotHandler).Methods("POST") - api.HandleFunc("/devices", getDevicesHandler).Methods("GET") - api.HandleFunc("/status", getStatusHandler).Methods("GET") - api.HandleFunc("/logs", getLogsHandler).Methods("GET") - api.HandleFunc("/logs", clearLogsHandler).Methods("DELETE") - - // WebSocket pour les logs en temps réel - r.HandleFunc("/ws/logs", websocketHandler) - - // Servir les fichiers statiques - sub, err := fs.Sub(_assets, "static") - if err != nil { - log.Fatal("Unable to cd to static/ directory:", err) - } - Assets := http.FS(sub) - r.PathPrefix("/").Handler(http.FileServer(Assets)) - - addLog("Système", "Serveur API démarré sur le port 8080") - log.Fatal(http.ListenAndServe(":8080", r)) -} - -func initializeStatus() { - statusMutex.Lock() - defer statusMutex.Unlock() - - currentStatus = SystemStatus{ - Connected: false, - ConnectedSSID: "", - HotspotEnabled: true, - ConnectedCount: 0, - DataUsage: 0.0, - Uptime: 0, - } -} - -// Handlers API - -func scanWiFiHandler(w http.ResponseWriter, r *http.Request) { - networks, err := scanWiFiNetworks() - if err != nil { - addLog("WiFi", fmt.Sprintf("Erreur lors du scan: %v", err)) - http.Error(w, "Erreur lors du scan WiFi", http.StatusInternalServerError) - return - } - - addLog("WiFi", fmt.Sprintf("Scan terminé - %d réseaux trouvés", len(networks))) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(networks) -} - -func connectWiFiHandler(w http.ResponseWriter, r *http.Request) { - var req WiFiConnectRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Données invalides", http.StatusBadRequest) - return - } - - addLog("WiFi", fmt.Sprintf("Tentative de connexion à %s", req.SSID)) - - err := connectToWiFiDBus(req.SSID, req.Password) - if err != nil { - addLog("WiFi", fmt.Sprintf("Échec de connexion: %v", err)) - http.Error(w, fmt.Sprintf("Échec de connexion: %v", err), http.StatusInternalServerError) - return - } - - statusMutex.Lock() - currentStatus.Connected = true - currentStatus.ConnectedSSID = req.SSID - statusMutex.Unlock() - - addLog("WiFi", fmt.Sprintf("Connexion réussie à %s", req.SSID)) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "success"}) -} - -func disconnectWiFiHandler(w http.ResponseWriter, r *http.Request) { - addLog("WiFi", "Tentative de déconnexion") - - err := disconnectWiFiDBus() - if err != nil { - addLog("WiFi", fmt.Sprintf("Erreur de déconnexion: %v", err)) - http.Error(w, fmt.Sprintf("Erreur de déconnexion: %v", err), http.StatusInternalServerError) - return - } - - statusMutex.Lock() - currentStatus.Connected = false - currentStatus.ConnectedSSID = "" - statusMutex.Unlock() - - addLog("WiFi", "Déconnexion réussie") - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "success"}) -} - -func configureHotspotHandler(w http.ResponseWriter, r *http.Request) { - var config HotspotConfig - if err := json.NewDecoder(r.Body).Decode(&config); err != nil { - http.Error(w, "Données invalides", http.StatusBadRequest) - return - } - - err := configureHotspot(config) - if err != nil { - addLog("Hotspot", fmt.Sprintf("Erreur de configuration: %v", err)) - http.Error(w, fmt.Sprintf("Erreur de configuration: %v", err), http.StatusInternalServerError) - return - } - - addLog("Hotspot", fmt.Sprintf("Configuration mise à jour: %s (Canal %d)", config.SSID, config.Channel)) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "success"}) -} - -func toggleHotspotHandler(w http.ResponseWriter, r *http.Request) { - statusMutex.Lock() - currentStatus.HotspotEnabled = !currentStatus.HotspotEnabled - enabled := currentStatus.HotspotEnabled - statusMutex.Unlock() - - var err error - if enabled { - err = startHotspot() - addLog("Hotspot", "Hotspot activé") - } else { - err = stopHotspot() - addLog("Hotspot", "Hotspot désactivé") - } - - if err != nil { - addLog("Hotspot", fmt.Sprintf("Erreur: %v", err)) - http.Error(w, fmt.Sprintf("Erreur: %v", err), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]bool{"enabled": enabled}) -} - -func getDevicesHandler(w http.ResponseWriter, r *http.Request) { - devices, err := getConnectedDevices() - if err != nil { - addLog("Système", fmt.Sprintf("Erreur lors de la récupération des appareils: %v", err)) - http.Error(w, "Erreur lors de la récupération des appareils", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(devices) -} - -func getStatusHandler(w http.ResponseWriter, r *http.Request) { - statusMutex.RLock() - status := currentStatus - status.Uptime = int64(time.Since(startTime).Seconds()) - statusMutex.RUnlock() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(status) -} - -func getLogsHandler(w http.ResponseWriter, r *http.Request) { - logMutex.RLock() - logs := make([]LogEntry, len(logEntries)) - copy(logs, logEntries) - logMutex.RUnlock() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(logs) -} - -func clearLogsHandler(w http.ResponseWriter, r *http.Request) { - logMutex.Lock() - logEntries = []LogEntry{} - logMutex.Unlock() - - addLog("Système", "Logs effacés") - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "success"}) -} - -// Fonctions WiFi avec D-Bus - -func scanWiFiNetworks() ([]WiFiNetwork, error) { - interfacePath, err := getWiFiInterfacePath() - if err != nil { - return nil, fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) - } - - // Déclencher un scan - wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) - call := wifiInterface.Call(WPA_INTERFACE_IFACE+".Scan", 0, map[string]dbus.Variant{"Type": dbus.MakeVariant("active")}) - if call.Err != nil { - return nil, fmt.Errorf("erreur lors du scan: %v", call.Err) - } - - // Attendre un peu pour que le scan se termine - time.Sleep(2 * time.Second) - - // Récupérer la liste des BSS - bssePaths, err := wifiInterface.GetProperty(WPA_INTERFACE_IFACE + ".BSSs") - if err != nil { - return nil, fmt.Errorf("erreur lors de la récupération des BSS: %v", err) - } - - var networks []WiFiNetwork - seenSSIDs := make(map[string]bool) - - for _, bssPath := range bssePaths.Value().([]dbus.ObjectPath) { - bss := dbusConn.Object(WPA_SUPPLICANT_SERVICE, bssPath) - - // Récupérer les propriétés du BSS - var props map[string]dbus.Variant - err = bss.Call("org.freedesktop.DBus.Properties.GetAll", 0, WPA_BSS_IFACE).Store(&props) - if err != nil { - continue - } - - network := WiFiNetwork{} - - // Extraire SSID - if ssidBytes, ok := props["SSID"].Value().([]byte); ok { - network.SSID = string(ssidBytes) - } - - // Éviter les doublons - if network.SSID == "" || seenSSIDs[network.SSID] { - continue - } - seenSSIDs[network.SSID] = true - - // Extraire BSSID - if bssidBytes, ok := props["BSSID"].Value().([]byte); ok { - network.BSSID = fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", - bssidBytes[0], bssidBytes[1], bssidBytes[2], bssidBytes[3], bssidBytes[4], bssidBytes[5]) - } - - // Extraire la force du signal - if signal, ok := props["Signal"].Value().(int16); ok { - network.Signal = signalToStrength(int(signal)) - } - - // Extraire la fréquence et calculer le canal - if frequency, ok := props["Frequency"].Value().(uint16); ok { - network.Channel = frequencyToChannel(int(frequency)) - } - - // Déterminer la sécurité - if privacyVal, ok := props["Privacy"].Value().(bool); ok && privacyVal { - if wpaProps, ok := props["WPA"].Value().(map[string]dbus.Variant); ok && len(wpaProps) > 0 { - network.Security = "WPA" - } else if rsnProps, ok := props["RSN"].Value().(map[string]dbus.Variant); ok && len(rsnProps) > 0 { - network.Security = "WPA2" - } else { - network.Security = "WEP" - } - } else { - network.Security = "Open" - } - - networks = append(networks, network) - } - - // Trier par force du signal - sort.Slice(networks, func(i, j int) bool { - return networks[i].Signal > networks[j].Signal - }) - - return networks, nil -} - -func connectToWiFiDBus(ssid, password string) error { - interfacePath, err := getWiFiInterfacePath() - if err != nil { - return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) - } - - wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) - - // Créer un nouveau réseau - networkConfig := map[string]dbus.Variant{ - "ssid": dbus.MakeVariant(ssid), - } - - if password != "" { - networkConfig["psk"] = dbus.MakeVariant(password) - } - - var networkPath dbus.ObjectPath - err = wifiInterface.Call(WPA_INTERFACE_IFACE+".AddNetwork", 0, networkConfig).Store(&networkPath) - if err != nil { - return fmt.Errorf("erreur lors de l'ajout du réseau: %v", err) - } - - // Sélectionner le réseau - err = wifiInterface.Call(WPA_INTERFACE_IFACE+".SelectNetwork", 0, networkPath).Err - if err != nil { - return fmt.Errorf("erreur lors de la sélection du réseau: %v", err) - } - - // Attendre la connexion - for i := 0; i < 20; i++ { - time.Sleep(500 * time.Millisecond) - if isConnectedDBus() { - return nil - } - } - - return fmt.Errorf("timeout lors de la connexion") -} - -func disconnectWiFiDBus() error { - interfacePath, err := getWiFiInterfacePath() - if err != nil { - return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) - } - - wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) - - // Déconnecter - err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Disconnect", 0).Err - if err != nil { - return fmt.Errorf("erreur lors de la déconnexion: %v", err) - } - - // Supprimer tous les réseaux - var networks []dbus.ObjectPath - err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "Networks").Store(&networks) - if err == nil { - for _, networkPath := range networks { - wifiInterface.Call(WPA_INTERFACE_IFACE+".RemoveNetwork", 0, networkPath) - } - } - - return nil -} - -func getWiFiInterfacePath() (dbus.ObjectPath, error) { - var interfacePath dbus.ObjectPath - err := wpaSupplicant.Call(WPA_SUPPLICANT_IFACE+".GetInterface", 0, WLAN_INTERFACE).Store(&interfacePath) - if err != nil { - return "", fmt.Errorf("erreur lors de la récupération des interfaces: %v", err) - } - - return interfacePath, nil -} - -func isConnectedDBus() bool { - interfacePath, err := getWiFiInterfacePath() - if err != nil { - return false - } - - wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) - var state string - err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "State").Store(&state) - if err != nil { - return false - } - - return state == "completed" -} - -func frequencyToChannel(frequency int) int { - if frequency >= 2412 && frequency <= 2484 { - if frequency == 2484 { - return 14 - } - return (frequency-2412)/5 + 1 - } else if frequency >= 5170 && frequency <= 5825 { - return (frequency - 5000) / 5 - } - return 0 -} - -func signalToStrength(level int) int { - if level >= -30 { - return 5 - } else if level >= -50 { - return 4 - } else if level >= -60 { - return 3 - } else if level >= -70 { - return 2 - } else { - return 1 - } -} - -func connectToWiFi(ssid, password string) error { - // Créer la configuration wpa_supplicant - config := fmt.Sprintf(`ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev -update_config=1 -country=FR - -network={ - ssid="%s" - psk="%s" -} -`, ssid, password) - - err := os.WriteFile(WPA_CONF, []byte(config), 0600) - if err != nil { - return err - } - - // Redémarrer wpa_supplicant - cmd := exec.Command("systemctl", "restart", "wpa_supplicant") - if err := cmd.Run(); err != nil { - return err - } - - // Attendre la connexion - for i := 0; i < 10; i++ { - time.Sleep(1 * time.Second) - if isConnected() { - return nil - } - } - - return fmt.Errorf("timeout lors de la connexion") -} - -func isConnected() bool { - cmd := exec.Command("iwconfig", WLAN_INTERFACE) - output, err := cmd.Output() - if err != nil { - return false - } - - return strings.Contains(string(output), "Access Point:") -} - -// Fonctions Hotspot - -func configureHotspot(config HotspotConfig) error { - hostapdConfig := fmt.Sprintf(`interface=%s -driver=nl80211 -ssid=%s -hw_mode=g -channel=%d -wmm_enabled=0 -macaddr_acl=0 -auth_algs=1 -ignore_broadcast_ssid=0 -wpa=2 -wpa_passphrase=%s -wpa_key_mgmt=WPA-PSK -wpa_pairwise=TKIP -rsn_pairwise=CCMP -`, AP_INTERFACE, config.SSID, config.Channel, config.Password) - - return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0644) -} - -func startHotspot() error { - cmd := exec.Command("systemctl", "start", "hostapd") - return cmd.Run() -} - -func stopHotspot() error { - cmd := exec.Command("systemctl", "stop", "hostapd") - return cmd.Run() -} - -// Fonctions pour les appareils connectés - -func getConnectedDevices() ([]ConnectedDevice, error) { - var devices []ConnectedDevice - - // Lire les baux DHCP - leases, err := parseDHCPLeases() - if err != nil { - return devices, err - } - - // Obtenir les informations ARP - arpInfo, err := getARPInfo() - if err != nil { - return devices, err - } - - for _, lease := range leases { - device := ConnectedDevice{ - Name: lease.Hostname, - MAC: lease.MAC, - IP: lease.IP, - Type: guessDeviceType(lease.Hostname, lease.MAC), - } - - // Vérifier si l'appareil est toujours connecté via ARP - if _, exists := arpInfo[lease.IP]; exists { - devices = append(devices, device) - } - } - - return devices, nil -} - -type DHCPLease struct { - IP string - MAC string - Hostname string -} - -func parseDHCPLeases() ([]DHCPLease, error) { - var leases []DHCPLease - - file, err := os.Open("/var/lib/dhcp/dhcpd.leases") - if err != nil { - return leases, err - } - defer file.Close() - - scanner := bufio.NewScanner(file) - var currentLease DHCPLease - - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - - if strings.HasPrefix(line, "lease ") { - ip := strings.Fields(line)[1] - currentLease = DHCPLease{IP: ip} - } else if strings.Contains(line, "hardware ethernet") { - mac := strings.Fields(line)[2] - mac = strings.TrimSuffix(mac, ";") - currentLease.MAC = mac - } else if strings.Contains(line, "client-hostname") { - hostname := strings.Fields(line)[1] - hostname = strings.Trim(hostname, `";`) - currentLease.Hostname = hostname - } else if line == "}" { - if currentLease.IP != "" && currentLease.MAC != "" { - leases = append(leases, currentLease) - } - currentLease = DHCPLease{} - } - } - - return leases, nil -} - -func getARPInfo() (map[string]string, error) { - arpInfo := make(map[string]string) - - cmd := exec.Command("arp", "-a") - output, err := cmd.Output() - if err != nil { - return arpInfo, err - } - - lines := strings.Split(string(output), "\n") - for _, line := range lines { - if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 { - ip := matches[1] - mac := matches[2] - arpInfo[ip] = mac - } - } - - return arpInfo, nil -} - -func guessDeviceType(hostname, mac string) string { - hostname = strings.ToLower(hostname) - - if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") { - return "mobile" - } else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") { - return "tablet" - } else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") { - return "laptop" - } - - // Deviner par préfixe MAC (OUI) - macPrefix := strings.ToUpper(mac[:8]) - switch macPrefix { - case "00:50:56", "00:0C:29", "00:05:69": // VMware - return "laptop" - case "08:00:27": // VirtualBox - return "laptop" - default: - return "mobile" - } -} - -// Fonctions de logging - -func addLog(source, message string) { - logMutex.Lock() - entry := LogEntry{ - Timestamp: time.Now(), - Source: source, - Message: message, - } - logEntries = append(logEntries, entry) - - // Garder seulement les 100 derniers logs - if len(logEntries) > 100 { - logEntries = logEntries[len(logEntries)-100:] - } - logMutex.Unlock() - - // Envoyer aux clients WebSocket - broadcastToWebSockets(entry) - - // Log vers la console - log.Printf("[%s] %s", source, message) -} - -// WebSocket pour les logs en temps réel - -func websocketHandler(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - log.Printf("Erreur WebSocket: %v", err) - return - } - defer conn.Close() - - clientsMutex.Lock() - websocketClients[conn] = true - clientsMutex.Unlock() - - defer func() { - clientsMutex.Lock() - delete(websocketClients, conn) - clientsMutex.Unlock() - }() - - // Envoyer les logs existants - logMutex.RLock() - for _, entry := range logEntries { - conn.WriteJSON(entry) - } - logMutex.RUnlock() - - // Maintenir la connexion - for { - _, _, err := conn.ReadMessage() - if err != nil { - break - } - } -} - -func broadcastToWebSockets(entry LogEntry) { - clientsMutex.RLock() - defer clientsMutex.RUnlock() - - for client := range websocketClients { - err := client.WriteJSON(entry) - if err != nil { - client.Close() - delete(websocketClients, client) - } - } -} - -// Tâches périodiques - -func periodicStatusUpdate() { - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - for range ticker.C { - statusMutex.Lock() - currentStatus.Connected = isConnected() - if !currentStatus.Connected { - currentStatus.ConnectedSSID = "" - } - statusMutex.Unlock() - } -} - -func periodicDeviceUpdate() { - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - for range ticker.C { - devices, err := getConnectedDevices() - if err != nil { - continue - } - - statusMutex.Lock() - currentStatus.ConnectedDevices = devices - currentStatus.ConnectedCount = len(devices) - statusMutex.Unlock() - } -} diff --git a/oapi-gin.cfg.yaml b/oapi-gin.cfg.yaml new file mode 100644 index 0000000..b30fef2 --- /dev/null +++ b/oapi-gin.cfg.yaml @@ -0,0 +1,5 @@ +package: api +generate: + - gin + - embedded-spec +output: internal/api/routes.gen.go diff --git a/oapi-types.cfg.yaml b/oapi-types.cfg.yaml new file mode 100644 index 0000000..57b44d2 --- /dev/null +++ b/oapi-types.cfg.yaml @@ -0,0 +1,4 @@ +package: api +generate: + - types +output: internal/api/types.gen.go diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..4357d09 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,552 @@ +openapi: 3.0.3 +info: + title: Travel Router Control API + description: | + API for controlling a mini travel router with dual WiFi interfaces and Ethernet connectivity. + The router can operate as a WiFi repeater, connecting to upstream networks while providing + a hotspot for client devices. + version: 1.0.0 + contact: + name: API Support + license: + name: MIT + +servers: + - url: http://localhost:8080 + description: Local router API + +tags: + - name: WiFi + description: WiFi client operations (upstream network connection) + - name: Hotspot + description: Access point operations (client-facing hotspot) + - name: Devices + description: Connected devices management + - name: System + description: System status and monitoring + - name: Logs + description: System logs and real-time monitoring + +paths: + /api/wifi/networks: + get: + tags: + - WiFi + summary: Get discovered WiFi networks + description: | + Returns the list of WiFi networks from the last scan without triggering a new scan. + Returns an empty list if no scan has been performed yet. + operationId: getWiFiNetworks + responses: + '200': + description: List of discovered networks + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WiFiNetwork' + example: + - ssid: "Hotel-Guest" + signal: 5 + security: "WPA2" + channel: 6 + bssid: "aa:bb:cc:dd:ee:ff" + - ssid: "Public-WiFi" + signal: 3 + security: "Open" + channel: 11 + bssid: "11:22:33:44:55:66" + '500': + description: Error retrieving networks + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/wifi/scan: + get: + tags: + - WiFi + summary: Scan for available WiFi networks + description: | + Triggers a WiFi scan using wpa_supplicant via D-Bus and returns all discovered networks + sorted by signal strength. The scan takes approximately 2 seconds to complete. + operationId: scanWiFi + responses: + '200': + description: Successfully scanned networks + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WiFiNetwork' + example: + - ssid: "Hotel-Guest" + signal: 5 + security: "WPA2" + channel: 6 + bssid: "aa:bb:cc:dd:ee:ff" + - ssid: "Public-WiFi" + signal: 3 + security: "Open" + channel: 11 + bssid: "11:22:33:44:55:66" + '500': + description: Scan error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/wifi/connect: + post: + tags: + - WiFi + summary: Connect to a WiFi network + description: | + Connects the router to an upstream WiFi network using wpa_supplicant. + Supports both open and password-protected networks (WPA/WPA2). + operationId: connectWiFi + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WiFiConnectRequest' + examples: + protected: + summary: WPA2 protected network + value: + ssid: "Hotel-Guest" + password: "guest1234" + open: + summary: Open network + value: + ssid: "Public-WiFi" + password: "" + responses: + '200': + description: Successfully connected + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '400': + description: Invalid request data + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Connection failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/wifi/disconnect: + post: + tags: + - WiFi + summary: Disconnect from WiFi network + description: | + Disconnects from the currently connected upstream WiFi network + and removes all saved network configurations. + operationId: disconnectWiFi + responses: + '200': + description: Successfully disconnected + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '500': + description: Disconnection failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/hotspot/toggle: + post: + tags: + - Hotspot + summary: Toggle hotspot on/off + description: | + Enables or disables the hotspot (access point) by starting/stopping + the hostapd service. Returns the new enabled state and updates + the system status with current hostapd_cli information. + operationId: toggleHotspot + responses: + '200': + description: Hotspot state changed successfully + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + description: Current hotspot state after toggle + required: + - enabled + example: + enabled: true + '500': + description: Failed to change hotspot state + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/devices: + get: + tags: + - Devices + summary: Get connected devices + description: | + Returns a list of all devices currently connected to the hotspot. + Device information is gathered from DHCP leases and ARP tables. + Only devices with active ARP entries are considered connected. + operationId: getDevices + responses: + '200': + description: List of connected devices + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ConnectedDevice' + example: + - name: "iPhone-12" + type: "mobile" + mac: "aa:bb:cc:11:22:33" + ip: "192.168.1.100" + - name: "MacBook-Pro" + type: "laptop" + mac: "dd:ee:ff:44:55:66" + ip: "192.168.1.101" + '500': + description: Failed to retrieve device list + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/status: + get: + tags: + - System + summary: Get system status + description: | + Returns comprehensive system status including WiFi connection state, + detailed hotspot status from hostapd_cli, connected device count, + data usage, and uptime. + operationId: getStatus + responses: + '200': + description: Current system status + content: + application/json: + schema: + $ref: '#/components/schemas/SystemStatus' + + /api/logs: + get: + tags: + - Logs + summary: Get system logs + description: | + Returns the last 100 log entries from the system. + For real-time log streaming, use the WebSocket endpoint. + operationId: getLogs + responses: + '200': + description: List of log entries + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LogEntry' + example: + - timestamp: "2025-10-28T14:32:10Z" + source: "WiFi" + message: "Scan terminé - 5 réseaux trouvés" + - timestamp: "2025-10-28T14:32:15Z" + source: "WiFi" + message: "Tentative de connexion à Hotel-Guest" + delete: + tags: + - Logs + summary: Clear system logs + description: Clears all stored log entries (keeps only the "logs cleared" entry) + operationId: clearLogs + responses: + '200': + description: Logs cleared successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /ws/logs: + get: + tags: + - Logs + summary: WebSocket for real-time logs + description: | + WebSocket endpoint for receiving real-time log updates. + Upon connection, all existing logs are sent, followed by new logs as they occur. + + This is a WebSocket endpoint - upgrade the HTTP connection to WebSocket protocol. + operationId: logsWebSocket + responses: + '101': + description: WebSocket connection established + '400': + description: WebSocket upgrade failed + +components: + schemas: + WiFiNetwork: + type: object + description: Discovered WiFi network information + properties: + ssid: + type: string + description: Network SSID (name) + example: "Hotel-Guest" + signal: + type: integer + description: Signal strength (1-5 scale) + minimum: 1 + maximum: 5 + example: 4 + security: + type: string + description: Security type + enum: + - Open + - WEP + - WPA + - WPA2 + example: "WPA2" + channel: + type: integer + description: WiFi channel number + minimum: 1 + maximum: 165 + example: 6 + bssid: + type: string + description: Access point MAC address + pattern: '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$' + example: "aa:bb:cc:dd:ee:ff" + required: + - ssid + - signal + - security + - channel + - bssid + + WiFiConnectRequest: + type: object + description: Request to connect to a WiFi network + properties: + ssid: + type: string + description: Network SSID to connect to + example: "Hotel-Guest" + password: + type: string + description: Network password (empty string for open networks) + example: "guest1234" + required: + - ssid + - password + + HotspotStatus: + type: object + description: Detailed hotspot status from hostapd_cli + properties: + state: + type: string + description: Hotspot state (ENABLED, DISABLED, etc.) + example: "ENABLED" + ssid: + type: string + description: Current SSID being broadcast + example: "TravelRouter" + bssid: + type: string + description: MAC address of the access point + pattern: '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$' + example: "4a:e3:4e:09:57:f8" + channel: + type: integer + description: Current WiFi channel + minimum: 1 + maximum: 14 + example: 11 + frequency: + type: integer + description: Frequency in MHz + example: 2462 + numStations: + type: integer + description: Number of connected stations + minimum: 0 + example: 2 + hwMode: + type: string + description: Hardware mode (g, a, n, ac, etc.) + example: "g" + countryCode: + type: string + description: Country code + example: "VN" + required: + - state + + ConnectedDevice: + type: object + description: Device connected to the hotspot + properties: + name: + type: string + description: Device hostname + example: "iPhone-12" + type: + type: string + description: Detected device type + enum: + - mobile + - tablet + - laptop + - desktop + - unknown + example: "mobile" + mac: + type: string + description: Device MAC address + pattern: '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$' + example: "aa:bb:cc:11:22:33" + ip: + type: string + description: Assigned IP address + pattern: '^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$' + example: "192.168.1.100" + required: + - name + - type + - mac + - ip + + SystemStatus: + type: object + description: Overall system status + properties: + connected: + type: boolean + description: Whether router is connected to upstream WiFi + example: true + connectionState: + type: string + description: Current WiFi connection state + enum: + - connected + - disconnected + - connecting + - disconnecting + - roaming + example: "connected" + connectedSSID: + type: string + description: SSID of connected upstream network (empty if not connected) + example: "Hotel-Guest" + hotspotStatus: + allOf: + - $ref: '#/components/schemas/HotspotStatus' + nullable: true + description: Detailed hotspot status (null if hotspot is not running) + connectedCount: + type: integer + description: Number of devices connected to hotspot + minimum: 0 + example: 3 + dataUsage: + type: number + format: double + description: Total data usage in MB + example: 145.7 + uptime: + type: integer + format: int64 + description: System uptime in seconds + example: 3600 + connectedDevices: + type: array + description: List of devices connected to hotspot + items: + $ref: '#/components/schemas/ConnectedDevice' + required: + - connected + - connectionState + - connectedSSID + - connectedCount + - dataUsage + - uptime + - connectedDevices + + LogEntry: + type: object + description: System log entry + properties: + timestamp: + type: string + format: date-time + description: When the log entry was created + example: "2025-10-28T14:32:10Z" + source: + type: string + description: Log source component + enum: + - Système + - WiFi + - Hotspot + example: "WiFi" + message: + type: string + description: Log message + example: "Scan terminé - 5 réseaux trouvés" + required: + - timestamp + - source + - message + + SuccessResponse: + type: object + description: Generic success response + properties: + status: + type: string + enum: + - success + example: "success" + required: + - status + + Error: + type: object + description: Error response + properties: + error: + type: string + description: Error message + example: "Erreur lors du scan WiFi" + required: + - error diff --git a/static/app.js b/static/app.js deleted file mode 100644 index 6fce809..0000000 --- a/static/app.js +++ /dev/null @@ -1,239 +0,0 @@ -// État global de l'application -let appState = { - selectedWifi: null, - hotspotEnabled: true, - connectedDevices: [], - wifiNetworks: [], - uptime: 0, - dataUsage: 0 -}; - -// Simulation de données -const mockDevices = [ - { name: "iPhone 13", type: "mobile", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.101" }, - { name: "MacBook Pro", type: "laptop", mac: "11:22:33:44:55:66", ip: "192.168.1.102" }, - { name: "iPad", type: "tablet", mac: "77:88:99:AA:BB:CC", ip: "192.168.1.103" } -]; - -// Initialisation -document.addEventListener('DOMContentLoaded', function() { - initializeApp(); - startPeriodicUpdates(); -}); - -function initializeApp() { - updateWifiList(); - updateDevicesList(); - updateStats(); - addLog("Système", "Interface web initialisée"); -} - -async function updateWifiList() { - const wifiList = document.getElementById('wifiList'); - wifiList.innerHTML = ''; - - (await (await fetch('/api/wifi/scan')).json()).forEach((network, index) => { - const wifiItem = document.createElement('div'); - wifiItem.className = 'wifi-item'; - wifiItem.onclick = () => selectWifi(network, wifiItem); - - wifiItem.innerHTML = ` -
- ${network.ssid} -
${network.security} • Canal ${network.channel}
-
-
- ${generateSignalBars(network.signal)} -
- `; - - wifiList.appendChild(wifiItem); - }); -} - -function generateSignalBars(strength) { - const bars = []; - for (let i = 1; i <= 5; i++) { - const height = i * 3; - const active = i <= strength ? 'active' : ''; - bars.push(`
`); - } - return `
${bars.join('')}
`; -} - -function selectWifi(network, element) { - // Retirer la sélection précédente - document.querySelectorAll('.wifi-item').forEach(item => { - item.classList.remove('selected'); - }); - - // Ajouter la sélection - element.classList.add('selected'); - appState.selectedWifi = network; - - addLog("WiFi", `Réseau sélectionné: ${network.ssid}`); -} - -function updateDevicesList() { - const devicesList = document.getElementById('devicesList'); - devicesList.innerHTML = ''; - - mockDevices.forEach(device => { - const deviceCard = document.createElement('div'); - deviceCard.className = 'device-card'; - - const deviceIcon = getDeviceIcon(device.type); - - deviceCard.innerHTML = ` - ${deviceIcon} -
${device.name}
-
${device.ip}
- `; - - devicesList.appendChild(deviceCard); - }); - - document.getElementById('connectedDevices').textContent = mockDevices.length; -} - -function getDeviceIcon(type) { - const icons = { - mobile: '', - laptop: '', - tablet: '' - }; - return icons[type] || icons.mobile; -} - -function updateStats() { - appState.uptime += 1; - appState.dataUsage += Math.random() * 0.5; - - const hours = Math.floor(appState.uptime / 3600); - const minutes = Math.floor((appState.uptime % 3600) / 60); - const seconds = appState.uptime % 60; - - document.getElementById('uptime').textContent = - `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - - document.getElementById('dataUsage').textContent = `${appState.dataUsage.toFixed(1)} MB`; -} - -function addLog(source, message) { - const logContainer = document.getElementById('logContainer'); - const timestamp = new Date().toLocaleTimeString(); - const logEntry = document.createElement('div'); - logEntry.className = 'log-entry'; - logEntry.innerHTML = `[${timestamp}] ${source}: ${message}`; - - logContainer.appendChild(logEntry); - logContainer.scrollTop = logContainer.scrollHeight; -} - -function showNotification(message, type = 'success') { - const notification = document.getElementById('notification'); - notification.textContent = message; - notification.className = `notification ${type}`; - notification.classList.add('show'); - - setTimeout(() => { - notification.classList.remove('show'); - }, 3000); -} - -// Fonctions d'action -function scanWifi() { - const scanBtn = document.getElementById('scanBtn'); - const originalText = scanBtn.textContent; - - scanBtn.innerHTML = '
Scan en cours...'; - - setTimeout(() => { - updateWifiList(); - scanBtn.textContent = originalText; - showNotification('Scan terminé - Réseaux mis à jour'); - addLog("WiFi", "Scan des réseaux terminé"); - }, 2000); -} - -function connectToWifi() { - if (!appState.selectedWifi) { - showNotification('Veuillez sélectionner un réseau WiFi', 'error'); - return; - } - - const password = document.getElementById('wifiPassword').value; - if (!password && appState.selectedWifi.security !== 'Open') { - showNotification('Mot de passe requis', 'error'); - return; - } - - const connectBtn = document.getElementById('connectBtn'); - const originalText = connectBtn.textContent; - - connectBtn.innerHTML = '
Connexion...'; - - setTimeout(() => { - connectBtn.textContent = originalText; - showNotification(`Connecté à ${appState.selectedWifi.ssid}`); - addLog("WiFi", `Connexion établie avec ${appState.selectedWifi.ssid}`); - - // Mettre à jour le statut - document.getElementById('connectionStatus').innerHTML = ` -
- Connecté à ${appState.selectedWifi.ssid} - `; - }, 3000); -} - -function updateHotspot() { - const name = document.getElementById('hotspotName').value; - const password = document.getElementById('hotspotPassword').value; - const channel = document.getElementById('hotspotChannel').value; - - if (!name || !password) { - showNotification('Nom et mot de passe requis', 'error'); - return; - } - - showNotification('Configuration du hotspot mise à jour'); - addLog("Hotspot", `Configuration mise à jour: ${name} (Canal ${channel})`); -} - -function toggleHotspot() { - appState.hotspotEnabled = !appState.hotspotEnabled; - const btn = document.getElementById('hotspotBtn'); - - if (appState.hotspotEnabled) { - btn.textContent = 'Arrêter le hotspot'; - showNotification('Hotspot activé'); - addLog("Hotspot", "Hotspot activé"); - } else { - btn.textContent = 'Démarrer le hotspot'; - showNotification('Hotspot désactivé'); - addLog("Hotspot", "Hotspot désactivé"); - } -} - -function clearLogs() { - document.getElementById('logContainer').innerHTML = ''; - addLog("Système", "Logs effacés"); -} - -// Mises à jour périodiques -function startPeriodicUpdates() { - setInterval(updateStats, 1000); - setInterval(() => { - // Simulation de nouveaux logs - if (Math.random() > 0.95) { - const events = [ - "Nouveau client connecté", - "Paquet routé vers l'extérieur", - "Vérification de la connexion", - "Mise à jour des tables de routage" - ]; - const randomEvent = events[Math.floor(Math.random() * events.length)]; - addLog("Système", randomEvent); - } - }, 5000); -} diff --git a/static/index.html b/static/index.html deleted file mode 100644 index dbb7131..0000000 --- a/static/index.html +++ /dev/null @@ -1,135 +0,0 @@ - - - - - - WiFi Repeater Control - - - -
-
-

🌐 WiFi Repeater Control

-
-
- En ligne -
-
- -
-
-
0
-
Appareils connectés
-
-
-
0 MB
-
Données utilisées
-
-
-
00:00:00
-
Temps de fonctionnement
-
-
- -
-
-

- - - - Connexion WiFi Externe -

- -
- -
- -
-
- -
- - -
- - - - -
- -
-

- - - - Configuration Hotspot -

- -
- - -
- -
- - -
- -
- - -
- - - -
-
- -
-
-

- - - - Appareils connectés -

- -
- -
-
- -
-

- - - - Logs système -

- -
- -
- - -
-
-
- -
- - - - diff --git a/static/style.css b/static/style.css deleted file mode 100644 index 3ea544a..0000000 --- a/static/style.css +++ /dev/null @@ -1,338 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - min-height: 100vh; - padding: 20px; -} - -.container { - max-width: 1200px; - margin: 0 auto; - background: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(10px); - border-radius: 20px; - padding: 30px; - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); -} - -.header { - text-align: center; - margin-bottom: 40px; - padding-bottom: 20px; - border-bottom: 2px solid #e0e0e0; -} - -.header h1 { - color: #333; - font-size: 2.5em; - margin-bottom: 10px; -} - -.status-indicator { - display: inline-flex; - align-items: center; - gap: 10px; - padding: 10px 20px; - border-radius: 25px; - font-weight: 500; - margin-top: 10px; -} - -.status-online { - background: #d4edda; - color: #155724; -} - -.status-offline { - background: #f8d7da; - color: #721c24; -} - -.status-dot { - width: 12px; - height: 12px; - border-radius: 50%; - background: currentColor; - animation: pulse 2s infinite; -} - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } -} - -.grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); - gap: 30px; - margin-bottom: 30px; -} - -.card { - background: white; - border-radius: 15px; - padding: 25px; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); - transition: transform 0.3s ease, box-shadow 0.3s ease; -} - -.card:hover { - transform: translateY(-5px); - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15); -} - -.card h2 { - color: #333; - margin-bottom: 20px; - font-size: 1.4em; - display: flex; - align-items: center; - gap: 10px; -} - -.icon { - width: 24px; - height: 24px; - fill: currentColor; -} - -.form-group { - margin-bottom: 20px; -} - -.form-group label { - display: block; - margin-bottom: 8px; - color: #555; - font-weight: 500; -} - -.form-group input, .form-group select { - width: 100%; - padding: 12px 16px; - border: 2px solid #e0e0e0; - border-radius: 8px; - font-size: 16px; - transition: border-color 0.3s ease; -} - -.form-group input:focus, .form-group select:focus { - outline: none; - border-color: #667eea; -} - -.btn { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - border: none; - padding: 12px 24px; - border-radius: 8px; - font-size: 16px; - font-weight: 500; - cursor: pointer; - transition: transform 0.2s ease, box-shadow 0.2s ease; - width: 100%; -} - -.btn:hover { - transform: translateY(-2px); - box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); -} - -.btn:active { - transform: translateY(0); -} - -.btn-secondary { - background: #6c757d; - margin-top: 10px; -} - -.btn-danger { - background: #dc3545; -} - -.wifi-list { - max-height: 300px; - overflow-y: auto; - border: 1px solid #e0e0e0; - border-radius: 8px; - margin-bottom: 20px; -} - -.wifi-item { - padding: 12px 16px; - border-bottom: 1px solid #f0f0f0; - cursor: pointer; - transition: background-color 0.2s ease; - display: flex; - justify-content: space-between; - align-items: center; -} - -.wifi-item:hover { - background-color: #f8f9fa; -} - -.wifi-item:last-child { - border-bottom: none; -} - -.wifi-item.selected { - background-color: #e7f3ff; - border-left: 4px solid #667eea; -} - -.wifi-signal { - display: flex; - align-items: center; - gap: 8px; -} - -.signal-strength { - width: 20px; - height: 20px; - position: relative; -} - -.signal-bars { - display: flex; - gap: 2px; - align-items: flex-end; -} - -.signal-bar { - width: 3px; - background: #ccc; - border-radius: 1px; -} - -.signal-bar.active { - background: #28a745; -} - -.devices-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 15px; -} - -.device-card { - background: #f8f9fa; - border-radius: 10px; - padding: 15px; - text-align: center; - transition: transform 0.2s ease; -} - -.device-card:hover { - transform: scale(1.05); -} - -.device-icon { - width: 40px; - height: 40px; - margin: 0 auto 10px; - fill: #667eea; -} - -.log-container { - background: #1a1a1a; - color: #00ff00; - padding: 20px; - border-radius: 10px; - font-family: 'Courier New', monospace; - font-size: 14px; - max-height: 300px; - overflow-y: auto; - line-height: 1.4; -} - -.log-entry { - margin-bottom: 5px; -} - -.log-timestamp { - color: #888; -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 20px; -} - -.stat-card { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - padding: 20px; - border-radius: 10px; - text-align: center; -} - -.stat-value { - font-size: 2em; - font-weight: bold; - margin-bottom: 5px; -} - -.stat-label { - font-size: 0.9em; - opacity: 0.9; -} - -.loading { - display: inline-block; - width: 20px; - height: 20px; - border: 3px solid #f3f3f3; - border-top: 3px solid #667eea; - border-radius: 50%; - animation: spin 1s linear infinite; -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -.notification { - position: fixed; - top: 20px; - right: 20px; - padding: 15px 20px; - border-radius: 10px; - color: white; - font-weight: 500; - z-index: 1000; - transform: translateX(100%); - transition: transform 0.3s ease; -} - -.notification.show { - transform: translateX(0); -} - -.notification.success { - background: #28a745; -} - -.notification.error { - background: #dc3545; -} - -@media (max-width: 768px) { - .grid { - grid-template-columns: 1fr; - } - - .container { - padding: 20px; - } -}