Compare commits
No commits in common. "2922a037243549cf70cc00a42a36ea1f885eb34f" and "b3da6774acb691a6df2dc67436e9fa4abd870973" have entirely different histories.
2922a03724
...
b3da6774ac
61 changed files with 1540 additions and 7899 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,3 +1 @@
|
||||||
internal/api/routes.gen.go
|
repeater
|
||||||
internal/api/types.gen.go
|
|
||||||
/repeater
|
|
||||||
32
Makefile
32
Makefile
|
|
@ -1,32 +0,0 @@
|
||||||
.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
|
|
||||||
102
README.md
102
README.md
|
|
@ -1,102 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/nemunaire/repeater/internal/app"
|
|
||||||
"github.com/nemunaire/repeater/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed all:static
|
|
||||||
var assets embed.FS
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Load and parse options
|
|
||||||
cfg, err := config.ConsolidateConfig()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create application instance
|
|
||||||
application := app.New(assets)
|
|
||||||
|
|
||||||
// Initialize the application
|
|
||||||
if err := application.Initialize(cfg); err != nil {
|
|
||||||
log.Fatalf("Failed to initialize application: %v", err)
|
|
||||||
}
|
|
||||||
defer application.Shutdown()
|
|
||||||
|
|
||||||
// Handle graceful shutdown
|
|
||||||
sigChan := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
go func() {
|
|
||||||
<-sigChan
|
|
||||||
log.Println("Shutting down gracefully...")
|
|
||||||
application.Shutdown()
|
|
||||||
os.Exit(0)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Start the server
|
|
||||||
if err := application.Run(cfg.Bind); err != nil {
|
|
||||||
log.Fatalf("Failed to start server: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,791 +0,0 @@
|
||||||
// Application state
|
|
||||||
const appState = {
|
|
||||||
selectedWifi: null,
|
|
||||||
hotspotEnabled: true,
|
|
||||||
autoScrollLogs: true,
|
|
||||||
ws: null,
|
|
||||||
wifiWs: null,
|
|
||||||
reconnectAttempts: 0,
|
|
||||||
wifiReconnectAttempts: 0,
|
|
||||||
maxReconnectAttempts: 5,
|
|
||||||
connectedSSID: null,
|
|
||||||
connectionState: 'disconnected',
|
|
||||||
networks: [],
|
|
||||||
uptime: 0,
|
|
||||||
uptimeInterval: null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize the application
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
initializeApp();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function initializeApp() {
|
|
||||||
console.log('Initializing Travel Router Control Panel...');
|
|
||||||
|
|
||||||
// Load initial data
|
|
||||||
await Promise.all([
|
|
||||||
loadStatus(),
|
|
||||||
scanWifi(),
|
|
||||||
loadDevices(),
|
|
||||||
loadLogs()
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Set up WebSockets for real-time updates
|
|
||||||
connectWebSocket();
|
|
||||||
connectWifiWebSocket();
|
|
||||||
|
|
||||||
// Start periodic updates
|
|
||||||
startPeriodicUpdates();
|
|
||||||
|
|
||||||
// Start uptime counter
|
|
||||||
startUptimeCounter();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== API Functions =====
|
|
||||||
|
|
||||||
async function loadStatus() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/status');
|
|
||||||
const status = await response.json();
|
|
||||||
|
|
||||||
updateStatusDisplay(status);
|
|
||||||
return status;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading status:', error);
|
|
||||||
showToast('error', 'Erreur', 'Impossible de charger le statut');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scanWifi() {
|
|
||||||
const wifiList = document.getElementById('wifiList');
|
|
||||||
const scanBtn = document.querySelector('[onclick="scanWifi()"]');
|
|
||||||
|
|
||||||
if (scanBtn) {
|
|
||||||
scanBtn.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
wifiList.innerHTML = '<div class="wifi-item loading"><span>Recherche des réseaux disponibles...</span></div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/wifi/scan');
|
|
||||||
const networks = await response.json();
|
|
||||||
|
|
||||||
appState.networks = networks;
|
|
||||||
displayWifiNetworks(networks);
|
|
||||||
showToast('success', 'Scan WiFi', `${networks.length} réseau(x) trouvé(s)`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error scanning WiFi:', error);
|
|
||||||
|
|
||||||
// Fallback to cached networks
|
|
||||||
try {
|
|
||||||
const fallbackResponse = await fetch('/api/wifi/networks');
|
|
||||||
const cachedNetworks = await fallbackResponse.json();
|
|
||||||
|
|
||||||
appState.networks = cachedNetworks;
|
|
||||||
displayWifiNetworks(cachedNetworks);
|
|
||||||
showToast('warning', 'Scan échoué', `Affichage des réseaux en cache (${cachedNetworks.length})`);
|
|
||||||
} catch (fallbackError) {
|
|
||||||
console.error('Error loading cached networks:', fallbackError);
|
|
||||||
wifiList.innerHTML = '<div class="wifi-item loading"><span style="color: var(--danger-color);">Erreur lors du scan</span></div>';
|
|
||||||
showToast('error', 'Erreur', 'Échec du scan WiFi');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (scanBtn) {
|
|
||||||
scanBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectToWifi() {
|
|
||||||
if (!appState.selectedWifi) {
|
|
||||||
showToast('warning', 'Attention', 'Veuillez sélectionner un réseau WiFi');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const password = document.getElementById('wifiPassword').value;
|
|
||||||
|
|
||||||
// Password requirement disabled
|
|
||||||
// if (appState.selectedWifi.security !== 'Open' && !password) {
|
|
||||||
// showToast('warning', 'Attention', 'Mot de passe requis pour ce réseau');
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
showLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/wifi/connect', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
ssid: appState.selectedWifi.ssid,
|
|
||||||
password: password
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
showToast('success', 'Connecté', `Connexion établie avec ${appState.selectedWifi.ssid}`);
|
|
||||||
document.getElementById('wifiPassword').value = '';
|
|
||||||
await loadStatus();
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error || 'Connection failed');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error connecting to WiFi:', error);
|
|
||||||
showToast('error', 'Erreur', 'Échec de la connexion: ' + error.message);
|
|
||||||
} finally {
|
|
||||||
showLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function disconnectWifi() {
|
|
||||||
showLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/wifi/disconnect', {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
showToast('success', 'Déconnecté', 'Déconnexion WiFi réussie');
|
|
||||||
await loadStatus();
|
|
||||||
// Force refresh WiFi list to remove green highlighting
|
|
||||||
if (appState.networks.length > 0) {
|
|
||||||
displayWifiNetworks(appState.networks);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error('Disconnection failed');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error disconnecting WiFi:', error);
|
|
||||||
showToast('error', 'Erreur', 'Échec de la déconnexion');
|
|
||||||
} finally {
|
|
||||||
showLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleHotspot() {
|
|
||||||
const toggle = document.getElementById('hotspotToggle');
|
|
||||||
const enabled = toggle.checked;
|
|
||||||
|
|
||||||
showLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/hotspot/toggle', {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
appState.hotspotEnabled = result.enabled;
|
|
||||||
toggle.checked = result.enabled;
|
|
||||||
showToast('success', 'Hotspot', result.enabled ? 'Hotspot activé' : 'Hotspot désactivé');
|
|
||||||
await loadStatus();
|
|
||||||
} else {
|
|
||||||
toggle.checked = !enabled;
|
|
||||||
throw new Error('Toggle failed');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling hotspot:', error);
|
|
||||||
toggle.checked = !enabled;
|
|
||||||
showToast('error', 'Erreur', 'Échec du basculement');
|
|
||||||
} finally {
|
|
||||||
showLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDevices() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/devices');
|
|
||||||
const devices = await response.json();
|
|
||||||
|
|
||||||
displayDevices(devices);
|
|
||||||
document.getElementById('deviceCount').textContent = devices.length;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading devices:', error);
|
|
||||||
const devicesList = document.getElementById('devicesList');
|
|
||||||
devicesList.innerHTML = '<div class="device-placeholder"><p>Erreur de chargement</p></div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = '<div class="log-placeholder"><svg viewBox="0 0 24 24" width="48" height="48"><path fill="currentColor" opacity="0.3" d="M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,19H5V5H19V19Z"/></svg><p>Aucun log disponible</p></div>';
|
|
||||||
} 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 = '<div class="log-placeholder"><svg viewBox="0 0 24 24" width="48" height="48"><path fill="currentColor" opacity="0.3" d="M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,19H5V5H19V19Z"/></svg><p>Aucun log disponible</p></div>';
|
|
||||||
showToast('success', 'Logs', 'Logs effacés');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error clearing logs:', error);
|
|
||||||
showToast('error', 'Erreur', 'Échec de la suppression des logs');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Display Functions =====
|
|
||||||
|
|
||||||
function updateStatusDisplay(status) {
|
|
||||||
// Update WiFi status badge
|
|
||||||
const wifiStatus = document.getElementById('wifiStatus');
|
|
||||||
const wifiDot = wifiStatus.querySelector('.status-dot');
|
|
||||||
const wifiText = wifiStatus.querySelector('.status-text');
|
|
||||||
|
|
||||||
// Use connectionState for more detailed status
|
|
||||||
const state = status.connectionState || (status.connected ? 'connected' : 'disconnected');
|
|
||||||
appState.connectionState = state;
|
|
||||||
|
|
||||||
switch (state) {
|
|
||||||
case 'connected':
|
|
||||||
wifiDot.className = 'status-dot active';
|
|
||||||
wifiText.textContent = `Connecté: ${status.connectedSSID}`;
|
|
||||||
break;
|
|
||||||
case 'connecting':
|
|
||||||
wifiDot.className = 'status-dot connecting';
|
|
||||||
wifiText.textContent = status.connectedSSID ? `Connexion à ${status.connectedSSID}...` : 'Connexion...';
|
|
||||||
break;
|
|
||||||
case 'disconnecting':
|
|
||||||
wifiDot.className = 'status-dot disconnecting';
|
|
||||||
wifiText.textContent = 'Déconnexion...';
|
|
||||||
break;
|
|
||||||
case 'roaming':
|
|
||||||
wifiDot.className = 'status-dot active';
|
|
||||||
wifiText.textContent = `Roaming: ${status.connectedSSID}`;
|
|
||||||
break;
|
|
||||||
case 'disconnected':
|
|
||||||
default:
|
|
||||||
wifiDot.className = 'status-dot offline';
|
|
||||||
wifiText.textContent = 'Déconnecté';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update hotspot status badge
|
|
||||||
const hotspotStatus = document.getElementById('hotspotStatus');
|
|
||||||
const hotspotDot = hotspotStatus.querySelector('.status-dot');
|
|
||||||
const hotspotText = hotspotStatus.querySelector('.status-text');
|
|
||||||
const hotspotToggle = document.getElementById('hotspotToggle');
|
|
||||||
|
|
||||||
const isHotspotEnabled = status.hotspotStatus && status.hotspotStatus.state === 'ENABLED';
|
|
||||||
|
|
||||||
if (isHotspotEnabled) {
|
|
||||||
hotspotDot.className = 'status-dot active';
|
|
||||||
const numStations = status.hotspotStatus.numStations || 0;
|
|
||||||
hotspotText.textContent = `Hotspot actif (${numStations} client${numStations > 1 ? 's' : ''})`;
|
|
||||||
hotspotToggle.checked = true;
|
|
||||||
} else {
|
|
||||||
hotspotDot.className = 'status-dot offline';
|
|
||||||
hotspotText.textContent = 'Hotspot inactif';
|
|
||||||
hotspotToggle.checked = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
appState.hotspotEnabled = isHotspotEnabled;
|
|
||||||
|
|
||||||
// Update hotspot details if available
|
|
||||||
updateHotspotDetails(status.hotspotStatus);
|
|
||||||
|
|
||||||
// Check if connectedSSID or state changed and refresh WiFi list if needed
|
|
||||||
const prevSSID = appState.connectedSSID;
|
|
||||||
const prevState = appState.connectionState;
|
|
||||||
appState.connectedSSID = status.connectedSSID;
|
|
||||||
|
|
||||||
const connectedChanged = prevSSID !== status.connectedSSID || prevState !== state;
|
|
||||||
|
|
||||||
if (connectedChanged && appState.networks.length > 0) {
|
|
||||||
displayWifiNetworks(appState.networks);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
document.getElementById('connectedDevices').textContent = status.connectedCount;
|
|
||||||
document.getElementById('dataUsage').textContent = `${status.dataUsage.toFixed(1)} MB`;
|
|
||||||
|
|
||||||
// Update uptime in state (will be incremented by the interval)
|
|
||||||
appState.uptime = status.uptime;
|
|
||||||
document.getElementById('uptime').textContent = formatUptime(status.uptime);
|
|
||||||
|
|
||||||
document.getElementById('currentNetwork').textContent = status.connectedSSID || '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayWifiNetworks(networks) {
|
|
||||||
const wifiList = document.getElementById('wifiList');
|
|
||||||
wifiList.innerHTML = '';
|
|
||||||
|
|
||||||
if (networks.length === 0) {
|
|
||||||
wifiList.innerHTML = '<div class="wifi-item loading"><span>Aucun réseau trouvé</span></div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
networks.forEach(network => {
|
|
||||||
const wifiItem = document.createElement('div');
|
|
||||||
wifiItem.className = 'wifi-item';
|
|
||||||
|
|
||||||
// Mark the network based on connection state
|
|
||||||
if (appState.connectedSSID && network.ssid === appState.connectedSSID) {
|
|
||||||
switch (appState.connectionState) {
|
|
||||||
case 'connected':
|
|
||||||
case 'roaming':
|
|
||||||
wifiItem.classList.add('connected');
|
|
||||||
break;
|
|
||||||
case 'connecting':
|
|
||||||
wifiItem.classList.add('connecting');
|
|
||||||
break;
|
|
||||||
case 'disconnecting':
|
|
||||||
wifiItem.classList.add('disconnecting');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wifiItem.onclick = () => selectWifi(network, wifiItem);
|
|
||||||
|
|
||||||
wifiItem.innerHTML = `
|
|
||||||
<div class="wifi-info">
|
|
||||||
<div class="wifi-ssid">${escapeHtml(network.ssid)}</div>
|
|
||||||
<div class="wifi-details">
|
|
||||||
<span>${escapeHtml(network.security)}</span>
|
|
||||||
<span>Canal ${escapeHtml(String(network.channel))}</span>
|
|
||||||
<span>${escapeHtml(network.bssid)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="wifi-signal">
|
|
||||||
${generateSignalBars(network.signal)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
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 = `
|
|
||||||
<div class="device-placeholder">
|
|
||||||
<svg viewBox="0 0 24 24" width="48" height="48">
|
|
||||||
<path fill="currentColor" opacity="0.3" d="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2M12,20C7.58,20 4,16.42 4,12C4,7.58 7.58,4 12,4C16.42,4 20,7.58 20,12C20,16.42 16.42,20 12,20Z"/>
|
|
||||||
</svg>
|
|
||||||
<p>Aucun appareil connecté</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
devices.forEach(device => {
|
|
||||||
const deviceCard = document.createElement('div');
|
|
||||||
deviceCard.className = 'device-card';
|
|
||||||
|
|
||||||
deviceCard.innerHTML = `
|
|
||||||
${getDeviceIcon(device.type)}
|
|
||||||
<div class="device-name">${escapeHtml(device.name)}</div>
|
|
||||||
<div class="device-type">${device.type}</div>
|
|
||||||
<div class="device-info">
|
|
||||||
<div>${device.ip}</div>
|
|
||||||
<div style="font-size: 0.75rem;">${device.mac}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
devicesList.appendChild(deviceCard);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLogEntry(log) {
|
|
||||||
const logContainer = document.getElementById('logContainer');
|
|
||||||
|
|
||||||
// Remove placeholder if it exists
|
|
||||||
const placeholder = logContainer.querySelector('.log-placeholder');
|
|
||||||
if (placeholder) {
|
|
||||||
placeholder.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
const logEntry = document.createElement('div');
|
|
||||||
logEntry.className = 'log-entry';
|
|
||||||
|
|
||||||
const timestamp = new Date(log.timestamp).toLocaleTimeString('fr-FR');
|
|
||||||
|
|
||||||
const timestampSpan = document.createElement('span');
|
|
||||||
timestampSpan.className = 'log-timestamp';
|
|
||||||
timestampSpan.textContent = timestamp;
|
|
||||||
|
|
||||||
const sourceSpan = document.createElement('span');
|
|
||||||
sourceSpan.className = 'log-source';
|
|
||||||
sourceSpan.textContent = `[${log.source}]`;
|
|
||||||
|
|
||||||
const messageSpan = document.createElement('span');
|
|
||||||
messageSpan.className = 'log-message';
|
|
||||||
messageSpan.textContent = log.message;
|
|
||||||
|
|
||||||
logEntry.appendChild(timestampSpan);
|
|
||||||
logEntry.appendChild(sourceSpan);
|
|
||||||
logEntry.appendChild(messageSpan);
|
|
||||||
|
|
||||||
logContainer.appendChild(logEntry);
|
|
||||||
|
|
||||||
if (appState.autoScrollLogs) {
|
|
||||||
logContainer.scrollTop = logContainer.scrollHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHotspotInfoItem(label, value) {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'hotspot-info-item';
|
|
||||||
|
|
||||||
const labelSpan = document.createElement('span');
|
|
||||||
labelSpan.className = 'info-label';
|
|
||||||
labelSpan.textContent = label + ':';
|
|
||||||
|
|
||||||
const valueSpan = document.createElement('span');
|
|
||||||
valueSpan.className = 'info-value';
|
|
||||||
valueSpan.textContent = value;
|
|
||||||
|
|
||||||
item.appendChild(labelSpan);
|
|
||||||
item.appendChild(valueSpan);
|
|
||||||
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHotspotDetails(hotspotStatus) {
|
|
||||||
const detailsContainer = document.getElementById('hotspotDetails');
|
|
||||||
if (!detailsContainer) return;
|
|
||||||
|
|
||||||
// Clear existing content
|
|
||||||
detailsContainer.innerHTML = '';
|
|
||||||
|
|
||||||
if (!hotspotStatus || hotspotStatus.state !== 'ENABLED') {
|
|
||||||
detailsContainer.appendChild(createHotspotInfoItem('État', 'Inactif'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
detailsContainer.appendChild(createHotspotInfoItem('État', hotspotStatus.state));
|
|
||||||
|
|
||||||
if (hotspotStatus.ssid) {
|
|
||||||
detailsContainer.appendChild(createHotspotInfoItem('SSID', hotspotStatus.ssid));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hotspotStatus.channel) {
|
|
||||||
detailsContainer.appendChild(createHotspotInfoItem('Canal', `${hotspotStatus.channel} (${hotspotStatus.frequency} MHz)`));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hotspotStatus.numStations !== undefined) {
|
|
||||||
detailsContainer.appendChild(createHotspotInfoItem('Clients connectés', hotspotStatus.numStations.toString()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hotspotStatus.bssid) {
|
|
||||||
detailsContainer.appendChild(createHotspotInfoItem('BSSID', hotspotStatus.bssid));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== WebSocket Functions =====
|
|
||||||
|
|
||||||
function connectWebSocket() {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/logs`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
appState.ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
appState.ws.onopen = function() {
|
|
||||||
console.log('WebSocket connected');
|
|
||||||
appState.reconnectAttempts = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
appState.ws.onmessage = function(event) {
|
|
||||||
try {
|
|
||||||
const log = JSON.parse(event.data);
|
|
||||||
addLogEntry(log);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing WebSocket message:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
appState.ws.onerror = function(error) {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
};
|
|
||||||
|
|
||||||
appState.ws.onclose = function() {
|
|
||||||
console.log('WebSocket disconnected');
|
|
||||||
|
|
||||||
// Attempt to reconnect
|
|
||||||
if (appState.reconnectAttempts < appState.maxReconnectAttempts) {
|
|
||||||
appState.reconnectAttempts++;
|
|
||||||
setTimeout(connectWebSocket, 5000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating WebSocket:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectWifiWebSocket() {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/wifi`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
appState.wifiWs = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
appState.wifiWs.onopen = function() {
|
|
||||||
console.log('WiFi WebSocket connected');
|
|
||||||
appState.wifiReconnectAttempts = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
appState.wifiWs.onmessage = function(event) {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
handleWifiEvent(msg);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing WiFi WebSocket message:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
appState.wifiWs.onerror = function(error) {
|
|
||||||
console.error('WiFi WebSocket error:', error);
|
|
||||||
};
|
|
||||||
|
|
||||||
appState.wifiWs.onclose = function() {
|
|
||||||
console.log('WiFi WebSocket disconnected');
|
|
||||||
|
|
||||||
// Attempt to reconnect
|
|
||||||
if (appState.wifiReconnectAttempts < appState.maxReconnectAttempts) {
|
|
||||||
appState.wifiReconnectAttempts++;
|
|
||||||
setTimeout(connectWifiWebSocket, 5000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating WiFi WebSocket:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWifiEvent(event) {
|
|
||||||
console.log('WiFi event received:', event.type, event);
|
|
||||||
|
|
||||||
switch (event.type) {
|
|
||||||
case 'scan_update':
|
|
||||||
handleScanUpdate(event.data);
|
|
||||||
break;
|
|
||||||
case 'state_change':
|
|
||||||
handleStateChange(event.data);
|
|
||||||
break;
|
|
||||||
case 'signal_update':
|
|
||||||
handleSignalUpdate(event.data);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.warn('Unknown WiFi event type:', event.type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleScanUpdate(data) {
|
|
||||||
// Update the network list in real-time
|
|
||||||
appState.networks = data.networks;
|
|
||||||
displayWifiNetworks(data.networks);
|
|
||||||
console.log(`Scan update: ${data.networks.length} network(s) found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleStateChange(data) {
|
|
||||||
// Update WiFi status badge
|
|
||||||
const wifiStatus = document.getElementById('wifiStatus');
|
|
||||||
const wifiDot = wifiStatus.querySelector('.status-dot');
|
|
||||||
const wifiText = wifiStatus.querySelector('.status-text');
|
|
||||||
|
|
||||||
// Update state in appState
|
|
||||||
appState.connectionState = data.state;
|
|
||||||
|
|
||||||
switch (data.state) {
|
|
||||||
case 'connected':
|
|
||||||
wifiDot.className = 'status-dot active';
|
|
||||||
wifiText.textContent = `Connecté: ${data.ssid}`;
|
|
||||||
appState.connectedSSID = data.ssid;
|
|
||||||
|
|
||||||
// Refresh network list to show connected network
|
|
||||||
if (appState.networks.length > 0) {
|
|
||||||
displayWifiNetworks(appState.networks);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'connecting':
|
|
||||||
wifiDot.className = 'status-dot connecting';
|
|
||||||
wifiText.textContent = data.ssid ? `Connexion à ${data.ssid}...` : 'Connexion...';
|
|
||||||
|
|
||||||
// Refresh network list to show connecting state
|
|
||||||
if (appState.networks.length > 0) {
|
|
||||||
displayWifiNetworks(appState.networks);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'disconnecting':
|
|
||||||
wifiDot.className = 'status-dot disconnecting';
|
|
||||||
wifiText.textContent = 'Déconnexion...';
|
|
||||||
|
|
||||||
// Refresh network list to show disconnecting state
|
|
||||||
if (appState.networks.length > 0) {
|
|
||||||
displayWifiNetworks(appState.networks);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'roaming':
|
|
||||||
wifiDot.className = 'status-dot active';
|
|
||||||
wifiText.textContent = `Roaming: ${data.ssid}`;
|
|
||||||
appState.connectedSSID = data.ssid;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'disconnected':
|
|
||||||
wifiDot.className = 'status-dot offline';
|
|
||||||
wifiText.textContent = 'Déconnecté';
|
|
||||||
appState.connectedSSID = null;
|
|
||||||
|
|
||||||
// Refresh network list to remove connected highlighting
|
|
||||||
if (appState.networks.length > 0) {
|
|
||||||
displayWifiNetworks(appState.networks);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
console.warn('Unknown WiFi state:', data.state);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`WiFi state changed: ${data.previous_state} → ${data.state}`, data.ssid);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSignalUpdate(data) {
|
|
||||||
// Update signal strength display if needed
|
|
||||||
console.log(`Signal update for ${data.ssid}: ${data.signal}/5 (${data.dbm} dBm)`);
|
|
||||||
// Could update the network list to reflect new signal strength
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Utility Functions =====
|
|
||||||
|
|
||||||
function generateSignalBars(strength) {
|
|
||||||
const bars = [];
|
|
||||||
for (let i = 1; i <= 4; i++) {
|
|
||||||
const active = i <= strength ? 'active' : '';
|
|
||||||
bars.push(`<div class="signal-bar ${active}"></div>`);
|
|
||||||
}
|
|
||||||
return `<div class="signal-bars">${bars.join('')}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDeviceIcon(type) {
|
|
||||||
const icons = {
|
|
||||||
mobile: `<svg class="device-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M17,19H7V5H17M17,1H7C5.89,1 5,1.89 5,3V21A2,2 0 0,0 7,23H17A2,2 0 0,0 19,21V3C19,1.89 18.1,1 17,1Z"/></svg>`,
|
|
||||||
laptop: `<svg class="device-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z"/></svg>`,
|
|
||||||
tablet: `<svg class="device-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M19,18H5V6H19M21,4H3C1.89,4 1,4.89 1,6V18A2,2 0 0,0 3,20H21A2,2 0 0,0 23,18V6C23,4.89 22.1,4 21,4Z"/></svg>`,
|
|
||||||
desktop: `<svg class="device-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M21,16H3V4H21M21,2H3C1.89,2 1,2.89 1,4V16A2,2 0 0,0 3,18H10V20H8V22H16V20H14V18H21A2,2 0 0,0 23,16V4C23,2.89 22.1,2 21,2Z"/></svg>`,
|
|
||||||
unknown: `<svg class="device-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2Z"/></svg>`
|
|
||||||
};
|
|
||||||
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: '<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4M11,16.5L6.5,12L7.91,10.59L11,13.67L16.59,8.09L18,9.5L11,16.5Z"/></svg>',
|
|
||||||
error: '<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/></svg>',
|
|
||||||
warning: '<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M13,14H11V10H13M13,18H11V16H13M1,21H23L12,2L1,21Z"/></svg>',
|
|
||||||
info: '<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/></svg>'
|
|
||||||
};
|
|
||||||
|
|
||||||
toast.className = `toast ${type}`;
|
|
||||||
toastIcon.innerHTML = icons[type];
|
|
||||||
toastTitle.textContent = title;
|
|
||||||
toastMessage.textContent = message;
|
|
||||||
|
|
||||||
setTimeout(() => toast.classList.add('show'), 10);
|
|
||||||
|
|
||||||
setTimeout(() => toast.classList.remove('show'), 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideToast() {
|
|
||||||
document.getElementById('toast').classList.remove('show');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLoading(show) {
|
|
||||||
const overlay = document.getElementById('loadingOverlay');
|
|
||||||
if (show) {
|
|
||||||
overlay.classList.add('show');
|
|
||||||
} else {
|
|
||||||
overlay.classList.remove('show');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAutoScroll() {
|
|
||||||
appState.autoScrollLogs = !appState.autoScrollLogs;
|
|
||||||
const btn = document.getElementById('autoScrollBtn');
|
|
||||||
|
|
||||||
if (appState.autoScrollLogs) {
|
|
||||||
btn.style.opacity = '1';
|
|
||||||
showToast('info', 'Auto-scroll', 'Auto-scroll activé');
|
|
||||||
} else {
|
|
||||||
btn.style.opacity = '0.5';
|
|
||||||
showToast('info', 'Auto-scroll', 'Auto-scroll désactivé');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Periodic Updates =====
|
|
||||||
|
|
||||||
function startPeriodicUpdates() {
|
|
||||||
// Update status every 5 seconds
|
|
||||||
setInterval(() => {
|
|
||||||
loadStatus();
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
// Update devices every 10 seconds
|
|
||||||
setInterval(() => {
|
|
||||||
loadDevices();
|
|
||||||
}, 10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function startUptimeCounter() {
|
|
||||||
// Clear any existing interval
|
|
||||||
if (appState.uptimeInterval) {
|
|
||||||
clearInterval(appState.uptimeInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment uptime every second
|
|
||||||
appState.uptimeInterval = setInterval(() => {
|
|
||||||
appState.uptime++;
|
|
||||||
document.getElementById('uptime').textContent = formatUptime(appState.uptime);
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Travel Router Control Panel</title>
|
|
||||||
<link rel="stylesheet" href="/style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="header-content">
|
|
||||||
<h1>
|
|
||||||
<svg class="logo-icon" viewBox="0 0 24 24" width="36" height="36">
|
|
||||||
<path fill="currentColor" d="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2M12,20C7.58,20 4,16.42 4,12C4,7.58 7.58,4 12,4C16.42,4 20,7.58 20,12C20,16.42 16.42,20 12,20M16.59,7.58L10,14.17L7.41,11.59L6,13L10,17L18,9L16.59,7.58Z"/>
|
|
||||||
</svg>
|
|
||||||
Travel Router Control
|
|
||||||
</h1>
|
|
||||||
<div class="connection-info">
|
|
||||||
<div class="status-badge" id="wifiStatus">
|
|
||||||
<span class="status-dot offline"></span>
|
|
||||||
<span class="status-text">Déconnecté</span>
|
|
||||||
</div>
|
|
||||||
<div class="status-badge" id="hotspotStatus">
|
|
||||||
<span class="status-dot active"></span>
|
|
||||||
<span class="status-text">Hotspot actif</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon">
|
|
||||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
|
||||||
<path fill="currentColor" d="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2M12,20C7.58,20 4,16.42 4,12C4,7.58 7.58,4 12,4C16.42,4 20,7.58 20,12C20,16.42 16.42,20 12,20Z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-content">
|
|
||||||
<div class="stat-value" id="connectedDevices">0</div>
|
|
||||||
<div class="stat-label">Appareils connectés</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon">
|
|
||||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
|
||||||
<path fill="currentColor" d="M5,9.2H19V10.9H5V9.2M5,13H19V14.8H5V13M5,16.8H19V18.6H5V16.8Z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-content">
|
|
||||||
<div class="stat-value" id="dataUsage">0 MB</div>
|
|
||||||
<div class="stat-label">Données utilisées</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon">
|
|
||||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
|
||||||
<path fill="currentColor" d="M12,20A7,7 0 0,1 5,13A7,7 0 0,1 12,6A7,7 0 0,1 19,13A7,7 0 0,1 12,20M12,4A9,9 0 0,0 3,13A9,9 0 0,0 12,22A9,9 0 0,0 21,13A9,9 0 0,0 12,4M12.5,8H11V14L15.75,16.85L16.5,15.62L12.5,13.25V8Z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-content">
|
|
||||||
<div class="stat-value" id="uptime">00:00:00</div>
|
|
||||||
<div class="stat-label">Temps de fonctionnement</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon">
|
|
||||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
|
||||||
<path fill="currentColor" d="M1,9L2,11C4.97,8.03 9.03,6.17 13.5,6.17C17.97,6.17 22.03,8.03 25,11L23,9C19.68,5.68 15.09,3.5 10,3.5C4.91,3.5 0.32,5.68 -3,9L-1,11M12,21L18,15C16.34,13.34 13.34,12.34 10,12.34C6.66,12.34 3.66,13.34 2,15L8,21H12Z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-content">
|
|
||||||
<div class="stat-value" id="currentNetwork">-</div>
|
|
||||||
<div class="stat-label">Réseau actuel</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>
|
|
||||||
<svg class="icon" viewBox="0 0 24 24" width="20" height="20">
|
|
||||||
<path fill="currentColor" d="M1,9L2,11C4.97,8.03 9.03,6.17 13.5,6.17C17.97,6.17 22.03,8.03 25,11L23,9C19.68,5.68 15.09,3.5 10,3.5C4.91,3.5 0.32,5.68 -3,9L-1,11M12,21L18,15C16.34,13.34 13.34,12.34 10,12.34C6.66,12.34 3.66,13.34 2,15L8,21H12Z"/>
|
|
||||||
</svg>
|
|
||||||
Connexion WiFi Upstream
|
|
||||||
</h2>
|
|
||||||
<button class="btn-icon" onclick="scanWifi()" title="Scanner les réseaux">
|
|
||||||
<svg viewBox="0 0 24 24" width="20" height="20">
|
|
||||||
<path fill="currentColor" d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Réseaux disponibles</label>
|
|
||||||
<div class="wifi-list" id="wifiList">
|
|
||||||
<div class="wifi-item loading">
|
|
||||||
<span>Chargement des réseaux...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="wifiPassword">
|
|
||||||
<svg class="input-icon" viewBox="0 0 24 24" width="16" height="16">
|
|
||||||
<path fill="currentColor" d="M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z"/>
|
|
||||||
</svg>
|
|
||||||
Mot de passe
|
|
||||||
</label>
|
|
||||||
<input type="password" id="wifiPassword" placeholder="Entrez le mot de passe WiFi (laissez vide pour réseau ouvert)">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="button-group">
|
|
||||||
<button class="btn btn-primary" onclick="connectToWifi()">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18">
|
|
||||||
<path fill="currentColor" d="M17,7L15.59,8.41L18.17,11H8V13H18.17L15.59,15.58L17,17L22,12M4,5H12V3H4C2.89,3 2,3.89 2,5V19A2,2 0 0,0 4,21H12V19H4V5Z"/>
|
|
||||||
</svg>
|
|
||||||
<span id="connectBtn">Se connecter</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" onclick="disconnectWifi()">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18">
|
|
||||||
<path fill="currentColor" d="M16,17V14H9V10H16V7L21,12L16,17M14,2A2,2 0 0,1 16,4V6H14V4H5V20H14V18H16V20A2,2 0 0,1 14,22H5A2,2 0 0,1 3,20V4A2,2 0 0,1 5,2H14Z"/>
|
|
||||||
</svg>
|
|
||||||
Déconnecter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>
|
|
||||||
<svg class="icon" viewBox="0 0 24 24" width="20" height="20">
|
|
||||||
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4M12,6A6,6 0 0,0 6,12A6,6 0 0,0 12,18A6,6 0 0,0 18,12A6,6 0 0,0 12,6M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8Z"/>
|
|
||||||
</svg>
|
|
||||||
Hotspot Status
|
|
||||||
</h2>
|
|
||||||
<div class="toggle-switch">
|
|
||||||
<input type="checkbox" id="hotspotToggle" checked onchange="toggleHotspot()">
|
|
||||||
<label for="hotspotToggle"></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hotspot-details" id="hotspotDetails">
|
|
||||||
<div class="hotspot-info-item">
|
|
||||||
<span class="info-label">État:</span>
|
|
||||||
<span class="info-value">Chargement...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>
|
|
||||||
<svg class="icon" viewBox="0 0 24 24" width="20" height="20">
|
|
||||||
<path fill="currentColor" d="M16,17V19H2V17S2,13 9,13 16,17 16,17M12.5,7.5A3.5,3.5 0 0,1 9,11A3.5,3.5 0 0,1 5.5,7.5A3.5,3.5 0 0,1 9,4A3.5,3.5 0 0,1 12.5,7.5M15.94,13A5.32,5.32 0 0,1 18,17V19H22V17S22,13.37 15.94,13M15,4A3.39,3.39 0 0,0 13.07,4.59A5,5 0 0,1 13.07,10.41A3.39,3.39 0 0,0 15,11A3.5,3.5 0 0,0 18.5,7.5A3.5,3.5 0 0,0 15,4Z"/>
|
|
||||||
</svg>
|
|
||||||
Appareils connectés
|
|
||||||
</h2>
|
|
||||||
<span class="device-count" id="deviceCount">0</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="devices-grid" id="devicesList">
|
|
||||||
<div class="device-placeholder">
|
|
||||||
<svg viewBox="0 0 24 24" width="48" height="48">
|
|
||||||
<path fill="currentColor" opacity="0.3" d="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2M12,20C7.58,20 4,16.42 4,12C4,7.58 7.58,4 12,4C16.42,4 20,7.58 20,12C20,16.42 16.42,20 12,20Z"/>
|
|
||||||
</svg>
|
|
||||||
<p>Aucun appareil connecté</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2>
|
|
||||||
<svg class="icon" viewBox="0 0 24 24" width="20" height="20">
|
|
||||||
<path fill="currentColor" d="M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,19H5V5H19V19M13.96,12.29L11.21,15.83L9.25,13.47L6.5,17H17.5L13.96,12.29Z"/>
|
|
||||||
</svg>
|
|
||||||
Logs système
|
|
||||||
</h2>
|
|
||||||
<div class="log-controls">
|
|
||||||
<button class="btn-icon" onclick="clearLogs()" title="Effacer les logs">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18">
|
|
||||||
<path fill="currentColor" d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="btn-icon" onclick="toggleAutoScroll()" title="Auto-scroll" id="autoScrollBtn">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18">
|
|
||||||
<path fill="currentColor" d="M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="log-container" id="logContainer">
|
|
||||||
<div class="log-placeholder">
|
|
||||||
<svg viewBox="0 0 24 24" width="48" height="48">
|
|
||||||
<path fill="currentColor" opacity="0.3" d="M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,19H5V5H19V19Z"/>
|
|
||||||
</svg>
|
|
||||||
<p>Aucun log disponible</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Toast Notification -->
|
|
||||||
<div class="toast" id="toast">
|
|
||||||
<div class="toast-icon" id="toastIcon"></div>
|
|
||||||
<div class="toast-content">
|
|
||||||
<div class="toast-title" id="toastTitle"></div>
|
|
||||||
<div class="toast-message" id="toastMessage"></div>
|
|
||||||
</div>
|
|
||||||
<button class="toast-close" onclick="hideToast()">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18">
|
|
||||||
<path fill="currentColor" d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading Overlay -->
|
|
||||||
<div class="loading-overlay" id="loadingOverlay">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<p>Chargement...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,878 +0,0 @@
|
||||||
/* CSS Variables for theming */
|
|
||||||
:root {
|
|
||||||
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
--secondary-gradient: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|
||||||
--success-color: #10b981;
|
|
||||||
--danger-color: #ef4444;
|
|
||||||
--warning-color: #f59e0b;
|
|
||||||
--info-color: #3b82f6;
|
|
||||||
--background: #f3f4f6;
|
|
||||||
--card-background: #ffffff;
|
|
||||||
--text-primary: #1f2937;
|
|
||||||
--text-secondary: #6b7280;
|
|
||||||
--border-color: #e5e7eb;
|
|
||||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
|
||||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
||||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
|
||||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
|
||||||
--radius-sm: 0.375rem;
|
|
||||||
--radius-md: 0.5rem;
|
|
||||||
--radius-lg: 0.75rem;
|
|
||||||
--radius-xl: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
background: var(--primary-gradient);
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 1.5rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: rgba(255, 255, 255, 0.98);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
padding: 2rem;
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header Styles */
|
|
||||||
.header {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
border-bottom: 2px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-icon {
|
|
||||||
color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-info {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
background: var(--background);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 0.625rem;
|
|
||||||
height: 0.625rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.active {
|
|
||||||
background: var(--success-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.offline {
|
|
||||||
background: var(--text-secondary);
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.connecting {
|
|
||||||
background: var(--warning-color);
|
|
||||||
animation: blink 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.disconnecting {
|
|
||||||
background: var(--warning-color);
|
|
||||||
animation: blink 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.5; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.3; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stats Grid */
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: var(--card-background);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon {
|
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--primary-gradient);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon svg {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 1.875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grid Layout */
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card Styles */
|
|
||||||
.card {
|
|
||||||
background: var(--card-background);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header h2 {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Elements */
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-icon {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input,
|
|
||||||
.form-group select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
background: var(--card-background);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #667eea;
|
|
||||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
width: 100%;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--primary-gradient);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #4b5563;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: var(--danger-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background: #dc2626;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon:hover {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle Switch */
|
|
||||||
.toggle-switch {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 50px;
|
|
||||||
height: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch input {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch label {
|
|
||||||
position: absolute;
|
|
||||||
cursor: pointer;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: var(--text-secondary);
|
|
||||||
transition: 0.3s;
|
|
||||||
border-radius: 34px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch label:before {
|
|
||||||
position: absolute;
|
|
||||||
content: "";
|
|
||||||
height: 20px;
|
|
||||||
width: 20px;
|
|
||||||
left: 3px;
|
|
||||||
bottom: 3px;
|
|
||||||
background-color: white;
|
|
||||||
transition: 0.3s;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch input:checked + label {
|
|
||||||
background: var(--success-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch input:checked + label:before {
|
|
||||||
transform: translateX(24px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* WiFi List */
|
|
||||||
.wifi-list {
|
|
||||||
max-height: 320px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-item {
|
|
||||||
padding: 1rem;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--card-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-item:hover {
|
|
||||||
background: var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-item.selected {
|
|
||||||
background: #eff6ff;
|
|
||||||
border-left: 4px solid #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-item.connected {
|
|
||||||
background: #d1fae5;
|
|
||||||
border-left-color: var(--success-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-item.connected .wifi-ssid {
|
|
||||||
color: var(--success-color);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-item.connecting {
|
|
||||||
background: #fef3c7;
|
|
||||||
border-left-color: var(--warning-color) !important;
|
|
||||||
animation: pulse-item 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-item.connecting .wifi-ssid {
|
|
||||||
color: var(--warning-color);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-item.disconnecting {
|
|
||||||
background: #fee2e2;
|
|
||||||
border-left-color: #dc2626 !important;
|
|
||||||
animation: pulse-item 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-item.disconnecting .wifi-ssid {
|
|
||||||
color: #dc2626;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-item {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.7; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-item.loading {
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-ssid {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-details {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-signal {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signal-bars {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
align-items: flex-end;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signal-bar {
|
|
||||||
width: 3px;
|
|
||||||
background: var(--border-color);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signal-bar:nth-child(1) { height: 25%; }
|
|
||||||
.signal-bar:nth-child(2) { height: 50%; }
|
|
||||||
.signal-bar:nth-child(3) { height: 75%; }
|
|
||||||
.signal-bar:nth-child(4) { height: 100%; }
|
|
||||||
|
|
||||||
.signal-bar.active {
|
|
||||||
background: var(--success-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Devices */
|
|
||||||
.devices-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-card {
|
|
||||||
background: var(--background);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 1.25rem;
|
|
||||||
text-align: center;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
margin: 0 auto 0.75rem;
|
|
||||||
color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-type {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-info {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-placeholder {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 1rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-placeholder svg {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-count {
|
|
||||||
background: var(--primary-gradient);
|
|
||||||
color: white;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Logs */
|
|
||||||
.log-container {
|
|
||||||
background: #1f2937;
|
|
||||||
color: #10b981;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Consolas', 'Courier New', monospace;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
line-height: 1.6;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-timestamp {
|
|
||||||
color: #6b7280;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-source {
|
|
||||||
color: #3b82f6;
|
|
||||||
font-weight: 600;
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-message {
|
|
||||||
color: #d1d5db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-placeholder {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 1rem;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-placeholder svg {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toast Notifications */
|
|
||||||
.toast {
|
|
||||||
position: fixed;
|
|
||||||
top: 1.5rem;
|
|
||||||
right: 1.5rem;
|
|
||||||
max-width: 400px;
|
|
||||||
background: white;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
padding: 1rem;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: start;
|
|
||||||
transform: translateX(calc(100% + 2rem));
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
z-index: 1000;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.show {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.success .toast-icon {
|
|
||||||
background: #d1fae5;
|
|
||||||
color: var(--success-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.error .toast-icon {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: var(--danger-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.warning .toast-icon {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: var(--warning-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.info .toast-icon {
|
|
||||||
background: #dbeafe;
|
|
||||||
color: var(--info-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-content {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-title {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-message {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-close {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.25rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-close:hover {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading Overlay */
|
|
||||||
.loading-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 2000;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-overlay.show {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-overlay p {
|
|
||||||
color: white;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
|
||||||
border-top-color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar Styling */
|
|
||||||
.wifi-list::-webkit-scrollbar,
|
|
||||||
.log-container::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-list::-webkit-scrollbar-track,
|
|
||||||
.log-container::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-list::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-container::-webkit-scrollbar-thumb {
|
|
||||||
background: #374151;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi-list::-webkit-scrollbar-thumb:hover,
|
|
||||||
.log-container::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hotspot Details */
|
|
||||||
.hotspot-details {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: var(--background);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hotspot-info-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hotspot-info-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Design */
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
body {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-content {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
left: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.stat-card {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.devices-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
//go:generate go tool oapi-codegen -config oapi-types.cfg.yaml openapi.yaml
|
|
||||||
//go:generate go tool oapi-codegen -config oapi-gin.cfg.yaml openapi.yaml
|
|
||||||
55
go.mod
55
go.mod
|
|
@ -1,62 +1,9 @@
|
||||||
module github.com/nemunaire/repeater
|
module git.nemunai.re/nemunaire/repeater
|
||||||
|
|
||||||
go 1.24.4
|
go 1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/getkin/kin-openapi v0.132.0
|
|
||||||
github.com/gin-gonic/gin v1.11.0
|
|
||||||
github.com/godbus/dbus/v5 v5.1.0
|
github.com/godbus/dbus/v5 v5.1.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
|
||||||
github.com/go-openapi/swag v0.23.0 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect
|
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
|
||||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
|
||||||
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
|
|
||||||
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
|
||||||
golang.org/x/crypto v0.40.0 // indirect
|
|
||||||
golang.org/x/mod v0.25.0 // indirect
|
|
||||||
golang.org/x/net v0.42.0 // indirect
|
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
|
||||||
golang.org/x/text v0.27.0 // indirect
|
|
||||||
golang.org/x/tools v0.34.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
|
||||||
|
|
||||||
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
|
||||||
|
|
|
||||||
233
go.sum
233
go.sum
|
|
@ -1,239 +1,6 @@
|
||||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
|
||||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
|
||||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
|
||||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
|
||||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
|
||||||
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
|
|
||||||
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
|
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
|
||||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
|
||||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
|
||||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
|
||||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
|
||||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
|
||||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
|
||||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
|
||||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
|
||||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
|
||||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
|
||||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
|
||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
|
||||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
|
||||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
|
||||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
|
||||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
|
||||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU=
|
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w=
|
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
|
||||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
|
||||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
|
||||||
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
|
||||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
|
||||||
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
|
||||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
|
||||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
|
||||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
|
||||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
|
||||||
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
|
|
||||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
|
||||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
|
||||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
|
||||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
|
||||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
|
||||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
|
||||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
|
||||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
|
||||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
|
||||||
github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8=
|
|
||||||
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
|
|
||||||
github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
|
|
||||||
github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
|
||||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
|
||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
|
||||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
|
||||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
|
||||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
|
||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
|
||||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
|
||||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
|
||||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|
||||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
|
||||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
|
||||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
|
||||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
|
||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
|
||||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
|
||||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
|
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/nemunaire/repeater/internal/config"
|
|
||||||
"github.com/nemunaire/repeater/internal/hotspot"
|
|
||||||
"github.com/nemunaire/repeater/internal/logging"
|
|
||||||
"github.com/nemunaire/repeater/internal/models"
|
|
||||||
"github.com/nemunaire/repeater/internal/station"
|
|
||||||
"github.com/nemunaire/repeater/internal/wifi"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetWiFiNetworks returns cached WiFi networks without scanning
|
|
||||||
func GetWiFiNetworks(c *gin.Context) {
|
|
||||||
networks, err := wifi.GetCachedNetworks()
|
|
||||||
if err != nil {
|
|
||||||
logging.AddLog("WiFi", "Erreur lors de la récupération des réseaux: "+err.Error())
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des réseaux"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, networks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScanWiFi handles WiFi network scanning
|
|
||||||
func ScanWiFi(c *gin.Context) {
|
|
||||||
networks, err := wifi.ScanNetworks()
|
|
||||||
if err != nil {
|
|
||||||
logging.AddLog("WiFi", "Erreur lors du scan: "+err.Error())
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du scan WiFi"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logging.AddLog("WiFi", "Scan terminé - "+string(rune(len(networks)))+" réseaux trouvés")
|
|
||||||
c.JSON(http.StatusOK, networks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConnectWiFi handles WiFi connection requests
|
|
||||||
func ConnectWiFi(c *gin.Context) {
|
|
||||||
var req models.WiFiConnectRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logging.AddLog("WiFi", "Tentative de connexion à "+req.SSID)
|
|
||||||
|
|
||||||
err := wifi.Connect(req.SSID, req.Password)
|
|
||||||
if err != nil {
|
|
||||||
logging.AddLog("WiFi", "Échec de connexion: "+err.Error())
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Échec de connexion: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logging.AddLog("WiFi", "Connexion réussie à "+req.SSID)
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DisconnectWiFi handles WiFi disconnection
|
|
||||||
func DisconnectWiFi(c *gin.Context) {
|
|
||||||
logging.AddLog("WiFi", "Tentative de déconnexion")
|
|
||||||
|
|
||||||
err := wifi.Disconnect()
|
|
||||||
if err != nil {
|
|
||||||
logging.AddLog("WiFi", "Erreur de déconnexion: "+err.Error())
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de déconnexion: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logging.AddLog("WiFi", "Déconnexion réussie")
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConfigureHotspot handles hotspot configuration
|
|
||||||
func ConfigureHotspot(c *gin.Context) {
|
|
||||||
var config models.HotspotConfig
|
|
||||||
if err := c.ShouldBindJSON(&config); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := hotspot.Configure(config)
|
|
||||||
if err != nil {
|
|
||||||
logging.AddLog("Hotspot", "Erreur de configuration: "+err.Error())
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de configuration: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logging.AddLog("Hotspot", "Configuration mise à jour: "+config.SSID)
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToggleHotspot handles hotspot enable/disable
|
|
||||||
func ToggleHotspot(c *gin.Context, status *models.SystemStatus) {
|
|
||||||
// Determine current state
|
|
||||||
isEnabled := status.HotspotStatus != nil && status.HotspotStatus.State == "ENABLED"
|
|
||||||
|
|
||||||
var err error
|
|
||||||
if !isEnabled {
|
|
||||||
err = hotspot.Start()
|
|
||||||
logging.AddLog("Hotspot", "Hotspot activé")
|
|
||||||
} else {
|
|
||||||
err = hotspot.Stop()
|
|
||||||
logging.AddLog("Hotspot", "Hotspot désactivé")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logging.AddLog("Hotspot", "Erreur: "+err.Error())
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status immediately
|
|
||||||
status.HotspotStatus = hotspot.GetDetailedStatus()
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"enabled": !isEnabled})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDevices returns connected devices
|
|
||||||
func GetDevices(c *gin.Context, cfg *config.Config) {
|
|
||||||
devices, err := station.GetStations()
|
|
||||||
if err != nil {
|
|
||||||
logging.AddLog("Système", "Erreur lors de la récupération des appareils: "+err.Error())
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des appareils"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, devices)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStatus returns system status
|
|
||||||
func GetStatus(c *gin.Context, status *models.SystemStatus) {
|
|
||||||
c.JSON(http.StatusOK, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLogs returns system logs
|
|
||||||
func GetLogs(c *gin.Context) {
|
|
||||||
logs := logging.GetLogs()
|
|
||||||
c.JSON(http.StatusOK, logs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearLogs clears system logs
|
|
||||||
func ClearLogs(c *gin.Context) {
|
|
||||||
logging.ClearLogs()
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/nemunaire/repeater/internal/wifi"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WebSocketWifi handles WebSocket connections for real-time WiFi events
|
|
||||||
func WebSocketWifi(c *gin.Context) {
|
|
||||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Erreur WebSocket WiFi: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
// Register client
|
|
||||||
wifi.RegisterWebSocketClient(conn)
|
|
||||||
defer wifi.UnregisterWebSocketClient(conn)
|
|
||||||
|
|
||||||
// Keep connection alive
|
|
||||||
for {
|
|
||||||
_, _, err := conn.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/nemunaire/repeater/internal/api/handlers"
|
|
||||||
"github.com/nemunaire/repeater/internal/config"
|
|
||||||
"github.com/nemunaire/repeater/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SetupRouter creates and configures the Gin router
|
|
||||||
func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.FS) *gin.Engine {
|
|
||||||
// Set Gin to release mode (can be overridden with GIN_MODE env var)
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
|
||||||
|
|
||||||
r := gin.Default()
|
|
||||||
|
|
||||||
// API routes
|
|
||||||
api := r.Group("/api")
|
|
||||||
{
|
|
||||||
// WiFi endpoints
|
|
||||||
wifi := api.Group("/wifi")
|
|
||||||
{
|
|
||||||
wifi.GET("/networks", handlers.GetWiFiNetworks)
|
|
||||||
wifi.GET("/scan", handlers.ScanWiFi)
|
|
||||||
wifi.POST("/connect", handlers.ConnectWiFi)
|
|
||||||
wifi.POST("/disconnect", handlers.DisconnectWiFi)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hotspot endpoints
|
|
||||||
hotspot := api.Group("/hotspot")
|
|
||||||
{
|
|
||||||
hotspot.POST("/config", handlers.ConfigureHotspot)
|
|
||||||
hotspot.POST("/toggle", func(c *gin.Context) {
|
|
||||||
handlers.ToggleHotspot(c, status)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Device endpoints
|
|
||||||
api.GET("/devices", func(c *gin.Context) {
|
|
||||||
handlers.GetDevices(c, cfg)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Status endpoint
|
|
||||||
api.GET("/status", func(c *gin.Context) {
|
|
||||||
handlers.GetStatus(c, status)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Log endpoints
|
|
||||||
api.GET("/logs", handlers.GetLogs)
|
|
||||||
api.DELETE("/logs", handlers.ClearLogs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebSocket endpoints
|
|
||||||
r.GET("/ws/logs", handlers.WebSocketLogs)
|
|
||||||
r.GET("/ws/wifi", handlers.WebSocketWifi)
|
|
||||||
|
|
||||||
// Serve static files
|
|
||||||
sub, err := fs.Sub(assets, "static")
|
|
||||||
if err != nil {
|
|
||||||
panic("Unable to access static directory: " + err.Error())
|
|
||||||
}
|
|
||||||
r.NoRoute(gin.WrapH(http.FileServer(http.FS(sub))))
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nemunaire/repeater/internal/api"
|
|
||||||
"github.com/nemunaire/repeater/internal/config"
|
|
||||||
"github.com/nemunaire/repeater/internal/hotspot"
|
|
||||||
"github.com/nemunaire/repeater/internal/logging"
|
|
||||||
"github.com/nemunaire/repeater/internal/models"
|
|
||||||
"github.com/nemunaire/repeater/internal/station"
|
|
||||||
"github.com/nemunaire/repeater/internal/station/backend"
|
|
||||||
"github.com/nemunaire/repeater/internal/syslog"
|
|
||||||
"github.com/nemunaire/repeater/internal/wifi"
|
|
||||||
)
|
|
||||||
|
|
||||||
// App represents the application
|
|
||||||
type App struct {
|
|
||||||
Status models.SystemStatus
|
|
||||||
StatusMutex sync.RWMutex
|
|
||||||
StartTime time.Time
|
|
||||||
Assets embed.FS
|
|
||||||
Config *config.Config
|
|
||||||
SyslogTailer *syslog.SyslogTailer
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new application instance
|
|
||||||
func New(assets embed.FS) *App {
|
|
||||||
return &App{
|
|
||||||
Status: models.SystemStatus{
|
|
||||||
Connected: false,
|
|
||||||
ConnectionState: "disconnected",
|
|
||||||
ConnectedSSID: "",
|
|
||||||
HotspotStatus: nil,
|
|
||||||
ConnectedCount: 0,
|
|
||||||
DataUsage: 0.0,
|
|
||||||
Uptime: 0,
|
|
||||||
},
|
|
||||||
StartTime: time.Now(),
|
|
||||||
Assets: assets,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize initializes the application
|
|
||||||
func (a *App) Initialize(cfg *config.Config) error {
|
|
||||||
// Store config reference
|
|
||||||
a.Config = cfg
|
|
||||||
|
|
||||||
// Initialize WiFi backend
|
|
||||||
if err := wifi.Initialize(cfg.WifiInterface, cfg.WifiBackend); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start WiFi event monitoring
|
|
||||||
if err := wifi.StartEventMonitoring(); err != nil {
|
|
||||||
log.Printf("Warning: WiFi event monitoring failed: %v", err)
|
|
||||||
// Don't fail - polling fallback still works
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize station backend
|
|
||||||
stationConfig := backend.BackendConfig{
|
|
||||||
InterfaceName: cfg.HotspotInterface,
|
|
||||||
ARPTablePath: cfg.ARPTablePath,
|
|
||||||
DHCPLeasesPath: cfg.DHCPLeasesPath,
|
|
||||||
HostapdInterface: cfg.HotspotInterface,
|
|
||||||
}
|
|
||||||
if err := station.Initialize(cfg.StationBackend, stationConfig); err != nil {
|
|
||||||
log.Printf("Warning: Station backend initialization failed: %v", err)
|
|
||||||
// Don't fail - will continue without station discovery
|
|
||||||
} else {
|
|
||||||
// Start event monitoring for station events
|
|
||||||
if err := station.StartEventMonitoring(backend.EventCallbacks{
|
|
||||||
OnStationConnected: a.handleStationConnected,
|
|
||||||
OnStationDisconnected: a.handleStationDisconnected,
|
|
||||||
OnStationUpdated: a.handleStationUpdated,
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("Warning: Station event monitoring failed: %v", err)
|
|
||||||
// Don't fail - polling fallback still works
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start syslog tailing if enabled
|
|
||||||
if cfg.SyslogEnabled {
|
|
||||||
a.SyslogTailer = syslog.NewSyslogTailer(
|
|
||||||
cfg.SyslogPath,
|
|
||||||
cfg.SyslogFilter,
|
|
||||||
cfg.SyslogSource,
|
|
||||||
)
|
|
||||||
if err := a.SyslogTailer.Start(); err != nil {
|
|
||||||
log.Printf("Warning: Failed to start syslog tailing: %v", err)
|
|
||||||
// Don't fail - app continues without syslog
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start periodic tasks
|
|
||||||
go a.periodicStatusUpdate()
|
|
||||||
go a.periodicDeviceUpdate()
|
|
||||||
|
|
||||||
logging.AddLog("Système", "Application initialisée")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run starts the HTTP server
|
|
||||||
func (a *App) Run(addr string) error {
|
|
||||||
router := api.SetupRouter(&a.Status, a.Config, a.Assets)
|
|
||||||
|
|
||||||
logging.AddLog("Système", "Serveur API démarré sur "+addr)
|
|
||||||
return router.Run(addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shutdown gracefully shuts down the application
|
|
||||||
func (a *App) Shutdown() {
|
|
||||||
// Stop syslog tailing if running
|
|
||||||
if a.SyslogTailer != nil {
|
|
||||||
a.SyslogTailer.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop station monitoring and close backend
|
|
||||||
station.StopEventMonitoring()
|
|
||||||
station.Close()
|
|
||||||
|
|
||||||
wifi.StopEventMonitoring()
|
|
||||||
wifi.Close()
|
|
||||||
logging.AddLog("Système", "Application arrêtée")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSystemUptime reads system uptime from /proc/uptime
|
|
||||||
func getSystemUptime() int64 {
|
|
||||||
data, err := os.ReadFile("/proc/uptime")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error reading /proc/uptime: %v", err)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fields := strings.Fields(string(data))
|
|
||||||
if len(fields) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
uptime, err := strconv.ParseFloat(fields[0], 64)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error parsing uptime: %v", err)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return int64(uptime)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getInterfaceBytes reads rx and tx bytes for a network interface
|
|
||||||
func getInterfaceBytes(interfaceName string) (rxBytes, txBytes int64) {
|
|
||||||
rxPath := "/sys/class/net/" + interfaceName + "/statistics/rx_bytes"
|
|
||||||
txPath := "/sys/class/net/" + interfaceName + "/statistics/tx_bytes"
|
|
||||||
|
|
||||||
// Read RX bytes
|
|
||||||
rxData, err := os.ReadFile(rxPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error reading rx_bytes for %s: %v", interfaceName, err)
|
|
||||||
} else {
|
|
||||||
rxBytes, _ = strconv.ParseInt(strings.TrimSpace(string(rxData)), 10, 64)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read TX bytes
|
|
||||||
txData, err := os.ReadFile(txPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error reading tx_bytes for %s: %v", interfaceName, err)
|
|
||||||
} else {
|
|
||||||
txBytes, _ = strconv.ParseInt(strings.TrimSpace(string(txData)), 10, 64)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rxBytes, txBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
// periodicStatusUpdate updates WiFi connection status periodically
|
|
||||||
func (a *App) periodicStatusUpdate() {
|
|
||||||
ticker := time.NewTicker(5 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for range ticker.C {
|
|
||||||
a.StatusMutex.Lock()
|
|
||||||
a.Status.Connected = wifi.IsConnected()
|
|
||||||
a.Status.ConnectionState = wifi.GetConnectionState()
|
|
||||||
a.Status.ConnectedSSID = wifi.GetConnectedSSID()
|
|
||||||
a.Status.Uptime = getSystemUptime()
|
|
||||||
|
|
||||||
// Get detailed hotspot status
|
|
||||||
a.Status.HotspotStatus = hotspot.GetDetailedStatus()
|
|
||||||
|
|
||||||
// Get network data usage for WiFi interface
|
|
||||||
if a.Config != nil {
|
|
||||||
rxBytes, txBytes := getInterfaceBytes(a.Config.WifiInterface)
|
|
||||||
// Convert to MB and sum rx + tx
|
|
||||||
a.Status.DataUsage = float64(rxBytes+txBytes) / (1024 * 1024)
|
|
||||||
}
|
|
||||||
|
|
||||||
a.StatusMutex.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// periodicDeviceUpdate updates connected devices list periodically
|
|
||||||
func (a *App) periodicDeviceUpdate() {
|
|
||||||
ticker := time.NewTicker(10 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for range ticker.C {
|
|
||||||
devices, err := station.GetStations()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error getting connected devices: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
a.StatusMutex.Lock()
|
|
||||||
a.Status.ConnectedDevices = devices
|
|
||||||
a.Status.ConnectedCount = len(devices)
|
|
||||||
a.StatusMutex.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStationConnected handles station connection events
|
|
||||||
func (a *App) handleStationConnected(st backend.Station) {
|
|
||||||
a.StatusMutex.Lock()
|
|
||||||
defer a.StatusMutex.Unlock()
|
|
||||||
|
|
||||||
// Convert backend.Station to models.ConnectedDevice
|
|
||||||
device := models.ConnectedDevice{
|
|
||||||
Name: st.Hostname,
|
|
||||||
Type: st.Type,
|
|
||||||
MAC: st.MAC,
|
|
||||||
IP: st.IP,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if device already exists
|
|
||||||
found := false
|
|
||||||
for i, d := range a.Status.ConnectedDevices {
|
|
||||||
if d.MAC == device.MAC {
|
|
||||||
a.Status.ConnectedDevices[i] = device
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new device if not found
|
|
||||||
if !found {
|
|
||||||
a.Status.ConnectedDevices = append(a.Status.ConnectedDevices, device)
|
|
||||||
a.Status.ConnectedCount = len(a.Status.ConnectedDevices)
|
|
||||||
logging.AddLog("Stations", "Device connected: "+device.MAC+" ("+device.IP+")")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStationDisconnected handles station disconnection events
|
|
||||||
func (a *App) handleStationDisconnected(mac string) {
|
|
||||||
a.StatusMutex.Lock()
|
|
||||||
defer a.StatusMutex.Unlock()
|
|
||||||
|
|
||||||
// Remove device from list
|
|
||||||
for i, d := range a.Status.ConnectedDevices {
|
|
||||||
if d.MAC == mac {
|
|
||||||
a.Status.ConnectedDevices = append(a.Status.ConnectedDevices[:i], a.Status.ConnectedDevices[i+1:]...)
|
|
||||||
a.Status.ConnectedCount = len(a.Status.ConnectedDevices)
|
|
||||||
logging.AddLog("Stations", "Device disconnected: "+mac)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStationUpdated handles station update events
|
|
||||||
func (a *App) handleStationUpdated(st backend.Station) {
|
|
||||||
a.StatusMutex.Lock()
|
|
||||||
defer a.StatusMutex.Unlock()
|
|
||||||
|
|
||||||
// Update existing device
|
|
||||||
for i, d := range a.Status.ConnectedDevices {
|
|
||||||
if d.MAC == st.MAC {
|
|
||||||
a.Status.ConnectedDevices[i] = models.ConnectedDevice{
|
|
||||||
Name: st.Hostname,
|
|
||||||
Type: st.Type,
|
|
||||||
MAC: st.MAC,
|
|
||||||
IP: st.IP,
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
)
|
|
||||||
|
|
||||||
// declareFlags registers flags for the structure Options.
|
|
||||||
func declareFlags(o *Config) {
|
|
||||||
flag.StringVar(&o.Bind, "bind", ":8081", "Bind port/socket")
|
|
||||||
flag.StringVar(&o.WifiInterface, "wifi-interface", "wlan0", "WiFi interface name")
|
|
||||||
flag.StringVar(&o.HotspotInterface, "hotspot-interface", "wlan1", "Hotspot WiFi interface name")
|
|
||||||
flag.StringVar(&o.WifiBackend, "wifi-backend", "", "WiFi backend to use: 'iwd' or 'wpasupplicant' (required)")
|
|
||||||
flag.StringVar(&o.StationBackend, "station-backend", "hostapd", "Station discovery backend: 'arp', 'dhcp', or 'hostapd'")
|
|
||||||
flag.StringVar(&o.DHCPLeasesPath, "dhcp-leases-path", "/var/lib/dhcp/dhcpd.leases", "Path to DHCP leases file")
|
|
||||||
flag.StringVar(&o.ARPTablePath, "arp-table-path", "/proc/net/arp", "Path to ARP table file")
|
|
||||||
flag.BoolVar(&o.SyslogEnabled, "syslog-enabled", false, "Enable syslog tailing for iwd messages")
|
|
||||||
flag.StringVar(&o.SyslogPath, "syslog-path", "/var/log/messages", "Path to syslog file")
|
|
||||||
flag.Var(&StringArray{&o.SyslogFilter}, "daemon.info iwd:", "Filter string for syslog lines")
|
|
||||||
flag.StringVar(&o.SyslogSource, "syslog-source", "iwd", "Source name for syslog entries in logs")
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseCLI parse the flags and treats extra args as configuration filename.
|
|
||||||
func parseCLI(o *Config) error {
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
for _, conf := range flag.Args() {
|
|
||||||
err := parseFile(o, conf)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Bind string
|
|
||||||
WifiInterface string
|
|
||||||
HotspotInterface string
|
|
||||||
WifiBackend string
|
|
||||||
StationBackend string // "arp", "dhcp", or "hostapd"
|
|
||||||
DHCPLeasesPath string
|
|
||||||
ARPTablePath string
|
|
||||||
SyslogEnabled bool
|
|
||||||
SyslogPath string
|
|
||||||
SyslogFilter []string
|
|
||||||
SyslogSource string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConsolidateConfig fills an Options struct by reading configuration from
|
|
||||||
// config files, environment, then command line.
|
|
||||||
//
|
|
||||||
// Should be called only one time.
|
|
||||||
func ConsolidateConfig() (opts *Config, err error) {
|
|
||||||
// Define defaults options
|
|
||||||
opts = &Config{
|
|
||||||
Bind: ":8080",
|
|
||||||
WifiInterface: "wlan0",
|
|
||||||
HotspotInterface: "wlan1",
|
|
||||||
DHCPLeasesPath: "/var/lib/dhcp/dhcpd.leases",
|
|
||||||
ARPTablePath: "/proc/net/arp",
|
|
||||||
SyslogEnabled: false,
|
|
||||||
SyslogPath: "/var/log/messages",
|
|
||||||
SyslogFilter: []string{"daemon.info wpa_supplicant:", "daemon.info iwd:", "daemon.info hostapd:"},
|
|
||||||
SyslogSource: "iwd",
|
|
||||||
}
|
|
||||||
|
|
||||||
declareFlags(opts)
|
|
||||||
|
|
||||||
// Establish a list of possible configuration file locations
|
|
||||||
configLocations := []string{
|
|
||||||
"repeater.conf",
|
|
||||||
}
|
|
||||||
|
|
||||||
if home, err := os.UserConfigDir(); err == nil {
|
|
||||||
configLocations = append(configLocations, path.Join(home, "repeater", "repeater.conf"))
|
|
||||||
}
|
|
||||||
|
|
||||||
configLocations = append(configLocations, path.Join("etc", "repeater.conf"))
|
|
||||||
|
|
||||||
// If config file exists, read configuration from it
|
|
||||||
for _, filename := range configLocations {
|
|
||||||
if _, e := os.Stat(filename); !os.IsNotExist(e) {
|
|
||||||
log.Printf("Loading configuration from %s\n", filename)
|
|
||||||
err = parseFile(opts, filename)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then, overwrite that by what is present in the environment
|
|
||||||
err = parseEnvironmentVariables(opts)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finaly, command line takes precedence
|
|
||||||
err = parseCLI(opts)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate configuration
|
|
||||||
if opts.WifiBackend != "iwd" && opts.WifiBackend != "wpasupplicant" {
|
|
||||||
log.Fatalf("wifi-backend must be set to 'iwd' or 'wpasupplicant' (got: '%s')", opts.WifiBackend)
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.StationBackend != "arp" && opts.StationBackend != "dhcp" && opts.StationBackend != "hostapd" {
|
|
||||||
log.Fatalf("station-backend must be set to 'arp', 'dhcp', or 'hostapd' (got: '%s')", opts.StationBackend)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseLine treats a config line and place the read value in the variable
|
|
||||||
// declared to the corresponding flag.
|
|
||||||
func parseLine(o *Config, line string) (err error) {
|
|
||||||
fields := strings.SplitN(line, "=", 2)
|
|
||||||
orig_key := strings.TrimSpace(fields[0])
|
|
||||||
value := strings.TrimSpace(fields[1])
|
|
||||||
|
|
||||||
key := strings.TrimPrefix(orig_key, "REPEATER_")
|
|
||||||
key = strings.Replace(key, "_", "-", -1)
|
|
||||||
key = strings.ToLower(key)
|
|
||||||
|
|
||||||
err = flag.Set(key, value)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StringArray is a custom type for handling multiple string values in flags.
|
|
||||||
type StringArray struct {
|
|
||||||
Array *[]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns a string representation of the StringArray.
|
|
||||||
func (i *StringArray) String() string {
|
|
||||||
return fmt.Sprintf("%v", i.Array)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set appends a new string value to the StringArray.
|
|
||||||
func (i *StringArray) Set(value string) error {
|
|
||||||
*i.Array = append(*i.Array, value)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type URL struct {
|
|
||||||
URL *url.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *URL) String() string {
|
|
||||||
if i.URL != nil {
|
|
||||||
return i.URL.String()
|
|
||||||
} else {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *URL) Set(value string) error {
|
|
||||||
u, err := url.Parse(value)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
*i.URL = *u
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// parseEnvironmentVariables analyzes all the environment variables to find
|
|
||||||
// each one starting by REPEATER_
|
|
||||||
func parseEnvironmentVariables(o *Config) (err error) {
|
|
||||||
for _, line := range os.Environ() {
|
|
||||||
if strings.HasPrefix(line, "REPEATER_") {
|
|
||||||
err := parseLine(o, line)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error in environment (%q): %w", line, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// parseFile opens the file at the given filename path, then treat each line
|
|
||||||
// not starting with '#' as a configuration statement.
|
|
||||||
func parseFile(o *Config, filename string) error {
|
|
||||||
fp, err := os.Open(filename)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer fp.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(fp)
|
|
||||||
n := 0
|
|
||||||
for scanner.Scan() {
|
|
||||||
n += 1
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
|
|
||||||
err := parseLine(o, line)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%v:%d: error in configuration: %w", filename, n, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
package hotspot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/nemunaire/repeater/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
AP_INTERFACE = "wlan1"
|
|
||||||
HOSTAPD_CONF = "/etc/hostapd/hostapd.conf"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Configure updates the hotspot configuration
|
|
||||||
func Configure(config models.HotspotConfig) error {
|
|
||||||
hostapdConfig := fmt.Sprintf(`interface=%s
|
|
||||||
driver=nl80211
|
|
||||||
ssid=%s
|
|
||||||
hw_mode=g
|
|
||||||
channel=%d
|
|
||||||
wmm_enabled=0
|
|
||||||
macaddr_acl=0
|
|
||||||
auth_algs=1
|
|
||||||
ignore_broadcast_ssid=0
|
|
||||||
wpa=2
|
|
||||||
wpa_passphrase=%s
|
|
||||||
wpa_key_mgmt=WPA-PSK
|
|
||||||
wpa_pairwise=TKIP
|
|
||||||
rsn_pairwise=CCMP
|
|
||||||
`, AP_INTERFACE, config.SSID, config.Channel, config.Password)
|
|
||||||
|
|
||||||
return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start starts the hotspot
|
|
||||||
func Start() error {
|
|
||||||
cmd := exec.Command("/etc/init.d/hostapd", "start")
|
|
||||||
return cmd.Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops the hotspot
|
|
||||||
func Stop() error {
|
|
||||||
cmd := exec.Command("/etc/init.d/hostapd", "stop")
|
|
||||||
return cmd.Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status checks if the hotspot is running.
|
|
||||||
// Returns nil if the service is running, or an error if it's stopped or crashed.
|
|
||||||
func Status() error {
|
|
||||||
cmd := exec.Command("/etc/init.d/hostapd", "status")
|
|
||||||
return cmd.Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDetailedStatus retrieves detailed status information from hostapd_cli.
|
|
||||||
// Returns nil if hostapd is not running or if there's an error.
|
|
||||||
func GetDetailedStatus() *models.HotspotStatus {
|
|
||||||
cmd := exec.Command("hostapd_cli", "status")
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
status := &models.HotspotStatus{}
|
|
||||||
lines := strings.Split(string(output), "\n")
|
|
||||||
|
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" || strings.HasPrefix(line, "Selected interface") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.SplitN(line, "=", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
key := strings.TrimSpace(parts[0])
|
|
||||||
value := strings.TrimSpace(parts[1])
|
|
||||||
|
|
||||||
switch key {
|
|
||||||
case "state":
|
|
||||||
status.State = value
|
|
||||||
case "channel":
|
|
||||||
if ch, err := strconv.Atoi(value); err == nil {
|
|
||||||
status.Channel = ch
|
|
||||||
}
|
|
||||||
case "freq":
|
|
||||||
if freq, err := strconv.Atoi(value); err == nil {
|
|
||||||
status.Frequency = freq
|
|
||||||
}
|
|
||||||
case "ssid[0]":
|
|
||||||
status.SSID = value
|
|
||||||
case "bssid[0]":
|
|
||||||
status.BSSID = value
|
|
||||||
case "num_sta[0]":
|
|
||||||
if num, err := strconv.Atoi(value); err == nil {
|
|
||||||
status.NumStations = num
|
|
||||||
}
|
|
||||||
case "hw_mode":
|
|
||||||
status.HWMode = value
|
|
||||||
case "country_code":
|
|
||||||
status.CountryCode = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
package models
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// WiFiNetwork represents a discovered WiFi network
|
|
||||||
type WiFiNetwork struct {
|
|
||||||
SSID string `json:"ssid"`
|
|
||||||
Signal int `json:"signal"`
|
|
||||||
Security string `json:"security"`
|
|
||||||
Channel int `json:"channel"`
|
|
||||||
BSSID string `json:"bssid"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConnectedDevice represents a device connected to the hotspot
|
|
||||||
type ConnectedDevice struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
MAC string `json:"mac"`
|
|
||||||
IP string `json:"ip"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HotspotConfig represents hotspot configuration
|
|
||||||
type HotspotConfig struct {
|
|
||||||
SSID string `json:"ssid"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
Channel int `json:"channel"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HotspotStatus represents detailed hotspot status
|
|
||||||
type HotspotStatus struct {
|
|
||||||
State string `json:"state"` // ENABLED, DISABLED, etc.
|
|
||||||
SSID string `json:"ssid"` // Current SSID being broadcast
|
|
||||||
BSSID string `json:"bssid"` // MAC address of the AP
|
|
||||||
Channel int `json:"channel"` // Current channel
|
|
||||||
Frequency int `json:"frequency"` // Frequency in MHz
|
|
||||||
NumStations int `json:"numStations"` // Number of connected stations
|
|
||||||
HWMode string `json:"hwMode"` // Hardware mode (g, a, n, ac, etc.)
|
|
||||||
CountryCode string `json:"countryCode"` // Country code
|
|
||||||
}
|
|
||||||
|
|
||||||
// SystemStatus represents overall system status
|
|
||||||
type SystemStatus struct {
|
|
||||||
Connected bool `json:"connected"`
|
|
||||||
ConnectionState string `json:"connectionState"` // Connection state: connected, disconnected, connecting, disconnecting, roaming
|
|
||||||
ConnectedSSID string `json:"connectedSSID"`
|
|
||||||
HotspotStatus *HotspotStatus `json:"hotspotStatus,omitempty"` // Detailed hotspot status
|
|
||||||
ConnectedCount int `json:"connectedCount"`
|
|
||||||
DataUsage float64 `json:"dataUsage"`
|
|
||||||
Uptime int64 `json:"uptime"`
|
|
||||||
ConnectedDevices []ConnectedDevice `json:"connectedDevices"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// WiFiConnectRequest represents a request to connect to WiFi
|
|
||||||
type WiFiConnectRequest struct {
|
|
||||||
SSID string `json:"ssid" binding:"required"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogEntry represents a system log entry
|
|
||||||
type LogEntry struct {
|
|
||||||
Timestamp time.Time `json:"timestamp"`
|
|
||||||
Source string `json:"source"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DHCPLease represents a DHCP lease entry
|
|
||||||
type DHCPLease struct {
|
|
||||||
IP string
|
|
||||||
MAC string
|
|
||||||
Hostname string
|
|
||||||
}
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
package arp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nemunaire/repeater/internal/station/backend"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Backend implements StationBackend using ARP table discovery
|
|
||||||
type Backend struct {
|
|
||||||
arpTablePath string
|
|
||||||
lastStations map[string]backend.Station // Key: MAC address
|
|
||||||
callbacks backend.EventCallbacks
|
|
||||||
stopChan chan struct{}
|
|
||||||
mu sync.RWMutex
|
|
||||||
running bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBackend creates a new ARP backend
|
|
||||||
func NewBackend() *Backend {
|
|
||||||
return &Backend{
|
|
||||||
lastStations: make(map[string]backend.Station),
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize initializes the ARP backend
|
|
||||||
func (b *Backend) Initialize(config backend.BackendConfig) error {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
b.arpTablePath = config.ARPTablePath
|
|
||||||
if b.arpTablePath == "" {
|
|
||||||
b.arpTablePath = "/proc/net/arp"
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close cleans up backend resources
|
|
||||||
func (b *Backend) Close() error {
|
|
||||||
b.StopEventMonitoring()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStations returns all connected stations from ARP table
|
|
||||||
func (b *Backend) GetStations() ([]backend.Station, error) {
|
|
||||||
b.mu.RLock()
|
|
||||||
arpTablePath := b.arpTablePath
|
|
||||||
b.mu.RUnlock()
|
|
||||||
|
|
||||||
arpEntries, err := parseARPTable(arpTablePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var stations []backend.Station
|
|
||||||
for _, entry := range arpEntries {
|
|
||||||
// Only include entries with valid flags (2 = COMPLETE, 6 = COMPLETE|PERM)
|
|
||||||
if entry.Flags == 2 || entry.Flags == 6 {
|
|
||||||
st := backend.Station{
|
|
||||||
MAC: entry.HWAddress.String(),
|
|
||||||
IP: entry.IP.String(),
|
|
||||||
Hostname: "", // No hostname available from ARP
|
|
||||||
Type: backend.GuessDeviceType("", entry.HWAddress.String()),
|
|
||||||
Signal: 0, // Not available from ARP
|
|
||||||
RxBytes: 0, // Not available from ARP
|
|
||||||
TxBytes: 0, // Not available from ARP
|
|
||||||
ConnectedAt: time.Now(),
|
|
||||||
}
|
|
||||||
stations = append(stations, st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return stations, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartEventMonitoring starts monitoring for station events via polling
|
|
||||||
func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
if b.running {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
b.callbacks = callbacks
|
|
||||||
b.running = true
|
|
||||||
|
|
||||||
// Start polling goroutine
|
|
||||||
go b.pollLoop()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopEventMonitoring stops event monitoring
|
|
||||||
func (b *Backend) StopEventMonitoring() {
|
|
||||||
b.mu.Lock()
|
|
||||||
if !b.running {
|
|
||||||
b.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b.running = false
|
|
||||||
b.mu.Unlock()
|
|
||||||
|
|
||||||
close(b.stopChan)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SupportsRealTimeEvents returns false (ARP is polling-based)
|
|
||||||
func (b *Backend) SupportsRealTimeEvents() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollLoop polls the ARP table and simulates events
|
|
||||||
func (b *Backend) pollLoop() {
|
|
||||||
ticker := time.NewTicker(10 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
b.checkForChanges()
|
|
||||||
case <-b.stopChan:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkForChanges compares current state with last state and triggers callbacks
|
|
||||||
func (b *Backend) checkForChanges() {
|
|
||||||
// Get current stations
|
|
||||||
current, err := b.GetStations()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build map of current stations
|
|
||||||
currentMap := make(map[string]backend.Station)
|
|
||||||
for _, station := range current {
|
|
||||||
currentMap[station.MAC] = station
|
|
||||||
}
|
|
||||||
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
// Check for new stations (connected)
|
|
||||||
for mac, station := range currentMap {
|
|
||||||
if _, exists := b.lastStations[mac]; !exists {
|
|
||||||
// New station connected
|
|
||||||
if b.callbacks.OnStationConnected != nil {
|
|
||||||
go b.callbacks.OnStationConnected(station)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Check for updates (IP change, etc.)
|
|
||||||
oldStation := b.lastStations[mac]
|
|
||||||
if oldStation.IP != station.IP || oldStation.Hostname != station.Hostname {
|
|
||||||
if b.callbacks.OnStationUpdated != nil {
|
|
||||||
go b.callbacks.OnStationUpdated(station)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for disconnected stations
|
|
||||||
for mac := range b.lastStations {
|
|
||||||
if _, exists := currentMap[mac]; !exists {
|
|
||||||
// Station disconnected
|
|
||||||
if b.callbacks.OnStationDisconnected != nil {
|
|
||||||
go b.callbacks.OnStationDisconnected(mac)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last state
|
|
||||||
b.lastStations = currentMap
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
package arp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ARPEntry represents an entry in the ARP table
|
|
||||||
type ARPEntry struct {
|
|
||||||
IP net.IP
|
|
||||||
HWType int
|
|
||||||
Flags int
|
|
||||||
HWAddress net.HardwareAddr
|
|
||||||
Mask string
|
|
||||||
Device string
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseARPTable reads and parses ARP table from /proc/net/arp format
|
|
||||||
func parseARPTable(path string) ([]ARPEntry, error) {
|
|
||||||
var entries []ARPEntry
|
|
||||||
|
|
||||||
content, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return entries, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range strings.Split(string(content), "\n") {
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) > 5 {
|
|
||||||
var entry ARPEntry
|
|
||||||
|
|
||||||
// Parse HWType (hex format)
|
|
||||||
if _, err := fmt.Sscanf(fields[1], "0x%x", &entry.HWType); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse Flags (hex format)
|
|
||||||
if _, err := fmt.Sscanf(fields[2], "0x%x", &entry.Flags); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse IP address
|
|
||||||
entry.IP = net.ParseIP(fields[0])
|
|
||||||
if entry.IP == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse MAC address
|
|
||||||
entry.HWAddress, err = net.ParseMAC(fields[3])
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.Mask = fields[4]
|
|
||||||
entry.Device = fields[5]
|
|
||||||
|
|
||||||
entries = append(entries, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StationBackend defines the interface for station/device discovery backends.
|
|
||||||
// Implementations include ARP-based, DHCP-based, and Hostapd DBus-based discovery.
|
|
||||||
type StationBackend interface {
|
|
||||||
// Initialize initializes the backend with the given configuration
|
|
||||||
Initialize(config BackendConfig) error
|
|
||||||
|
|
||||||
// Close cleans up backend resources
|
|
||||||
Close() error
|
|
||||||
|
|
||||||
// GetStations returns all currently connected stations
|
|
||||||
GetStations() ([]Station, error)
|
|
||||||
|
|
||||||
// StartEventMonitoring starts monitoring for station events
|
|
||||||
// Backends that don't support real-time events will poll and simulate events
|
|
||||||
StartEventMonitoring(callbacks EventCallbacks) error
|
|
||||||
|
|
||||||
// StopEventMonitoring stops event monitoring
|
|
||||||
StopEventMonitoring()
|
|
||||||
|
|
||||||
// SupportsRealTimeEvents returns true if backend supports real-time events (e.g., DBus)
|
|
||||||
// Returns false for polling-based backends (ARP, DHCP)
|
|
||||||
SupportsRealTimeEvents() bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Station represents a connected device in a backend-agnostic format
|
|
||||||
type Station struct {
|
|
||||||
MAC string // Hardware MAC address (required, primary identifier)
|
|
||||||
IP string // IP address (may be empty for some backends initially)
|
|
||||||
Hostname string // Device hostname (may be empty)
|
|
||||||
Type string // Device type: "mobile", "laptop", "tablet", "unknown"
|
|
||||||
Signal int32 // Signal strength in dBm (0 if not available)
|
|
||||||
RxBytes uint64 // Received bytes (0 if not available)
|
|
||||||
TxBytes uint64 // Transmitted bytes (0 if not available)
|
|
||||||
ConnectedAt time.Time // When station connected (best effort)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EventCallbacks defines callback functions for station events.
|
|
||||||
// Backends call these when stations connect, disconnect, or update.
|
|
||||||
type EventCallbacks struct {
|
|
||||||
// OnStationConnected is called when a new station connects
|
|
||||||
OnStationConnected func(station Station)
|
|
||||||
|
|
||||||
// OnStationDisconnected is called when a station disconnects
|
|
||||||
OnStationDisconnected func(mac string)
|
|
||||||
|
|
||||||
// OnStationUpdated is called when station information changes
|
|
||||||
// (e.g., IP discovered, signal strength changed)
|
|
||||||
OnStationUpdated func(station Station)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BackendConfig provides configuration for backend initialization
|
|
||||||
type BackendConfig struct {
|
|
||||||
// Common
|
|
||||||
InterfaceName string // Network interface (e.g., "wlan1")
|
|
||||||
|
|
||||||
// ARP-specific
|
|
||||||
ARPTablePath string // Path to /proc/net/arp (default: "/proc/net/arp")
|
|
||||||
|
|
||||||
// DHCP-specific
|
|
||||||
DHCPLeasesPath string // Path to DHCP leases file
|
|
||||||
|
|
||||||
// Hostapd-specific
|
|
||||||
HostapdInterface string // Hostapd interface name for DBus
|
|
||||||
}
|
|
||||||
|
|
||||||
// GuessDeviceType attempts to guess device type from hostname and MAC address
|
|
||||||
func GuessDeviceType(hostname, mac string) string {
|
|
||||||
hostname = strings.ToLower(hostname)
|
|
||||||
|
|
||||||
if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") {
|
|
||||||
return "mobile"
|
|
||||||
} else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") {
|
|
||||||
return "tablet"
|
|
||||||
} else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") {
|
|
||||||
return "laptop"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guess by MAC prefix (OUI)
|
|
||||||
if len(mac) >= 8 {
|
|
||||||
macPrefix := strings.ToUpper(mac[:8])
|
|
||||||
switch macPrefix {
|
|
||||||
case "00:50:56", "00:0C:29", "00:05:69": // VMware
|
|
||||||
return "laptop"
|
|
||||||
case "08:00:27": // VirtualBox
|
|
||||||
return "laptop"
|
|
||||||
default:
|
|
||||||
return "mobile"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
package dhcp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nemunaire/repeater/internal/station/backend"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Backend implements StationBackend using DHCP lease discovery
|
|
||||||
type Backend struct {
|
|
||||||
dhcpLeasesPath string
|
|
||||||
lastStations map[string]backend.Station // Key: MAC address
|
|
||||||
callbacks backend.EventCallbacks
|
|
||||||
stopChan chan struct{}
|
|
||||||
mu sync.RWMutex
|
|
||||||
running bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBackend creates a new DHCP backend
|
|
||||||
func NewBackend() *Backend {
|
|
||||||
return &Backend{
|
|
||||||
lastStations: make(map[string]backend.Station),
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize initializes the DHCP backend
|
|
||||||
func (b *Backend) Initialize(config backend.BackendConfig) error {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
b.dhcpLeasesPath = config.DHCPLeasesPath
|
|
||||||
if b.dhcpLeasesPath == "" {
|
|
||||||
b.dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases"
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close cleans up backend resources
|
|
||||||
func (b *Backend) Close() error {
|
|
||||||
b.StopEventMonitoring()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStations returns all connected stations from DHCP leases validated by ARP
|
|
||||||
func (b *Backend) GetStations() ([]backend.Station, error) {
|
|
||||||
b.mu.RLock()
|
|
||||||
dhcpLeasesPath := b.dhcpLeasesPath
|
|
||||||
b.mu.RUnlock()
|
|
||||||
|
|
||||||
// Read DHCP leases
|
|
||||||
leases, err := parseDHCPLeases(dhcpLeasesPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get ARP information for validation
|
|
||||||
arpInfo, err := getARPInfo()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var stations []backend.Station
|
|
||||||
for _, lease := range leases {
|
|
||||||
// Check if the device is still connected via ARP
|
|
||||||
if _, exists := arpInfo[lease.IP]; exists {
|
|
||||||
st := backend.Station{
|
|
||||||
MAC: lease.MAC,
|
|
||||||
IP: lease.IP,
|
|
||||||
Hostname: lease.Hostname,
|
|
||||||
Type: backend.GuessDeviceType(lease.Hostname, lease.MAC),
|
|
||||||
Signal: 0, // Not available from DHCP
|
|
||||||
RxBytes: 0, // Not available from DHCP
|
|
||||||
TxBytes: 0, // Not available from DHCP
|
|
||||||
ConnectedAt: time.Now(),
|
|
||||||
}
|
|
||||||
stations = append(stations, st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return stations, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartEventMonitoring starts monitoring for station events via polling
|
|
||||||
func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
if b.running {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
b.callbacks = callbacks
|
|
||||||
b.running = true
|
|
||||||
|
|
||||||
// Start polling goroutine
|
|
||||||
go b.pollLoop()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopEventMonitoring stops event monitoring
|
|
||||||
func (b *Backend) StopEventMonitoring() {
|
|
||||||
b.mu.Lock()
|
|
||||||
if !b.running {
|
|
||||||
b.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b.running = false
|
|
||||||
b.mu.Unlock()
|
|
||||||
|
|
||||||
close(b.stopChan)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SupportsRealTimeEvents returns false (DHCP is polling-based)
|
|
||||||
func (b *Backend) SupportsRealTimeEvents() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollLoop polls DHCP leases and simulates events
|
|
||||||
func (b *Backend) pollLoop() {
|
|
||||||
ticker := time.NewTicker(10 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
b.checkForChanges()
|
|
||||||
case <-b.stopChan:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkForChanges compares current state with last state and triggers callbacks
|
|
||||||
func (b *Backend) checkForChanges() {
|
|
||||||
// Get current stations
|
|
||||||
current, err := b.GetStations()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build map of current stations
|
|
||||||
currentMap := make(map[string]backend.Station)
|
|
||||||
for _, st := range current {
|
|
||||||
currentMap[st.MAC] = st
|
|
||||||
}
|
|
||||||
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
// Check for new stations (connected)
|
|
||||||
for mac, st := range currentMap {
|
|
||||||
if _, exists := b.lastStations[mac]; !exists {
|
|
||||||
// New station connected
|
|
||||||
if b.callbacks.OnStationConnected != nil {
|
|
||||||
go b.callbacks.OnStationConnected(st)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Check for updates (IP change, hostname change, etc.)
|
|
||||||
oldStation := b.lastStations[mac]
|
|
||||||
if oldStation.IP != st.IP || oldStation.Hostname != st.Hostname {
|
|
||||||
if b.callbacks.OnStationUpdated != nil {
|
|
||||||
go b.callbacks.OnStationUpdated(st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for disconnected stations
|
|
||||||
for mac := range b.lastStations {
|
|
||||||
if _, exists := currentMap[mac]; !exists {
|
|
||||||
// Station disconnected
|
|
||||||
if b.callbacks.OnStationDisconnected != nil {
|
|
||||||
go b.callbacks.OnStationDisconnected(mac)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last state
|
|
||||||
b.lastStations = currentMap
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
package dhcp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/nemunaire/repeater/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// parseDHCPLeases reads and parses DHCP lease file
|
|
||||||
func parseDHCPLeases(path string) ([]models.DHCPLease, error) {
|
|
||||||
var leases []models.DHCPLease
|
|
||||||
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return leases, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
var currentLease models.DHCPLease
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
|
|
||||||
if strings.HasPrefix(line, "lease ") {
|
|
||||||
ip := strings.Fields(line)[1]
|
|
||||||
currentLease = models.DHCPLease{IP: ip}
|
|
||||||
} else if strings.Contains(line, "hardware ethernet") {
|
|
||||||
mac := strings.Fields(line)[2]
|
|
||||||
mac = strings.TrimSuffix(mac, ";")
|
|
||||||
currentLease.MAC = mac
|
|
||||||
} else if strings.Contains(line, "client-hostname") {
|
|
||||||
hostname := strings.Fields(line)[1]
|
|
||||||
hostname = strings.Trim(hostname, `";`)
|
|
||||||
currentLease.Hostname = hostname
|
|
||||||
} else if line == "}" {
|
|
||||||
if currentLease.IP != "" && currentLease.MAC != "" {
|
|
||||||
leases = append(leases, currentLease)
|
|
||||||
}
|
|
||||||
currentLease = models.DHCPLease{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return leases, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getARPInfo retrieves ARP table information using arp command
|
|
||||||
// Returns a map of IP -> MAC address
|
|
||||||
func getARPInfo() (map[string]string, error) {
|
|
||||||
arpInfo := make(map[string]string)
|
|
||||||
|
|
||||||
cmd := exec.Command("arp", "-a")
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return arpInfo, err
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(string(output), "\n")
|
|
||||||
for _, line := range lines {
|
|
||||||
if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 {
|
|
||||||
ip := matches[1]
|
|
||||||
mac := matches[2]
|
|
||||||
arpInfo[ip] = mac
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return arpInfo, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
package station
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/nemunaire/repeater/internal/station/arp"
|
|
||||||
"github.com/nemunaire/repeater/internal/station/backend"
|
|
||||||
"github.com/nemunaire/repeater/internal/station/dhcp"
|
|
||||||
"github.com/nemunaire/repeater/internal/station/hostapd"
|
|
||||||
)
|
|
||||||
|
|
||||||
// createBackend creates a station backend based on the backend name
|
|
||||||
func createBackend(backendName string) (backend.StationBackend, error) {
|
|
||||||
switch backendName {
|
|
||||||
case "arp":
|
|
||||||
return arp.NewBackend(), nil
|
|
||||||
case "dhcp":
|
|
||||||
return dhcp.NewBackend(), nil
|
|
||||||
case "hostapd":
|
|
||||||
return hostapd.NewBackend(), nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("invalid station backend: %s (must be 'arp', 'dhcp', or 'hostapd')", backendName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,345 +0,0 @@
|
||||||
package hostapd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nemunaire/repeater/internal/station/backend"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Backend implements StationBackend using hostapd_cli
|
|
||||||
type Backend struct {
|
|
||||||
interfaceName string
|
|
||||||
hostapdCLI string // Path to hostapd_cli executable
|
|
||||||
|
|
||||||
stations map[string]*HostapdStation // Key: MAC address
|
|
||||||
callbacks backend.EventCallbacks
|
|
||||||
|
|
||||||
mu sync.RWMutex
|
|
||||||
running bool
|
|
||||||
stopCh chan struct{}
|
|
||||||
|
|
||||||
// IP correlation - will be populated by periodic DHCP lease correlation
|
|
||||||
ipByMAC map[string]string // MAC -> IP mapping
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBackend creates a new hostapd backend
|
|
||||||
func NewBackend() *Backend {
|
|
||||||
return &Backend{
|
|
||||||
stations: make(map[string]*HostapdStation),
|
|
||||||
ipByMAC: make(map[string]string),
|
|
||||||
hostapdCLI: "hostapd_cli",
|
|
||||||
stopCh: make(chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize initializes the hostapd backend
|
|
||||||
func (b *Backend) Initialize(config backend.BackendConfig) error {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
b.interfaceName = config.InterfaceName
|
|
||||||
if b.interfaceName == "" {
|
|
||||||
b.interfaceName = "wlan1" // Default AP interface
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if hostapd_cli is available
|
|
||||||
if _, err := exec.LookPath(b.hostapdCLI); err != nil {
|
|
||||||
return fmt.Errorf("hostapd_cli not found in PATH: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify we can communicate with hostapd
|
|
||||||
if err := b.runCommand("ping"); err != nil {
|
|
||||||
return fmt.Errorf("failed to communicate with hostapd on interface %s: %w", b.interfaceName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Hostapd backend initialized for interface %s", b.interfaceName)
|
|
||||||
|
|
||||||
// Load initial station list
|
|
||||||
if err := b.loadStations(); err != nil {
|
|
||||||
log.Printf("Warning: Failed to load initial stations: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close cleans up backend resources
|
|
||||||
func (b *Backend) Close() error {
|
|
||||||
b.StopEventMonitoring()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// runCommand executes a hostapd_cli command and returns the output
|
|
||||||
func (b *Backend) runCommand(args ...string) error {
|
|
||||||
cmdArgs := []string{"-i", b.interfaceName}
|
|
||||||
cmdArgs = append(cmdArgs, args...)
|
|
||||||
cmd := exec.Command(b.hostapdCLI, cmdArgs...)
|
|
||||||
return cmd.Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// runCommandOutput executes a hostapd_cli command and returns the output
|
|
||||||
func (b *Backend) runCommandOutput(args ...string) (string, error) {
|
|
||||||
cmdArgs := []string{"-i", b.interfaceName}
|
|
||||||
cmdArgs = append(cmdArgs, args...)
|
|
||||||
cmd := exec.Command(b.hostapdCLI, cmdArgs...)
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(out), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStations returns all connected stations
|
|
||||||
func (b *Backend) GetStations() ([]backend.Station, error) {
|
|
||||||
b.mu.RLock()
|
|
||||||
defer b.mu.RUnlock()
|
|
||||||
|
|
||||||
stations := make([]backend.Station, 0, len(b.stations))
|
|
||||||
for mac, hs := range b.stations {
|
|
||||||
station := b.convertStation(mac, hs)
|
|
||||||
stations = append(stations, station)
|
|
||||||
}
|
|
||||||
|
|
||||||
return stations, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartEventMonitoring starts monitoring for station events via polling
|
|
||||||
func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
if b.running {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
b.callbacks = callbacks
|
|
||||||
b.running = true
|
|
||||||
|
|
||||||
// Start polling goroutine
|
|
||||||
go b.pollStations()
|
|
||||||
|
|
||||||
log.Printf("Hostapd event monitoring started (polling mode)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopEventMonitoring stops event monitoring
|
|
||||||
func (b *Backend) StopEventMonitoring() {
|
|
||||||
b.mu.Lock()
|
|
||||||
if !b.running {
|
|
||||||
b.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b.running = false
|
|
||||||
b.mu.Unlock()
|
|
||||||
|
|
||||||
close(b.stopCh)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SupportsRealTimeEvents returns false (hostapd_cli uses polling)
|
|
||||||
func (b *Backend) SupportsRealTimeEvents() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollStations periodically polls for station changes
|
|
||||||
func (b *Backend) pollStations() {
|
|
||||||
ticker := time.NewTicker(2 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-b.stopCh:
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
if err := b.checkStationChanges(); err != nil {
|
|
||||||
log.Printf("Error polling stations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkStationChanges checks for station connect/disconnect events
|
|
||||||
func (b *Backend) checkStationChanges() error {
|
|
||||||
// Get current stations from hostapd
|
|
||||||
currentStations, err := b.fetchStations()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
// Build a map of current MACs
|
|
||||||
currentMACs := make(map[string]bool)
|
|
||||||
for mac := range currentStations {
|
|
||||||
currentMACs[mac] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for new stations
|
|
||||||
for mac, station := range currentStations {
|
|
||||||
if _, exists := b.stations[mac]; !exists {
|
|
||||||
// New station connected
|
|
||||||
b.stations[mac] = station
|
|
||||||
if b.callbacks.OnStationConnected != nil {
|
|
||||||
st := b.convertStation(mac, station)
|
|
||||||
go b.callbacks.OnStationConnected(st)
|
|
||||||
}
|
|
||||||
log.Printf("Station connected: %s", mac)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for removed stations
|
|
||||||
for mac := range b.stations {
|
|
||||||
if !currentMACs[mac] {
|
|
||||||
// Station disconnected
|
|
||||||
delete(b.stations, mac)
|
|
||||||
delete(b.ipByMAC, mac)
|
|
||||||
if b.callbacks.OnStationDisconnected != nil {
|
|
||||||
go b.callbacks.OnStationDisconnected(mac)
|
|
||||||
}
|
|
||||||
log.Printf("Station disconnected: %s", mac)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadStations loads the initial list of stations from hostapd
|
|
||||||
func (b *Backend) loadStations() error {
|
|
||||||
stations, err := b.fetchStations()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
b.stations = stations
|
|
||||||
log.Printf("Loaded %d initial stations from hostapd", len(b.stations))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchStations fetches all stations using hostapd_cli all_sta command
|
|
||||||
func (b *Backend) fetchStations() (map[string]*HostapdStation, error) {
|
|
||||||
output, err := b.runCommandOutput("all_sta")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get stations: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.parseAllStaOutput(output), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseAllStaOutput parses the output of "hostapd_cli all_sta"
|
|
||||||
func (b *Backend) parseAllStaOutput(output string) map[string]*HostapdStation {
|
|
||||||
stations := make(map[string]*HostapdStation)
|
|
||||||
scanner := bufio.NewScanner(bytes.NewBufferString(output))
|
|
||||||
|
|
||||||
var currentMAC string
|
|
||||||
var currentStation *HostapdStation
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a MAC address line (starts the station block)
|
|
||||||
if !strings.Contains(line, "=") && len(line) == 17 && strings.Count(line, ":") == 5 {
|
|
||||||
// Save previous station if exists
|
|
||||||
if currentMAC != "" && currentStation != nil {
|
|
||||||
stations[currentMAC] = currentStation
|
|
||||||
}
|
|
||||||
// Start new station
|
|
||||||
currentMAC = strings.ToLower(line)
|
|
||||||
currentStation = &HostapdStation{}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse key=value pairs
|
|
||||||
if currentStation != nil && strings.Contains(line, "=") {
|
|
||||||
parts := strings.SplitN(line, "=", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.TrimSpace(parts[0])
|
|
||||||
value := strings.TrimSpace(parts[1])
|
|
||||||
|
|
||||||
switch key {
|
|
||||||
case "signal":
|
|
||||||
if v, err := strconv.Atoi(value); err == nil {
|
|
||||||
currentStation.Signal = int32(v)
|
|
||||||
}
|
|
||||||
case "rx_bytes":
|
|
||||||
if v, err := strconv.ParseUint(value, 10, 64); err == nil {
|
|
||||||
currentStation.RxBytes = v
|
|
||||||
}
|
|
||||||
case "tx_bytes":
|
|
||||||
if v, err := strconv.ParseUint(value, 10, 64); err == nil {
|
|
||||||
currentStation.TxBytes = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save last station
|
|
||||||
if currentMAC != "" && currentStation != nil {
|
|
||||||
stations[currentMAC] = currentStation
|
|
||||||
}
|
|
||||||
|
|
||||||
return stations
|
|
||||||
}
|
|
||||||
|
|
||||||
// convertStation converts HostapdStation to backend.Station
|
|
||||||
func (b *Backend) convertStation(mac string, hs *HostapdStation) backend.Station {
|
|
||||||
// Get IP address if available from correlation
|
|
||||||
ip := b.ipByMAC[mac]
|
|
||||||
|
|
||||||
// Attempt hostname resolution if we have an IP
|
|
||||||
hostname := ""
|
|
||||||
// TODO: Could do reverse DNS lookup here if needed
|
|
||||||
|
|
||||||
return backend.Station{
|
|
||||||
MAC: mac,
|
|
||||||
IP: ip,
|
|
||||||
Hostname: hostname,
|
|
||||||
Type: backend.GuessDeviceType(hostname, mac),
|
|
||||||
Signal: hs.Signal,
|
|
||||||
RxBytes: hs.RxBytes,
|
|
||||||
TxBytes: hs.TxBytes,
|
|
||||||
ConnectedAt: time.Now(), // We don't have exact connection time
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateIPMapping updates the MAC -> IP mapping from external source (e.g., DHCP)
|
|
||||||
// This should be called periodically to correlate hostapd stations with IP addresses
|
|
||||||
func (b *Backend) UpdateIPMapping(macToIP map[string]string) {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
// Track which stations got IP updates
|
|
||||||
updated := make(map[string]bool)
|
|
||||||
|
|
||||||
for mac, ip := range macToIP {
|
|
||||||
if oldIP, exists := b.ipByMAC[mac]; exists && oldIP != ip {
|
|
||||||
// IP changed
|
|
||||||
updated[mac] = true
|
|
||||||
} else if !exists {
|
|
||||||
// New IP mapping
|
|
||||||
updated[mac] = true
|
|
||||||
}
|
|
||||||
b.ipByMAC[mac] = ip
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger update callbacks for stations that got new/changed IPs
|
|
||||||
for mac := range updated {
|
|
||||||
if station, exists := b.stations[mac]; exists {
|
|
||||||
if b.callbacks.OnStationUpdated != nil {
|
|
||||||
st := b.convertStation(mac, station)
|
|
||||||
go b.callbacks.OnStationUpdated(st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
package hostapd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nemunaire/repeater/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DHCPCorrelator helps correlate hostapd stations with DHCP leases to discover IP addresses
|
|
||||||
type DHCPCorrelator struct {
|
|
||||||
backend *Backend
|
|
||||||
dhcpLeasesPath string
|
|
||||||
stopChan chan struct{}
|
|
||||||
running bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewDHCPCorrelator creates a new DHCP correlator
|
|
||||||
func NewDHCPCorrelator(backend *Backend, dhcpLeasesPath string) *DHCPCorrelator {
|
|
||||||
if dhcpLeasesPath == "" {
|
|
||||||
dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases"
|
|
||||||
}
|
|
||||||
|
|
||||||
return &DHCPCorrelator{
|
|
||||||
backend: backend,
|
|
||||||
dhcpLeasesPath: dhcpLeasesPath,
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start begins periodic correlation of DHCP leases with hostapd stations
|
|
||||||
func (dc *DHCPCorrelator) Start() {
|
|
||||||
if dc.running {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dc.running = true
|
|
||||||
go dc.correlationLoop()
|
|
||||||
log.Printf("DHCP correlation started for hostapd backend")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops the correlation loop
|
|
||||||
func (dc *DHCPCorrelator) Stop() {
|
|
||||||
if !dc.running {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dc.running = false
|
|
||||||
close(dc.stopChan)
|
|
||||||
log.Printf("DHCP correlation stopped")
|
|
||||||
}
|
|
||||||
|
|
||||||
// correlationLoop periodically correlates DHCP leases with stations
|
|
||||||
func (dc *DHCPCorrelator) correlationLoop() {
|
|
||||||
// Do an initial correlation immediately
|
|
||||||
dc.correlate()
|
|
||||||
|
|
||||||
// Then correlate every 10 seconds
|
|
||||||
ticker := time.NewTicker(10 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
dc.correlate()
|
|
||||||
case <-dc.stopChan:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// correlate performs one correlation cycle
|
|
||||||
func (dc *DHCPCorrelator) correlate() {
|
|
||||||
// Parse DHCP leases
|
|
||||||
leases, err := parseDHCPLeases(dc.dhcpLeasesPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Warning: Failed to parse DHCP leases: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build MAC -> IP mapping
|
|
||||||
macToIP := make(map[string]string)
|
|
||||||
for _, lease := range leases {
|
|
||||||
macToIP[lease.MAC] = lease.IP
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update backend with IP mappings
|
|
||||||
dc.backend.UpdateIPMapping(macToIP)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDHCPLeases reads and parses DHCP lease file
|
|
||||||
func parseDHCPLeases(path string) ([]models.DHCPLease, error) {
|
|
||||||
var leases []models.DHCPLease
|
|
||||||
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return leases, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
var currentLease models.DHCPLease
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
|
|
||||||
if strings.HasPrefix(line, "lease ") {
|
|
||||||
ip := strings.Fields(line)[1]
|
|
||||||
currentLease = models.DHCPLease{IP: ip}
|
|
||||||
} else if strings.Contains(line, "hardware ethernet") {
|
|
||||||
mac := strings.Fields(line)[2]
|
|
||||||
mac = strings.TrimSuffix(mac, ";")
|
|
||||||
currentLease.MAC = mac
|
|
||||||
} else if strings.Contains(line, "client-hostname") {
|
|
||||||
hostname := strings.Fields(line)[1]
|
|
||||||
hostname = strings.Trim(hostname, `";`)
|
|
||||||
currentLease.Hostname = hostname
|
|
||||||
} else if line == "}" {
|
|
||||||
if currentLease.IP != "" && currentLease.MAC != "" {
|
|
||||||
leases = append(leases, currentLease)
|
|
||||||
}
|
|
||||||
currentLease = models.DHCPLease{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return leases, scanner.Err()
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
package hostapd
|
|
||||||
|
|
||||||
// HostapdStation represents station properties from hostapd_cli
|
|
||||||
type HostapdStation struct {
|
|
||||||
RxPackets uint64
|
|
||||||
TxPackets uint64
|
|
||||||
RxBytes uint64
|
|
||||||
TxBytes uint64
|
|
||||||
Signal int32 // Signal in dBm
|
|
||||||
}
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
package station
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/nemunaire/repeater/internal/models"
|
|
||||||
"github.com/nemunaire/repeater/internal/station/backend"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
currentBackend backend.StationBackend
|
|
||||||
mu sync.RWMutex
|
|
||||||
)
|
|
||||||
|
|
||||||
// Initialize initializes the station discovery backend
|
|
||||||
func Initialize(backendName string, config backend.BackendConfig) error {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
// Close existing backend if any
|
|
||||||
if currentBackend != nil {
|
|
||||||
currentBackend.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new backend
|
|
||||||
b, err := createBackend(backendName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the backend
|
|
||||||
if err := b.Initialize(config); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
currentBackend = b
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStations returns all connected stations as ConnectedDevice models
|
|
||||||
func GetStations() ([]models.ConnectedDevice, error) {
|
|
||||||
mu.RLock()
|
|
||||||
defer mu.RUnlock()
|
|
||||||
|
|
||||||
if currentBackend == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
stations, err := currentBackend.GetStations()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert backend.Station to models.ConnectedDevice
|
|
||||||
devices := make([]models.ConnectedDevice, len(stations))
|
|
||||||
for i, s := range stations {
|
|
||||||
devices[i] = models.ConnectedDevice{
|
|
||||||
Name: s.Hostname,
|
|
||||||
Type: s.Type,
|
|
||||||
MAC: s.MAC,
|
|
||||||
IP: s.IP,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return devices, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartEventMonitoring starts monitoring for station events
|
|
||||||
func StartEventMonitoring(callbacks backend.EventCallbacks) error {
|
|
||||||
mu.RLock()
|
|
||||||
defer mu.RUnlock()
|
|
||||||
|
|
||||||
if currentBackend == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentBackend.StartEventMonitoring(callbacks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopEventMonitoring stops monitoring for station events
|
|
||||||
func StopEventMonitoring() {
|
|
||||||
mu.RLock()
|
|
||||||
defer mu.RUnlock()
|
|
||||||
|
|
||||||
if currentBackend != nil {
|
|
||||||
currentBackend.StopEventMonitoring()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes the current backend
|
|
||||||
func Close() {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
if currentBackend != nil {
|
|
||||||
currentBackend.Close()
|
|
||||||
currentBackend = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SupportsRealTimeEvents returns true if the current backend supports real-time events
|
|
||||||
func SupportsRealTimeEvents() bool {
|
|
||||||
mu.RLock()
|
|
||||||
defer mu.RUnlock()
|
|
||||||
|
|
||||||
if currentBackend == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentBackend.SupportsRealTimeEvents()
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
package syslog
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ParseSyslogLine extracts the message content from a syslog line.
|
|
||||||
// It looks for the daemon prefix in the line and returns the message after it.
|
|
||||||
//
|
|
||||||
// Example input: "Dec 2 02:01:33 tyet daemon.info iwd: Error loading /var/lib/iwd//nemuphone.psk"
|
|
||||||
// Example output: "Error loading /var/lib/iwd//nemuphone.psk", true
|
|
||||||
//
|
|
||||||
// Returns the message and a boolean indicating if the line was successfully parsed.
|
|
||||||
func ParseSyslogLine(line, daemonPrefix string) (string, bool) {
|
|
||||||
// Find the daemon prefix in the line (e.g., "iwd:")
|
|
||||||
idx := strings.Index(line, daemonPrefix)
|
|
||||||
if idx == -1 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract everything after the daemon prefix
|
|
||||||
message := line[idx+len(daemonPrefix):]
|
|
||||||
|
|
||||||
// Trim leading/trailing whitespace
|
|
||||||
message = strings.TrimSpace(message)
|
|
||||||
|
|
||||||
if message == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
return message, true
|
|
||||||
}
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
package syslog
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nemunaire/repeater/internal/logging"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SyslogTailer tails a syslog file and filters messages to the logging system.
|
|
||||||
type SyslogTailer struct {
|
|
||||||
path string
|
|
||||||
filters []string
|
|
||||||
source string
|
|
||||||
|
|
||||||
file *os.File
|
|
||||||
done chan struct{}
|
|
||||||
wg sync.WaitGroup
|
|
||||||
mu sync.Mutex
|
|
||||||
running bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSyslogTailer creates a new syslog tailer.
|
|
||||||
// path: Path to the syslog file (e.g., "/var/log/messages")
|
|
||||||
// filter: Filter string to match in lines (e.g., "daemon.info iwd:")
|
|
||||||
// source: Source name for logging (e.g., "iwd")
|
|
||||||
func NewSyslogTailer(path string, filters []string, source string) *SyslogTailer {
|
|
||||||
return &SyslogTailer{
|
|
||||||
path: path,
|
|
||||||
filters: filters,
|
|
||||||
source: source,
|
|
||||||
done: make(chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start opens the syslog file and begins tailing it.
|
|
||||||
func (t *SyslogTailer) Start() error {
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
|
|
||||||
if t.running {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to open the file
|
|
||||||
file, err := os.Open(t.path)
|
|
||||||
if err != nil {
|
|
||||||
// File might not exist yet, we'll retry in the goroutine
|
|
||||||
log.Printf("Warning: Cannot open syslog file %s: %v (will retry)", t.path, err)
|
|
||||||
} else {
|
|
||||||
// Seek to the end to only read new entries
|
|
||||||
_, err = file.Seek(0, io.SeekEnd)
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
t.file = file
|
|
||||||
}
|
|
||||||
|
|
||||||
t.running = true
|
|
||||||
t.wg.Add(1)
|
|
||||||
go t.tail()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop signals the tailer to stop and waits for it to finish.
|
|
||||||
func (t *SyslogTailer) Stop() {
|
|
||||||
t.mu.Lock()
|
|
||||||
if !t.running {
|
|
||||||
t.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.mu.Unlock()
|
|
||||||
|
|
||||||
close(t.done)
|
|
||||||
t.wg.Wait()
|
|
||||||
|
|
||||||
t.mu.Lock()
|
|
||||||
if t.file != nil {
|
|
||||||
t.file.Close()
|
|
||||||
t.file = nil
|
|
||||||
}
|
|
||||||
t.running = false
|
|
||||||
t.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// tail is the main loop that reads from the syslog file.
|
|
||||||
func (t *SyslogTailer) tail() {
|
|
||||||
defer t.wg.Done()
|
|
||||||
|
|
||||||
retryDelay := 1 * time.Second
|
|
||||||
maxRetryDelay := 30 * time.Second
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-t.done:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have a file open
|
|
||||||
t.mu.Lock()
|
|
||||||
file := t.file
|
|
||||||
t.mu.Unlock()
|
|
||||||
|
|
||||||
if file == nil {
|
|
||||||
// Try to open the file
|
|
||||||
newFile, err := os.Open(t.path)
|
|
||||||
if err != nil {
|
|
||||||
// File doesn't exist or can't be opened, wait and retry
|
|
||||||
select {
|
|
||||||
case <-t.done:
|
|
||||||
return
|
|
||||||
case <-time.After(retryDelay):
|
|
||||||
// Exponential backoff
|
|
||||||
retryDelay *= 2
|
|
||||||
if retryDelay > maxRetryDelay {
|
|
||||||
retryDelay = maxRetryDelay
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seek to the end
|
|
||||||
_, err = newFile.Seek(0, io.SeekEnd)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error seeking syslog file: %v", err)
|
|
||||||
newFile.Close()
|
|
||||||
time.Sleep(retryDelay)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
t.mu.Lock()
|
|
||||||
t.file = newFile
|
|
||||||
file = newFile
|
|
||||||
t.mu.Unlock()
|
|
||||||
|
|
||||||
retryDelay = 1 * time.Second
|
|
||||||
log.Printf("Syslog tailer: opened %s", t.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read lines from the file
|
|
||||||
if err := t.readLines(file); err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
// End of file, wait a bit and try again
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other error, close the file and retry
|
|
||||||
log.Printf("Error reading syslog file: %v", err)
|
|
||||||
t.mu.Lock()
|
|
||||||
if t.file != nil {
|
|
||||||
t.file.Close()
|
|
||||||
t.file = nil
|
|
||||||
}
|
|
||||||
t.mu.Unlock()
|
|
||||||
|
|
||||||
time.Sleep(retryDelay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readLines reads and processes lines from the file.
|
|
||||||
func (t *SyslogTailer) readLines(file *os.File) error {
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
|
|
||||||
// Increase buffer size to handle long log lines
|
|
||||||
const maxCapacity = 512 * 1024
|
|
||||||
buf := make([]byte, maxCapacity)
|
|
||||||
scanner.Buffer(buf, maxCapacity)
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
select {
|
|
||||||
case <-t.done:
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
line := scanner.Text()
|
|
||||||
|
|
||||||
// Check if the line contains any of the filter strings
|
|
||||||
var matchedFilter string
|
|
||||||
for _, filter := range t.filters {
|
|
||||||
if strings.Contains(line, filter) {
|
|
||||||
matchedFilter = filter
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if matchedFilter == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the syslog line to extract the message
|
|
||||||
// We look for "iwd:" (or whatever comes after the filter)
|
|
||||||
// The filter is "daemon.info iwd:" so we want to extract text after "iwd:"
|
|
||||||
daemonPrefix := extractDaemonPrefix(matchedFilter)
|
|
||||||
message, ok := ParseSyslogLine(line, daemonPrefix)
|
|
||||||
if !ok {
|
|
||||||
// Couldn't parse the line, skip it
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to logging system
|
|
||||||
logging.AddLog(t.source, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return scanner.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractDaemonPrefix extracts the daemon prefix from the filter string.
|
|
||||||
// For example, "daemon.info iwd:" returns "iwd:"
|
|
||||||
func extractDaemonPrefix(filter string) string {
|
|
||||||
parts := strings.Fields(filter)
|
|
||||||
if len(parts) > 0 {
|
|
||||||
return parts[len(parts)-1]
|
|
||||||
}
|
|
||||||
return filter
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
package backend
|
|
||||||
|
|
||||||
// WiFiBackend is the interface that must be implemented by all WiFi backends (iwd, wpa_supplicant, etc.)
|
|
||||||
type WiFiBackend interface {
|
|
||||||
// Lifecycle Management
|
|
||||||
Initialize(interfaceName string) error
|
|
||||||
Close() error
|
|
||||||
|
|
||||||
// Network Discovery
|
|
||||||
ScanNetworks() error
|
|
||||||
GetOrderedNetworks() ([]BackendNetwork, error)
|
|
||||||
IsScanning() (bool, error)
|
|
||||||
|
|
||||||
// Connection Management
|
|
||||||
Connect(ssid, password string) error
|
|
||||||
Disconnect() error
|
|
||||||
GetConnectionState() (ConnectionState, error)
|
|
||||||
GetConnectedSSID() string
|
|
||||||
|
|
||||||
// Event Monitoring
|
|
||||||
StartEventMonitoring(callbacks EventCallbacks) error
|
|
||||||
StopEventMonitoring()
|
|
||||||
}
|
|
||||||
|
|
||||||
// BackendNetwork represents a WiFi network in a backend-agnostic format.
|
|
||||||
// Both iwd and wpa_supplicant backends convert their native representations to this type.
|
|
||||||
type BackendNetwork struct {
|
|
||||||
SSID string
|
|
||||||
SignalDBm int16 // Signal strength in dBm (-100 to 0)
|
|
||||||
SecurityType string // "open", "wep", "psk", "8021x"
|
|
||||||
BSSID string // MAC address of the access point
|
|
||||||
Frequency uint32 // Frequency in MHz (e.g., 2412 for channel 1, 5180 for channel 36)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConnectionState represents the WiFi connection state in a backend-agnostic way.
|
|
||||||
type ConnectionState string
|
|
||||||
|
|
||||||
const (
|
|
||||||
StateConnected ConnectionState = "connected"
|
|
||||||
StateDisconnected ConnectionState = "disconnected"
|
|
||||||
StateConnecting ConnectionState = "connecting"
|
|
||||||
StateDisconnecting ConnectionState = "disconnecting"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EventCallbacks defines callback functions that backends use to notify the wifi package of events.
|
|
||||||
// This allows the wifi package to remain backend-agnostic while still receiving real-time updates.
|
|
||||||
type EventCallbacks struct {
|
|
||||||
// OnStateChange is called when the connection state changes
|
|
||||||
OnStateChange func(state ConnectionState, ssid string)
|
|
||||||
|
|
||||||
// OnScanComplete is called when a network scan completes
|
|
||||||
OnScanComplete func()
|
|
||||||
|
|
||||||
// OnSignalUpdate is called when signal strength changes for the connected network
|
|
||||||
OnSignalUpdate func(ssid string, signalDBm int16)
|
|
||||||
}
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
package wifi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
"github.com/nemunaire/repeater/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WifiBroadcaster manages WebSocket clients and broadcasts WiFi events
|
|
||||||
type WifiBroadcaster struct {
|
|
||||||
clients map[*websocket.Conn]bool
|
|
||||||
clientsMu sync.RWMutex
|
|
||||||
|
|
||||||
// State deduplication
|
|
||||||
lastState string
|
|
||||||
lastSSID string
|
|
||||||
lastNetworks []models.WiFiNetwork
|
|
||||||
stateMu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWifiBroadcaster creates a new WiFi broadcaster
|
|
||||||
func NewWifiBroadcaster() *WifiBroadcaster {
|
|
||||||
return &WifiBroadcaster{
|
|
||||||
clients: make(map[*websocket.Conn]bool),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterClient registers a new WebSocket client
|
|
||||||
func (wb *WifiBroadcaster) RegisterClient(conn *websocket.Conn) {
|
|
||||||
wb.clientsMu.Lock()
|
|
||||||
wb.clients[conn] = true
|
|
||||||
wb.clientsMu.Unlock()
|
|
||||||
|
|
||||||
// Send initial state to the new client
|
|
||||||
wb.sendInitialState(conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnregisterClient removes a WebSocket client
|
|
||||||
func (wb *WifiBroadcaster) UnregisterClient(conn *websocket.Conn) {
|
|
||||||
wb.clientsMu.Lock()
|
|
||||||
delete(wb.clients, conn)
|
|
||||||
wb.clientsMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendInitialState sends the current WiFi state to a newly connected client
|
|
||||||
func (wb *WifiBroadcaster) sendInitialState(conn *websocket.Conn) {
|
|
||||||
wb.stateMu.RLock()
|
|
||||||
lastState := wb.lastState
|
|
||||||
lastSSID := wb.lastSSID
|
|
||||||
lastNetworks := make([]models.WiFiNetwork, len(wb.lastNetworks))
|
|
||||||
copy(lastNetworks, wb.lastNetworks)
|
|
||||||
wb.stateMu.RUnlock()
|
|
||||||
|
|
||||||
// Send last known state if available
|
|
||||||
if lastState != "" {
|
|
||||||
event := NewStateChangeEvent(lastState, lastSSID, "")
|
|
||||||
conn.WriteJSON(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send last known network list if available
|
|
||||||
if len(lastNetworks) > 0 {
|
|
||||||
event := NewScanUpdateEvent(lastNetworks)
|
|
||||||
conn.WriteJSON(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BroadcastScanUpdate broadcasts a scan update event to all clients
|
|
||||||
func (wb *WifiBroadcaster) BroadcastScanUpdate(networks []models.WiFiNetwork) {
|
|
||||||
// Check for changes to avoid duplicate broadcasts
|
|
||||||
wb.stateMu.Lock()
|
|
||||||
if networksEqual(wb.lastNetworks, networks) {
|
|
||||||
wb.stateMu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
wb.lastNetworks = make([]models.WiFiNetwork, len(networks))
|
|
||||||
copy(wb.lastNetworks, networks)
|
|
||||||
wb.stateMu.Unlock()
|
|
||||||
|
|
||||||
event := NewScanUpdateEvent(networks)
|
|
||||||
wb.broadcast(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BroadcastStateChange broadcasts a state change event to all clients
|
|
||||||
func (wb *WifiBroadcaster) BroadcastStateChange(state, ssid string) {
|
|
||||||
// Check for changes to avoid duplicate broadcasts
|
|
||||||
wb.stateMu.Lock()
|
|
||||||
if wb.lastState == state && wb.lastSSID == ssid {
|
|
||||||
wb.stateMu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
previousState := wb.lastState
|
|
||||||
wb.lastState = state
|
|
||||||
wb.lastSSID = ssid
|
|
||||||
wb.stateMu.Unlock()
|
|
||||||
|
|
||||||
event := NewStateChangeEvent(state, ssid, previousState)
|
|
||||||
wb.broadcast(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BroadcastSignalUpdate broadcasts a signal update event to all clients
|
|
||||||
func (wb *WifiBroadcaster) BroadcastSignalUpdate(ssid string, signal, dbm int) {
|
|
||||||
event := NewSignalUpdateEvent(ssid, signal, dbm)
|
|
||||||
wb.broadcast(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// broadcast sends an event to all connected clients
|
|
||||||
func (wb *WifiBroadcaster) broadcast(event WifiEvent) {
|
|
||||||
// Get list of clients with read lock
|
|
||||||
wb.clientsMu.RLock()
|
|
||||||
clients := make([]*websocket.Conn, 0, len(wb.clients))
|
|
||||||
for client := range wb.clients {
|
|
||||||
clients = append(clients, client)
|
|
||||||
}
|
|
||||||
wb.clientsMu.RUnlock()
|
|
||||||
|
|
||||||
// Broadcast to all clients
|
|
||||||
for _, client := range clients {
|
|
||||||
err := client.WriteJSON(event)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Erreur lors de l'envoi WebSocket WiFi: %v", err)
|
|
||||||
client.Close()
|
|
||||||
wb.UnregisterClient(client)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// networksEqual compares two network slices for equality
|
|
||||||
func networksEqual(a, b []models.WiFiNetwork) bool {
|
|
||||||
if len(a) != len(b) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := range a {
|
|
||||||
if a[i].SSID != b[i].SSID || a[i].Signal != b[i].Signal {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
package wifi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nemunaire/repeater/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WifiEvent represents a WiFi event to be sent over WebSocket
|
|
||||||
type WifiEvent struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Timestamp time.Time `json:"timestamp"`
|
|
||||||
Data interface{} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScanUpdateData contains network list update information
|
|
||||||
type ScanUpdateData struct {
|
|
||||||
Networks []models.WiFiNetwork `json:"networks"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StateChangeData contains connection state change information
|
|
||||||
type StateChangeData struct {
|
|
||||||
State string `json:"state"`
|
|
||||||
SSID string `json:"ssid,omitempty"`
|
|
||||||
PreviousState string `json:"previous_state,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SignalUpdateData contains signal strength update information
|
|
||||||
type SignalUpdateData struct {
|
|
||||||
SSID string `json:"ssid"`
|
|
||||||
Signal int `json:"signal"` // 1-5 scale
|
|
||||||
DBm int `json:"dbm"` // Raw dBm value
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewScanUpdateEvent creates a new scan update event
|
|
||||||
func NewScanUpdateEvent(networks []models.WiFiNetwork) WifiEvent {
|
|
||||||
return WifiEvent{
|
|
||||||
Type: "scan_update",
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Data: ScanUpdateData{
|
|
||||||
Networks: networks,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStateChangeEvent creates a new state change event
|
|
||||||
func NewStateChangeEvent(state, ssid, previousState string) WifiEvent {
|
|
||||||
return WifiEvent{
|
|
||||||
Type: "state_change",
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Data: StateChangeData{
|
|
||||||
State: state,
|
|
||||||
SSID: ssid,
|
|
||||||
PreviousState: previousState,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSignalUpdateEvent creates a new signal update event
|
|
||||||
func NewSignalUpdateEvent(ssid string, signal, dbm int) WifiEvent {
|
|
||||||
return WifiEvent{
|
|
||||||
Type: "signal_update",
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Data: SignalUpdateData{
|
|
||||||
SSID: ssid,
|
|
||||||
Signal: signal,
|
|
||||||
DBm: dbm,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
package wifi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/nemunaire/repeater/internal/wifi/backend"
|
|
||||||
"github.com/nemunaire/repeater/internal/wifi/iwd"
|
|
||||||
"github.com/nemunaire/repeater/internal/wifi/wpasupplicant"
|
|
||||||
)
|
|
||||||
|
|
||||||
// createBackend creates the appropriate WiFi backend based on the backend name
|
|
||||||
func createBackend(backendName string) (backend.WiFiBackend, error) {
|
|
||||||
switch backendName {
|
|
||||||
case "iwd":
|
|
||||||
return iwd.NewIWDBackend(), nil
|
|
||||||
case "wpasupplicant":
|
|
||||||
return wpasupplicant.NewWPABackend(), nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("invalid wifi backend: %s (must be 'iwd' or 'wpasupplicant')", backendName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
package iwd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
"github.com/godbus/dbus/v5/introspect"
|
|
||||||
)
|
|
||||||
|
|
||||||
const agentIntrospectXML = `
|
|
||||||
<node>
|
|
||||||
<interface name="net.connman.iwd.Agent">
|
|
||||||
<method name="Release">
|
|
||||||
</method>
|
|
||||||
<method name="RequestPassphrase">
|
|
||||||
<arg name="network" type="o" direction="in"/>
|
|
||||||
<arg name="passphrase" type="s" direction="out"/>
|
|
||||||
</method>
|
|
||||||
<method name="RequestPrivateKeyPassphrase">
|
|
||||||
<arg name="network" type="o" direction="in"/>
|
|
||||||
<arg name="passphrase" type="s" direction="out"/>
|
|
||||||
</method>
|
|
||||||
<method name="RequestUserNameAndPassword">
|
|
||||||
<arg name="network" type="o" direction="in"/>
|
|
||||||
<arg name="username" type="s" direction="out"/>
|
|
||||||
<arg name="password" type="s" direction="out"/>
|
|
||||||
</method>
|
|
||||||
<method name="RequestUserPassword">
|
|
||||||
<arg name="network" type="o" direction="in"/>
|
|
||||||
<arg name="user" type="s" direction="in"/>
|
|
||||||
<arg name="password" type="s" direction="out"/>
|
|
||||||
</method>
|
|
||||||
<method name="Cancel">
|
|
||||||
<arg name="reason" type="s" direction="in"/>
|
|
||||||
</method>
|
|
||||||
</interface>
|
|
||||||
<interface name="org.freedesktop.DBus.Introspectable">
|
|
||||||
<method name="Introspect">
|
|
||||||
<arg name="xml" type="s" direction="out"/>
|
|
||||||
</method>
|
|
||||||
</interface>
|
|
||||||
</node>`
|
|
||||||
|
|
||||||
// Agent implements the net.connman.iwd.Agent interface for credential callbacks
|
|
||||||
type Agent struct {
|
|
||||||
conn *dbus.Conn
|
|
||||||
path dbus.ObjectPath
|
|
||||||
passphraseStore map[string]string
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAgent creates a new Agent instance
|
|
||||||
func NewAgent(conn *dbus.Conn, path dbus.ObjectPath) *Agent {
|
|
||||||
return &Agent{
|
|
||||||
conn: conn,
|
|
||||||
path: path,
|
|
||||||
passphraseStore: make(map[string]string),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPassphrase stores a passphrase for a given network SSID
|
|
||||||
func (a *Agent) SetPassphrase(ssid, passphrase string) {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
a.passphraseStore[ssid] = passphrase
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearPassphrase removes the stored passphrase for a network
|
|
||||||
func (a *Agent) ClearPassphrase(ssid string) {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
delete(a.passphraseStore, ssid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export registers the agent object on D-Bus
|
|
||||||
func (a *Agent) Export() error {
|
|
||||||
err := a.conn.Export(a, a.path, AgentInterface)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to export agent: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = a.conn.Export(introspect.Introspectable(agentIntrospectXML), a.path, "org.freedesktop.DBus.Introspectable")
|
|
||||||
if err != nil {
|
|
||||||
a.conn.Export(nil, a.path, AgentInterface)
|
|
||||||
return fmt.Errorf("failed to export introspection: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unexport unregisters the agent from D-Bus
|
|
||||||
func (a *Agent) Unexport() {
|
|
||||||
a.conn.Export(nil, a.path, AgentInterface)
|
|
||||||
a.conn.Export(nil, a.path, "org.freedesktop.DBus.Introspectable")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getNetworkSSID queries the network object to get its SSID (Name property)
|
|
||||||
func (a *Agent) getNetworkSSID(networkPath dbus.ObjectPath) (string, error) {
|
|
||||||
obj := a.conn.Object(Service, networkPath)
|
|
||||||
variant, err := obj.GetProperty(NetworkInterface + ".Name")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get network name: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
name, ok := variant.Value().(string)
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("network name is not a string")
|
|
||||||
}
|
|
||||||
|
|
||||||
return name, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestPassphrase is called by iwd when connecting to PSK networks
|
|
||||||
func (a *Agent) RequestPassphrase(network dbus.ObjectPath) (string, *dbus.Error) {
|
|
||||||
fmt.Printf("[Agent] RequestPassphrase called for network: %s\n", network)
|
|
||||||
|
|
||||||
ssid, err := a.getNetworkSSID(network)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("[Agent] Failed to get SSID: %v\n", err)
|
|
||||||
return "", dbus.MakeFailedError(fmt.Errorf("failed to get network SSID: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("[Agent] Network SSID: %s\n", ssid)
|
|
||||||
|
|
||||||
a.mu.RLock()
|
|
||||||
passphrase, ok := a.passphraseStore[ssid]
|
|
||||||
a.mu.RUnlock()
|
|
||||||
|
|
||||||
if !ok {
|
|
||||||
fmt.Printf("[Agent] No passphrase stored for SSID: %s\n", ssid)
|
|
||||||
return "", dbus.MakeFailedError(fmt.Errorf("no passphrase stored for network '%s'", ssid))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("[Agent] Returning passphrase for SSID: %s\n", ssid)
|
|
||||||
return passphrase, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestPrivateKeyPassphrase is called for encrypted private keys
|
|
||||||
func (a *Agent) RequestPrivateKeyPassphrase(network dbus.ObjectPath) (string, *dbus.Error) {
|
|
||||||
// Not implemented for now
|
|
||||||
return "", dbus.MakeFailedError(fmt.Errorf("RequestPrivateKeyPassphrase not implemented"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestUserNameAndPassword is called for enterprise networks
|
|
||||||
func (a *Agent) RequestUserNameAndPassword(network dbus.ObjectPath) (string, string, *dbus.Error) {
|
|
||||||
// Not implemented for now
|
|
||||||
return "", "", dbus.MakeFailedError(fmt.Errorf("RequestUserNameAndPassword not implemented"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestUserPassword is called for enterprise networks with known username
|
|
||||||
func (a *Agent) RequestUserPassword(network dbus.ObjectPath, user string) (string, *dbus.Error) {
|
|
||||||
// Not implemented for now
|
|
||||||
return "", dbus.MakeFailedError(fmt.Errorf("RequestUserPassword not implemented"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel is called when a request is canceled
|
|
||||||
func (a *Agent) Cancel(reason string) *dbus.Error {
|
|
||||||
// Nothing to do, just acknowledge
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release is called when the agent is unregistered
|
|
||||||
func (a *Agent) Release() *dbus.Error {
|
|
||||||
// Cleanup if needed
|
|
||||||
a.mu.Lock()
|
|
||||||
a.passphraseStore = make(map[string]string)
|
|
||||||
a.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
package iwd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AgentManager handles agent registration with iwd
|
|
||||||
type AgentManager struct {
|
|
||||||
conn *dbus.Conn
|
|
||||||
obj dbus.BusObject
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAgentManager creates a new AgentManager instance
|
|
||||||
func NewAgentManager(conn *dbus.Conn) *AgentManager {
|
|
||||||
return &AgentManager{
|
|
||||||
conn: conn,
|
|
||||||
obj: conn.Object(Service, "/net/connman/iwd"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterAgent registers an agent with iwd
|
|
||||||
func (am *AgentManager) RegisterAgent(agentPath dbus.ObjectPath) error {
|
|
||||||
err := am.obj.Call(AgentManagerInterface+".RegisterAgent", 0, agentPath).Err
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to register agent: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnregisterAgent unregisters an agent from iwd
|
|
||||||
func (am *AgentManager) UnregisterAgent(agentPath dbus.ObjectPath) error {
|
|
||||||
err := am.obj.Call(AgentManagerInterface+".UnregisterAgent", 0, agentPath).Err
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to unregister agent: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
package iwd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
"github.com/nemunaire/repeater/internal/wifi/backend"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
AgentPath = "/com/github/nemunaire/repeater/agent"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IWDBackend implements the WiFiBackend interface for iwd (Intel Wireless Daemon)
|
|
||||||
type IWDBackend struct {
|
|
||||||
conn *dbus.Conn
|
|
||||||
manager *Manager
|
|
||||||
station *Station
|
|
||||||
agent *Agent
|
|
||||||
agentManager *AgentManager
|
|
||||||
signalMonitor *SignalMonitor
|
|
||||||
interfaceName string
|
|
||||||
callbacks backend.EventCallbacks
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewIWDBackend creates a new IWD backend instance
|
|
||||||
func NewIWDBackend() *IWDBackend {
|
|
||||||
return &IWDBackend{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize initializes the iwd backend with the given interface name
|
|
||||||
func (b *IWDBackend) Initialize(interfaceName string) error {
|
|
||||||
b.interfaceName = interfaceName
|
|
||||||
var err error
|
|
||||||
|
|
||||||
// Connect to D-Bus
|
|
||||||
b.conn, err = dbus.SystemBus()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("échec de connexion à D-Bus: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find station for interface
|
|
||||||
b.manager = NewManager(b.conn)
|
|
||||||
b.station, err = b.manager.FindStation(interfaceName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("impossible de trouver la station pour %s: %v", interfaceName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and register agent for credential callbacks
|
|
||||||
b.agent = NewAgent(b.conn, dbus.ObjectPath(AgentPath))
|
|
||||||
if err := b.agent.Export(); err != nil {
|
|
||||||
return fmt.Errorf("échec de l'export de l'agent: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
b.agentManager = NewAgentManager(b.conn)
|
|
||||||
if err := b.agentManager.RegisterAgent(dbus.ObjectPath(AgentPath)); err != nil {
|
|
||||||
b.agent.Unexport()
|
|
||||||
return fmt.Errorf("échec de l'enregistrement de l'agent: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes the D-Bus connection and unregisters the agent
|
|
||||||
func (b *IWDBackend) Close() error {
|
|
||||||
if b.agentManager != nil && b.agent != nil {
|
|
||||||
b.agentManager.UnregisterAgent(dbus.ObjectPath(AgentPath))
|
|
||||||
b.agent.Unexport()
|
|
||||||
}
|
|
||||||
if b.conn != nil {
|
|
||||||
b.conn.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScanNetworks triggers a network scan
|
|
||||||
func (b *IWDBackend) ScanNetworks() error {
|
|
||||||
err := b.station.Scan()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("erreur lors du scan: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetOrderedNetworks returns networks sorted by signal strength in backend-agnostic format
|
|
||||||
func (b *IWDBackend) GetOrderedNetworks() ([]backend.BackendNetwork, error) {
|
|
||||||
networkInfos, err := b.station.GetOrderedNetworks()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var networks []backend.BackendNetwork
|
|
||||||
seenSSIDs := make(map[string]bool)
|
|
||||||
|
|
||||||
for _, netInfo := range networkInfos {
|
|
||||||
network := NewNetwork(b.conn, netInfo.Path)
|
|
||||||
props, err := network.GetProperties()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if props.Name == "" || seenSSIDs[props.Name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seenSSIDs[props.Name] = true
|
|
||||||
|
|
||||||
// Convert iwd network to backend-agnostic format
|
|
||||||
backendNet := backend.BackendNetwork{
|
|
||||||
SSID: props.Name,
|
|
||||||
SignalDBm: netInfo.Signal / 100, // iwd provides 100*dBm, convert to dBm
|
|
||||||
SecurityType: props.Type,
|
|
||||||
BSSID: generateSyntheticBSSID(props.Name), // iwd doesn't expose BSSID
|
|
||||||
Frequency: 0, // iwd doesn't expose frequency in GetOrderedNetworks
|
|
||||||
}
|
|
||||||
|
|
||||||
networks = append(networks, backendNet)
|
|
||||||
}
|
|
||||||
|
|
||||||
return networks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsScanning checks if a scan is currently in progress
|
|
||||||
func (b *IWDBackend) IsScanning() (bool, error) {
|
|
||||||
return b.station.IsScanning()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect connects to a WiFi network
|
|
||||||
func (b *IWDBackend) Connect(ssid, password string) error {
|
|
||||||
// Store passphrase in agent for callback
|
|
||||||
if password != "" {
|
|
||||||
b.agent.SetPassphrase(ssid, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure passphrase is cleared after connection attempt
|
|
||||||
defer func() {
|
|
||||||
if password != "" {
|
|
||||||
b.agent.ClearPassphrase(ssid)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Get network object
|
|
||||||
network, err := b.station.GetNetwork(ssid)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("réseau '%s' non trouvé: %v", ssid, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect - iwd will call agent.RequestPassphrase() if needed
|
|
||||||
if err := network.Connect(); err != nil {
|
|
||||||
return fmt.Errorf("erreur lors de la connexion: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disconnect disconnects from the current WiFi network
|
|
||||||
func (b *IWDBackend) Disconnect() error {
|
|
||||||
if err := b.station.Disconnect(); err != nil {
|
|
||||||
return fmt.Errorf("erreur lors de la déconnexion: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConnectionState returns the current WiFi connection state
|
|
||||||
func (b *IWDBackend) GetConnectionState() (backend.ConnectionState, error) {
|
|
||||||
state, err := b.station.GetState()
|
|
||||||
if err != nil {
|
|
||||||
return backend.StateDisconnected, err
|
|
||||||
}
|
|
||||||
return mapIWDState(state), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConnectedSSID returns the SSID of the currently connected network
|
|
||||||
func (b *IWDBackend) GetConnectedSSID() string {
|
|
||||||
network, err := b.station.GetConnectedNetwork()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
props, err := network.GetProperties()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return props.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartEventMonitoring starts monitoring WiFi events
|
|
||||||
func (b *IWDBackend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
|
|
||||||
b.callbacks = callbacks
|
|
||||||
|
|
||||||
// Create signal monitor
|
|
||||||
b.signalMonitor = NewSignalMonitor(b.conn, b.station)
|
|
||||||
|
|
||||||
// Register callbacks - wrap to convert iwd types to backend types
|
|
||||||
b.signalMonitor.OnStateChange(func(state StationState, ssid string) {
|
|
||||||
if b.callbacks.OnStateChange != nil {
|
|
||||||
b.callbacks.OnStateChange(mapIWDState(state), ssid)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
b.signalMonitor.OnScanComplete(func() {
|
|
||||||
if b.callbacks.OnScanComplete != nil {
|
|
||||||
b.callbacks.OnScanComplete()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Start monitoring
|
|
||||||
return b.signalMonitor.Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopEventMonitoring stops monitoring WiFi events
|
|
||||||
func (b *IWDBackend) StopEventMonitoring() {
|
|
||||||
if b.signalMonitor != nil {
|
|
||||||
b.signalMonitor.Stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapIWDState maps iwd-specific states to backend-agnostic states
|
|
||||||
func mapIWDState(state StationState) backend.ConnectionState {
|
|
||||||
switch state {
|
|
||||||
case StateConnected:
|
|
||||||
return backend.StateConnected
|
|
||||||
case StateConnecting:
|
|
||||||
return backend.StateConnecting
|
|
||||||
case StateDisconnecting:
|
|
||||||
return backend.StateDisconnecting
|
|
||||||
case StateDisconnected:
|
|
||||||
return backend.StateDisconnected
|
|
||||||
case StateRoaming:
|
|
||||||
// Map roaming to connected since we're still connected during roaming
|
|
||||||
return backend.StateConnected
|
|
||||||
default:
|
|
||||||
return backend.StateDisconnected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateSyntheticBSSID generates a consistent fake BSSID from SSID
|
|
||||||
// (iwd doesn't expose real BSSID)
|
|
||||||
func generateSyntheticBSSID(ssid string) string {
|
|
||||||
// Use a simple hash approach - consistent per SSID
|
|
||||||
hash := 0
|
|
||||||
for _, c := range ssid {
|
|
||||||
hash = ((hash << 5) - hash) + int(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate 6 bytes for MAC address
|
|
||||||
b1 := byte((hash >> 0) & 0xff)
|
|
||||||
b2 := byte((hash >> 8) & 0xff)
|
|
||||||
b3 := byte((hash >> 16) & 0xff)
|
|
||||||
b4 := byte((hash >> 24) & 0xff)
|
|
||||||
b5 := byte(len(ssid) & 0xff)
|
|
||||||
b6 := byte((len(ssid) >> 8) & 0xff)
|
|
||||||
|
|
||||||
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b1, b2, b3, b4, b5, b6)
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
package iwd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Manager handles iwd object discovery via ObjectManager
|
|
||||||
type Manager struct {
|
|
||||||
conn *dbus.Conn
|
|
||||||
obj dbus.BusObject
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewManager creates a new Manager instance
|
|
||||||
func NewManager(conn *dbus.Conn) *Manager {
|
|
||||||
return &Manager{
|
|
||||||
conn: conn,
|
|
||||||
obj: conn.Object(Service, dbus.ObjectPath(ManagerPath)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetManagedObjects returns all iwd managed objects
|
|
||||||
func (m *Manager) GetManagedObjects() (map[dbus.ObjectPath]map[string]map[string]dbus.Variant, error) {
|
|
||||||
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
|
|
||||||
err := m.obj.Call("org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&objects)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get managed objects: %v", err)
|
|
||||||
}
|
|
||||||
return objects, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindStation finds the Station object for the given interface name
|
|
||||||
func (m *Manager) FindStation(interfaceName string) (*Station, error) {
|
|
||||||
objects, err := m.GetManagedObjects()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// First, find the device with matching interface name
|
|
||||||
var devicePath dbus.ObjectPath
|
|
||||||
for path, interfaces := range objects {
|
|
||||||
if deviceProps, ok := interfaces[DeviceInterface]; ok {
|
|
||||||
if nameVariant, ok := deviceProps["Name"]; ok {
|
|
||||||
if name, ok := nameVariant.Value().(string); ok && name == interfaceName {
|
|
||||||
devicePath = path
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if devicePath == "" {
|
|
||||||
return nil, fmt.Errorf("device with interface '%s' not found", interfaceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now find the station object under this device
|
|
||||||
// Station path is typically the same as device path or a child of it
|
|
||||||
for path, interfaces := range objects {
|
|
||||||
if _, ok := interfaces[StationInterface]; ok {
|
|
||||||
// Check if this station belongs to our device
|
|
||||||
// Station path should be the device path or start with it
|
|
||||||
if path == devicePath || strings.HasPrefix(string(path), string(devicePath)+"/") {
|
|
||||||
return NewStation(m.conn, path), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("station for device '%s' not found", interfaceName)
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
package iwd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Network represents an iwd Network interface
|
|
||||||
type Network struct {
|
|
||||||
path dbus.ObjectPath
|
|
||||||
conn *dbus.Conn
|
|
||||||
obj dbus.BusObject
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewNetwork creates a new Network instance
|
|
||||||
func NewNetwork(conn *dbus.Conn, path dbus.ObjectPath) *Network {
|
|
||||||
return &Network{
|
|
||||||
path: path,
|
|
||||||
conn: conn,
|
|
||||||
obj: conn.Object(Service, path),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProperties retrieves all network properties
|
|
||||||
func (n *Network) GetProperties() (*NetworkProperties, error) {
|
|
||||||
var props map[string]dbus.Variant
|
|
||||||
err := n.obj.Call("org.freedesktop.DBus.Properties.GetAll", 0, NetworkInterface).Store(&props)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get network properties: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
netProps := &NetworkProperties{}
|
|
||||||
|
|
||||||
if nameVariant, ok := props["Name"]; ok {
|
|
||||||
if name, ok := nameVariant.Value().(string); ok {
|
|
||||||
netProps.Name = name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if typeVariant, ok := props["Type"]; ok {
|
|
||||||
if netType, ok := typeVariant.Value().(string); ok {
|
|
||||||
netProps.Type = netType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if connectedVariant, ok := props["Connected"]; ok {
|
|
||||||
if connected, ok := connectedVariant.Value().(bool); ok {
|
|
||||||
netProps.Connected = connected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return netProps, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect initiates a connection to this network
|
|
||||||
// Credentials are provided via the registered agent's RequestPassphrase callback
|
|
||||||
func (n *Network) Connect() error {
|
|
||||||
err := n.obj.Call(NetworkInterface+".Connect", 0).Err
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connect failed: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
package iwd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SignalMonitor monitors D-Bus signals from iwd
|
|
||||||
type SignalMonitor struct {
|
|
||||||
conn *dbus.Conn
|
|
||||||
station *Station
|
|
||||||
|
|
||||||
// Signal channel
|
|
||||||
signalChan chan *dbus.Signal
|
|
||||||
|
|
||||||
// Callbacks
|
|
||||||
onStateChange func(state StationState, ssid string)
|
|
||||||
onScanComplete func()
|
|
||||||
|
|
||||||
// Control
|
|
||||||
stopChan chan struct{}
|
|
||||||
mu sync.RWMutex
|
|
||||||
running bool
|
|
||||||
|
|
||||||
// State tracking
|
|
||||||
lastScanning bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSignalMonitor creates a new signal monitor
|
|
||||||
func NewSignalMonitor(conn *dbus.Conn, station *Station) *SignalMonitor {
|
|
||||||
return &SignalMonitor{
|
|
||||||
conn: conn,
|
|
||||||
station: station,
|
|
||||||
signalChan: make(chan *dbus.Signal, 100),
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OnStateChange registers a callback for state changes
|
|
||||||
func (sm *SignalMonitor) OnStateChange(callback func(state StationState, ssid string)) {
|
|
||||||
sm.mu.Lock()
|
|
||||||
defer sm.mu.Unlock()
|
|
||||||
sm.onStateChange = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
// OnScanComplete registers a callback for scan completion
|
|
||||||
func (sm *SignalMonitor) OnScanComplete(callback func()) {
|
|
||||||
sm.mu.Lock()
|
|
||||||
defer sm.mu.Unlock()
|
|
||||||
sm.onScanComplete = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start begins monitoring D-Bus signals
|
|
||||||
func (sm *SignalMonitor) Start() error {
|
|
||||||
sm.mu.Lock()
|
|
||||||
if sm.running {
|
|
||||||
sm.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
sm.running = true
|
|
||||||
sm.mu.Unlock()
|
|
||||||
|
|
||||||
// Subscribe to PropertiesChanged signals for Station interface
|
|
||||||
stationPath := sm.station.GetPath()
|
|
||||||
|
|
||||||
// Add signal match for PropertiesChanged on Station interface
|
|
||||||
matchOptions := []dbus.MatchOption{
|
|
||||||
dbus.WithMatchObjectPath(stationPath),
|
|
||||||
dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
|
|
||||||
dbus.WithMatchMember("PropertiesChanged"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := sm.conn.AddMatchSignal(matchOptions...); err != nil {
|
|
||||||
sm.mu.Lock()
|
|
||||||
sm.running = false
|
|
||||||
sm.mu.Unlock()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register signal channel
|
|
||||||
sm.conn.Signal(sm.signalChan)
|
|
||||||
|
|
||||||
// Get initial scanning state
|
|
||||||
scanning, err := sm.station.IsScanning()
|
|
||||||
if err == nil {
|
|
||||||
sm.lastScanning = scanning
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start monitoring goroutine
|
|
||||||
go sm.monitor()
|
|
||||||
|
|
||||||
log.Printf("D-Bus signal monitoring started for station %s", stationPath)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops monitoring D-Bus signals
|
|
||||||
func (sm *SignalMonitor) Stop() {
|
|
||||||
sm.mu.Lock()
|
|
||||||
if !sm.running {
|
|
||||||
sm.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sm.running = false
|
|
||||||
sm.mu.Unlock()
|
|
||||||
|
|
||||||
// Signal stop
|
|
||||||
close(sm.stopChan)
|
|
||||||
|
|
||||||
// Remove signal channel
|
|
||||||
sm.conn.RemoveSignal(sm.signalChan)
|
|
||||||
|
|
||||||
log.Printf("D-Bus signal monitoring stopped")
|
|
||||||
}
|
|
||||||
|
|
||||||
// monitor is the main signal processing loop
|
|
||||||
func (sm *SignalMonitor) monitor() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case sig := <-sm.signalChan:
|
|
||||||
sm.handleSignal(sig)
|
|
||||||
case <-sm.stopChan:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSignal processes a D-Bus signal
|
|
||||||
func (sm *SignalMonitor) handleSignal(sig *dbus.Signal) {
|
|
||||||
// Only process PropertiesChanged signals
|
|
||||||
if sig.Name != "org.freedesktop.DBus.Properties.PropertiesChanged" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify signal is from Station interface
|
|
||||||
if len(sig.Body) < 2 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
interfaceName, ok := sig.Body[0].(string)
|
|
||||||
if !ok || interfaceName != StationInterface {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse changed properties
|
|
||||||
changedProps, ok := sig.Body[1].(map[string]dbus.Variant)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for State property change
|
|
||||||
if stateVariant, ok := changedProps["State"]; ok {
|
|
||||||
if state, ok := stateVariant.Value().(string); ok {
|
|
||||||
sm.handleStateChange(StationState(state))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for Scanning property change
|
|
||||||
if scanningVariant, ok := changedProps["Scanning"]; ok {
|
|
||||||
if scanning, ok := scanningVariant.Value().(bool); ok {
|
|
||||||
sm.handleScanningChange(scanning)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for ConnectedNetwork property change
|
|
||||||
if _, ok := changedProps["ConnectedNetwork"]; ok {
|
|
||||||
// Network connection changed, trigger state update
|
|
||||||
sm.handleConnectionChange()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStateChange processes a state change
|
|
||||||
func (sm *SignalMonitor) handleStateChange(state StationState) {
|
|
||||||
sm.mu.RLock()
|
|
||||||
callback := sm.onStateChange
|
|
||||||
sm.mu.RUnlock()
|
|
||||||
|
|
||||||
if callback == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get connected SSID if connected
|
|
||||||
ssid := ""
|
|
||||||
if state == StateConnected {
|
|
||||||
network, err := sm.station.GetConnectedNetwork()
|
|
||||||
if err == nil {
|
|
||||||
props, err := network.GetProperties()
|
|
||||||
if err == nil {
|
|
||||||
ssid = props.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
callback(state, ssid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleScanningChange processes scanning state changes
|
|
||||||
func (sm *SignalMonitor) handleScanningChange(scanning bool) {
|
|
||||||
// Detect scan completion (transition from true to false)
|
|
||||||
if sm.lastScanning && !scanning {
|
|
||||||
sm.mu.RLock()
|
|
||||||
callback := sm.onScanComplete
|
|
||||||
sm.mu.RUnlock()
|
|
||||||
|
|
||||||
if callback != nil {
|
|
||||||
callback()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sm.lastScanning = scanning
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleConnectionChange processes connection changes
|
|
||||||
func (sm *SignalMonitor) handleConnectionChange() {
|
|
||||||
// Get current state and trigger state change callback
|
|
||||||
state, err := sm.station.GetState()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sm.handleStateChange(state)
|
|
||||||
}
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
package iwd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Station represents an iwd Station interface
|
|
||||||
type Station struct {
|
|
||||||
path dbus.ObjectPath
|
|
||||||
conn *dbus.Conn
|
|
||||||
obj dbus.BusObject
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStation creates a new Station instance
|
|
||||||
func NewStation(conn *dbus.Conn, path dbus.ObjectPath) *Station {
|
|
||||||
return &Station{
|
|
||||||
path: path,
|
|
||||||
conn: conn,
|
|
||||||
obj: conn.Object(Service, path),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan triggers a network scan
|
|
||||||
func (s *Station) Scan() error {
|
|
||||||
err := s.obj.Call(StationInterface+".Scan", 0).Err
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("scan failed: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsScanning checks if a scan is currently in progress
|
|
||||||
func (s *Station) IsScanning() (bool, error) {
|
|
||||||
prop, err := s.obj.GetProperty(StationInterface + ".Scanning")
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("failed to get Scanning property: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
scanning, ok := prop.Value().(bool)
|
|
||||||
if !ok {
|
|
||||||
return false, fmt.Errorf("Scanning property is not a boolean")
|
|
||||||
}
|
|
||||||
|
|
||||||
return scanning, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetOrderedNetworks returns networks sorted by signal strength
|
|
||||||
func (s *Station) GetOrderedNetworks() ([]NetworkInfo, error) {
|
|
||||||
var result []struct {
|
|
||||||
Path dbus.ObjectPath
|
|
||||||
Signal int16
|
|
||||||
}
|
|
||||||
|
|
||||||
err := s.obj.Call(StationInterface+".GetOrderedNetworks", 0).Store(&result)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get ordered networks: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
networks := make([]NetworkInfo, len(result))
|
|
||||||
for i, r := range result {
|
|
||||||
networks[i] = NetworkInfo{
|
|
||||||
Path: r.Path,
|
|
||||||
Signal: r.Signal,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return networks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetState returns the current connection state
|
|
||||||
func (s *Station) GetState() (StationState, error) {
|
|
||||||
prop, err := s.obj.GetProperty(StationInterface + ".State")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get State property: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
state, ok := prop.Value().(string)
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("State property is not a string")
|
|
||||||
}
|
|
||||||
|
|
||||||
return StationState(state), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disconnect disconnects from the current network
|
|
||||||
func (s *Station) Disconnect() error {
|
|
||||||
err := s.obj.Call(StationInterface+".Disconnect", 0).Err
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("disconnect failed: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetNetwork finds and returns a Network object by SSID
|
|
||||||
func (s *Station) GetNetwork(ssid string) (*Network, error) {
|
|
||||||
networks, err := s.GetOrderedNetworks()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the network with matching SSID
|
|
||||||
for _, netInfo := range networks {
|
|
||||||
network := NewNetwork(s.conn, netInfo.Path)
|
|
||||||
props, err := network.GetProperties()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if props.Name == ssid {
|
|
||||||
return network, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("network '%s' not found", ssid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConnectedNetwork returns the currently connected network
|
|
||||||
func (s *Station) GetConnectedNetwork() (*Network, error) {
|
|
||||||
prop, err := s.obj.GetProperty(StationInterface + ".ConnectedNetwork")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get ConnectedNetwork property: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
path, ok := prop.Value().(dbus.ObjectPath)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("ConnectedNetwork property is not an ObjectPath")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if path is empty (not connected)
|
|
||||||
if path == "/" || path == "" {
|
|
||||||
return nil, fmt.Errorf("not connected to any network")
|
|
||||||
}
|
|
||||||
|
|
||||||
return NewNetwork(s.conn, path), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPath returns the D-Bus object path for this station
|
|
||||||
func (s *Station) GetPath() dbus.ObjectPath {
|
|
||||||
return s.path
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
package iwd
|
|
||||||
|
|
||||||
import "github.com/godbus/dbus/v5"
|
|
||||||
|
|
||||||
const (
|
|
||||||
// D-Bus service and interfaces
|
|
||||||
Service = "net.connman.iwd"
|
|
||||||
ManagerPath = "/"
|
|
||||||
DeviceInterface = "net.connman.iwd.Device"
|
|
||||||
StationInterface = "net.connman.iwd.Station"
|
|
||||||
NetworkInterface = "net.connman.iwd.Network"
|
|
||||||
AgentInterface = "net.connman.iwd.Agent"
|
|
||||||
AgentManagerInterface = "net.connman.iwd.AgentManager"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NetworkInfo represents a network with its signal strength
|
|
||||||
type NetworkInfo struct {
|
|
||||||
Path dbus.ObjectPath
|
|
||||||
Signal int16 // 100 * dBm (0 to -10000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NetworkProperties holds network properties
|
|
||||||
type NetworkProperties struct {
|
|
||||||
Name string // SSID
|
|
||||||
Type string // "open", "wep", "psk", "8021x"
|
|
||||||
Connected bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// StationState represents the connection state
|
|
||||||
type StationState string
|
|
||||||
|
|
||||||
const (
|
|
||||||
StateConnected StationState = "connected"
|
|
||||||
StateDisconnected StationState = "disconnected"
|
|
||||||
StateConnecting StationState = "connecting"
|
|
||||||
StateDisconnecting StationState = "disconnecting"
|
|
||||||
StateRoaming StationState = "roaming"
|
|
||||||
)
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
package wifi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
"github.com/nemunaire/repeater/internal/models"
|
|
||||||
"github.com/nemunaire/repeater/internal/wifi/backend"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
wifiBackend backend.WiFiBackend
|
|
||||||
wifiBroadcaster *WifiBroadcaster
|
|
||||||
)
|
|
||||||
|
|
||||||
// Initialize initializes the WiFi service with the specified backend
|
|
||||||
func Initialize(interfaceName string, backendName string) error {
|
|
||||||
// Create the appropriate backend using the factory
|
|
||||||
var err error
|
|
||||||
wifiBackend, err = createBackend(backendName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the backend
|
|
||||||
return wifiBackend.Initialize(interfaceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes the backend connection
|
|
||||||
func Close() {
|
|
||||||
if wifiBackend != nil {
|
|
||||||
wifiBackend.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCachedNetworks returns previously discovered networks without triggering a scan
|
|
||||||
func GetCachedNetworks() ([]models.WiFiNetwork, error) {
|
|
||||||
// Get ordered networks from backend
|
|
||||||
backendNetworks, err := wifiBackend.GetOrderedNetworks()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert backend networks to models
|
|
||||||
networks := make([]models.WiFiNetwork, 0, len(backendNetworks))
|
|
||||||
for _, backendNet := range backendNetworks {
|
|
||||||
wifiNet := models.WiFiNetwork{
|
|
||||||
SSID: backendNet.SSID,
|
|
||||||
Signal: signalToStrength(int(backendNet.SignalDBm)),
|
|
||||||
Security: mapSecurityType(backendNet.SecurityType),
|
|
||||||
BSSID: backendNet.BSSID,
|
|
||||||
Channel: 0, // Not yet exposed by backends
|
|
||||||
}
|
|
||||||
networks = append(networks, wifiNet)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by signal strength (descending)
|
|
||||||
sort.Slice(networks, func(i, j int) bool {
|
|
||||||
return networks[i].Signal > networks[j].Signal
|
|
||||||
})
|
|
||||||
|
|
||||||
return networks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScanNetworks scans for available WiFi networks
|
|
||||||
func ScanNetworks() ([]models.WiFiNetwork, error) {
|
|
||||||
// Check if already scanning
|
|
||||||
scanning, err := wifiBackend.IsScanning()
|
|
||||||
if err == nil && scanning {
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
} else {
|
|
||||||
// Trigger scan
|
|
||||||
err := wifiBackend.ScanNetworks()
|
|
||||||
if err != nil && !strings.Contains(err.Error(), "rejected") {
|
|
||||||
return nil, fmt.Errorf("erreur lors du scan: %v", err)
|
|
||||||
}
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get ordered networks from backend
|
|
||||||
backendNetworks, err := wifiBackend.GetOrderedNetworks()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert backend networks to models
|
|
||||||
networks := make([]models.WiFiNetwork, 0, len(backendNetworks))
|
|
||||||
for _, backendNet := range backendNetworks {
|
|
||||||
wifiNet := models.WiFiNetwork{
|
|
||||||
SSID: backendNet.SSID,
|
|
||||||
Signal: signalToStrength(int(backendNet.SignalDBm)),
|
|
||||||
Security: mapSecurityType(backendNet.SecurityType),
|
|
||||||
BSSID: backendNet.BSSID,
|
|
||||||
Channel: 0, // Not yet exposed by backends
|
|
||||||
}
|
|
||||||
networks = append(networks, wifiNet)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by signal strength (descending)
|
|
||||||
sort.Slice(networks, func(i, j int) bool {
|
|
||||||
return networks[i].Signal > networks[j].Signal
|
|
||||||
})
|
|
||||||
|
|
||||||
// Broadcast to WebSocket clients if available
|
|
||||||
if wifiBroadcaster != nil {
|
|
||||||
wifiBroadcaster.BroadcastScanUpdate(networks)
|
|
||||||
}
|
|
||||||
|
|
||||||
return networks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect connects to a WiFi network
|
|
||||||
func Connect(ssid, password string) error {
|
|
||||||
// Use backend to connect
|
|
||||||
if err := wifiBackend.Connect(ssid, password); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll for connection
|
|
||||||
for i := 0; i < 20; i++ {
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
if IsConnected() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("timeout lors de la connexion")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disconnect disconnects from the current WiFi network
|
|
||||||
func Disconnect() error {
|
|
||||||
return wifiBackend.Disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsConnected checks if WiFi is connected
|
|
||||||
func IsConnected() bool {
|
|
||||||
state, err := wifiBackend.GetConnectionState()
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return state == backend.StateConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConnectedSSID returns the SSID of the currently connected network
|
|
||||||
func GetConnectedSSID() string {
|
|
||||||
return wifiBackend.GetConnectedSSID()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConnectionState returns the current WiFi connection state
|
|
||||||
func GetConnectionState() string {
|
|
||||||
state, err := wifiBackend.GetConnectionState()
|
|
||||||
if err != nil {
|
|
||||||
return string(backend.StateDisconnected)
|
|
||||||
}
|
|
||||||
return string(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartEventMonitoring initializes signal monitoring and WebSocket broadcasting
|
|
||||||
func StartEventMonitoring() error {
|
|
||||||
// Initialize broadcaster
|
|
||||||
wifiBroadcaster = NewWifiBroadcaster()
|
|
||||||
|
|
||||||
// Set up callbacks
|
|
||||||
callbacks := backend.EventCallbacks{
|
|
||||||
OnStateChange: handleStateChange,
|
|
||||||
OnScanComplete: handleScanComplete,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start backend monitoring
|
|
||||||
return wifiBackend.StartEventMonitoring(callbacks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopEventMonitoring stops signal monitoring
|
|
||||||
func StopEventMonitoring() {
|
|
||||||
if wifiBackend != nil {
|
|
||||||
wifiBackend.StopEventMonitoring()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterWebSocketClient registers a new WebSocket client for WiFi events
|
|
||||||
func RegisterWebSocketClient(conn *websocket.Conn) {
|
|
||||||
if wifiBroadcaster != nil {
|
|
||||||
wifiBroadcaster.RegisterClient(conn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnregisterWebSocketClient removes a WebSocket client
|
|
||||||
func UnregisterWebSocketClient(conn *websocket.Conn) {
|
|
||||||
if wifiBroadcaster != nil {
|
|
||||||
wifiBroadcaster.UnregisterClient(conn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStateChange is called when WiFi connection state changes
|
|
||||||
func handleStateChange(newState backend.ConnectionState, connectedSSID string) {
|
|
||||||
if wifiBroadcaster != nil {
|
|
||||||
wifiBroadcaster.BroadcastStateChange(string(newState), connectedSSID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleScanComplete is called when a WiFi scan completes
|
|
||||||
func handleScanComplete() {
|
|
||||||
// Get updated network list
|
|
||||||
networks, err := GetCachedNetworks()
|
|
||||||
if err == nil && wifiBroadcaster != nil {
|
|
||||||
wifiBroadcaster.BroadcastScanUpdate(networks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapSecurityType maps backend security types to display format
|
|
||||||
func mapSecurityType(securityType string) string {
|
|
||||||
switch securityType {
|
|
||||||
case "open":
|
|
||||||
return "Open"
|
|
||||||
case "wep":
|
|
||||||
return "WEP"
|
|
||||||
case "psk":
|
|
||||||
return "WPA2"
|
|
||||||
case "8021x":
|
|
||||||
return "WPA2"
|
|
||||||
default:
|
|
||||||
return "WPA2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// signalToStrength converts signal level (dBm) to strength (1-5)
|
|
||||||
func signalToStrength(level int) int {
|
|
||||||
if level >= -30 {
|
|
||||||
return 5
|
|
||||||
} else if level >= -50 {
|
|
||||||
return 4
|
|
||||||
} else if level >= -60 {
|
|
||||||
return 3
|
|
||||||
} else if level >= -70 {
|
|
||||||
return 2
|
|
||||||
} else {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,265 +0,0 @@
|
||||||
package wpasupplicant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
"github.com/nemunaire/repeater/internal/wifi/backend"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WPABackend implements the WiFiBackend interface for wpa_supplicant
|
|
||||||
type WPABackend struct {
|
|
||||||
conn *dbus.Conn
|
|
||||||
wpasupplicant dbus.BusObject
|
|
||||||
iface *WPAInterface
|
|
||||||
signalMonitor *SignalMonitor
|
|
||||||
interfaceName string
|
|
||||||
currentNetwork dbus.ObjectPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWPABackend creates a new wpa_supplicant backend instance
|
|
||||||
func NewWPABackend() *WPABackend {
|
|
||||||
return &WPABackend{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize initializes the wpa_supplicant backend with the given interface name
|
|
||||||
func (b *WPABackend) Initialize(interfaceName string) error {
|
|
||||||
b.interfaceName = interfaceName
|
|
||||||
var err error
|
|
||||||
|
|
||||||
// Connect to D-Bus
|
|
||||||
b.conn, err = dbus.SystemBus()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to connect to D-Bus: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get wpa_supplicant root object
|
|
||||||
b.wpasupplicant = b.conn.Object(Service, dbus.ObjectPath(RootPath))
|
|
||||||
|
|
||||||
// Get interface path for the given interface name
|
|
||||||
interfacePath, err := b.getInterfacePath(interfaceName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get interface for %s: %v", interfaceName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
b.iface = NewWPAInterface(b.conn, interfacePath)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getInterfacePath gets or creates the wpa_supplicant Interface object path
|
|
||||||
func (b *WPABackend) getInterfacePath(interfaceName string) (dbus.ObjectPath, error) {
|
|
||||||
var interfacePath dbus.ObjectPath
|
|
||||||
|
|
||||||
// Try to get existing interface
|
|
||||||
err := b.wpasupplicant.Call(Service+".GetInterface", 0, interfaceName).Store(&interfacePath)
|
|
||||||
if err == nil {
|
|
||||||
return interfacePath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interface doesn't exist, create it
|
|
||||||
args := map[string]dbus.Variant{
|
|
||||||
"Ifname": dbus.MakeVariant(interfaceName),
|
|
||||||
}
|
|
||||||
|
|
||||||
err = b.wpasupplicant.Call(Service+".CreateInterface", 0, args).Store(&interfacePath)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create interface: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return interfacePath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes the D-Bus connection
|
|
||||||
func (b *WPABackend) Close() error {
|
|
||||||
if b.conn != nil {
|
|
||||||
b.conn.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScanNetworks triggers a network scan
|
|
||||||
func (b *WPABackend) ScanNetworks() error {
|
|
||||||
err := b.iface.Scan("active")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to trigger scan: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetOrderedNetworks returns networks sorted by signal strength in backend-agnostic format
|
|
||||||
func (b *WPABackend) GetOrderedNetworks() ([]backend.BackendNetwork, error) {
|
|
||||||
// Get BSS list
|
|
||||||
bssPaths, err := b.iface.GetBSSs()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get BSSs: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var networks []backend.BackendNetwork
|
|
||||||
seenSSIDs := make(map[string]bool)
|
|
||||||
|
|
||||||
// Iterate through BSSs and collect network info
|
|
||||||
for _, bssPath := range bssPaths {
|
|
||||||
bss := NewBSS(b.conn, bssPath)
|
|
||||||
props, err := bss.GetProperties()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ssid := string(props.SSID)
|
|
||||||
if ssid == "" || seenSSIDs[ssid] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seenSSIDs[ssid] = true
|
|
||||||
|
|
||||||
// Get BSSID string
|
|
||||||
bssidStr, err := bss.GetBSSIDString()
|
|
||||||
if err != nil {
|
|
||||||
bssidStr = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to backend-agnostic format
|
|
||||||
backendNet := backend.BackendNetwork{
|
|
||||||
SSID: ssid,
|
|
||||||
SignalDBm: props.Signal,
|
|
||||||
SecurityType: props.DetermineSecurityType(),
|
|
||||||
BSSID: bssidStr,
|
|
||||||
Frequency: props.Frequency,
|
|
||||||
}
|
|
||||||
|
|
||||||
networks = append(networks, backendNet)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by signal strength (descending)
|
|
||||||
// Note: This is a simple bubble sort for demonstration
|
|
||||||
// In production, use sort.Slice
|
|
||||||
for i := 0; i < len(networks); i++ {
|
|
||||||
for j := i + 1; j < len(networks); j++ {
|
|
||||||
if networks[j].SignalDBm > networks[i].SignalDBm {
|
|
||||||
networks[i], networks[j] = networks[j], networks[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return networks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsScanning checks if a scan is currently in progress
|
|
||||||
func (b *WPABackend) IsScanning() (bool, error) {
|
|
||||||
return b.iface.GetScanning()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect connects to a WiFi network
|
|
||||||
func (b *WPABackend) Connect(ssid, password string) error {
|
|
||||||
// Create network configuration
|
|
||||||
config := make(map[string]interface{})
|
|
||||||
config["ssid"] = fmt.Sprintf("\"%s\"", ssid) // wpa_supplicant expects quoted SSID
|
|
||||||
|
|
||||||
if password != "" {
|
|
||||||
// For WPA/WPA2-PSK networks
|
|
||||||
config["psk"] = fmt.Sprintf("\"%s\"", password)
|
|
||||||
} else {
|
|
||||||
// For open networks
|
|
||||||
config["key_mgmt"] = "NONE"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add network
|
|
||||||
networkPath, err := b.iface.AddNetwork(config)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to add network: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store current network path for cleanup
|
|
||||||
b.currentNetwork = networkPath
|
|
||||||
|
|
||||||
// Select (connect to) the network
|
|
||||||
err = b.iface.SelectNetwork(networkPath)
|
|
||||||
if err != nil {
|
|
||||||
// Clean up network on failure
|
|
||||||
b.iface.RemoveNetwork(networkPath)
|
|
||||||
return fmt.Errorf("failed to select network: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disconnect disconnects from the current WiFi network
|
|
||||||
func (b *WPABackend) Disconnect() error {
|
|
||||||
// Disconnect from current network
|
|
||||||
if err := b.iface.Disconnect(); err != nil {
|
|
||||||
return fmt.Errorf("failed to disconnect: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the network configuration if we have one
|
|
||||||
if b.currentNetwork != "" && b.currentNetwork != "/" {
|
|
||||||
b.iface.RemoveNetwork(b.currentNetwork)
|
|
||||||
b.currentNetwork = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConnectionState returns the current WiFi connection state
|
|
||||||
func (b *WPABackend) GetConnectionState() (backend.ConnectionState, error) {
|
|
||||||
state, err := b.iface.GetState()
|
|
||||||
if err != nil {
|
|
||||||
return backend.StateDisconnected, err
|
|
||||||
}
|
|
||||||
return mapWPAState(state), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConnectedSSID returns the SSID of the currently connected network
|
|
||||||
func (b *WPABackend) GetConnectedSSID() string {
|
|
||||||
// Get current BSS
|
|
||||||
bssPath, err := b.iface.GetCurrentBSS()
|
|
||||||
if err != nil || bssPath == "/" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get BSS object
|
|
||||||
bss := NewBSS(b.conn, bssPath)
|
|
||||||
ssid, err := bss.GetSSIDString()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return ssid
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartEventMonitoring starts monitoring WiFi events
|
|
||||||
func (b *WPABackend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
|
|
||||||
// Create signal monitor
|
|
||||||
b.signalMonitor = NewSignalMonitor(b.conn, b.iface)
|
|
||||||
|
|
||||||
// Start monitoring
|
|
||||||
return b.signalMonitor.Start(callbacks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopEventMonitoring stops monitoring WiFi events
|
|
||||||
func (b *WPABackend) StopEventMonitoring() {
|
|
||||||
if b.signalMonitor != nil {
|
|
||||||
b.signalMonitor.Stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for scan to complete (helper method)
|
|
||||||
func (b *WPABackend) waitForScanComplete(timeout time.Duration) error {
|
|
||||||
start := time.Now()
|
|
||||||
for {
|
|
||||||
if time.Since(start) > timeout {
|
|
||||||
return fmt.Errorf("scan timeout")
|
|
||||||
}
|
|
||||||
|
|
||||||
scanning, err := b.iface.GetScanning()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !scanning {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
package wpasupplicant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BSS represents a wpa_supplicant BSS (Basic Service Set) object
|
|
||||||
type BSS struct {
|
|
||||||
path dbus.ObjectPath
|
|
||||||
conn *dbus.Conn
|
|
||||||
obj dbus.BusObject
|
|
||||||
}
|
|
||||||
|
|
||||||
// BSSProperties holds the properties of a BSS
|
|
||||||
type BSSProperties struct {
|
|
||||||
SSID []byte
|
|
||||||
BSSID []byte
|
|
||||||
Signal int16 // Signal strength in dBm
|
|
||||||
Frequency uint32 // Frequency in MHz
|
|
||||||
Privacy bool // Whether encryption is enabled
|
|
||||||
RSN map[string]dbus.Variant
|
|
||||||
WPA map[string]dbus.Variant
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBSS creates a new BSS instance
|
|
||||||
func NewBSS(conn *dbus.Conn, path dbus.ObjectPath) *BSS {
|
|
||||||
return &BSS{
|
|
||||||
path: path,
|
|
||||||
conn: conn,
|
|
||||||
obj: conn.Object(Service, path),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProperties returns all properties of the BSS
|
|
||||||
func (b *BSS) GetProperties() (*BSSProperties, error) {
|
|
||||||
props := &BSSProperties{}
|
|
||||||
|
|
||||||
// Get SSID
|
|
||||||
if ssidProp, err := b.obj.GetProperty(BSSInterface + ".SSID"); err == nil {
|
|
||||||
if ssid, ok := ssidProp.Value().([]byte); ok {
|
|
||||||
props.SSID = ssid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get BSSID
|
|
||||||
if bssidProp, err := b.obj.GetProperty(BSSInterface + ".BSSID"); err == nil {
|
|
||||||
if bssid, ok := bssidProp.Value().([]byte); ok {
|
|
||||||
props.BSSID = bssid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Signal
|
|
||||||
if signalProp, err := b.obj.GetProperty(BSSInterface + ".Signal"); err == nil {
|
|
||||||
if signal, ok := signalProp.Value().(int16); ok {
|
|
||||||
props.Signal = signal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Frequency
|
|
||||||
if freqProp, err := b.obj.GetProperty(BSSInterface + ".Frequency"); err == nil {
|
|
||||||
if freq, ok := freqProp.Value().(uint16); ok {
|
|
||||||
props.Frequency = uint32(freq)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Privacy
|
|
||||||
if privacyProp, err := b.obj.GetProperty(BSSInterface + ".Privacy"); err == nil {
|
|
||||||
if privacy, ok := privacyProp.Value().(bool); ok {
|
|
||||||
props.Privacy = privacy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get RSN (WPA2) information
|
|
||||||
if rsnProp, err := b.obj.GetProperty(BSSInterface + ".RSN"); err == nil {
|
|
||||||
if rsn, ok := rsnProp.Value().(map[string]dbus.Variant); ok {
|
|
||||||
props.RSN = rsn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get WPA information
|
|
||||||
if wpaProp, err := b.obj.GetProperty(BSSInterface + ".WPA"); err == nil {
|
|
||||||
if wpa, ok := wpaProp.Value().(map[string]dbus.Variant); ok {
|
|
||||||
props.WPA = wpa
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return props, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSSIDString returns the SSID as a string
|
|
||||||
func (b *BSS) GetSSIDString() (string, error) {
|
|
||||||
prop, err := b.obj.GetProperty(BSSInterface + ".SSID")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get SSID property: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ssid, ok := prop.Value().([]byte)
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("SSID property is not a byte array")
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(ssid), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBSSIDString returns the BSSID as a formatted MAC address string
|
|
||||||
func (b *BSS) GetBSSIDString() (string, error) {
|
|
||||||
prop, err := b.obj.GetProperty(BSSInterface + ".BSSID")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get BSSID property: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bssid, ok := prop.Value().([]byte)
|
|
||||||
if !ok || len(bssid) != 6 {
|
|
||||||
return "", fmt.Errorf("BSSID property is not a valid MAC address")
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
|
|
||||||
bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSignal returns the signal strength in dBm
|
|
||||||
func (b *BSS) GetSignal() (int16, error) {
|
|
||||||
prop, err := b.obj.GetProperty(BSSInterface + ".Signal")
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to get Signal property: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
signal, ok := prop.Value().(int16)
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("Signal property is not an int16")
|
|
||||||
}
|
|
||||||
|
|
||||||
return signal, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DetermineSecurityType determines the security type based on BSS properties
|
|
||||||
func (p *BSSProperties) DetermineSecurityType() string {
|
|
||||||
// Check for WPA2 (RSN)
|
|
||||||
if len(p.RSN) > 0 {
|
|
||||||
return "psk"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for WPA
|
|
||||||
if len(p.WPA) > 0 {
|
|
||||||
return "psk"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for WEP (privacy but no WPA/RSN)
|
|
||||||
if p.Privacy {
|
|
||||||
return "wep"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open network
|
|
||||||
return "open"
|
|
||||||
}
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
package wpasupplicant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WPAInterface represents a wpa_supplicant Interface object
|
|
||||||
type WPAInterface struct {
|
|
||||||
path dbus.ObjectPath
|
|
||||||
conn *dbus.Conn
|
|
||||||
obj dbus.BusObject
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWPAInterface creates a new WPAInterface instance
|
|
||||||
func NewWPAInterface(conn *dbus.Conn, path dbus.ObjectPath) *WPAInterface {
|
|
||||||
return &WPAInterface{
|
|
||||||
path: path,
|
|
||||||
conn: conn,
|
|
||||||
obj: conn.Object(Service, path),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan triggers a network scan
|
|
||||||
func (i *WPAInterface) Scan(scanType string) error {
|
|
||||||
args := map[string]interface{}{
|
|
||||||
"Type": scanType, // "active" or "passive"
|
|
||||||
}
|
|
||||||
|
|
||||||
err := i.obj.Call(InterfaceInterface+".Scan", 0, args).Err
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("scan failed: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBSSs returns a list of BSS (Basic Service Set) object paths
|
|
||||||
func (i *WPAInterface) GetBSSs() ([]dbus.ObjectPath, error) {
|
|
||||||
prop, err := i.obj.GetProperty(InterfaceInterface + ".BSSs")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get BSSs property: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bsss, ok := prop.Value().([]dbus.ObjectPath)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("BSSs property is not an array of ObjectPath")
|
|
||||||
}
|
|
||||||
|
|
||||||
return bsss, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetState returns the current connection state
|
|
||||||
func (i *WPAInterface) GetState() (WPAState, error) {
|
|
||||||
prop, err := i.obj.GetProperty(InterfaceInterface + ".State")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get State property: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
state, ok := prop.Value().(string)
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("State property is not a string")
|
|
||||||
}
|
|
||||||
|
|
||||||
return WPAState(state), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentBSS returns the currently connected BSS object path
|
|
||||||
func (i *WPAInterface) GetCurrentBSS() (dbus.ObjectPath, error) {
|
|
||||||
prop, err := i.obj.GetProperty(InterfaceInterface + ".CurrentBSS")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get CurrentBSS property: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bss, ok := prop.Value().(dbus.ObjectPath)
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("CurrentBSS property is not an ObjectPath")
|
|
||||||
}
|
|
||||||
|
|
||||||
return bss, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddNetwork creates a new network configuration
|
|
||||||
func (i *WPAInterface) AddNetwork(config map[string]interface{}) (dbus.ObjectPath, error) {
|
|
||||||
var networkPath dbus.ObjectPath
|
|
||||||
|
|
||||||
// Convert config to proper DBus variant format
|
|
||||||
dbusConfig := make(map[string]dbus.Variant)
|
|
||||||
for key, value := range config {
|
|
||||||
dbusConfig[key] = dbus.MakeVariant(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := i.obj.Call(InterfaceInterface+".AddNetwork", 0, dbusConfig).Store(&networkPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to add network: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return networkPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SelectNetwork connects to a network
|
|
||||||
func (i *WPAInterface) SelectNetwork(networkPath dbus.ObjectPath) error {
|
|
||||||
err := i.obj.Call(InterfaceInterface+".SelectNetwork", 0, networkPath).Err
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to select network: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveNetwork removes a network configuration
|
|
||||||
func (i *WPAInterface) RemoveNetwork(networkPath dbus.ObjectPath) error {
|
|
||||||
err := i.obj.Call(InterfaceInterface+".RemoveNetwork", 0, networkPath).Err
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to remove network: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disconnect disconnects from the current network
|
|
||||||
func (i *WPAInterface) Disconnect() error {
|
|
||||||
err := i.obj.Call(InterfaceInterface+".Disconnect", 0).Err
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("disconnect failed: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPath returns the D-Bus object path for this interface
|
|
||||||
func (i *WPAInterface) GetPath() dbus.ObjectPath {
|
|
||||||
return i.path
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetScanning returns whether a scan is currently in progress
|
|
||||||
func (i *WPAInterface) GetScanning() (bool, error) {
|
|
||||||
prop, err := i.obj.GetProperty(InterfaceInterface + ".Scanning")
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("failed to get Scanning property: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
scanning, ok := prop.Value().(bool)
|
|
||||||
if !ok {
|
|
||||||
return false, fmt.Errorf("Scanning property is not a boolean")
|
|
||||||
}
|
|
||||||
|
|
||||||
return scanning, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
package wpasupplicant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Network represents a wpa_supplicant Network configuration object
|
|
||||||
type Network struct {
|
|
||||||
path dbus.ObjectPath
|
|
||||||
conn *dbus.Conn
|
|
||||||
obj dbus.BusObject
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewNetwork creates a new Network instance
|
|
||||||
func NewNetwork(conn *dbus.Conn, path dbus.ObjectPath) *Network {
|
|
||||||
return &Network{
|
|
||||||
path: path,
|
|
||||||
conn: conn,
|
|
||||||
obj: conn.Object(Service, path),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPath returns the D-Bus object path for this network
|
|
||||||
func (n *Network) GetPath() dbus.ObjectPath {
|
|
||||||
return n.path
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProperties returns properties of the network configuration
|
|
||||||
func (n *Network) GetProperties() (map[string]dbus.Variant, error) {
|
|
||||||
prop, err := n.obj.GetProperty(NetworkInterface + ".Properties")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
props, ok := prop.Value().(map[string]dbus.Variant)
|
|
||||||
if !ok {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return props, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
package wpasupplicant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
"github.com/nemunaire/repeater/internal/wifi/backend"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SignalMonitor monitors D-Bus signals from wpa_supplicant
|
|
||||||
type SignalMonitor struct {
|
|
||||||
conn *dbus.Conn
|
|
||||||
iface *WPAInterface
|
|
||||||
callbacks backend.EventCallbacks
|
|
||||||
|
|
||||||
// Signal channel
|
|
||||||
signalChan chan *dbus.Signal
|
|
||||||
|
|
||||||
// Control
|
|
||||||
stopChan chan struct{}
|
|
||||||
mu sync.RWMutex
|
|
||||||
running bool
|
|
||||||
|
|
||||||
// State tracking
|
|
||||||
lastState WPAState
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSignalMonitor creates a new signal monitor
|
|
||||||
func NewSignalMonitor(conn *dbus.Conn, iface *WPAInterface) *SignalMonitor {
|
|
||||||
return &SignalMonitor{
|
|
||||||
conn: conn,
|
|
||||||
iface: iface,
|
|
||||||
signalChan: make(chan *dbus.Signal, 100),
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start begins monitoring D-Bus signals
|
|
||||||
func (sm *SignalMonitor) Start(callbacks backend.EventCallbacks) error {
|
|
||||||
sm.mu.Lock()
|
|
||||||
if sm.running {
|
|
||||||
sm.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
sm.running = true
|
|
||||||
sm.callbacks = callbacks
|
|
||||||
sm.mu.Unlock()
|
|
||||||
|
|
||||||
interfacePath := sm.iface.GetPath()
|
|
||||||
|
|
||||||
// Add signal match for PropertiesChanged on Interface
|
|
||||||
matchOptions := []dbus.MatchOption{
|
|
||||||
dbus.WithMatchObjectPath(interfacePath),
|
|
||||||
dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
|
|
||||||
dbus.WithMatchMember("PropertiesChanged"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := sm.conn.AddMatchSignal(matchOptions...); err != nil {
|
|
||||||
sm.mu.Lock()
|
|
||||||
sm.running = false
|
|
||||||
sm.mu.Unlock()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add signal match for ScanDone
|
|
||||||
scanDoneOptions := []dbus.MatchOption{
|
|
||||||
dbus.WithMatchObjectPath(interfacePath),
|
|
||||||
dbus.WithMatchInterface(InterfaceInterface),
|
|
||||||
dbus.WithMatchMember("ScanDone"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := sm.conn.AddMatchSignal(scanDoneOptions...); err != nil {
|
|
||||||
sm.mu.Lock()
|
|
||||||
sm.running = false
|
|
||||||
sm.mu.Unlock()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register signal channel
|
|
||||||
sm.conn.Signal(sm.signalChan)
|
|
||||||
|
|
||||||
// Get initial state
|
|
||||||
state, err := sm.iface.GetState()
|
|
||||||
if err == nil {
|
|
||||||
sm.lastState = state
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start monitoring goroutine
|
|
||||||
go sm.monitor()
|
|
||||||
|
|
||||||
log.Printf("D-Bus signal monitoring started for wpa_supplicant interface %s", interfacePath)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops monitoring D-Bus signals
|
|
||||||
func (sm *SignalMonitor) Stop() {
|
|
||||||
sm.mu.Lock()
|
|
||||||
if !sm.running {
|
|
||||||
sm.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sm.running = false
|
|
||||||
sm.mu.Unlock()
|
|
||||||
|
|
||||||
// Signal stop
|
|
||||||
close(sm.stopChan)
|
|
||||||
|
|
||||||
// Remove signal channel
|
|
||||||
sm.conn.RemoveSignal(sm.signalChan)
|
|
||||||
|
|
||||||
log.Printf("D-Bus signal monitoring stopped for wpa_supplicant")
|
|
||||||
}
|
|
||||||
|
|
||||||
// monitor is the main signal processing loop
|
|
||||||
func (sm *SignalMonitor) monitor() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case sig := <-sm.signalChan:
|
|
||||||
sm.handleSignal(sig)
|
|
||||||
case <-sm.stopChan:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSignal processes a D-Bus signal
|
|
||||||
func (sm *SignalMonitor) handleSignal(sig *dbus.Signal) {
|
|
||||||
// Handle ScanDone signal
|
|
||||||
if sig.Name == InterfaceInterface+".ScanDone" {
|
|
||||||
sm.handleScanDone(sig)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle PropertiesChanged signals
|
|
||||||
if sig.Name != "org.freedesktop.DBus.Properties.PropertiesChanged" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify signal is from Interface
|
|
||||||
if len(sig.Body) < 2 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
interfaceName, ok := sig.Body[0].(string)
|
|
||||||
if !ok || interfaceName != InterfaceInterface {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse changed properties
|
|
||||||
changedProps, ok := sig.Body[1].(map[string]dbus.Variant)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for State property change
|
|
||||||
if stateVariant, ok := changedProps["State"]; ok {
|
|
||||||
if state, ok := stateVariant.Value().(string); ok {
|
|
||||||
sm.handleStateChange(WPAState(state))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for CurrentBSS property change (connection status)
|
|
||||||
if _, ok := changedProps["CurrentBSS"]; ok {
|
|
||||||
// BSS changed, trigger state update
|
|
||||||
sm.handleConnectionChange()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStateChange processes a state change
|
|
||||||
func (sm *SignalMonitor) handleStateChange(state WPAState) {
|
|
||||||
sm.lastState = state
|
|
||||||
|
|
||||||
sm.mu.RLock()
|
|
||||||
callback := sm.callbacks.OnStateChange
|
|
||||||
sm.mu.RUnlock()
|
|
||||||
|
|
||||||
if callback == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map wpa_supplicant state to backend state
|
|
||||||
backendState := mapWPAState(state)
|
|
||||||
|
|
||||||
// Get connected SSID if connected
|
|
||||||
ssid := ""
|
|
||||||
if backendState == backend.StateConnected {
|
|
||||||
if bssPath, err := sm.iface.GetCurrentBSS(); err == nil && bssPath != "/" {
|
|
||||||
bss := NewBSS(sm.conn, bssPath)
|
|
||||||
if ssidStr, err := bss.GetSSIDString(); err == nil {
|
|
||||||
ssid = ssidStr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
callback(backendState, ssid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleConnectionChange processes connection changes
|
|
||||||
func (sm *SignalMonitor) handleConnectionChange() {
|
|
||||||
// Get current state and trigger state change callback
|
|
||||||
state, err := sm.iface.GetState()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sm.handleStateChange(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleScanDone processes scan completion
|
|
||||||
func (sm *SignalMonitor) handleScanDone(sig *dbus.Signal) {
|
|
||||||
sm.mu.RLock()
|
|
||||||
callback := sm.callbacks.OnScanComplete
|
|
||||||
sm.mu.RUnlock()
|
|
||||||
|
|
||||||
if callback != nil {
|
|
||||||
callback()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapWPAState maps wpa_supplicant states to backend-agnostic states
|
|
||||||
func mapWPAState(state WPAState) backend.ConnectionState {
|
|
||||||
switch state {
|
|
||||||
case StateCompleted:
|
|
||||||
return backend.StateConnected
|
|
||||||
case StateAuthenticating, StateAssociating, StateAssociated, State4WayHandshake, StateGroupHandshake:
|
|
||||||
return backend.StateConnecting
|
|
||||||
case StateDisconnected, StateInactive, StateInterfaceDisabled:
|
|
||||||
return backend.StateDisconnected
|
|
||||||
case StateScanning:
|
|
||||||
// Keep as disconnected if just scanning
|
|
||||||
return backend.StateDisconnected
|
|
||||||
default:
|
|
||||||
return backend.StateDisconnected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
package wpasupplicant
|
|
||||||
|
|
||||||
const (
|
|
||||||
// D-Bus service and interfaces
|
|
||||||
Service = "fi.w1.wpa_supplicant1"
|
|
||||||
RootPath = "/fi/w1/wpa_supplicant1"
|
|
||||||
InterfaceInterface = "fi.w1.wpa_supplicant1.Interface"
|
|
||||||
BSSInterface = "fi.w1.wpa_supplicant1.BSS"
|
|
||||||
NetworkInterface = "fi.w1.wpa_supplicant1.Network"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WPAState represents the wpa_supplicant connection state
|
|
||||||
type WPAState string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// wpa_supplicant state strings
|
|
||||||
StateDisconnected WPAState = "disconnected"
|
|
||||||
StateInactive WPAState = "inactive"
|
|
||||||
StateScanning WPAState = "scanning"
|
|
||||||
StateAuthenticating WPAState = "authenticating"
|
|
||||||
StateAssociating WPAState = "associating"
|
|
||||||
StateAssociated WPAState = "associated"
|
|
||||||
State4WayHandshake WPAState = "4way_handshake"
|
|
||||||
StateGroupHandshake WPAState = "group_handshake"
|
|
||||||
StateCompleted WPAState = "completed"
|
|
||||||
StateInterfaceDisabled WPAState = "interface_disabled"
|
|
||||||
)
|
|
||||||
826
main.go
Normal file
826
main.go
Normal file
|
|
@ -0,0 +1,826 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
package: api
|
|
||||||
generate:
|
|
||||||
- gin
|
|
||||||
- embedded-spec
|
|
||||||
output: internal/api/routes.gen.go
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
package: api
|
|
||||||
generate:
|
|
||||||
- types
|
|
||||||
output: internal/api/types.gen.go
|
|
||||||
552
openapi.yaml
552
openapi.yaml
|
|
@ -1,552 +0,0 @@
|
||||||
openapi: 3.0.3
|
|
||||||
info:
|
|
||||||
title: Travel Router Control API
|
|
||||||
description: |
|
|
||||||
API for controlling a mini travel router with dual WiFi interfaces and Ethernet connectivity.
|
|
||||||
The router can operate as a WiFi repeater, connecting to upstream networks while providing
|
|
||||||
a hotspot for client devices.
|
|
||||||
version: 1.0.0
|
|
||||||
contact:
|
|
||||||
name: API Support
|
|
||||||
license:
|
|
||||||
name: MIT
|
|
||||||
|
|
||||||
servers:
|
|
||||||
- url: http://localhost:8080
|
|
||||||
description: Local router API
|
|
||||||
|
|
||||||
tags:
|
|
||||||
- name: WiFi
|
|
||||||
description: WiFi client operations (upstream network connection)
|
|
||||||
- name: Hotspot
|
|
||||||
description: Access point operations (client-facing hotspot)
|
|
||||||
- name: Devices
|
|
||||||
description: Connected devices management
|
|
||||||
- name: System
|
|
||||||
description: System status and monitoring
|
|
||||||
- name: Logs
|
|
||||||
description: System logs and real-time monitoring
|
|
||||||
|
|
||||||
paths:
|
|
||||||
/api/wifi/networks:
|
|
||||||
get:
|
|
||||||
tags:
|
|
||||||
- WiFi
|
|
||||||
summary: Get discovered WiFi networks
|
|
||||||
description: |
|
|
||||||
Returns the list of WiFi networks from the last scan without triggering a new scan.
|
|
||||||
Returns an empty list if no scan has been performed yet.
|
|
||||||
operationId: getWiFiNetworks
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List of discovered networks
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/WiFiNetwork'
|
|
||||||
example:
|
|
||||||
- ssid: "Hotel-Guest"
|
|
||||||
signal: 5
|
|
||||||
security: "WPA2"
|
|
||||||
channel: 6
|
|
||||||
bssid: "aa:bb:cc:dd:ee:ff"
|
|
||||||
- ssid: "Public-WiFi"
|
|
||||||
signal: 3
|
|
||||||
security: "Open"
|
|
||||||
channel: 11
|
|
||||||
bssid: "11:22:33:44:55:66"
|
|
||||||
'500':
|
|
||||||
description: Error retrieving networks
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Error'
|
|
||||||
|
|
||||||
/api/wifi/scan:
|
|
||||||
get:
|
|
||||||
tags:
|
|
||||||
- WiFi
|
|
||||||
summary: Scan for available WiFi networks
|
|
||||||
description: |
|
|
||||||
Triggers a WiFi scan using wpa_supplicant via D-Bus and returns all discovered networks
|
|
||||||
sorted by signal strength. The scan takes approximately 2 seconds to complete.
|
|
||||||
operationId: scanWiFi
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Successfully scanned networks
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/WiFiNetwork'
|
|
||||||
example:
|
|
||||||
- ssid: "Hotel-Guest"
|
|
||||||
signal: 5
|
|
||||||
security: "WPA2"
|
|
||||||
channel: 6
|
|
||||||
bssid: "aa:bb:cc:dd:ee:ff"
|
|
||||||
- ssid: "Public-WiFi"
|
|
||||||
signal: 3
|
|
||||||
security: "Open"
|
|
||||||
channel: 11
|
|
||||||
bssid: "11:22:33:44:55:66"
|
|
||||||
'500':
|
|
||||||
description: Scan error
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Error'
|
|
||||||
|
|
||||||
/api/wifi/connect:
|
|
||||||
post:
|
|
||||||
tags:
|
|
||||||
- WiFi
|
|
||||||
summary: Connect to a WiFi network
|
|
||||||
description: |
|
|
||||||
Connects the router to an upstream WiFi network using wpa_supplicant.
|
|
||||||
Supports both open and password-protected networks (WPA/WPA2).
|
|
||||||
operationId: connectWiFi
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/WiFiConnectRequest'
|
|
||||||
examples:
|
|
||||||
protected:
|
|
||||||
summary: WPA2 protected network
|
|
||||||
value:
|
|
||||||
ssid: "Hotel-Guest"
|
|
||||||
password: "guest1234"
|
|
||||||
open:
|
|
||||||
summary: Open network
|
|
||||||
value:
|
|
||||||
ssid: "Public-WiFi"
|
|
||||||
password: ""
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Successfully connected
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/SuccessResponse'
|
|
||||||
'400':
|
|
||||||
description: Invalid request data
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Error'
|
|
||||||
'500':
|
|
||||||
description: Connection failed
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Error'
|
|
||||||
|
|
||||||
/api/wifi/disconnect:
|
|
||||||
post:
|
|
||||||
tags:
|
|
||||||
- WiFi
|
|
||||||
summary: Disconnect from WiFi network
|
|
||||||
description: |
|
|
||||||
Disconnects from the currently connected upstream WiFi network
|
|
||||||
and removes all saved network configurations.
|
|
||||||
operationId: disconnectWiFi
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Successfully disconnected
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/SuccessResponse'
|
|
||||||
'500':
|
|
||||||
description: Disconnection failed
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Error'
|
|
||||||
|
|
||||||
/api/hotspot/toggle:
|
|
||||||
post:
|
|
||||||
tags:
|
|
||||||
- Hotspot
|
|
||||||
summary: Toggle hotspot on/off
|
|
||||||
description: |
|
|
||||||
Enables or disables the hotspot (access point) by starting/stopping
|
|
||||||
the hostapd service. Returns the new enabled state and updates
|
|
||||||
the system status with current hostapd_cli information.
|
|
||||||
operationId: toggleHotspot
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Hotspot state changed successfully
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
enabled:
|
|
||||||
type: boolean
|
|
||||||
description: Current hotspot state after toggle
|
|
||||||
required:
|
|
||||||
- enabled
|
|
||||||
example:
|
|
||||||
enabled: true
|
|
||||||
'500':
|
|
||||||
description: Failed to change hotspot state
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Error'
|
|
||||||
|
|
||||||
/api/devices:
|
|
||||||
get:
|
|
||||||
tags:
|
|
||||||
- Devices
|
|
||||||
summary: Get connected devices
|
|
||||||
description: |
|
|
||||||
Returns a list of all devices currently connected to the hotspot.
|
|
||||||
Device information is gathered from DHCP leases and ARP tables.
|
|
||||||
Only devices with active ARP entries are considered connected.
|
|
||||||
operationId: getDevices
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List of connected devices
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/ConnectedDevice'
|
|
||||||
example:
|
|
||||||
- name: "iPhone-12"
|
|
||||||
type: "mobile"
|
|
||||||
mac: "aa:bb:cc:11:22:33"
|
|
||||||
ip: "192.168.1.100"
|
|
||||||
- name: "MacBook-Pro"
|
|
||||||
type: "laptop"
|
|
||||||
mac: "dd:ee:ff:44:55:66"
|
|
||||||
ip: "192.168.1.101"
|
|
||||||
'500':
|
|
||||||
description: Failed to retrieve device list
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Error'
|
|
||||||
|
|
||||||
/api/status:
|
|
||||||
get:
|
|
||||||
tags:
|
|
||||||
- System
|
|
||||||
summary: Get system status
|
|
||||||
description: |
|
|
||||||
Returns comprehensive system status including WiFi connection state,
|
|
||||||
detailed hotspot status from hostapd_cli, connected device count,
|
|
||||||
data usage, and uptime.
|
|
||||||
operationId: getStatus
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Current system status
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/SystemStatus'
|
|
||||||
|
|
||||||
/api/logs:
|
|
||||||
get:
|
|
||||||
tags:
|
|
||||||
- Logs
|
|
||||||
summary: Get system logs
|
|
||||||
description: |
|
|
||||||
Returns the last 100 log entries from the system.
|
|
||||||
For real-time log streaming, use the WebSocket endpoint.
|
|
||||||
operationId: getLogs
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List of log entries
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/LogEntry'
|
|
||||||
example:
|
|
||||||
- timestamp: "2025-10-28T14:32:10Z"
|
|
||||||
source: "WiFi"
|
|
||||||
message: "Scan terminé - 5 réseaux trouvés"
|
|
||||||
- timestamp: "2025-10-28T14:32:15Z"
|
|
||||||
source: "WiFi"
|
|
||||||
message: "Tentative de connexion à Hotel-Guest"
|
|
||||||
delete:
|
|
||||||
tags:
|
|
||||||
- Logs
|
|
||||||
summary: Clear system logs
|
|
||||||
description: Clears all stored log entries (keeps only the "logs cleared" entry)
|
|
||||||
operationId: clearLogs
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Logs cleared successfully
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/SuccessResponse'
|
|
||||||
|
|
||||||
/ws/logs:
|
|
||||||
get:
|
|
||||||
tags:
|
|
||||||
- Logs
|
|
||||||
summary: WebSocket for real-time logs
|
|
||||||
description: |
|
|
||||||
WebSocket endpoint for receiving real-time log updates.
|
|
||||||
Upon connection, all existing logs are sent, followed by new logs as they occur.
|
|
||||||
|
|
||||||
This is a WebSocket endpoint - upgrade the HTTP connection to WebSocket protocol.
|
|
||||||
operationId: logsWebSocket
|
|
||||||
responses:
|
|
||||||
'101':
|
|
||||||
description: WebSocket connection established
|
|
||||||
'400':
|
|
||||||
description: WebSocket upgrade failed
|
|
||||||
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
WiFiNetwork:
|
|
||||||
type: object
|
|
||||||
description: Discovered WiFi network information
|
|
||||||
properties:
|
|
||||||
ssid:
|
|
||||||
type: string
|
|
||||||
description: Network SSID (name)
|
|
||||||
example: "Hotel-Guest"
|
|
||||||
signal:
|
|
||||||
type: integer
|
|
||||||
description: Signal strength (1-5 scale)
|
|
||||||
minimum: 1
|
|
||||||
maximum: 5
|
|
||||||
example: 4
|
|
||||||
security:
|
|
||||||
type: string
|
|
||||||
description: Security type
|
|
||||||
enum:
|
|
||||||
- Open
|
|
||||||
- WEP
|
|
||||||
- WPA
|
|
||||||
- WPA2
|
|
||||||
example: "WPA2"
|
|
||||||
channel:
|
|
||||||
type: integer
|
|
||||||
description: WiFi channel number
|
|
||||||
minimum: 1
|
|
||||||
maximum: 165
|
|
||||||
example: 6
|
|
||||||
bssid:
|
|
||||||
type: string
|
|
||||||
description: Access point MAC address
|
|
||||||
pattern: '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$'
|
|
||||||
example: "aa:bb:cc:dd:ee:ff"
|
|
||||||
required:
|
|
||||||
- ssid
|
|
||||||
- signal
|
|
||||||
- security
|
|
||||||
- channel
|
|
||||||
- bssid
|
|
||||||
|
|
||||||
WiFiConnectRequest:
|
|
||||||
type: object
|
|
||||||
description: Request to connect to a WiFi network
|
|
||||||
properties:
|
|
||||||
ssid:
|
|
||||||
type: string
|
|
||||||
description: Network SSID to connect to
|
|
||||||
example: "Hotel-Guest"
|
|
||||||
password:
|
|
||||||
type: string
|
|
||||||
description: Network password (empty string for open networks)
|
|
||||||
example: "guest1234"
|
|
||||||
required:
|
|
||||||
- ssid
|
|
||||||
- password
|
|
||||||
|
|
||||||
HotspotStatus:
|
|
||||||
type: object
|
|
||||||
description: Detailed hotspot status from hostapd_cli
|
|
||||||
properties:
|
|
||||||
state:
|
|
||||||
type: string
|
|
||||||
description: Hotspot state (ENABLED, DISABLED, etc.)
|
|
||||||
example: "ENABLED"
|
|
||||||
ssid:
|
|
||||||
type: string
|
|
||||||
description: Current SSID being broadcast
|
|
||||||
example: "TravelRouter"
|
|
||||||
bssid:
|
|
||||||
type: string
|
|
||||||
description: MAC address of the access point
|
|
||||||
pattern: '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$'
|
|
||||||
example: "4a:e3:4e:09:57:f8"
|
|
||||||
channel:
|
|
||||||
type: integer
|
|
||||||
description: Current WiFi channel
|
|
||||||
minimum: 1
|
|
||||||
maximum: 14
|
|
||||||
example: 11
|
|
||||||
frequency:
|
|
||||||
type: integer
|
|
||||||
description: Frequency in MHz
|
|
||||||
example: 2462
|
|
||||||
numStations:
|
|
||||||
type: integer
|
|
||||||
description: Number of connected stations
|
|
||||||
minimum: 0
|
|
||||||
example: 2
|
|
||||||
hwMode:
|
|
||||||
type: string
|
|
||||||
description: Hardware mode (g, a, n, ac, etc.)
|
|
||||||
example: "g"
|
|
||||||
countryCode:
|
|
||||||
type: string
|
|
||||||
description: Country code
|
|
||||||
example: "VN"
|
|
||||||
required:
|
|
||||||
- state
|
|
||||||
|
|
||||||
ConnectedDevice:
|
|
||||||
type: object
|
|
||||||
description: Device connected to the hotspot
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
description: Device hostname
|
|
||||||
example: "iPhone-12"
|
|
||||||
type:
|
|
||||||
type: string
|
|
||||||
description: Detected device type
|
|
||||||
enum:
|
|
||||||
- mobile
|
|
||||||
- tablet
|
|
||||||
- laptop
|
|
||||||
- desktop
|
|
||||||
- unknown
|
|
||||||
example: "mobile"
|
|
||||||
mac:
|
|
||||||
type: string
|
|
||||||
description: Device MAC address
|
|
||||||
pattern: '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$'
|
|
||||||
example: "aa:bb:cc:11:22:33"
|
|
||||||
ip:
|
|
||||||
type: string
|
|
||||||
description: Assigned IP address
|
|
||||||
pattern: '^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$'
|
|
||||||
example: "192.168.1.100"
|
|
||||||
required:
|
|
||||||
- name
|
|
||||||
- type
|
|
||||||
- mac
|
|
||||||
- ip
|
|
||||||
|
|
||||||
SystemStatus:
|
|
||||||
type: object
|
|
||||||
description: Overall system status
|
|
||||||
properties:
|
|
||||||
connected:
|
|
||||||
type: boolean
|
|
||||||
description: Whether router is connected to upstream WiFi
|
|
||||||
example: true
|
|
||||||
connectionState:
|
|
||||||
type: string
|
|
||||||
description: Current WiFi connection state
|
|
||||||
enum:
|
|
||||||
- connected
|
|
||||||
- disconnected
|
|
||||||
- connecting
|
|
||||||
- disconnecting
|
|
||||||
- roaming
|
|
||||||
example: "connected"
|
|
||||||
connectedSSID:
|
|
||||||
type: string
|
|
||||||
description: SSID of connected upstream network (empty if not connected)
|
|
||||||
example: "Hotel-Guest"
|
|
||||||
hotspotStatus:
|
|
||||||
allOf:
|
|
||||||
- $ref: '#/components/schemas/HotspotStatus'
|
|
||||||
nullable: true
|
|
||||||
description: Detailed hotspot status (null if hotspot is not running)
|
|
||||||
connectedCount:
|
|
||||||
type: integer
|
|
||||||
description: Number of devices connected to hotspot
|
|
||||||
minimum: 0
|
|
||||||
example: 3
|
|
||||||
dataUsage:
|
|
||||||
type: number
|
|
||||||
format: double
|
|
||||||
description: Total data usage in MB
|
|
||||||
example: 145.7
|
|
||||||
uptime:
|
|
||||||
type: integer
|
|
||||||
format: int64
|
|
||||||
description: System uptime in seconds
|
|
||||||
example: 3600
|
|
||||||
connectedDevices:
|
|
||||||
type: array
|
|
||||||
description: List of devices connected to hotspot
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/ConnectedDevice'
|
|
||||||
required:
|
|
||||||
- connected
|
|
||||||
- connectionState
|
|
||||||
- connectedSSID
|
|
||||||
- connectedCount
|
|
||||||
- dataUsage
|
|
||||||
- uptime
|
|
||||||
- connectedDevices
|
|
||||||
|
|
||||||
LogEntry:
|
|
||||||
type: object
|
|
||||||
description: System log entry
|
|
||||||
properties:
|
|
||||||
timestamp:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
description: When the log entry was created
|
|
||||||
example: "2025-10-28T14:32:10Z"
|
|
||||||
source:
|
|
||||||
type: string
|
|
||||||
description: Log source component
|
|
||||||
enum:
|
|
||||||
- Système
|
|
||||||
- WiFi
|
|
||||||
- Hotspot
|
|
||||||
example: "WiFi"
|
|
||||||
message:
|
|
||||||
type: string
|
|
||||||
description: Log message
|
|
||||||
example: "Scan terminé - 5 réseaux trouvés"
|
|
||||||
required:
|
|
||||||
- timestamp
|
|
||||||
- source
|
|
||||||
- message
|
|
||||||
|
|
||||||
SuccessResponse:
|
|
||||||
type: object
|
|
||||||
description: Generic success response
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- success
|
|
||||||
example: "success"
|
|
||||||
required:
|
|
||||||
- status
|
|
||||||
|
|
||||||
Error:
|
|
||||||
type: object
|
|
||||||
description: Error response
|
|
||||||
properties:
|
|
||||||
error:
|
|
||||||
type: string
|
|
||||||
description: Error message
|
|
||||||
example: "Erreur lors du scan WiFi"
|
|
||||||
required:
|
|
||||||
- error
|
|
||||||
239
static/app.js
Normal file
239
static/app.js
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
// É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 = `
|
||||||
|
<div>
|
||||||
|
<strong>${network.ssid}</strong>
|
||||||
|
<div style="font-size: 0.8em; color: #666;">${network.security} • Canal ${network.channel}</div>
|
||||||
|
</div>
|
||||||
|
<div class="wifi-signal">
|
||||||
|
${generateSignalBars(network.signal)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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(`<div class="signal-bar ${active}" style="height: ${height}px;"></div>`);
|
||||||
|
}
|
||||||
|
return `<div class="signal-bars">${bars.join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
<div style="font-weight: 500;">${device.name}</div>
|
||||||
|
<div style="font-size: 0.8em; color: #666;">${device.ip}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
devicesList.appendChild(deviceCard);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('connectedDevices').textContent = mockDevices.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeviceIcon(type) {
|
||||||
|
const icons = {
|
||||||
|
mobile: '<svg class="device-icon" viewBox="0 0 24 24"><path d="M17 1H7c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 18H7V5h10v14z"/></svg>',
|
||||||
|
laptop: '<svg class="device-icon" viewBox="0 0 24 24"><path d="M20 18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z"/></svg>',
|
||||||
|
tablet: '<svg class="device-icon" viewBox="0 0 24 24"><path d="M21 4H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H3V6h18v12z"/></svg>'
|
||||||
|
};
|
||||||
|
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 = `<span class="log-timestamp">[${timestamp}]</span> ${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 = '<div class="loading"></div> 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 = '<div class="loading"></div> 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 = `
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<span>Connecté à ${appState.selectedWifi.ssid}</span>
|
||||||
|
`;
|
||||||
|
}, 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);
|
||||||
|
}
|
||||||
135
static/index.html
Normal file
135
static/index.html
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WiFi Repeater Control</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🌐 WiFi Repeater Control</h1>
|
||||||
|
<div class="status-indicator" id="connectionStatus">
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<span>En ligne</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="connectedDevices">0</div>
|
||||||
|
<div class="stat-label">Appareils connectés</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="dataUsage">0 MB</div>
|
||||||
|
<div class="stat-label">Données utilisées</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="uptime">00:00:00</div>
|
||||||
|
<div class="stat-label">Temps de fonctionnement</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
<svg class="icon" viewBox="0 0 24 24">
|
||||||
|
<path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.07 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/>
|
||||||
|
</svg>
|
||||||
|
Connexion WiFi Externe
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Réseaux disponibles</label>
|
||||||
|
<div class="wifi-list" id="wifiList">
|
||||||
|
<!-- Liste des réseaux WiFi sera remplie par JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="wifiPassword">Mot de passe</label>
|
||||||
|
<input type="password" id="wifiPassword" placeholder="Entrez le mot de passe WiFi">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn" onclick="connectToWifi()">
|
||||||
|
<span id="connectBtn">Se connecter</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-secondary" onclick="scanWifi()">
|
||||||
|
<span id="scanBtn">Scanner les réseaux</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
<svg class="icon" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||||
|
</svg>
|
||||||
|
Configuration Hotspot
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="hotspotName">Nom du réseau (SSID)</label>
|
||||||
|
<input type="text" id="hotspotName" value="MyRepeater" placeholder="Nom du hotspot">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="hotspotPassword">Mot de passe</label>
|
||||||
|
<input type="password" id="hotspotPassword" value="password123" placeholder="Mot de passe du hotspot">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="hotspotChannel">Canal</label>
|
||||||
|
<select id="hotspotChannel">
|
||||||
|
<option value="1">Canal 1</option>
|
||||||
|
<option value="6" selected>Canal 6</option>
|
||||||
|
<option value="11">Canal 11</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn" onclick="updateHotspot()">Mettre à jour</button>
|
||||||
|
<button class="btn btn-danger" onclick="toggleHotspot()">
|
||||||
|
<span id="hotspotBtn">Arrêter le hotspot</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
<svg class="icon" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/>
|
||||||
|
</svg>
|
||||||
|
Appareils connectés
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="devices-grid" id="devicesList">
|
||||||
|
<!-- Liste des appareils sera remplie par JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
<svg class="icon" viewBox="0 0 24 24">
|
||||||
|
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
|
||||||
|
</svg>
|
||||||
|
Logs système
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="log-container" id="logContainer">
|
||||||
|
<!-- Les logs seront ajoutés ici -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-secondary" onclick="clearLogs()" style="margin-top: 15px;">
|
||||||
|
Effacer les logs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notification" id="notification"></div>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
338
static/style.css
Normal file
338
static/style.css
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
* {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue