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 = '';
+ }
+}
+
+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 = '';
+ } 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 = '';
+ 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
+
+
+
+
+
+
+
+
+
+
+
0
+
Appareils connectés
+
+
+
+
+
+
0 MB
+
Données utilisées
+
+
+
+
+
+
00:00:00
+
Temps de fonctionnement
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Aucun appareil connecté
+
+
+
+
+
+
+
+
+
+
+
Aucun log disponible
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
-
-
-
-
-
-
-
-
-
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;
- }
-}