diff --git a/.gitignore b/.gitignore index 612d3ff..ea33d90 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ internal/api/routes.gen.go internal/api/types.gen.go -repeater \ No newline at end of file +/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..20752c8 --- /dev/null +++ b/cmd/repeater/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "embed" + "log" + "os" + "os/signal" + "syscall" + + "github.com/nemunaire/repeater/internal/app" +) + +//go:embed all:static +var assets embed.FS + +func main() { + // Create application instance + application := app.New(assets) + + // Initialize the application + if err := application.Initialize(); 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(":8080"); 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..bd29eec --- /dev/null +++ b/cmd/repeater/static/app.js @@ -0,0 +1,535 @@ +// Application state +const appState = { + selectedWifi: null, + hotspotEnabled: true, + autoScrollLogs: true, + ws: null, + reconnectAttempts: 0, + maxReconnectAttempts: 5 +}; + +// 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 WebSocket for real-time logs + connectWebSocket(); + + // Start periodic updates + startPeriodicUpdates(); + + showToast('success', 'Connecté', 'Interface web chargée avec succès'); +} + +// ===== 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(); + + displayWifiNetworks(networks); + showToast('success', 'Scan WiFi', `${networks.length} réseau(x) trouvé(s)`); + } catch (error) { + console.error('Error scanning WiFi:', error); + 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; + + 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(); + } 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 updateHotspot() { + const ssid = document.getElementById('hotspotName').value; + const password = document.getElementById('hotspotPassword').value; + const channel = parseInt(document.getElementById('hotspotChannel').value); + + if (!ssid || ssid.length > 32) { + showToast('warning', 'Attention', 'SSID invalide (1-32 caractères)'); + return; + } + + if (!password || password.length < 8 || password.length > 63) { + showToast('warning', 'Attention', 'Mot de passe invalide (8-63 caractères)'); + return; + } + + showLoading(true); + + try { + const response = await fetch('/api/hotspot/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ssid, password, channel }) + }); + + if (response.ok) { + showToast('success', 'Configuration', 'Hotspot configuré avec succès'); + } else { + throw new Error('Configuration failed'); + } + } catch (error) { + console.error('Error configuring hotspot:', error); + showToast('error', 'Erreur', 'Échec de la configuration'); + } 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'); + + if (status.connected) { + wifiDot.className = 'status-dot active'; + wifiText.textContent = `Connecté: ${status.connectedSSID}`; + } else { + wifiDot.className = 'status-dot offline'; + wifiText.textContent = 'Déconnecté'; + } + + // 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'); + + if (status.hotspotEnabled) { + hotspotDot.className = 'status-dot active'; + hotspotText.textContent = 'Hotspot actif'; + hotspotToggle.checked = true; + } else { + hotspotDot.className = 'status-dot offline'; + hotspotText.textContent = 'Hotspot inactif'; + hotspotToggle.checked = false; + } + + appState.hotspotEnabled = status.hotspotEnabled; + + // Update stats + document.getElementById('connectedDevices').textContent = status.connectedCount; + document.getElementById('dataUsage').textContent = `${status.dataUsage.toFixed(1)} MB`; + 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'; + wifiItem.onclick = () => selectWifi(network, wifiItem); + + wifiItem.innerHTML = ` +
+
${escapeHtml(network.ssid)}
+
+ ${network.security} + Canal ${network.channel} + ${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'); + + logEntry.innerHTML = ` + ${timestamp} + [${escapeHtml(log.source)}] + ${escapeHtml(log.message)} + `; + + logContainer.appendChild(logEntry); + + if (appState.autoScrollLogs) { + logContainer.scrollTop = logContainer.scrollHeight; + } +} + +// ===== 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); + } +} + +// ===== 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); +} diff --git a/cmd/repeater/static/index.html b/cmd/repeater/static/index.html new file mode 100644 index 0000000..90e4be4 --- /dev/null +++ b/cmd/repeater/static/index.html @@ -0,0 +1,273 @@ + + + + + + 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... +
+
+
+ +
+ + +
+ +
+ + +
+
+ +
+
+

+ + + + Configuration Hotspot +

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

+ + + + 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..2dc82b0 --- /dev/null +++ b/cmd/repeater/static/style.css @@ -0,0 +1,788 @@ +/* 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; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* 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; +} + +.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; +} + +.stat-value { + font-size: 1.875rem; + font-weight: 700; + color: var(--text-primary); + line-height: 1; + margin-bottom: 0.25rem; +} + +.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.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); +} + +/* 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/go.mod b/go.mod index c90ffaa..5e17b30 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module git.nemunai.re/nemunaire/repeater +module github.com/nemunaire/repeater go 1.24.4 diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go new file mode 100644 index 0000000..49d6c3a --- /dev/null +++ b/internal/api/handlers/handlers.go @@ -0,0 +1,132 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/nemunaire/repeater/internal/device" + "github.com/nemunaire/repeater/internal/hotspot" + "github.com/nemunaire/repeater/internal/logging" + "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/wifi" +) + +// 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) { + status.HotspotEnabled = !status.HotspotEnabled + enabled := status.HotspotEnabled + + var err error + if enabled { + 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 + } + + c.JSON(http.StatusOK, gin.H{"enabled": enabled}) +} + +// GetDevices returns connected devices +func GetDevices(c *gin.Context) { + devices, err := device.GetConnectedDevices() + 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/router.go b/internal/api/router.go new file mode 100644 index 0000000..5558494 --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,64 @@ +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/models" +) + +// SetupRouter creates and configures the Gin router +func SetupRouter(status *models.SystemStatus, 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("/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", handlers.GetDevices) + + // 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 endpoint + r.GET("/ws/logs", handlers.WebSocketLogs) + + // 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..39181e6 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,102 @@ +package app + +import ( + "embed" + "log" + "sync" + "time" + + "github.com/nemunaire/repeater/internal/api" + "github.com/nemunaire/repeater/internal/device" + "github.com/nemunaire/repeater/internal/logging" + "github.com/nemunaire/repeater/internal/models" + "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 +} + +// New creates a new application instance +func New(assets embed.FS) *App { + return &App{ + Status: models.SystemStatus{ + Connected: false, + ConnectedSSID: "", + HotspotEnabled: true, + ConnectedCount: 0, + DataUsage: 0.0, + Uptime: 0, + }, + StartTime: time.Now(), + Assets: assets, + } +} + +// Initialize initializes the application +func (a *App) Initialize() error { + // Initialize WiFi D-Bus connection + if err := wifi.Initialize(); err != nil { + return err + } + + // 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.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() { + wifi.Close() + logging.AddLog("Système", "Application arrêtée") +} + +// 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() + if !a.Status.Connected { + a.Status.ConnectedSSID = "" + } + a.Status.Uptime = int64(time.Since(a.StartTime).Seconds()) + 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 := device.GetConnectedDevices() + if err != nil { + log.Printf("Error getting connected devices: %v", err) + continue + } + + a.StatusMutex.Lock() + a.Status.ConnectedDevices = devices + a.Status.ConnectedCount = len(devices) + a.StatusMutex.Unlock() + } +} diff --git a/internal/device/device.go b/internal/device/device.go new file mode 100644 index 0000000..5eaaf06 --- /dev/null +++ b/internal/device/device.go @@ -0,0 +1,132 @@ +package device + +import ( + "bufio" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/nemunaire/repeater/internal/models" +) + +// GetConnectedDevices returns a list of connected devices +func GetConnectedDevices() ([]models.ConnectedDevice, error) { + var devices []models.ConnectedDevice + + // Read DHCP leases + leases, err := parseDHCPLeases() + if err != nil { + return devices, err + } + + // Get ARP information + arpInfo, err := getARPInfo() + if err != nil { + return devices, err + } + + for _, lease := range leases { + device := models.ConnectedDevice{ + Name: lease.Hostname, + MAC: lease.MAC, + IP: lease.IP, + Type: guessDeviceType(lease.Hostname, lease.MAC), + } + + // Check if the device is still connected via ARP + if _, exists := arpInfo[lease.IP]; exists { + devices = append(devices, device) + } + } + + return devices, nil +} + +// parseDHCPLeases reads and parses DHCP lease file +func parseDHCPLeases() ([]models.DHCPLease, error) { + var leases []models.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 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 +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 +} + +// 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/hotspot/hotspot.go b/internal/hotspot/hotspot.go new file mode 100644 index 0000000..aff0483 --- /dev/null +++ b/internal/hotspot/hotspot.go @@ -0,0 +1,47 @@ +package hotspot + +import ( + "fmt" + "os" + "os/exec" + + "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("systemctl", "start", "hostapd") + return cmd.Run() +} + +// Stop stops the hotspot +func Stop() error { + cmd := exec.Command("systemctl", "stop", "hostapd") + return cmd.Run() +} 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..d582364 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,58 @@ +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"` +} + +// SystemStatus represents overall system status +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"` +} + +// 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/wifi/wifi.go b/internal/wifi/wifi.go new file mode 100644 index 0000000..e10731b --- /dev/null +++ b/internal/wifi/wifi.go @@ -0,0 +1,273 @@ +package wifi + +import ( + "fmt" + "os/exec" + "sort" + "strings" + "time" + + "github.com/godbus/dbus/v5" + "github.com/nemunaire/repeater/internal/models" +) + +const ( + WLAN_INTERFACE = "wlan0" + WPA_CONF = "/etc/wpa_supplicant/wpa_supplicant.conf" + + // D-Bus constants for 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" +) + +var ( + dbusConn *dbus.Conn + wpaSupplicant dbus.BusObject +) + +// Initialize initializes the WiFi service with D-Bus connection +func Initialize() error { + var err error + dbusConn, err = dbus.SystemBus() + if err != nil { + return fmt.Errorf("failed to connect to D-Bus: %v", err) + } + + wpaSupplicant = dbusConn.Object(WPA_SUPPLICANT_SERVICE, dbus.ObjectPath(WPA_SUPPLICANT_PATH)) + return nil +} + +// Close closes the D-Bus connection +func Close() { + if dbusConn != nil { + dbusConn.Close() + } +} + +// ScanNetworks scans for available WiFi networks +func ScanNetworks() ([]models.WiFiNetwork, error) { + interfacePath, err := getWiFiInterfacePath() + if err != nil { + return nil, fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) + } + + // Trigger a 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) + } + + // Wait for scan to complete + time.Sleep(2 * time.Second) + + // Retrieve BSS list + 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 []models.WiFiNetwork + seenSSIDs := make(map[string]bool) + + for _, bssPath := range bssePaths.Value().([]dbus.ObjectPath) { + bss := dbusConn.Object(WPA_SUPPLICANT_SERVICE, bssPath) + + // Get BSS properties + 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 := models.WiFiNetwork{} + + // Extract SSID + if ssidBytes, ok := props["SSID"].Value().([]byte); ok { + network.SSID = string(ssidBytes) + } + + // Skip duplicates and empty SSIDs + if network.SSID == "" || seenSSIDs[network.SSID] { + continue + } + seenSSIDs[network.SSID] = true + + // Extract 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]) + } + + // Extract signal strength + if signal, ok := props["Signal"].Value().(int16); ok { + network.Signal = signalToStrength(int(signal)) + } + + // Extract frequency and calculate channel + if frequency, ok := props["Frequency"].Value().(uint16); ok { + network.Channel = frequencyToChannel(int(frequency)) + } + + // Determine security + 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) + } + + // Sort by signal strength + sort.Slice(networks, func(i, j int) bool { + return networks[i].Signal > networks[j].Signal + }) + + return networks, nil +} + +// Connect connects to a WiFi network using D-Bus +func Connect(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) + + // Create a new network + 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) + } + + // Select the network + 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) + } + + // Wait 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 { + interfacePath, err := getWiFiInterfacePath() + if err != nil { + return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) + } + + wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) + + // Disconnect + err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Disconnect", 0).Err + if err != nil { + return fmt.Errorf("erreur lors de la déconnexion: %v", err) + } + + // Remove all networks + 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 +} + +// IsConnected checks if WiFi is connected using D-Bus +func IsConnected() 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" +} + +// IsConnectedLegacy checks if WiFi is connected using iwconfig (fallback) +func IsConnectedLegacy() bool { + cmd := exec.Command("iwconfig", WLAN_INTERFACE) + output, err := cmd.Output() + if err != nil { + return false + } + + return strings.Contains(string(output), "Access Point:") +} + +// getWiFiInterfacePath retrieves the D-Bus path for the WiFi interface +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 +} + +// frequencyToChannel converts WiFi frequency to channel number +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 +} + +// 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/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/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; - } -}