Compare commits

..

13 commits

Author SHA1 Message Date
2922a03724 Refactor stations discovery and add hostapd discovery 2026-01-01 23:31:01 +07:00
69594c2fe4 syslog filter could take several filters 2026-01-01 23:31:01 +07:00
04ada45f44 Implement wpa_supplicant backend 2026-01-01 23:31:01 +07:00
79c28da9c5 Create wifi backend abstraction 2026-01-01 23:31:01 +07:00
f4481bca62 Stream logs from syslog 2026-01-01 23:31:01 +07:00
02b93a3ef0 Handle connecting/disconnecting states 2026-01-01 23:31:01 +07:00
1477d909b0 Add websocket wifi updates 2026-01-01 23:31:01 +07:00
c443fce24f Report hotspot config 2026-01-01 23:31:01 +07:00
17d665e21a Implementation with iwd 2026-01-01 23:31:01 +07:00
2b3a5b89f8 Add configuration system and ARP-based device discovery
Implement comprehensive configuration management with CLI flags for WiFi interface, device discovery method, and file paths. Add ARP table parsing as an alternative to DHCP leases for more reliable device detection. Improve WiFi scanning to handle concurrent scan requests gracefully.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-01 23:31:01 +07:00
accd7e75d8 Handle config options 2026-01-01 23:31:01 +07:00
b1b9eaa028 Migrate to a better architectured project 2026-01-01 23:31:01 +07:00
cc5ed5f23e Add OpenAPI specs 2025-10-28 18:25:45 +07:00
61 changed files with 7899 additions and 1540 deletions

4
.gitignore vendored
View file

@ -1 +1,3 @@
repeater
internal/api/routes.gen.go
internal/api/types.gen.go
/repeater

32
Makefile Normal file
View file

@ -0,0 +1,32 @@
.PHONY: build run clean install test
BINARY_NAME=repeater
CMD_PATH=./cmd/repeater
BUILD_DIR=.
build:
go build -v -o $(BUILD_DIR)/$(BINARY_NAME) $(CMD_PATH)
run: build
sudo ./$(BINARY_NAME)
clean:
go clean
rm -f $(BUILD_DIR)/$(BINARY_NAME)
install: build
sudo install -m 755 $(BUILD_DIR)/$(BINARY_NAME) /usr/local/bin/
test:
go test -v ./...
tidy:
go mod tidy
fmt:
go fmt ./...
vet:
go vet ./...
all: fmt vet build

102
README.md Normal file
View file

@ -0,0 +1,102 @@
# Travel Router Control
A Go application for controlling a mini travel router with dual WiFi interfaces and Ethernet connectivity. The router can operate as a WiFi repeater, connecting to upstream networks while providing a hotspot for client devices.
## Features
- WiFi network scanning and connection management
- Hotspot (access point) configuration and control
- Connected device monitoring
- Real-time system logs via WebSocket
- RESTful API following OpenAPI 3.0 specification
- Web interface for easy management
## Architecture
The application follows a clean architecture pattern:
```
.
├── cmd/
│ └── repeater/ # Application entry point
│ ├── main.go
│ └── static/ # Embedded web assets
├── internal/
│ ├── api/ # HTTP API layer
│ │ ├── router.go # Gin router setup
│ │ └── handlers/ # HTTP handlers
│ ├── app/ # Application logic & lifecycle
│ ├── device/ # Device management
│ ├── hotspot/ # Hotspot control
│ ├── logging/ # Logging system
│ ├── models/ # Data structures
│ └── wifi/ # WiFi operations (wpa_supplicant via D-Bus)
├── openapi.yaml # API specification
└── go.mod
```
## Building
```bash
go build -o repeater ./cmd/repeater
```
## Running
```bash
sudo ./repeater
```
The application requires root privileges to:
- Access D-Bus system bus for wpa_supplicant
- Control systemd services (hostapd)
- Read DHCP leases and ARP tables
The server will start on port 8080.
## API Endpoints
### WiFi Operations
- `GET /api/wifi/scan` - Scan for available networks
- `POST /api/wifi/connect` - Connect to a network
- `POST /api/wifi/disconnect` - Disconnect from current network
### Hotspot Operations
- `POST /api/hotspot/config` - Configure hotspot settings
- `POST /api/hotspot/toggle` - Enable/disable hotspot
### Device Management
- `GET /api/devices` - Get connected devices
### System
- `GET /api/status` - Get system status
- `GET /api/logs` - Get system logs
- `DELETE /api/logs` - Clear logs
### WebSocket
- `GET /ws/logs` - Real-time log streaming
See `openapi.yaml` for complete API documentation.
## Configuration
The application uses the following system resources:
- **WiFi Interface**: `wlan0` (for upstream connection)
- **AP Interface**: `wlan1` (for hotspot)
- **Hostapd Config**: `/etc/hostapd/hostapd.conf`
- **WPA Supplicant Config**: `/etc/wpa_supplicant/wpa_supplicant.conf`
These can be modified in the respective package constants.
## Dependencies
- **Gin**: HTTP web framework
- **godbus**: D-Bus client for wpa_supplicant control
- **gorilla/websocket**: WebSocket support
- **wpa_supplicant**: WiFi connection management
- **hostapd**: Hotspot functionality
## License
MIT

47
cmd/repeater/main.go Normal file
View file

@ -0,0 +1,47 @@
package main
import (
"embed"
"log"
"os"
"os/signal"
"syscall"
"github.com/nemunaire/repeater/internal/app"
"github.com/nemunaire/repeater/internal/config"
)
//go:embed all:static
var assets embed.FS
func main() {
// Load and parse options
cfg, err := config.ConsolidateConfig()
if err != nil {
log.Fatal(err)
}
// Create application instance
application := app.New(assets)
// Initialize the application
if err := application.Initialize(cfg); err != nil {
log.Fatalf("Failed to initialize application: %v", err)
}
defer application.Shutdown()
// Handle graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
log.Println("Shutting down gracefully...")
application.Shutdown()
os.Exit(0)
}()
// Start the server
if err := application.Run(cfg.Bind); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

791
cmd/repeater/static/app.js Normal file
View file

@ -0,0 +1,791 @@
// Application state
const appState = {
selectedWifi: null,
hotspotEnabled: true,
autoScrollLogs: true,
ws: null,
wifiWs: null,
reconnectAttempts: 0,
wifiReconnectAttempts: 0,
maxReconnectAttempts: 5,
connectedSSID: null,
connectionState: 'disconnected',
networks: [],
uptime: 0,
uptimeInterval: null
};
// Initialize the application
document.addEventListener('DOMContentLoaded', function() {
initializeApp();
});
async function initializeApp() {
console.log('Initializing Travel Router Control Panel...');
// Load initial data
await Promise.all([
loadStatus(),
scanWifi(),
loadDevices(),
loadLogs()
]);
// Set up WebSockets for real-time updates
connectWebSocket();
connectWifiWebSocket();
// Start periodic updates
startPeriodicUpdates();
// Start uptime counter
startUptimeCounter();
}
// ===== API Functions =====
async function loadStatus() {
try {
const response = await fetch('/api/status');
const status = await response.json();
updateStatusDisplay(status);
return status;
} catch (error) {
console.error('Error loading status:', error);
showToast('error', 'Erreur', 'Impossible de charger le statut');
}
}
async function scanWifi() {
const wifiList = document.getElementById('wifiList');
const scanBtn = document.querySelector('[onclick="scanWifi()"]');
if (scanBtn) {
scanBtn.disabled = true;
}
wifiList.innerHTML = '<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);
}

View file

@ -0,0 +1,231 @@
<!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>

View file

@ -0,0 +1,878 @@
/* CSS Variables for theming */
:root {
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--secondary-gradient: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--success-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--info-color: #3b82f6;
--background: #f3f4f6;
--card-background: #ffffff;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--border-color: #e5e7eb;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--primary-gradient);
min-height: 100vh;
padding: 1.5rem;
color: var(--text-primary);
}
.container {
max-width: 1400px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(20px);
border-radius: var(--radius-xl);
padding: 2rem;
box-shadow: var(--shadow-xl);
}
/* Header Styles */
.header {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 2px solid var(--border-color);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.header h1 {
color: var(--text-primary);
font-size: 2rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-icon {
color: #667eea;
}
.connection-info {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
background: var(--background);
border: 1px solid var(--border-color);
}
.status-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.status-dot.active {
background: var(--success-color);
}
.status-dot.offline {
background: var(--text-secondary);
animation: none;
}
.status-dot.connecting {
background: var(--warning-color);
animation: blink 1s ease-in-out infinite;
}
.status-dot.disconnecting {
background: var(--warning-color);
animation: blink 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--card-background);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
display: flex;
gap: 1rem;
align-items: center;
transition: all 0.3s ease;
max-width: 100%;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.stat-icon {
width: 3rem;
height: 3rem;
border-radius: var(--radius-md);
background: var(--primary-gradient);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon svg {
color: white;
}
.stat-content {
flex: 1;
min-width: 0;
}
.stat-value {
font-size: 1.875rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stat-label {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 500;
}
/* Grid Layout */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
/* Card Styles */
.card {
background: var(--card-background);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
transition: all 0.3s ease;
}
.card:hover {
box-shadow: var(--shadow-lg);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.card-header h2 {
color: var(--text-primary);
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.icon {
color: #667eea;
}
/* Form Elements */
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
font-weight: 500;
font-size: 0.875rem;
}
.input-icon {
color: var(--text-secondary);
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 0.9375rem;
transition: all 0.2s ease;
background: var(--card-background);
color: var(--text-primary);
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--radius-md);
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
color: white;
}
.btn svg {
flex-shrink: 0;
}
.btn-primary {
background: var(--primary-gradient);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: var(--text-secondary);
}
.btn-secondary:hover {
background: #4b5563;
transform: translateY(-1px);
}
.btn-danger {
background: var(--danger-color);
}
.btn-danger:hover {
background: #dc2626;
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-icon {
background: transparent;
border: none;
padding: 0.5rem;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover {
background: var(--background);
color: var(--text-primary);
}
.button-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-switch label {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--text-secondary);
transition: 0.3s;
border-radius: 34px;
}
.toggle-switch label:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
.toggle-switch input:checked + label {
background: var(--success-color);
}
.toggle-switch input:checked + label:before {
transform: translateX(24px);
}
/* WiFi List */
.wifi-list {
max-height: 320px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background);
}
.wifi-item {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--card-background);
}
.wifi-item:hover {
background: var(--background);
}
.wifi-item:last-child {
border-bottom: none;
}
.wifi-item.selected {
background: #eff6ff;
border-left: 4px solid #667eea;
}
.wifi-item.connected {
background: #d1fae5;
border-left-color: var(--success-color) !important;
}
.wifi-item.connected .wifi-ssid {
color: var(--success-color);
font-weight: 700;
}
.wifi-item.connecting {
background: #fef3c7;
border-left-color: var(--warning-color) !important;
animation: pulse-item 1.5s ease-in-out infinite;
}
.wifi-item.connecting .wifi-ssid {
color: var(--warning-color);
font-weight: 700;
}
.wifi-item.disconnecting {
background: #fee2e2;
border-left-color: #dc2626 !important;
animation: pulse-item 1.5s ease-in-out infinite;
}
.wifi-item.disconnecting .wifi-ssid {
color: #dc2626;
font-weight: 700;
}
@keyframes pulse-item {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.wifi-item.loading {
justify-content: center;
color: var(--text-secondary);
cursor: default;
}
.wifi-info {
flex: 1;
}
.wifi-ssid {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.wifi-details {
font-size: 0.8125rem;
color: var(--text-secondary);
display: flex;
gap: 1rem;
}
.wifi-signal {
display: flex;
align-items: center;
gap: 0.5rem;
}
.signal-bars {
display: flex;
gap: 2px;
align-items: flex-end;
height: 16px;
}
.signal-bar {
width: 3px;
background: var(--border-color);
border-radius: 2px;
}
.signal-bar:nth-child(1) { height: 25%; }
.signal-bar:nth-child(2) { height: 50%; }
.signal-bar:nth-child(3) { height: 75%; }
.signal-bar:nth-child(4) { height: 100%; }
.signal-bar.active {
background: var(--success-color);
}
/* Devices */
.devices-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
min-height: 200px;
}
.device-card {
background: var(--background);
border-radius: var(--radius-md);
padding: 1.25rem;
text-align: center;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
}
.device-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.device-icon {
width: 48px;
height: 48px;
margin: 0 auto 0.75rem;
color: #667eea;
}
.device-name {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
font-size: 0.9375rem;
}
.device-type {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.device-info {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.device-placeholder {
grid-column: 1 / -1;
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary);
}
.device-placeholder svg {
margin-bottom: 1rem;
}
.device-count {
background: var(--primary-gradient);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
}
/* Logs */
.log-container {
background: #1f2937;
color: #10b981;
padding: 1rem;
border-radius: var(--radius-md);
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Consolas', 'Courier New', monospace;
font-size: 0.8125rem;
max-height: 400px;
overflow-y: auto;
line-height: 1.6;
min-height: 200px;
}
.log-entry {
margin-bottom: 0.5rem;
display: flex;
gap: 0.75rem;
}
.log-timestamp {
color: #6b7280;
flex-shrink: 0;
}
.log-source {
color: #3b82f6;
font-weight: 600;
flex-shrink: 0;
min-width: 80px;
}
.log-message {
color: #d1d5db;
}
.log-controls {
display: flex;
gap: 0.5rem;
}
.log-placeholder {
text-align: center;
padding: 3rem 1rem;
color: #6b7280;
}
.log-placeholder svg {
margin-bottom: 1rem;
}
/* Toast Notifications */
.toast {
position: fixed;
top: 1.5rem;
right: 1.5rem;
max-width: 400px;
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
padding: 1rem;
display: flex;
gap: 0.75rem;
align-items: start;
transform: translateX(calc(100% + 2rem));
transition: transform 0.3s ease;
z-index: 1000;
border: 1px solid var(--border-color);
}
.toast.show {
transform: translateX(0);
}
.toast-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.toast.success .toast-icon {
background: #d1fae5;
color: var(--success-color);
}
.toast.error .toast-icon {
background: #fee2e2;
color: var(--danger-color);
}
.toast.warning .toast-icon {
background: #fef3c7;
color: var(--warning-color);
}
.toast.info .toast-icon {
background: #dbeafe;
color: var(--info-color);
}
.toast-content {
flex: 1;
}
.toast-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.toast-message {
font-size: 0.875rem;
color: var(--text-secondary);
}
.toast-close {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.25rem;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.toast-close:hover {
background: var(--background);
color: var(--text-primary);
}
/* Loading Overlay */
.loading-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: 2000;
flex-direction: column;
gap: 1rem;
}
.loading-overlay.show {
display: flex;
}
.loading-overlay p {
color: white;
font-weight: 500;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Scrollbar Styling */
.wifi-list::-webkit-scrollbar,
.log-container::-webkit-scrollbar {
width: 8px;
}
.wifi-list::-webkit-scrollbar-track,
.log-container::-webkit-scrollbar-track {
background: transparent;
}
.wifi-list::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
.log-container::-webkit-scrollbar-thumb {
background: #374151;
border-radius: 4px;
}
.wifi-list::-webkit-scrollbar-thumb:hover,
.log-container::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Hotspot Details */
.hotspot-details {
margin-top: 1.5rem;
padding: 1rem;
background: var(--background);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
}
.hotspot-info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color);
}
.hotspot-info-item:last-child {
border-bottom: none;
}
.info-label {
font-weight: 500;
color: var(--text-secondary);
font-size: 0.875rem;
}
.info-value {
font-weight: 600;
color: var(--text-primary);
font-size: 0.875rem;
}
/* Responsive Design */
@media (max-width: 1024px) {
.grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
body {
padding: 1rem;
}
.container {
padding: 1.5rem;
}
.header h1 {
font-size: 1.5rem;
}
.header-content {
flex-direction: column;
align-items: flex-start;
}
.stats-grid {
grid-template-columns: 1fr;
}
.grid {
grid-template-columns: 1fr;
}
.button-group {
grid-template-columns: 1fr;
}
.toast {
left: 1rem;
right: 1rem;
max-width: none;
}
}
@media (max-width: 480px) {
.stat-card {
flex-direction: column;
text-align: center;
}
.devices-grid {
grid-template-columns: 1fr;
}
}

4
generate.go Normal file
View file

@ -0,0 +1,4 @@
package main
//go:generate go tool oapi-codegen -config oapi-types.cfg.yaml openapi.yaml
//go:generate go tool oapi-codegen -config oapi-gin.cfg.yaml openapi.yaml

55
go.mod
View file

@ -1,9 +1,62 @@
module git.nemunai.re/nemunaire/repeater
module github.com/nemunaire/repeater
go 1.24.4
require (
github.com/getkin/kin-openapi v0.132.0
github.com/gin-gonic/gin v1.11.0
github.com/godbus/dbus/v5 v5.1.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen

233
go.sum
View file

@ -1,6 +1,239 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8=
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,148 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/nemunaire/repeater/internal/config"
"github.com/nemunaire/repeater/internal/hotspot"
"github.com/nemunaire/repeater/internal/logging"
"github.com/nemunaire/repeater/internal/models"
"github.com/nemunaire/repeater/internal/station"
"github.com/nemunaire/repeater/internal/wifi"
)
// GetWiFiNetworks returns cached WiFi networks without scanning
func GetWiFiNetworks(c *gin.Context) {
networks, err := wifi.GetCachedNetworks()
if err != nil {
logging.AddLog("WiFi", "Erreur lors de la récupération des réseaux: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des réseaux"})
return
}
c.JSON(http.StatusOK, networks)
}
// ScanWiFi handles WiFi network scanning
func ScanWiFi(c *gin.Context) {
networks, err := wifi.ScanNetworks()
if err != nil {
logging.AddLog("WiFi", "Erreur lors du scan: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du scan WiFi"})
return
}
logging.AddLog("WiFi", "Scan terminé - "+string(rune(len(networks)))+" réseaux trouvés")
c.JSON(http.StatusOK, networks)
}
// ConnectWiFi handles WiFi connection requests
func ConnectWiFi(c *gin.Context) {
var req models.WiFiConnectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
logging.AddLog("WiFi", "Tentative de connexion à "+req.SSID)
err := wifi.Connect(req.SSID, req.Password)
if err != nil {
logging.AddLog("WiFi", "Échec de connexion: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "Échec de connexion: " + err.Error()})
return
}
logging.AddLog("WiFi", "Connexion réussie à "+req.SSID)
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
// DisconnectWiFi handles WiFi disconnection
func DisconnectWiFi(c *gin.Context) {
logging.AddLog("WiFi", "Tentative de déconnexion")
err := wifi.Disconnect()
if err != nil {
logging.AddLog("WiFi", "Erreur de déconnexion: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de déconnexion: " + err.Error()})
return
}
logging.AddLog("WiFi", "Déconnexion réussie")
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
// ConfigureHotspot handles hotspot configuration
func ConfigureHotspot(c *gin.Context) {
var config models.HotspotConfig
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
err := hotspot.Configure(config)
if err != nil {
logging.AddLog("Hotspot", "Erreur de configuration: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de configuration: " + err.Error()})
return
}
logging.AddLog("Hotspot", "Configuration mise à jour: "+config.SSID)
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
// ToggleHotspot handles hotspot enable/disable
func ToggleHotspot(c *gin.Context, status *models.SystemStatus) {
// Determine current state
isEnabled := status.HotspotStatus != nil && status.HotspotStatus.State == "ENABLED"
var err error
if !isEnabled {
err = hotspot.Start()
logging.AddLog("Hotspot", "Hotspot activé")
} else {
err = hotspot.Stop()
logging.AddLog("Hotspot", "Hotspot désactivé")
}
if err != nil {
logging.AddLog("Hotspot", "Erreur: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur: " + err.Error()})
return
}
// Update status immediately
status.HotspotStatus = hotspot.GetDetailedStatus()
c.JSON(http.StatusOK, gin.H{"enabled": !isEnabled})
}
// GetDevices returns connected devices
func GetDevices(c *gin.Context, cfg *config.Config) {
devices, err := station.GetStations()
if err != nil {
logging.AddLog("Système", "Erreur lors de la récupération des appareils: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des appareils"})
return
}
c.JSON(http.StatusOK, devices)
}
// GetStatus returns system status
func GetStatus(c *gin.Context, status *models.SystemStatus) {
c.JSON(http.StatusOK, status)
}
// GetLogs returns system logs
func GetLogs(c *gin.Context) {
logs := logging.GetLogs()
c.JSON(http.StatusOK, logs)
}
// ClearLogs clears system logs
func ClearLogs(c *gin.Context) {
logging.ClearLogs()
c.JSON(http.StatusOK, gin.H{"status": "success"})
}

View file

@ -0,0 +1,38 @@
package handlers
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/nemunaire/repeater/internal/logging"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// WebSocketLogs handles WebSocket connections for real-time logs
func WebSocketLogs(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("Erreur WebSocket: %v", err)
return
}
defer conn.Close()
// Register client
logging.RegisterWebSocketClient(conn)
defer logging.UnregisterWebSocketClient(conn)
// Keep connection alive
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}

View file

@ -0,0 +1,30 @@
package handlers
import (
"log"
"github.com/gin-gonic/gin"
"github.com/nemunaire/repeater/internal/wifi"
)
// WebSocketWifi handles WebSocket connections for real-time WiFi events
func WebSocketWifi(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("Erreur WebSocket WiFi: %v", err)
return
}
defer conn.Close()
// Register client
wifi.RegisterWebSocketClient(conn)
defer wifi.UnregisterWebSocketClient(conn)
// Keep connection alive
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}

69
internal/api/router.go Normal file
View file

@ -0,0 +1,69 @@
package api
import (
"embed"
"io/fs"
"net/http"
"github.com/gin-gonic/gin"
"github.com/nemunaire/repeater/internal/api/handlers"
"github.com/nemunaire/repeater/internal/config"
"github.com/nemunaire/repeater/internal/models"
)
// SetupRouter creates and configures the Gin router
func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.FS) *gin.Engine {
// Set Gin to release mode (can be overridden with GIN_MODE env var)
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
// API routes
api := r.Group("/api")
{
// WiFi endpoints
wifi := api.Group("/wifi")
{
wifi.GET("/networks", handlers.GetWiFiNetworks)
wifi.GET("/scan", handlers.ScanWiFi)
wifi.POST("/connect", handlers.ConnectWiFi)
wifi.POST("/disconnect", handlers.DisconnectWiFi)
}
// Hotspot endpoints
hotspot := api.Group("/hotspot")
{
hotspot.POST("/config", handlers.ConfigureHotspot)
hotspot.POST("/toggle", func(c *gin.Context) {
handlers.ToggleHotspot(c, status)
})
}
// Device endpoints
api.GET("/devices", func(c *gin.Context) {
handlers.GetDevices(c, cfg)
})
// Status endpoint
api.GET("/status", func(c *gin.Context) {
handlers.GetStatus(c, status)
})
// Log endpoints
api.GET("/logs", handlers.GetLogs)
api.DELETE("/logs", handlers.ClearLogs)
}
// WebSocket endpoints
r.GET("/ws/logs", handlers.WebSocketLogs)
r.GET("/ws/wifi", handlers.WebSocketWifi)
// Serve static files
sub, err := fs.Sub(assets, "static")
if err != nil {
panic("Unable to access static directory: " + err.Error())
}
r.NoRoute(gin.WrapH(http.FileServer(http.FS(sub))))
return r
}

287
internal/app/app.go Normal file
View file

@ -0,0 +1,287 @@
package app
import (
"embed"
"log"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/nemunaire/repeater/internal/api"
"github.com/nemunaire/repeater/internal/config"
"github.com/nemunaire/repeater/internal/hotspot"
"github.com/nemunaire/repeater/internal/logging"
"github.com/nemunaire/repeater/internal/models"
"github.com/nemunaire/repeater/internal/station"
"github.com/nemunaire/repeater/internal/station/backend"
"github.com/nemunaire/repeater/internal/syslog"
"github.com/nemunaire/repeater/internal/wifi"
)
// App represents the application
type App struct {
Status models.SystemStatus
StatusMutex sync.RWMutex
StartTime time.Time
Assets embed.FS
Config *config.Config
SyslogTailer *syslog.SyslogTailer
}
// New creates a new application instance
func New(assets embed.FS) *App {
return &App{
Status: models.SystemStatus{
Connected: false,
ConnectionState: "disconnected",
ConnectedSSID: "",
HotspotStatus: nil,
ConnectedCount: 0,
DataUsage: 0.0,
Uptime: 0,
},
StartTime: time.Now(),
Assets: assets,
}
}
// Initialize initializes the application
func (a *App) Initialize(cfg *config.Config) error {
// Store config reference
a.Config = cfg
// Initialize WiFi backend
if err := wifi.Initialize(cfg.WifiInterface, cfg.WifiBackend); err != nil {
return err
}
// Start WiFi event monitoring
if err := wifi.StartEventMonitoring(); err != nil {
log.Printf("Warning: WiFi event monitoring failed: %v", err)
// Don't fail - polling fallback still works
}
// Initialize station backend
stationConfig := backend.BackendConfig{
InterfaceName: cfg.HotspotInterface,
ARPTablePath: cfg.ARPTablePath,
DHCPLeasesPath: cfg.DHCPLeasesPath,
HostapdInterface: cfg.HotspotInterface,
}
if err := station.Initialize(cfg.StationBackend, stationConfig); err != nil {
log.Printf("Warning: Station backend initialization failed: %v", err)
// Don't fail - will continue without station discovery
} else {
// Start event monitoring for station events
if err := station.StartEventMonitoring(backend.EventCallbacks{
OnStationConnected: a.handleStationConnected,
OnStationDisconnected: a.handleStationDisconnected,
OnStationUpdated: a.handleStationUpdated,
}); err != nil {
log.Printf("Warning: Station event monitoring failed: %v", err)
// Don't fail - polling fallback still works
}
}
// Start syslog tailing if enabled
if cfg.SyslogEnabled {
a.SyslogTailer = syslog.NewSyslogTailer(
cfg.SyslogPath,
cfg.SyslogFilter,
cfg.SyslogSource,
)
if err := a.SyslogTailer.Start(); err != nil {
log.Printf("Warning: Failed to start syslog tailing: %v", err)
// Don't fail - app continues without syslog
}
}
// Start periodic tasks
go a.periodicStatusUpdate()
go a.periodicDeviceUpdate()
logging.AddLog("Système", "Application initialisée")
return nil
}
// Run starts the HTTP server
func (a *App) Run(addr string) error {
router := api.SetupRouter(&a.Status, a.Config, a.Assets)
logging.AddLog("Système", "Serveur API démarré sur "+addr)
return router.Run(addr)
}
// Shutdown gracefully shuts down the application
func (a *App) Shutdown() {
// Stop syslog tailing if running
if a.SyslogTailer != nil {
a.SyslogTailer.Stop()
}
// Stop station monitoring and close backend
station.StopEventMonitoring()
station.Close()
wifi.StopEventMonitoring()
wifi.Close()
logging.AddLog("Système", "Application arrêtée")
}
// getSystemUptime reads system uptime from /proc/uptime
func getSystemUptime() int64 {
data, err := os.ReadFile("/proc/uptime")
if err != nil {
log.Printf("Error reading /proc/uptime: %v", err)
return 0
}
fields := strings.Fields(string(data))
if len(fields) == 0 {
return 0
}
uptime, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
log.Printf("Error parsing uptime: %v", err)
return 0
}
return int64(uptime)
}
// getInterfaceBytes reads rx and tx bytes for a network interface
func getInterfaceBytes(interfaceName string) (rxBytes, txBytes int64) {
rxPath := "/sys/class/net/" + interfaceName + "/statistics/rx_bytes"
txPath := "/sys/class/net/" + interfaceName + "/statistics/tx_bytes"
// Read RX bytes
rxData, err := os.ReadFile(rxPath)
if err != nil {
log.Printf("Error reading rx_bytes for %s: %v", interfaceName, err)
} else {
rxBytes, _ = strconv.ParseInt(strings.TrimSpace(string(rxData)), 10, 64)
}
// Read TX bytes
txData, err := os.ReadFile(txPath)
if err != nil {
log.Printf("Error reading tx_bytes for %s: %v", interfaceName, err)
} else {
txBytes, _ = strconv.ParseInt(strings.TrimSpace(string(txData)), 10, 64)
}
return rxBytes, txBytes
}
// periodicStatusUpdate updates WiFi connection status periodically
func (a *App) periodicStatusUpdate() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
a.StatusMutex.Lock()
a.Status.Connected = wifi.IsConnected()
a.Status.ConnectionState = wifi.GetConnectionState()
a.Status.ConnectedSSID = wifi.GetConnectedSSID()
a.Status.Uptime = getSystemUptime()
// Get detailed hotspot status
a.Status.HotspotStatus = hotspot.GetDetailedStatus()
// Get network data usage for WiFi interface
if a.Config != nil {
rxBytes, txBytes := getInterfaceBytes(a.Config.WifiInterface)
// Convert to MB and sum rx + tx
a.Status.DataUsage = float64(rxBytes+txBytes) / (1024 * 1024)
}
a.StatusMutex.Unlock()
}
}
// periodicDeviceUpdate updates connected devices list periodically
func (a *App) periodicDeviceUpdate() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
devices, err := station.GetStations()
if err != nil {
log.Printf("Error getting connected devices: %v", err)
}
a.StatusMutex.Lock()
a.Status.ConnectedDevices = devices
a.Status.ConnectedCount = len(devices)
a.StatusMutex.Unlock()
}
}
// handleStationConnected handles station connection events
func (a *App) handleStationConnected(st backend.Station) {
a.StatusMutex.Lock()
defer a.StatusMutex.Unlock()
// Convert backend.Station to models.ConnectedDevice
device := models.ConnectedDevice{
Name: st.Hostname,
Type: st.Type,
MAC: st.MAC,
IP: st.IP,
}
// Check if device already exists
found := false
for i, d := range a.Status.ConnectedDevices {
if d.MAC == device.MAC {
a.Status.ConnectedDevices[i] = device
found = true
break
}
}
// Add new device if not found
if !found {
a.Status.ConnectedDevices = append(a.Status.ConnectedDevices, device)
a.Status.ConnectedCount = len(a.Status.ConnectedDevices)
logging.AddLog("Stations", "Device connected: "+device.MAC+" ("+device.IP+")")
}
}
// handleStationDisconnected handles station disconnection events
func (a *App) handleStationDisconnected(mac string) {
a.StatusMutex.Lock()
defer a.StatusMutex.Unlock()
// Remove device from list
for i, d := range a.Status.ConnectedDevices {
if d.MAC == mac {
a.Status.ConnectedDevices = append(a.Status.ConnectedDevices[:i], a.Status.ConnectedDevices[i+1:]...)
a.Status.ConnectedCount = len(a.Status.ConnectedDevices)
logging.AddLog("Stations", "Device disconnected: "+mac)
break
}
}
}
// handleStationUpdated handles station update events
func (a *App) handleStationUpdated(st backend.Station) {
a.StatusMutex.Lock()
defer a.StatusMutex.Unlock()
// Update existing device
for i, d := range a.Status.ConnectedDevices {
if d.MAC == st.MAC {
a.Status.ConnectedDevices[i] = models.ConnectedDevice{
Name: st.Hostname,
Type: st.Type,
MAC: st.MAC,
IP: st.IP,
}
break
}
}
}

34
internal/config/cli.go Normal file
View file

@ -0,0 +1,34 @@
package config
import (
"flag"
)
// declareFlags registers flags for the structure Options.
func declareFlags(o *Config) {
flag.StringVar(&o.Bind, "bind", ":8081", "Bind port/socket")
flag.StringVar(&o.WifiInterface, "wifi-interface", "wlan0", "WiFi interface name")
flag.StringVar(&o.HotspotInterface, "hotspot-interface", "wlan1", "Hotspot WiFi interface name")
flag.StringVar(&o.WifiBackend, "wifi-backend", "", "WiFi backend to use: 'iwd' or 'wpasupplicant' (required)")
flag.StringVar(&o.StationBackend, "station-backend", "hostapd", "Station discovery backend: 'arp', 'dhcp', or 'hostapd'")
flag.StringVar(&o.DHCPLeasesPath, "dhcp-leases-path", "/var/lib/dhcp/dhcpd.leases", "Path to DHCP leases file")
flag.StringVar(&o.ARPTablePath, "arp-table-path", "/proc/net/arp", "Path to ARP table file")
flag.BoolVar(&o.SyslogEnabled, "syslog-enabled", false, "Enable syslog tailing for iwd messages")
flag.StringVar(&o.SyslogPath, "syslog-path", "/var/log/messages", "Path to syslog file")
flag.Var(&StringArray{&o.SyslogFilter}, "daemon.info iwd:", "Filter string for syslog lines")
flag.StringVar(&o.SyslogSource, "syslog-source", "iwd", "Source name for syslog entries in logs")
}
// parseCLI parse the flags and treats extra args as configuration filename.
func parseCLI(o *Config) error {
flag.Parse()
for _, conf := range flag.Args() {
err := parseFile(o, conf)
if err != nil {
return err
}
}
return nil
}

106
internal/config/config.go Normal file
View file

@ -0,0 +1,106 @@
package config
import (
"flag"
"log"
"os"
"path"
"strings"
)
type Config struct {
Bind string
WifiInterface string
HotspotInterface string
WifiBackend string
StationBackend string // "arp", "dhcp", or "hostapd"
DHCPLeasesPath string
ARPTablePath string
SyslogEnabled bool
SyslogPath string
SyslogFilter []string
SyslogSource string
}
// ConsolidateConfig fills an Options struct by reading configuration from
// config files, environment, then command line.
//
// Should be called only one time.
func ConsolidateConfig() (opts *Config, err error) {
// Define defaults options
opts = &Config{
Bind: ":8080",
WifiInterface: "wlan0",
HotspotInterface: "wlan1",
DHCPLeasesPath: "/var/lib/dhcp/dhcpd.leases",
ARPTablePath: "/proc/net/arp",
SyslogEnabled: false,
SyslogPath: "/var/log/messages",
SyslogFilter: []string{"daemon.info wpa_supplicant:", "daemon.info iwd:", "daemon.info hostapd:"},
SyslogSource: "iwd",
}
declareFlags(opts)
// Establish a list of possible configuration file locations
configLocations := []string{
"repeater.conf",
}
if home, err := os.UserConfigDir(); err == nil {
configLocations = append(configLocations, path.Join(home, "repeater", "repeater.conf"))
}
configLocations = append(configLocations, path.Join("etc", "repeater.conf"))
// If config file exists, read configuration from it
for _, filename := range configLocations {
if _, e := os.Stat(filename); !os.IsNotExist(e) {
log.Printf("Loading configuration from %s\n", filename)
err = parseFile(opts, filename)
if err != nil {
return
}
break
}
}
// Then, overwrite that by what is present in the environment
err = parseEnvironmentVariables(opts)
if err != nil {
return
}
// Finaly, command line takes precedence
err = parseCLI(opts)
if err != nil {
return
}
// Validate configuration
if opts.WifiBackend != "iwd" && opts.WifiBackend != "wpasupplicant" {
log.Fatalf("wifi-backend must be set to 'iwd' or 'wpasupplicant' (got: '%s')", opts.WifiBackend)
}
if opts.StationBackend != "arp" && opts.StationBackend != "dhcp" && opts.StationBackend != "hostapd" {
log.Fatalf("station-backend must be set to 'arp', 'dhcp', or 'hostapd' (got: '%s')", opts.StationBackend)
}
return
}
// parseLine treats a config line and place the read value in the variable
// declared to the corresponding flag.
func parseLine(o *Config, line string) (err error) {
fields := strings.SplitN(line, "=", 2)
orig_key := strings.TrimSpace(fields[0])
value := strings.TrimSpace(fields[1])
key := strings.TrimPrefix(orig_key, "REPEATER_")
key = strings.Replace(key, "_", "-", -1)
key = strings.ToLower(key)
err = flag.Set(key, value)
return
}

45
internal/config/custom.go Normal file
View file

@ -0,0 +1,45 @@
package config
import (
"fmt"
"net/url"
)
// StringArray is a custom type for handling multiple string values in flags.
type StringArray struct {
Array *[]string
}
// String returns a string representation of the StringArray.
func (i *StringArray) String() string {
return fmt.Sprintf("%v", i.Array)
}
// Set appends a new string value to the StringArray.
func (i *StringArray) Set(value string) error {
*i.Array = append(*i.Array, value)
return nil
}
type URL struct {
URL *url.URL
}
func (i *URL) String() string {
if i.URL != nil {
return i.URL.String()
} else {
return ""
}
}
func (i *URL) Set(value string) error {
u, err := url.Parse(value)
if err != nil {
return err
}
*i.URL = *u
return nil
}

21
internal/config/env.go Normal file
View file

@ -0,0 +1,21 @@
package config
import (
"fmt"
"os"
"strings"
)
// parseEnvironmentVariables analyzes all the environment variables to find
// each one starting by REPEATER_
func parseEnvironmentVariables(o *Config) (err error) {
for _, line := range os.Environ() {
if strings.HasPrefix(line, "REPEATER_") {
err := parseLine(o, line)
if err != nil {
return fmt.Errorf("error in environment (%q): %w", line, err)
}
}
}
return
}

33
internal/config/file.go Normal file
View file

@ -0,0 +1,33 @@
package config
import (
"bufio"
"fmt"
"os"
"strings"
)
// parseFile opens the file at the given filename path, then treat each line
// not starting with '#' as a configuration statement.
func parseFile(o *Config, filename string) error {
fp, err := os.Open(filename)
if err != nil {
return err
}
defer fp.Close()
scanner := bufio.NewScanner(fp)
n := 0
for scanner.Scan() {
n += 1
line := strings.TrimSpace(scanner.Text())
if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
err := parseLine(o, line)
if err != nil {
return fmt.Errorf("%v:%d: error in configuration: %w", filename, n, err)
}
}
}
return nil
}

111
internal/hotspot/hotspot.go Normal file
View file

@ -0,0 +1,111 @@
package hotspot
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"github.com/nemunaire/repeater/internal/models"
)
const (
AP_INTERFACE = "wlan1"
HOSTAPD_CONF = "/etc/hostapd/hostapd.conf"
)
// Configure updates the hotspot configuration
func Configure(config models.HotspotConfig) error {
hostapdConfig := fmt.Sprintf(`interface=%s
driver=nl80211
ssid=%s
hw_mode=g
channel=%d
wmm_enabled=0
macaddr_acl=0
auth_algs=1
ignore_broadcast_ssid=0
wpa=2
wpa_passphrase=%s
wpa_key_mgmt=WPA-PSK
wpa_pairwise=TKIP
rsn_pairwise=CCMP
`, AP_INTERFACE, config.SSID, config.Channel, config.Password)
return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0644)
}
// Start starts the hotspot
func Start() error {
cmd := exec.Command("/etc/init.d/hostapd", "start")
return cmd.Run()
}
// Stop stops the hotspot
func Stop() error {
cmd := exec.Command("/etc/init.d/hostapd", "stop")
return cmd.Run()
}
// Status checks if the hotspot is running.
// Returns nil if the service is running, or an error if it's stopped or crashed.
func Status() error {
cmd := exec.Command("/etc/init.d/hostapd", "status")
return cmd.Run()
}
// GetDetailedStatus retrieves detailed status information from hostapd_cli.
// Returns nil if hostapd is not running or if there's an error.
func GetDetailedStatus() *models.HotspotStatus {
cmd := exec.Command("hostapd_cli", "status")
output, err := cmd.Output()
if err != nil {
return nil
}
status := &models.HotspotStatus{}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "Selected interface") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "state":
status.State = value
case "channel":
if ch, err := strconv.Atoi(value); err == nil {
status.Channel = ch
}
case "freq":
if freq, err := strconv.Atoi(value); err == nil {
status.Frequency = freq
}
case "ssid[0]":
status.SSID = value
case "bssid[0]":
status.BSSID = value
case "num_sta[0]":
if num, err := strconv.Atoi(value); err == nil {
status.NumStations = num
}
case "hw_mode":
status.HWMode = value
case "country_code":
status.CountryCode = value
}
}
return status
}

View file

@ -0,0 +1,94 @@
package logging
import (
"log"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/nemunaire/repeater/internal/models"
)
var (
logEntries []models.LogEntry
logMutex sync.RWMutex
websocketClients = make(map[*websocket.Conn]bool)
clientsMutex sync.RWMutex
)
// AddLog adds a new log entry
func AddLog(source, message string) {
logMutex.Lock()
entry := models.LogEntry{
Timestamp: time.Now(),
Source: source,
Message: message,
}
logEntries = append(logEntries, entry)
// Keep only the last 100 logs
if len(logEntries) > 100 {
logEntries = logEntries[len(logEntries)-100:]
}
logMutex.Unlock()
// Broadcast to WebSocket clients
broadcastToWebSockets(entry)
// Log to console
log.Printf("[%s] %s", source, message)
}
// GetLogs returns all log entries
func GetLogs() []models.LogEntry {
logMutex.RLock()
defer logMutex.RUnlock()
logs := make([]models.LogEntry, len(logEntries))
copy(logs, logEntries)
return logs
}
// ClearLogs clears all log entries
func ClearLogs() {
logMutex.Lock()
logEntries = []models.LogEntry{}
logMutex.Unlock()
AddLog("Système", "Logs effacés")
}
// RegisterWebSocketClient registers a new WebSocket client
func RegisterWebSocketClient(conn *websocket.Conn) {
clientsMutex.Lock()
websocketClients[conn] = true
clientsMutex.Unlock()
// Send existing logs to the new client
logMutex.RLock()
for _, entry := range logEntries {
conn.WriteJSON(entry)
}
logMutex.RUnlock()
}
// UnregisterWebSocketClient removes a WebSocket client
func UnregisterWebSocketClient(conn *websocket.Conn) {
clientsMutex.Lock()
delete(websocketClients, conn)
clientsMutex.Unlock()
}
// broadcastToWebSockets sends a log entry to all connected WebSocket clients
func broadcastToWebSockets(entry models.LogEntry) {
clientsMutex.RLock()
defer clientsMutex.RUnlock()
for client := range websocketClients {
err := client.WriteJSON(entry)
if err != nil {
client.Close()
delete(websocketClients, client)
}
}
}

71
internal/models/models.go Normal file
View file

@ -0,0 +1,71 @@
package models
import "time"
// WiFiNetwork represents a discovered WiFi network
type WiFiNetwork struct {
SSID string `json:"ssid"`
Signal int `json:"signal"`
Security string `json:"security"`
Channel int `json:"channel"`
BSSID string `json:"bssid"`
}
// ConnectedDevice represents a device connected to the hotspot
type ConnectedDevice struct {
Name string `json:"name"`
Type string `json:"type"`
MAC string `json:"mac"`
IP string `json:"ip"`
}
// HotspotConfig represents hotspot configuration
type HotspotConfig struct {
SSID string `json:"ssid"`
Password string `json:"password"`
Channel int `json:"channel"`
}
// HotspotStatus represents detailed hotspot status
type HotspotStatus struct {
State string `json:"state"` // ENABLED, DISABLED, etc.
SSID string `json:"ssid"` // Current SSID being broadcast
BSSID string `json:"bssid"` // MAC address of the AP
Channel int `json:"channel"` // Current channel
Frequency int `json:"frequency"` // Frequency in MHz
NumStations int `json:"numStations"` // Number of connected stations
HWMode string `json:"hwMode"` // Hardware mode (g, a, n, ac, etc.)
CountryCode string `json:"countryCode"` // Country code
}
// SystemStatus represents overall system status
type SystemStatus struct {
Connected bool `json:"connected"`
ConnectionState string `json:"connectionState"` // Connection state: connected, disconnected, connecting, disconnecting, roaming
ConnectedSSID string `json:"connectedSSID"`
HotspotStatus *HotspotStatus `json:"hotspotStatus,omitempty"` // Detailed hotspot status
ConnectedCount int `json:"connectedCount"`
DataUsage float64 `json:"dataUsage"`
Uptime int64 `json:"uptime"`
ConnectedDevices []ConnectedDevice `json:"connectedDevices"`
}
// WiFiConnectRequest represents a request to connect to WiFi
type WiFiConnectRequest struct {
SSID string `json:"ssid" binding:"required"`
Password string `json:"password"`
}
// LogEntry represents a system log entry
type LogEntry struct {
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Message string `json:"message"`
}
// DHCPLease represents a DHCP lease entry
type DHCPLease struct {
IP string
MAC string
Hostname string
}

View file

@ -0,0 +1,177 @@
package arp
import (
"sync"
"time"
"github.com/nemunaire/repeater/internal/station/backend"
)
// Backend implements StationBackend using ARP table discovery
type Backend struct {
arpTablePath string
lastStations map[string]backend.Station // Key: MAC address
callbacks backend.EventCallbacks
stopChan chan struct{}
mu sync.RWMutex
running bool
}
// NewBackend creates a new ARP backend
func NewBackend() *Backend {
return &Backend{
lastStations: make(map[string]backend.Station),
stopChan: make(chan struct{}),
}
}
// Initialize initializes the ARP backend
func (b *Backend) Initialize(config backend.BackendConfig) error {
b.mu.Lock()
defer b.mu.Unlock()
b.arpTablePath = config.ARPTablePath
if b.arpTablePath == "" {
b.arpTablePath = "/proc/net/arp"
}
return nil
}
// Close cleans up backend resources
func (b *Backend) Close() error {
b.StopEventMonitoring()
return nil
}
// GetStations returns all connected stations from ARP table
func (b *Backend) GetStations() ([]backend.Station, error) {
b.mu.RLock()
arpTablePath := b.arpTablePath
b.mu.RUnlock()
arpEntries, err := parseARPTable(arpTablePath)
if err != nil {
return nil, err
}
var stations []backend.Station
for _, entry := range arpEntries {
// Only include entries with valid flags (2 = COMPLETE, 6 = COMPLETE|PERM)
if entry.Flags == 2 || entry.Flags == 6 {
st := backend.Station{
MAC: entry.HWAddress.String(),
IP: entry.IP.String(),
Hostname: "", // No hostname available from ARP
Type: backend.GuessDeviceType("", entry.HWAddress.String()),
Signal: 0, // Not available from ARP
RxBytes: 0, // Not available from ARP
TxBytes: 0, // Not available from ARP
ConnectedAt: time.Now(),
}
stations = append(stations, st)
}
}
return stations, nil
}
// StartEventMonitoring starts monitoring for station events via polling
func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.running {
return nil
}
b.callbacks = callbacks
b.running = true
// Start polling goroutine
go b.pollLoop()
return nil
}
// StopEventMonitoring stops event monitoring
func (b *Backend) StopEventMonitoring() {
b.mu.Lock()
if !b.running {
b.mu.Unlock()
return
}
b.running = false
b.mu.Unlock()
close(b.stopChan)
}
// SupportsRealTimeEvents returns false (ARP is polling-based)
func (b *Backend) SupportsRealTimeEvents() bool {
return false
}
// pollLoop polls the ARP table and simulates events
func (b *Backend) pollLoop() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
b.checkForChanges()
case <-b.stopChan:
return
}
}
}
// checkForChanges compares current state with last state and triggers callbacks
func (b *Backend) checkForChanges() {
// Get current stations
current, err := b.GetStations()
if err != nil {
return
}
// Build map of current stations
currentMap := make(map[string]backend.Station)
for _, station := range current {
currentMap[station.MAC] = station
}
b.mu.Lock()
defer b.mu.Unlock()
// Check for new stations (connected)
for mac, station := range currentMap {
if _, exists := b.lastStations[mac]; !exists {
// New station connected
if b.callbacks.OnStationConnected != nil {
go b.callbacks.OnStationConnected(station)
}
} else {
// Check for updates (IP change, etc.)
oldStation := b.lastStations[mac]
if oldStation.IP != station.IP || oldStation.Hostname != station.Hostname {
if b.callbacks.OnStationUpdated != nil {
go b.callbacks.OnStationUpdated(station)
}
}
}
}
// Check for disconnected stations
for mac := range b.lastStations {
if _, exists := currentMap[mac]; !exists {
// Station disconnected
if b.callbacks.OnStationDisconnected != nil {
go b.callbacks.OnStationDisconnected(mac)
}
}
}
// Update last state
b.lastStations = currentMap
}

View file

@ -0,0 +1,64 @@
package arp
import (
"fmt"
"net"
"os"
"strings"
)
// ARPEntry represents an entry in the ARP table
type ARPEntry struct {
IP net.IP
HWType int
Flags int
HWAddress net.HardwareAddr
Mask string
Device string
}
// parseARPTable reads and parses ARP table from /proc/net/arp format
func parseARPTable(path string) ([]ARPEntry, error) {
var entries []ARPEntry
content, err := os.ReadFile(path)
if err != nil {
return entries, err
}
for _, line := range strings.Split(string(content), "\n") {
fields := strings.Fields(line)
if len(fields) > 5 {
var entry ARPEntry
// Parse HWType (hex format)
if _, err := fmt.Sscanf(fields[1], "0x%x", &entry.HWType); err != nil {
continue
}
// Parse Flags (hex format)
if _, err := fmt.Sscanf(fields[2], "0x%x", &entry.Flags); err != nil {
continue
}
// Parse IP address
entry.IP = net.ParseIP(fields[0])
if entry.IP == nil {
continue
}
// Parse MAC address
entry.HWAddress, err = net.ParseMAC(fields[3])
if err != nil {
continue
}
entry.Mask = fields[4]
entry.Device = fields[5]
entries = append(entries, entry)
}
}
return entries, nil
}

View file

@ -0,0 +1,99 @@
package backend
import (
"strings"
"time"
)
// StationBackend defines the interface for station/device discovery backends.
// Implementations include ARP-based, DHCP-based, and Hostapd DBus-based discovery.
type StationBackend interface {
// Initialize initializes the backend with the given configuration
Initialize(config BackendConfig) error
// Close cleans up backend resources
Close() error
// GetStations returns all currently connected stations
GetStations() ([]Station, error)
// StartEventMonitoring starts monitoring for station events
// Backends that don't support real-time events will poll and simulate events
StartEventMonitoring(callbacks EventCallbacks) error
// StopEventMonitoring stops event monitoring
StopEventMonitoring()
// SupportsRealTimeEvents returns true if backend supports real-time events (e.g., DBus)
// Returns false for polling-based backends (ARP, DHCP)
SupportsRealTimeEvents() bool
}
// Station represents a connected device in a backend-agnostic format
type Station struct {
MAC string // Hardware MAC address (required, primary identifier)
IP string // IP address (may be empty for some backends initially)
Hostname string // Device hostname (may be empty)
Type string // Device type: "mobile", "laptop", "tablet", "unknown"
Signal int32 // Signal strength in dBm (0 if not available)
RxBytes uint64 // Received bytes (0 if not available)
TxBytes uint64 // Transmitted bytes (0 if not available)
ConnectedAt time.Time // When station connected (best effort)
}
// EventCallbacks defines callback functions for station events.
// Backends call these when stations connect, disconnect, or update.
type EventCallbacks struct {
// OnStationConnected is called when a new station connects
OnStationConnected func(station Station)
// OnStationDisconnected is called when a station disconnects
OnStationDisconnected func(mac string)
// OnStationUpdated is called when station information changes
// (e.g., IP discovered, signal strength changed)
OnStationUpdated func(station Station)
}
// BackendConfig provides configuration for backend initialization
type BackendConfig struct {
// Common
InterfaceName string // Network interface (e.g., "wlan1")
// ARP-specific
ARPTablePath string // Path to /proc/net/arp (default: "/proc/net/arp")
// DHCP-specific
DHCPLeasesPath string // Path to DHCP leases file
// Hostapd-specific
HostapdInterface string // Hostapd interface name for DBus
}
// GuessDeviceType attempts to guess device type from hostname and MAC address
func GuessDeviceType(hostname, mac string) string {
hostname = strings.ToLower(hostname)
if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") {
return "mobile"
} else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") {
return "tablet"
} else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") {
return "laptop"
}
// Guess by MAC prefix (OUI)
if len(mac) >= 8 {
macPrefix := strings.ToUpper(mac[:8])
switch macPrefix {
case "00:50:56", "00:0C:29", "00:05:69": // VMware
return "laptop"
case "08:00:27": // VirtualBox
return "laptop"
default:
return "mobile"
}
}
return "unknown"
}

View file

@ -0,0 +1,184 @@
package dhcp
import (
"sync"
"time"
"github.com/nemunaire/repeater/internal/station/backend"
)
// Backend implements StationBackend using DHCP lease discovery
type Backend struct {
dhcpLeasesPath string
lastStations map[string]backend.Station // Key: MAC address
callbacks backend.EventCallbacks
stopChan chan struct{}
mu sync.RWMutex
running bool
}
// NewBackend creates a new DHCP backend
func NewBackend() *Backend {
return &Backend{
lastStations: make(map[string]backend.Station),
stopChan: make(chan struct{}),
}
}
// Initialize initializes the DHCP backend
func (b *Backend) Initialize(config backend.BackendConfig) error {
b.mu.Lock()
defer b.mu.Unlock()
b.dhcpLeasesPath = config.DHCPLeasesPath
if b.dhcpLeasesPath == "" {
b.dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases"
}
return nil
}
// Close cleans up backend resources
func (b *Backend) Close() error {
b.StopEventMonitoring()
return nil
}
// GetStations returns all connected stations from DHCP leases validated by ARP
func (b *Backend) GetStations() ([]backend.Station, error) {
b.mu.RLock()
dhcpLeasesPath := b.dhcpLeasesPath
b.mu.RUnlock()
// Read DHCP leases
leases, err := parseDHCPLeases(dhcpLeasesPath)
if err != nil {
return nil, err
}
// Get ARP information for validation
arpInfo, err := getARPInfo()
if err != nil {
return nil, err
}
var stations []backend.Station
for _, lease := range leases {
// Check if the device is still connected via ARP
if _, exists := arpInfo[lease.IP]; exists {
st := backend.Station{
MAC: lease.MAC,
IP: lease.IP,
Hostname: lease.Hostname,
Type: backend.GuessDeviceType(lease.Hostname, lease.MAC),
Signal: 0, // Not available from DHCP
RxBytes: 0, // Not available from DHCP
TxBytes: 0, // Not available from DHCP
ConnectedAt: time.Now(),
}
stations = append(stations, st)
}
}
return stations, nil
}
// StartEventMonitoring starts monitoring for station events via polling
func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.running {
return nil
}
b.callbacks = callbacks
b.running = true
// Start polling goroutine
go b.pollLoop()
return nil
}
// StopEventMonitoring stops event monitoring
func (b *Backend) StopEventMonitoring() {
b.mu.Lock()
if !b.running {
b.mu.Unlock()
return
}
b.running = false
b.mu.Unlock()
close(b.stopChan)
}
// SupportsRealTimeEvents returns false (DHCP is polling-based)
func (b *Backend) SupportsRealTimeEvents() bool {
return false
}
// pollLoop polls DHCP leases and simulates events
func (b *Backend) pollLoop() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
b.checkForChanges()
case <-b.stopChan:
return
}
}
}
// checkForChanges compares current state with last state and triggers callbacks
func (b *Backend) checkForChanges() {
// Get current stations
current, err := b.GetStations()
if err != nil {
return
}
// Build map of current stations
currentMap := make(map[string]backend.Station)
for _, st := range current {
currentMap[st.MAC] = st
}
b.mu.Lock()
defer b.mu.Unlock()
// Check for new stations (connected)
for mac, st := range currentMap {
if _, exists := b.lastStations[mac]; !exists {
// New station connected
if b.callbacks.OnStationConnected != nil {
go b.callbacks.OnStationConnected(st)
}
} else {
// Check for updates (IP change, hostname change, etc.)
oldStation := b.lastStations[mac]
if oldStation.IP != st.IP || oldStation.Hostname != st.Hostname {
if b.callbacks.OnStationUpdated != nil {
go b.callbacks.OnStationUpdated(st)
}
}
}
}
// Check for disconnected stations
for mac := range b.lastStations {
if _, exists := currentMap[mac]; !exists {
// Station disconnected
if b.callbacks.OnStationDisconnected != nil {
go b.callbacks.OnStationDisconnected(mac)
}
}
}
// Update last state
b.lastStations = currentMap
}

View file

@ -0,0 +1,72 @@
package dhcp
import (
"bufio"
"os"
"os/exec"
"regexp"
"strings"
"github.com/nemunaire/repeater/internal/models"
)
// parseDHCPLeases reads and parses DHCP lease file
func parseDHCPLeases(path string) ([]models.DHCPLease, error) {
var leases []models.DHCPLease
file, err := os.Open(path)
if err != nil {
return leases, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
var currentLease models.DHCPLease
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "lease ") {
ip := strings.Fields(line)[1]
currentLease = models.DHCPLease{IP: ip}
} else if strings.Contains(line, "hardware ethernet") {
mac := strings.Fields(line)[2]
mac = strings.TrimSuffix(mac, ";")
currentLease.MAC = mac
} else if strings.Contains(line, "client-hostname") {
hostname := strings.Fields(line)[1]
hostname = strings.Trim(hostname, `";`)
currentLease.Hostname = hostname
} else if line == "}" {
if currentLease.IP != "" && currentLease.MAC != "" {
leases = append(leases, currentLease)
}
currentLease = models.DHCPLease{}
}
}
return leases, nil
}
// getARPInfo retrieves ARP table information using arp command
// Returns a map of IP -> MAC address
func getARPInfo() (map[string]string, error) {
arpInfo := make(map[string]string)
cmd := exec.Command("arp", "-a")
output, err := cmd.Output()
if err != nil {
return arpInfo, err
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 {
ip := matches[1]
mac := matches[2]
arpInfo[ip] = mac
}
}
return arpInfo, nil
}

View file

@ -0,0 +1,24 @@
package station
import (
"fmt"
"github.com/nemunaire/repeater/internal/station/arp"
"github.com/nemunaire/repeater/internal/station/backend"
"github.com/nemunaire/repeater/internal/station/dhcp"
"github.com/nemunaire/repeater/internal/station/hostapd"
)
// createBackend creates a station backend based on the backend name
func createBackend(backendName string) (backend.StationBackend, error) {
switch backendName {
case "arp":
return arp.NewBackend(), nil
case "dhcp":
return dhcp.NewBackend(), nil
case "hostapd":
return hostapd.NewBackend(), nil
default:
return nil, fmt.Errorf("invalid station backend: %s (must be 'arp', 'dhcp', or 'hostapd')", backendName)
}
}

View file

@ -0,0 +1,345 @@
package hostapd
import (
"bufio"
"bytes"
"fmt"
"log"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"github.com/nemunaire/repeater/internal/station/backend"
)
// Backend implements StationBackend using hostapd_cli
type Backend struct {
interfaceName string
hostapdCLI string // Path to hostapd_cli executable
stations map[string]*HostapdStation // Key: MAC address
callbacks backend.EventCallbacks
mu sync.RWMutex
running bool
stopCh chan struct{}
// IP correlation - will be populated by periodic DHCP lease correlation
ipByMAC map[string]string // MAC -> IP mapping
}
// NewBackend creates a new hostapd backend
func NewBackend() *Backend {
return &Backend{
stations: make(map[string]*HostapdStation),
ipByMAC: make(map[string]string),
hostapdCLI: "hostapd_cli",
stopCh: make(chan struct{}),
}
}
// Initialize initializes the hostapd backend
func (b *Backend) Initialize(config backend.BackendConfig) error {
b.mu.Lock()
defer b.mu.Unlock()
b.interfaceName = config.InterfaceName
if b.interfaceName == "" {
b.interfaceName = "wlan1" // Default AP interface
}
// Check if hostapd_cli is available
if _, err := exec.LookPath(b.hostapdCLI); err != nil {
return fmt.Errorf("hostapd_cli not found in PATH: %w", err)
}
// Verify we can communicate with hostapd
if err := b.runCommand("ping"); err != nil {
return fmt.Errorf("failed to communicate with hostapd on interface %s: %w", b.interfaceName, err)
}
log.Printf("Hostapd backend initialized for interface %s", b.interfaceName)
// Load initial station list
if err := b.loadStations(); err != nil {
log.Printf("Warning: Failed to load initial stations: %v", err)
}
return nil
}
// Close cleans up backend resources
func (b *Backend) Close() error {
b.StopEventMonitoring()
return nil
}
// runCommand executes a hostapd_cli command and returns the output
func (b *Backend) runCommand(args ...string) error {
cmdArgs := []string{"-i", b.interfaceName}
cmdArgs = append(cmdArgs, args...)
cmd := exec.Command(b.hostapdCLI, cmdArgs...)
return cmd.Run()
}
// runCommandOutput executes a hostapd_cli command and returns the output
func (b *Backend) runCommandOutput(args ...string) (string, error) {
cmdArgs := []string{"-i", b.interfaceName}
cmdArgs = append(cmdArgs, args...)
cmd := exec.Command(b.hostapdCLI, cmdArgs...)
out, err := cmd.Output()
if err != nil {
return "", err
}
return string(out), nil
}
// GetStations returns all connected stations
func (b *Backend) GetStations() ([]backend.Station, error) {
b.mu.RLock()
defer b.mu.RUnlock()
stations := make([]backend.Station, 0, len(b.stations))
for mac, hs := range b.stations {
station := b.convertStation(mac, hs)
stations = append(stations, station)
}
return stations, nil
}
// StartEventMonitoring starts monitoring for station events via polling
func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.running {
return nil
}
b.callbacks = callbacks
b.running = true
// Start polling goroutine
go b.pollStations()
log.Printf("Hostapd event monitoring started (polling mode)")
return nil
}
// StopEventMonitoring stops event monitoring
func (b *Backend) StopEventMonitoring() {
b.mu.Lock()
if !b.running {
b.mu.Unlock()
return
}
b.running = false
b.mu.Unlock()
close(b.stopCh)
}
// SupportsRealTimeEvents returns false (hostapd_cli uses polling)
func (b *Backend) SupportsRealTimeEvents() bool {
return false
}
// pollStations periodically polls for station changes
func (b *Backend) pollStations() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-b.stopCh:
return
case <-ticker.C:
if err := b.checkStationChanges(); err != nil {
log.Printf("Error polling stations: %v", err)
}
}
}
}
// checkStationChanges checks for station connect/disconnect events
func (b *Backend) checkStationChanges() error {
// Get current stations from hostapd
currentStations, err := b.fetchStations()
if err != nil {
return err
}
b.mu.Lock()
defer b.mu.Unlock()
// Build a map of current MACs
currentMACs := make(map[string]bool)
for mac := range currentStations {
currentMACs[mac] = true
}
// Check for new stations
for mac, station := range currentStations {
if _, exists := b.stations[mac]; !exists {
// New station connected
b.stations[mac] = station
if b.callbacks.OnStationConnected != nil {
st := b.convertStation(mac, station)
go b.callbacks.OnStationConnected(st)
}
log.Printf("Station connected: %s", mac)
}
}
// Check for removed stations
for mac := range b.stations {
if !currentMACs[mac] {
// Station disconnected
delete(b.stations, mac)
delete(b.ipByMAC, mac)
if b.callbacks.OnStationDisconnected != nil {
go b.callbacks.OnStationDisconnected(mac)
}
log.Printf("Station disconnected: %s", mac)
}
}
return nil
}
// loadStations loads the initial list of stations from hostapd
func (b *Backend) loadStations() error {
stations, err := b.fetchStations()
if err != nil {
return err
}
b.stations = stations
log.Printf("Loaded %d initial stations from hostapd", len(b.stations))
return nil
}
// fetchStations fetches all stations using hostapd_cli all_sta command
func (b *Backend) fetchStations() (map[string]*HostapdStation, error) {
output, err := b.runCommandOutput("all_sta")
if err != nil {
return nil, fmt.Errorf("failed to get stations: %w", err)
}
return b.parseAllStaOutput(output), nil
}
// parseAllStaOutput parses the output of "hostapd_cli all_sta"
func (b *Backend) parseAllStaOutput(output string) map[string]*HostapdStation {
stations := make(map[string]*HostapdStation)
scanner := bufio.NewScanner(bytes.NewBufferString(output))
var currentMAC string
var currentStation *HostapdStation
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// Check if this is a MAC address line (starts the station block)
if !strings.Contains(line, "=") && len(line) == 17 && strings.Count(line, ":") == 5 {
// Save previous station if exists
if currentMAC != "" && currentStation != nil {
stations[currentMAC] = currentStation
}
// Start new station
currentMAC = strings.ToLower(line)
currentStation = &HostapdStation{}
continue
}
// Parse key=value pairs
if currentStation != nil && strings.Contains(line, "=") {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "signal":
if v, err := strconv.Atoi(value); err == nil {
currentStation.Signal = int32(v)
}
case "rx_bytes":
if v, err := strconv.ParseUint(value, 10, 64); err == nil {
currentStation.RxBytes = v
}
case "tx_bytes":
if v, err := strconv.ParseUint(value, 10, 64); err == nil {
currentStation.TxBytes = v
}
}
}
}
// Save last station
if currentMAC != "" && currentStation != nil {
stations[currentMAC] = currentStation
}
return stations
}
// convertStation converts HostapdStation to backend.Station
func (b *Backend) convertStation(mac string, hs *HostapdStation) backend.Station {
// Get IP address if available from correlation
ip := b.ipByMAC[mac]
// Attempt hostname resolution if we have an IP
hostname := ""
// TODO: Could do reverse DNS lookup here if needed
return backend.Station{
MAC: mac,
IP: ip,
Hostname: hostname,
Type: backend.GuessDeviceType(hostname, mac),
Signal: hs.Signal,
RxBytes: hs.RxBytes,
TxBytes: hs.TxBytes,
ConnectedAt: time.Now(), // We don't have exact connection time
}
}
// UpdateIPMapping updates the MAC -> IP mapping from external source (e.g., DHCP)
// This should be called periodically to correlate hostapd stations with IP addresses
func (b *Backend) UpdateIPMapping(macToIP map[string]string) {
b.mu.Lock()
defer b.mu.Unlock()
// Track which stations got IP updates
updated := make(map[string]bool)
for mac, ip := range macToIP {
if oldIP, exists := b.ipByMAC[mac]; exists && oldIP != ip {
// IP changed
updated[mac] = true
} else if !exists {
// New IP mapping
updated[mac] = true
}
b.ipByMAC[mac] = ip
}
// Trigger update callbacks for stations that got new/changed IPs
for mac := range updated {
if station, exists := b.stations[mac]; exists {
if b.callbacks.OnStationUpdated != nil {
st := b.convertStation(mac, station)
go b.callbacks.OnStationUpdated(st)
}
}
}
}

View file

@ -0,0 +1,130 @@
package hostapd
import (
"bufio"
"log"
"os"
"strings"
"time"
"github.com/nemunaire/repeater/internal/models"
)
// DHCPCorrelator helps correlate hostapd stations with DHCP leases to discover IP addresses
type DHCPCorrelator struct {
backend *Backend
dhcpLeasesPath string
stopChan chan struct{}
running bool
}
// NewDHCPCorrelator creates a new DHCP correlator
func NewDHCPCorrelator(backend *Backend, dhcpLeasesPath string) *DHCPCorrelator {
if dhcpLeasesPath == "" {
dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases"
}
return &DHCPCorrelator{
backend: backend,
dhcpLeasesPath: dhcpLeasesPath,
stopChan: make(chan struct{}),
}
}
// Start begins periodic correlation of DHCP leases with hostapd stations
func (dc *DHCPCorrelator) Start() {
if dc.running {
return
}
dc.running = true
go dc.correlationLoop()
log.Printf("DHCP correlation started for hostapd backend")
}
// Stop stops the correlation loop
func (dc *DHCPCorrelator) Stop() {
if !dc.running {
return
}
dc.running = false
close(dc.stopChan)
log.Printf("DHCP correlation stopped")
}
// correlationLoop periodically correlates DHCP leases with stations
func (dc *DHCPCorrelator) correlationLoop() {
// Do an initial correlation immediately
dc.correlate()
// Then correlate every 10 seconds
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
dc.correlate()
case <-dc.stopChan:
return
}
}
}
// correlate performs one correlation cycle
func (dc *DHCPCorrelator) correlate() {
// Parse DHCP leases
leases, err := parseDHCPLeases(dc.dhcpLeasesPath)
if err != nil {
log.Printf("Warning: Failed to parse DHCP leases: %v", err)
return
}
// Build MAC -> IP mapping
macToIP := make(map[string]string)
for _, lease := range leases {
macToIP[lease.MAC] = lease.IP
}
// Update backend with IP mappings
dc.backend.UpdateIPMapping(macToIP)
}
// parseDHCPLeases reads and parses DHCP lease file
func parseDHCPLeases(path string) ([]models.DHCPLease, error) {
var leases []models.DHCPLease
file, err := os.Open(path)
if err != nil {
return leases, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
var currentLease models.DHCPLease
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "lease ") {
ip := strings.Fields(line)[1]
currentLease = models.DHCPLease{IP: ip}
} else if strings.Contains(line, "hardware ethernet") {
mac := strings.Fields(line)[2]
mac = strings.TrimSuffix(mac, ";")
currentLease.MAC = mac
} else if strings.Contains(line, "client-hostname") {
hostname := strings.Fields(line)[1]
hostname = strings.Trim(hostname, `";`)
currentLease.Hostname = hostname
} else if line == "}" {
if currentLease.IP != "" && currentLease.MAC != "" {
leases = append(leases, currentLease)
}
currentLease = models.DHCPLease{}
}
}
return leases, scanner.Err()
}

View file

@ -0,0 +1,10 @@
package hostapd
// HostapdStation represents station properties from hostapd_cli
type HostapdStation struct {
RxPackets uint64
TxPackets uint64
RxBytes uint64
TxBytes uint64
Signal int32 // Signal in dBm
}

111
internal/station/station.go Normal file
View file

@ -0,0 +1,111 @@
package station
import (
"sync"
"github.com/nemunaire/repeater/internal/models"
"github.com/nemunaire/repeater/internal/station/backend"
)
var (
currentBackend backend.StationBackend
mu sync.RWMutex
)
// Initialize initializes the station discovery backend
func Initialize(backendName string, config backend.BackendConfig) error {
mu.Lock()
defer mu.Unlock()
// Close existing backend if any
if currentBackend != nil {
currentBackend.Close()
}
// Create new backend
b, err := createBackend(backendName)
if err != nil {
return err
}
// Initialize the backend
if err := b.Initialize(config); err != nil {
return err
}
currentBackend = b
return nil
}
// GetStations returns all connected stations as ConnectedDevice models
func GetStations() ([]models.ConnectedDevice, error) {
mu.RLock()
defer mu.RUnlock()
if currentBackend == nil {
return nil, nil
}
stations, err := currentBackend.GetStations()
if err != nil {
return nil, err
}
// Convert backend.Station to models.ConnectedDevice
devices := make([]models.ConnectedDevice, len(stations))
for i, s := range stations {
devices[i] = models.ConnectedDevice{
Name: s.Hostname,
Type: s.Type,
MAC: s.MAC,
IP: s.IP,
}
}
return devices, nil
}
// StartEventMonitoring starts monitoring for station events
func StartEventMonitoring(callbacks backend.EventCallbacks) error {
mu.RLock()
defer mu.RUnlock()
if currentBackend == nil {
return nil
}
return currentBackend.StartEventMonitoring(callbacks)
}
// StopEventMonitoring stops monitoring for station events
func StopEventMonitoring() {
mu.RLock()
defer mu.RUnlock()
if currentBackend != nil {
currentBackend.StopEventMonitoring()
}
}
// Close closes the current backend
func Close() {
mu.Lock()
defer mu.Unlock()
if currentBackend != nil {
currentBackend.Close()
currentBackend = nil
}
}
// SupportsRealTimeEvents returns true if the current backend supports real-time events
func SupportsRealTimeEvents() bool {
mu.RLock()
defer mu.RUnlock()
if currentBackend == nil {
return false
}
return currentBackend.SupportsRealTimeEvents()
}

32
internal/syslog/parser.go Normal file
View file

@ -0,0 +1,32 @@
package syslog
import (
"strings"
)
// ParseSyslogLine extracts the message content from a syslog line.
// It looks for the daemon prefix in the line and returns the message after it.
//
// Example input: "Dec 2 02:01:33 tyet daemon.info iwd: Error loading /var/lib/iwd//nemuphone.psk"
// Example output: "Error loading /var/lib/iwd//nemuphone.psk", true
//
// Returns the message and a boolean indicating if the line was successfully parsed.
func ParseSyslogLine(line, daemonPrefix string) (string, bool) {
// Find the daemon prefix in the line (e.g., "iwd:")
idx := strings.Index(line, daemonPrefix)
if idx == -1 {
return "", false
}
// Extract everything after the daemon prefix
message := line[idx+len(daemonPrefix):]
// Trim leading/trailing whitespace
message = strings.TrimSpace(message)
if message == "" {
return "", false
}
return message, true
}

225
internal/syslog/syslog.go Normal file
View file

@ -0,0 +1,225 @@
package syslog
import (
"bufio"
"io"
"log"
"os"
"strings"
"sync"
"time"
"github.com/nemunaire/repeater/internal/logging"
)
// SyslogTailer tails a syslog file and filters messages to the logging system.
type SyslogTailer struct {
path string
filters []string
source string
file *os.File
done chan struct{}
wg sync.WaitGroup
mu sync.Mutex
running bool
}
// NewSyslogTailer creates a new syslog tailer.
// path: Path to the syslog file (e.g., "/var/log/messages")
// filter: Filter string to match in lines (e.g., "daemon.info iwd:")
// source: Source name for logging (e.g., "iwd")
func NewSyslogTailer(path string, filters []string, source string) *SyslogTailer {
return &SyslogTailer{
path: path,
filters: filters,
source: source,
done: make(chan struct{}),
}
}
// Start opens the syslog file and begins tailing it.
func (t *SyslogTailer) Start() error {
t.mu.Lock()
defer t.mu.Unlock()
if t.running {
return nil
}
// Try to open the file
file, err := os.Open(t.path)
if err != nil {
// File might not exist yet, we'll retry in the goroutine
log.Printf("Warning: Cannot open syslog file %s: %v (will retry)", t.path, err)
} else {
// Seek to the end to only read new entries
_, err = file.Seek(0, io.SeekEnd)
if err != nil {
file.Close()
return err
}
t.file = file
}
t.running = true
t.wg.Add(1)
go t.tail()
return nil
}
// Stop signals the tailer to stop and waits for it to finish.
func (t *SyslogTailer) Stop() {
t.mu.Lock()
if !t.running {
t.mu.Unlock()
return
}
t.mu.Unlock()
close(t.done)
t.wg.Wait()
t.mu.Lock()
if t.file != nil {
t.file.Close()
t.file = nil
}
t.running = false
t.mu.Unlock()
}
// tail is the main loop that reads from the syslog file.
func (t *SyslogTailer) tail() {
defer t.wg.Done()
retryDelay := 1 * time.Second
maxRetryDelay := 30 * time.Second
for {
select {
case <-t.done:
return
default:
}
// Check if we have a file open
t.mu.Lock()
file := t.file
t.mu.Unlock()
if file == nil {
// Try to open the file
newFile, err := os.Open(t.path)
if err != nil {
// File doesn't exist or can't be opened, wait and retry
select {
case <-t.done:
return
case <-time.After(retryDelay):
// Exponential backoff
retryDelay *= 2
if retryDelay > maxRetryDelay {
retryDelay = maxRetryDelay
}
continue
}
}
// Seek to the end
_, err = newFile.Seek(0, io.SeekEnd)
if err != nil {
log.Printf("Error seeking syslog file: %v", err)
newFile.Close()
time.Sleep(retryDelay)
continue
}
t.mu.Lock()
t.file = newFile
file = newFile
t.mu.Unlock()
retryDelay = 1 * time.Second
log.Printf("Syslog tailer: opened %s", t.path)
}
// Read lines from the file
if err := t.readLines(file); err != nil {
if err == io.EOF {
// End of file, wait a bit and try again
time.Sleep(100 * time.Millisecond)
continue
}
// Other error, close the file and retry
log.Printf("Error reading syslog file: %v", err)
t.mu.Lock()
if t.file != nil {
t.file.Close()
t.file = nil
}
t.mu.Unlock()
time.Sleep(retryDelay)
}
}
}
// readLines reads and processes lines from the file.
func (t *SyslogTailer) readLines(file *os.File) error {
scanner := bufio.NewScanner(file)
// Increase buffer size to handle long log lines
const maxCapacity = 512 * 1024
buf := make([]byte, maxCapacity)
scanner.Buffer(buf, maxCapacity)
for scanner.Scan() {
select {
case <-t.done:
return nil
default:
}
line := scanner.Text()
// Check if the line contains any of the filter strings
var matchedFilter string
for _, filter := range t.filters {
if strings.Contains(line, filter) {
matchedFilter = filter
break
}
}
if matchedFilter == "" {
continue
}
// Parse the syslog line to extract the message
// We look for "iwd:" (or whatever comes after the filter)
// The filter is "daemon.info iwd:" so we want to extract text after "iwd:"
daemonPrefix := extractDaemonPrefix(matchedFilter)
message, ok := ParseSyslogLine(line, daemonPrefix)
if !ok {
// Couldn't parse the line, skip it
continue
}
// Add to logging system
logging.AddLog(t.source, message)
}
return scanner.Err()
}
// extractDaemonPrefix extracts the daemon prefix from the filter string.
// For example, "daemon.info iwd:" returns "iwd:"
func extractDaemonPrefix(filter string) string {
parts := strings.Fields(filter)
if len(parts) > 0 {
return parts[len(parts)-1]
}
return filter
}

View file

@ -0,0 +1,56 @@
package backend
// WiFiBackend is the interface that must be implemented by all WiFi backends (iwd, wpa_supplicant, etc.)
type WiFiBackend interface {
// Lifecycle Management
Initialize(interfaceName string) error
Close() error
// Network Discovery
ScanNetworks() error
GetOrderedNetworks() ([]BackendNetwork, error)
IsScanning() (bool, error)
// Connection Management
Connect(ssid, password string) error
Disconnect() error
GetConnectionState() (ConnectionState, error)
GetConnectedSSID() string
// Event Monitoring
StartEventMonitoring(callbacks EventCallbacks) error
StopEventMonitoring()
}
// BackendNetwork represents a WiFi network in a backend-agnostic format.
// Both iwd and wpa_supplicant backends convert their native representations to this type.
type BackendNetwork struct {
SSID string
SignalDBm int16 // Signal strength in dBm (-100 to 0)
SecurityType string // "open", "wep", "psk", "8021x"
BSSID string // MAC address of the access point
Frequency uint32 // Frequency in MHz (e.g., 2412 for channel 1, 5180 for channel 36)
}
// ConnectionState represents the WiFi connection state in a backend-agnostic way.
type ConnectionState string
const (
StateConnected ConnectionState = "connected"
StateDisconnected ConnectionState = "disconnected"
StateConnecting ConnectionState = "connecting"
StateDisconnecting ConnectionState = "disconnecting"
)
// EventCallbacks defines callback functions that backends use to notify the wifi package of events.
// This allows the wifi package to remain backend-agnostic while still receiving real-time updates.
type EventCallbacks struct {
// OnStateChange is called when the connection state changes
OnStateChange func(state ConnectionState, ssid string)
// OnScanComplete is called when a network scan completes
OnScanComplete func()
// OnSignalUpdate is called when signal strength changes for the connected network
OnSignalUpdate func(ssid string, signalDBm int16)
}

View file

@ -0,0 +1,140 @@
package wifi
import (
"log"
"sync"
"github.com/gorilla/websocket"
"github.com/nemunaire/repeater/internal/models"
)
// WifiBroadcaster manages WebSocket clients and broadcasts WiFi events
type WifiBroadcaster struct {
clients map[*websocket.Conn]bool
clientsMu sync.RWMutex
// State deduplication
lastState string
lastSSID string
lastNetworks []models.WiFiNetwork
stateMu sync.RWMutex
}
// NewWifiBroadcaster creates a new WiFi broadcaster
func NewWifiBroadcaster() *WifiBroadcaster {
return &WifiBroadcaster{
clients: make(map[*websocket.Conn]bool),
}
}
// RegisterClient registers a new WebSocket client
func (wb *WifiBroadcaster) RegisterClient(conn *websocket.Conn) {
wb.clientsMu.Lock()
wb.clients[conn] = true
wb.clientsMu.Unlock()
// Send initial state to the new client
wb.sendInitialState(conn)
}
// UnregisterClient removes a WebSocket client
func (wb *WifiBroadcaster) UnregisterClient(conn *websocket.Conn) {
wb.clientsMu.Lock()
delete(wb.clients, conn)
wb.clientsMu.Unlock()
}
// sendInitialState sends the current WiFi state to a newly connected client
func (wb *WifiBroadcaster) sendInitialState(conn *websocket.Conn) {
wb.stateMu.RLock()
lastState := wb.lastState
lastSSID := wb.lastSSID
lastNetworks := make([]models.WiFiNetwork, len(wb.lastNetworks))
copy(lastNetworks, wb.lastNetworks)
wb.stateMu.RUnlock()
// Send last known state if available
if lastState != "" {
event := NewStateChangeEvent(lastState, lastSSID, "")
conn.WriteJSON(event)
}
// Send last known network list if available
if len(lastNetworks) > 0 {
event := NewScanUpdateEvent(lastNetworks)
conn.WriteJSON(event)
}
}
// BroadcastScanUpdate broadcasts a scan update event to all clients
func (wb *WifiBroadcaster) BroadcastScanUpdate(networks []models.WiFiNetwork) {
// Check for changes to avoid duplicate broadcasts
wb.stateMu.Lock()
if networksEqual(wb.lastNetworks, networks) {
wb.stateMu.Unlock()
return
}
wb.lastNetworks = make([]models.WiFiNetwork, len(networks))
copy(wb.lastNetworks, networks)
wb.stateMu.Unlock()
event := NewScanUpdateEvent(networks)
wb.broadcast(event)
}
// BroadcastStateChange broadcasts a state change event to all clients
func (wb *WifiBroadcaster) BroadcastStateChange(state, ssid string) {
// Check for changes to avoid duplicate broadcasts
wb.stateMu.Lock()
if wb.lastState == state && wb.lastSSID == ssid {
wb.stateMu.Unlock()
return
}
previousState := wb.lastState
wb.lastState = state
wb.lastSSID = ssid
wb.stateMu.Unlock()
event := NewStateChangeEvent(state, ssid, previousState)
wb.broadcast(event)
}
// BroadcastSignalUpdate broadcasts a signal update event to all clients
func (wb *WifiBroadcaster) BroadcastSignalUpdate(ssid string, signal, dbm int) {
event := NewSignalUpdateEvent(ssid, signal, dbm)
wb.broadcast(event)
}
// broadcast sends an event to all connected clients
func (wb *WifiBroadcaster) broadcast(event WifiEvent) {
// Get list of clients with read lock
wb.clientsMu.RLock()
clients := make([]*websocket.Conn, 0, len(wb.clients))
for client := range wb.clients {
clients = append(clients, client)
}
wb.clientsMu.RUnlock()
// Broadcast to all clients
for _, client := range clients {
err := client.WriteJSON(event)
if err != nil {
log.Printf("Erreur lors de l'envoi WebSocket WiFi: %v", err)
client.Close()
wb.UnregisterClient(client)
}
}
}
// networksEqual compares two network slices for equality
func networksEqual(a, b []models.WiFiNetwork) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].SSID != b[i].SSID || a[i].Signal != b[i].Signal {
return false
}
}
return true
}

70
internal/wifi/events.go Normal file
View file

@ -0,0 +1,70 @@
package wifi
import (
"time"
"github.com/nemunaire/repeater/internal/models"
)
// WifiEvent represents a WiFi event to be sent over WebSocket
type WifiEvent struct {
Type string `json:"type"`
Timestamp time.Time `json:"timestamp"`
Data interface{} `json:"data"`
}
// ScanUpdateData contains network list update information
type ScanUpdateData struct {
Networks []models.WiFiNetwork `json:"networks"`
}
// StateChangeData contains connection state change information
type StateChangeData struct {
State string `json:"state"`
SSID string `json:"ssid,omitempty"`
PreviousState string `json:"previous_state,omitempty"`
}
// SignalUpdateData contains signal strength update information
type SignalUpdateData struct {
SSID string `json:"ssid"`
Signal int `json:"signal"` // 1-5 scale
DBm int `json:"dbm"` // Raw dBm value
}
// NewScanUpdateEvent creates a new scan update event
func NewScanUpdateEvent(networks []models.WiFiNetwork) WifiEvent {
return WifiEvent{
Type: "scan_update",
Timestamp: time.Now(),
Data: ScanUpdateData{
Networks: networks,
},
}
}
// NewStateChangeEvent creates a new state change event
func NewStateChangeEvent(state, ssid, previousState string) WifiEvent {
return WifiEvent{
Type: "state_change",
Timestamp: time.Now(),
Data: StateChangeData{
State: state,
SSID: ssid,
PreviousState: previousState,
},
}
}
// NewSignalUpdateEvent creates a new signal update event
func NewSignalUpdateEvent(ssid string, signal, dbm int) WifiEvent {
return WifiEvent{
Type: "signal_update",
Timestamp: time.Now(),
Data: SignalUpdateData{
SSID: ssid,
Signal: signal,
DBm: dbm,
},
}
}

21
internal/wifi/factory.go Normal file
View file

@ -0,0 +1,21 @@
package wifi
import (
"fmt"
"github.com/nemunaire/repeater/internal/wifi/backend"
"github.com/nemunaire/repeater/internal/wifi/iwd"
"github.com/nemunaire/repeater/internal/wifi/wpasupplicant"
)
// createBackend creates the appropriate WiFi backend based on the backend name
func createBackend(backendName string) (backend.WiFiBackend, error) {
switch backendName {
case "iwd":
return iwd.NewIWDBackend(), nil
case "wpasupplicant":
return wpasupplicant.NewWPABackend(), nil
default:
return nil, fmt.Errorf("invalid wifi backend: %s (must be 'iwd' or 'wpasupplicant')", backendName)
}
}

170
internal/wifi/iwd/agent.go Normal file
View file

@ -0,0 +1,170 @@
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
}

View file

@ -0,0 +1,39 @@
package iwd
import (
"fmt"
"github.com/godbus/dbus/v5"
)
// AgentManager handles agent registration with iwd
type AgentManager struct {
conn *dbus.Conn
obj dbus.BusObject
}
// NewAgentManager creates a new AgentManager instance
func NewAgentManager(conn *dbus.Conn) *AgentManager {
return &AgentManager{
conn: conn,
obj: conn.Object(Service, "/net/connman/iwd"),
}
}
// RegisterAgent registers an agent with iwd
func (am *AgentManager) RegisterAgent(agentPath dbus.ObjectPath) error {
err := am.obj.Call(AgentManagerInterface+".RegisterAgent", 0, agentPath).Err
if err != nil {
return fmt.Errorf("failed to register agent: %v", err)
}
return nil
}
// UnregisterAgent unregisters an agent from iwd
func (am *AgentManager) UnregisterAgent(agentPath dbus.ObjectPath) error {
err := am.obj.Call(AgentManagerInterface+".UnregisterAgent", 0, agentPath).Err
if err != nil {
return fmt.Errorf("failed to unregister agent: %v", err)
}
return nil
}

View file

@ -0,0 +1,255 @@
package iwd
import (
"fmt"
"github.com/godbus/dbus/v5"
"github.com/nemunaire/repeater/internal/wifi/backend"
)
const (
AgentPath = "/com/github/nemunaire/repeater/agent"
)
// IWDBackend implements the WiFiBackend interface for iwd (Intel Wireless Daemon)
type IWDBackend struct {
conn *dbus.Conn
manager *Manager
station *Station
agent *Agent
agentManager *AgentManager
signalMonitor *SignalMonitor
interfaceName string
callbacks backend.EventCallbacks
}
// NewIWDBackend creates a new IWD backend instance
func NewIWDBackend() *IWDBackend {
return &IWDBackend{}
}
// Initialize initializes the iwd backend with the given interface name
func (b *IWDBackend) Initialize(interfaceName string) error {
b.interfaceName = interfaceName
var err error
// Connect to D-Bus
b.conn, err = dbus.SystemBus()
if err != nil {
return fmt.Errorf("échec de connexion à D-Bus: %v", err)
}
// Find station for interface
b.manager = NewManager(b.conn)
b.station, err = b.manager.FindStation(interfaceName)
if err != nil {
return fmt.Errorf("impossible de trouver la station pour %s: %v", interfaceName, err)
}
// Create and register agent for credential callbacks
b.agent = NewAgent(b.conn, dbus.ObjectPath(AgentPath))
if err := b.agent.Export(); err != nil {
return fmt.Errorf("échec de l'export de l'agent: %v", err)
}
b.agentManager = NewAgentManager(b.conn)
if err := b.agentManager.RegisterAgent(dbus.ObjectPath(AgentPath)); err != nil {
b.agent.Unexport()
return fmt.Errorf("échec de l'enregistrement de l'agent: %v", err)
}
return nil
}
// Close closes the D-Bus connection and unregisters the agent
func (b *IWDBackend) Close() error {
if b.agentManager != nil && b.agent != nil {
b.agentManager.UnregisterAgent(dbus.ObjectPath(AgentPath))
b.agent.Unexport()
}
if b.conn != nil {
b.conn.Close()
}
return nil
}
// ScanNetworks triggers a network scan
func (b *IWDBackend) ScanNetworks() error {
err := b.station.Scan()
if err != nil {
return fmt.Errorf("erreur lors du scan: %v", err)
}
return nil
}
// GetOrderedNetworks returns networks sorted by signal strength in backend-agnostic format
func (b *IWDBackend) GetOrderedNetworks() ([]backend.BackendNetwork, error) {
networkInfos, err := b.station.GetOrderedNetworks()
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err)
}
var networks []backend.BackendNetwork
seenSSIDs := make(map[string]bool)
for _, netInfo := range networkInfos {
network := NewNetwork(b.conn, netInfo.Path)
props, err := network.GetProperties()
if err != nil {
continue
}
if props.Name == "" || seenSSIDs[props.Name] {
continue
}
seenSSIDs[props.Name] = true
// Convert iwd network to backend-agnostic format
backendNet := backend.BackendNetwork{
SSID: props.Name,
SignalDBm: netInfo.Signal / 100, // iwd provides 100*dBm, convert to dBm
SecurityType: props.Type,
BSSID: generateSyntheticBSSID(props.Name), // iwd doesn't expose BSSID
Frequency: 0, // iwd doesn't expose frequency in GetOrderedNetworks
}
networks = append(networks, backendNet)
}
return networks, nil
}
// IsScanning checks if a scan is currently in progress
func (b *IWDBackend) IsScanning() (bool, error) {
return b.station.IsScanning()
}
// Connect connects to a WiFi network
func (b *IWDBackend) Connect(ssid, password string) error {
// Store passphrase in agent for callback
if password != "" {
b.agent.SetPassphrase(ssid, password)
}
// Ensure passphrase is cleared after connection attempt
defer func() {
if password != "" {
b.agent.ClearPassphrase(ssid)
}
}()
// Get network object
network, err := b.station.GetNetwork(ssid)
if err != nil {
return fmt.Errorf("réseau '%s' non trouvé: %v", ssid, err)
}
// Connect - iwd will call agent.RequestPassphrase() if needed
if err := network.Connect(); err != nil {
return fmt.Errorf("erreur lors de la connexion: %v", err)
}
return nil
}
// Disconnect disconnects from the current WiFi network
func (b *IWDBackend) Disconnect() error {
if err := b.station.Disconnect(); err != nil {
return fmt.Errorf("erreur lors de la déconnexion: %v", err)
}
return nil
}
// GetConnectionState returns the current WiFi connection state
func (b *IWDBackend) GetConnectionState() (backend.ConnectionState, error) {
state, err := b.station.GetState()
if err != nil {
return backend.StateDisconnected, err
}
return mapIWDState(state), nil
}
// GetConnectedSSID returns the SSID of the currently connected network
func (b *IWDBackend) GetConnectedSSID() string {
network, err := b.station.GetConnectedNetwork()
if err != nil {
return ""
}
props, err := network.GetProperties()
if err != nil {
return ""
}
return props.Name
}
// StartEventMonitoring starts monitoring WiFi events
func (b *IWDBackend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
b.callbacks = callbacks
// Create signal monitor
b.signalMonitor = NewSignalMonitor(b.conn, b.station)
// Register callbacks - wrap to convert iwd types to backend types
b.signalMonitor.OnStateChange(func(state StationState, ssid string) {
if b.callbacks.OnStateChange != nil {
b.callbacks.OnStateChange(mapIWDState(state), ssid)
}
})
b.signalMonitor.OnScanComplete(func() {
if b.callbacks.OnScanComplete != nil {
b.callbacks.OnScanComplete()
}
})
// Start monitoring
return b.signalMonitor.Start()
}
// StopEventMonitoring stops monitoring WiFi events
func (b *IWDBackend) StopEventMonitoring() {
if b.signalMonitor != nil {
b.signalMonitor.Stop()
}
}
// mapIWDState maps iwd-specific states to backend-agnostic states
func mapIWDState(state StationState) backend.ConnectionState {
switch state {
case StateConnected:
return backend.StateConnected
case StateConnecting:
return backend.StateConnecting
case StateDisconnecting:
return backend.StateDisconnecting
case StateDisconnected:
return backend.StateDisconnected
case StateRoaming:
// Map roaming to connected since we're still connected during roaming
return backend.StateConnected
default:
return backend.StateDisconnected
}
}
// generateSyntheticBSSID generates a consistent fake BSSID from SSID
// (iwd doesn't expose real BSSID)
func generateSyntheticBSSID(ssid string) string {
// Use a simple hash approach - consistent per SSID
hash := 0
for _, c := range ssid {
hash = ((hash << 5) - hash) + int(c)
}
// Generate 6 bytes for MAC address
b1 := byte((hash >> 0) & 0xff)
b2 := byte((hash >> 8) & 0xff)
b3 := byte((hash >> 16) & 0xff)
b4 := byte((hash >> 24) & 0xff)
b5 := byte(len(ssid) & 0xff)
b6 := byte((len(ssid) >> 8) & 0xff)
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b1, b2, b3, b4, b5, b6)
}

View file

@ -0,0 +1,71 @@
package iwd
import (
"fmt"
"strings"
"github.com/godbus/dbus/v5"
)
// Manager handles iwd object discovery via ObjectManager
type Manager struct {
conn *dbus.Conn
obj dbus.BusObject
}
// NewManager creates a new Manager instance
func NewManager(conn *dbus.Conn) *Manager {
return &Manager{
conn: conn,
obj: conn.Object(Service, dbus.ObjectPath(ManagerPath)),
}
}
// GetManagedObjects returns all iwd managed objects
func (m *Manager) GetManagedObjects() (map[dbus.ObjectPath]map[string]map[string]dbus.Variant, error) {
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
err := m.obj.Call("org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&objects)
if err != nil {
return nil, fmt.Errorf("failed to get managed objects: %v", err)
}
return objects, nil
}
// FindStation finds the Station object for the given interface name
func (m *Manager) FindStation(interfaceName string) (*Station, error) {
objects, err := m.GetManagedObjects()
if err != nil {
return nil, err
}
// First, find the device with matching interface name
var devicePath dbus.ObjectPath
for path, interfaces := range objects {
if deviceProps, ok := interfaces[DeviceInterface]; ok {
if nameVariant, ok := deviceProps["Name"]; ok {
if name, ok := nameVariant.Value().(string); ok && name == interfaceName {
devicePath = path
break
}
}
}
}
if devicePath == "" {
return nil, fmt.Errorf("device with interface '%s' not found", interfaceName)
}
// Now find the station object under this device
// Station path is typically the same as device path or a child of it
for path, interfaces := range objects {
if _, ok := interfaces[StationInterface]; ok {
// Check if this station belongs to our device
// Station path should be the device path or start with it
if path == devicePath || strings.HasPrefix(string(path), string(devicePath)+"/") {
return NewStation(m.conn, path), nil
}
}
}
return nil, fmt.Errorf("station for device '%s' not found", interfaceName)
}

View file

@ -0,0 +1,64 @@
package iwd
import (
"fmt"
"github.com/godbus/dbus/v5"
)
// Network represents an iwd Network interface
type Network struct {
path dbus.ObjectPath
conn *dbus.Conn
obj dbus.BusObject
}
// NewNetwork creates a new Network instance
func NewNetwork(conn *dbus.Conn, path dbus.ObjectPath) *Network {
return &Network{
path: path,
conn: conn,
obj: conn.Object(Service, path),
}
}
// GetProperties retrieves all network properties
func (n *Network) GetProperties() (*NetworkProperties, error) {
var props map[string]dbus.Variant
err := n.obj.Call("org.freedesktop.DBus.Properties.GetAll", 0, NetworkInterface).Store(&props)
if err != nil {
return nil, fmt.Errorf("failed to get network properties: %v", err)
}
netProps := &NetworkProperties{}
if nameVariant, ok := props["Name"]; ok {
if name, ok := nameVariant.Value().(string); ok {
netProps.Name = name
}
}
if typeVariant, ok := props["Type"]; ok {
if netType, ok := typeVariant.Value().(string); ok {
netProps.Type = netType
}
}
if connectedVariant, ok := props["Connected"]; ok {
if connected, ok := connectedVariant.Value().(bool); ok {
netProps.Connected = connected
}
}
return netProps, nil
}
// Connect initiates a connection to this network
// Credentials are provided via the registered agent's RequestPassphrase callback
func (n *Network) Connect() error {
err := n.obj.Call(NetworkInterface+".Connect", 0).Err
if err != nil {
return fmt.Errorf("connect failed: %v", err)
}
return nil
}

View file

@ -0,0 +1,223 @@
package iwd
import (
"log"
"sync"
"github.com/godbus/dbus/v5"
)
// SignalMonitor monitors D-Bus signals from iwd
type SignalMonitor struct {
conn *dbus.Conn
station *Station
// Signal channel
signalChan chan *dbus.Signal
// Callbacks
onStateChange func(state StationState, ssid string)
onScanComplete func()
// Control
stopChan chan struct{}
mu sync.RWMutex
running bool
// State tracking
lastScanning bool
}
// NewSignalMonitor creates a new signal monitor
func NewSignalMonitor(conn *dbus.Conn, station *Station) *SignalMonitor {
return &SignalMonitor{
conn: conn,
station: station,
signalChan: make(chan *dbus.Signal, 100),
stopChan: make(chan struct{}),
}
}
// OnStateChange registers a callback for state changes
func (sm *SignalMonitor) OnStateChange(callback func(state StationState, ssid string)) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.onStateChange = callback
}
// OnScanComplete registers a callback for scan completion
func (sm *SignalMonitor) OnScanComplete(callback func()) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.onScanComplete = callback
}
// Start begins monitoring D-Bus signals
func (sm *SignalMonitor) Start() error {
sm.mu.Lock()
if sm.running {
sm.mu.Unlock()
return nil
}
sm.running = true
sm.mu.Unlock()
// Subscribe to PropertiesChanged signals for Station interface
stationPath := sm.station.GetPath()
// Add signal match for PropertiesChanged on Station interface
matchOptions := []dbus.MatchOption{
dbus.WithMatchObjectPath(stationPath),
dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
dbus.WithMatchMember("PropertiesChanged"),
}
if err := sm.conn.AddMatchSignal(matchOptions...); err != nil {
sm.mu.Lock()
sm.running = false
sm.mu.Unlock()
return err
}
// Register signal channel
sm.conn.Signal(sm.signalChan)
// Get initial scanning state
scanning, err := sm.station.IsScanning()
if err == nil {
sm.lastScanning = scanning
}
// Start monitoring goroutine
go sm.monitor()
log.Printf("D-Bus signal monitoring started for station %s", stationPath)
return nil
}
// Stop stops monitoring D-Bus signals
func (sm *SignalMonitor) Stop() {
sm.mu.Lock()
if !sm.running {
sm.mu.Unlock()
return
}
sm.running = false
sm.mu.Unlock()
// Signal stop
close(sm.stopChan)
// Remove signal channel
sm.conn.RemoveSignal(sm.signalChan)
log.Printf("D-Bus signal monitoring stopped")
}
// monitor is the main signal processing loop
func (sm *SignalMonitor) monitor() {
for {
select {
case sig := <-sm.signalChan:
sm.handleSignal(sig)
case <-sm.stopChan:
return
}
}
}
// handleSignal processes a D-Bus signal
func (sm *SignalMonitor) handleSignal(sig *dbus.Signal) {
// Only process PropertiesChanged signals
if sig.Name != "org.freedesktop.DBus.Properties.PropertiesChanged" {
return
}
// Verify signal is from Station interface
if len(sig.Body) < 2 {
return
}
interfaceName, ok := sig.Body[0].(string)
if !ok || interfaceName != StationInterface {
return
}
// Parse changed properties
changedProps, ok := sig.Body[1].(map[string]dbus.Variant)
if !ok {
return
}
// Check for State property change
if stateVariant, ok := changedProps["State"]; ok {
if state, ok := stateVariant.Value().(string); ok {
sm.handleStateChange(StationState(state))
}
}
// Check for Scanning property change
if scanningVariant, ok := changedProps["Scanning"]; ok {
if scanning, ok := scanningVariant.Value().(bool); ok {
sm.handleScanningChange(scanning)
}
}
// Check for ConnectedNetwork property change
if _, ok := changedProps["ConnectedNetwork"]; ok {
// Network connection changed, trigger state update
sm.handleConnectionChange()
}
}
// handleStateChange processes a state change
func (sm *SignalMonitor) handleStateChange(state StationState) {
sm.mu.RLock()
callback := sm.onStateChange
sm.mu.RUnlock()
if callback == nil {
return
}
// Get connected SSID if connected
ssid := ""
if state == StateConnected {
network, err := sm.station.GetConnectedNetwork()
if err == nil {
props, err := network.GetProperties()
if err == nil {
ssid = props.Name
}
}
}
callback(state, ssid)
}
// handleScanningChange processes scanning state changes
func (sm *SignalMonitor) handleScanningChange(scanning bool) {
// Detect scan completion (transition from true to false)
if sm.lastScanning && !scanning {
sm.mu.RLock()
callback := sm.onScanComplete
sm.mu.RUnlock()
if callback != nil {
callback()
}
}
sm.lastScanning = scanning
}
// handleConnectionChange processes connection changes
func (sm *SignalMonitor) handleConnectionChange() {
// Get current state and trigger state change callback
state, err := sm.station.GetState()
if err != nil {
return
}
sm.handleStateChange(state)
}

View file

@ -0,0 +1,142 @@
package iwd
import (
"fmt"
"github.com/godbus/dbus/v5"
)
// Station represents an iwd Station interface
type Station struct {
path dbus.ObjectPath
conn *dbus.Conn
obj dbus.BusObject
}
// NewStation creates a new Station instance
func NewStation(conn *dbus.Conn, path dbus.ObjectPath) *Station {
return &Station{
path: path,
conn: conn,
obj: conn.Object(Service, path),
}
}
// Scan triggers a network scan
func (s *Station) Scan() error {
err := s.obj.Call(StationInterface+".Scan", 0).Err
if err != nil {
return fmt.Errorf("scan failed: %v", err)
}
return nil
}
// IsScanning checks if a scan is currently in progress
func (s *Station) IsScanning() (bool, error) {
prop, err := s.obj.GetProperty(StationInterface + ".Scanning")
if err != nil {
return false, fmt.Errorf("failed to get Scanning property: %v", err)
}
scanning, ok := prop.Value().(bool)
if !ok {
return false, fmt.Errorf("Scanning property is not a boolean")
}
return scanning, nil
}
// GetOrderedNetworks returns networks sorted by signal strength
func (s *Station) GetOrderedNetworks() ([]NetworkInfo, error) {
var result []struct {
Path dbus.ObjectPath
Signal int16
}
err := s.obj.Call(StationInterface+".GetOrderedNetworks", 0).Store(&result)
if err != nil {
return nil, fmt.Errorf("failed to get ordered networks: %v", err)
}
networks := make([]NetworkInfo, len(result))
for i, r := range result {
networks[i] = NetworkInfo{
Path: r.Path,
Signal: r.Signal,
}
}
return networks, nil
}
// GetState returns the current connection state
func (s *Station) GetState() (StationState, error) {
prop, err := s.obj.GetProperty(StationInterface + ".State")
if err != nil {
return "", fmt.Errorf("failed to get State property: %v", err)
}
state, ok := prop.Value().(string)
if !ok {
return "", fmt.Errorf("State property is not a string")
}
return StationState(state), nil
}
// Disconnect disconnects from the current network
func (s *Station) Disconnect() error {
err := s.obj.Call(StationInterface+".Disconnect", 0).Err
if err != nil {
return fmt.Errorf("disconnect failed: %v", err)
}
return nil
}
// GetNetwork finds and returns a Network object by SSID
func (s *Station) GetNetwork(ssid string) (*Network, error) {
networks, err := s.GetOrderedNetworks()
if err != nil {
return nil, err
}
// Find the network with matching SSID
for _, netInfo := range networks {
network := NewNetwork(s.conn, netInfo.Path)
props, err := network.GetProperties()
if err != nil {
continue
}
if props.Name == ssid {
return network, nil
}
}
return nil, fmt.Errorf("network '%s' not found", ssid)
}
// GetConnectedNetwork returns the currently connected network
func (s *Station) GetConnectedNetwork() (*Network, error) {
prop, err := s.obj.GetProperty(StationInterface + ".ConnectedNetwork")
if err != nil {
return nil, fmt.Errorf("failed to get ConnectedNetwork property: %v", err)
}
path, ok := prop.Value().(dbus.ObjectPath)
if !ok {
return nil, fmt.Errorf("ConnectedNetwork property is not an ObjectPath")
}
// Check if path is empty (not connected)
if path == "/" || path == "" {
return nil, fmt.Errorf("not connected to any network")
}
return NewNetwork(s.conn, path), nil
}
// GetPath returns the D-Bus object path for this station
func (s *Station) GetPath() dbus.ObjectPath {
return s.path
}

View file

@ -0,0 +1,38 @@
package iwd
import "github.com/godbus/dbus/v5"
const (
// D-Bus service and interfaces
Service = "net.connman.iwd"
ManagerPath = "/"
DeviceInterface = "net.connman.iwd.Device"
StationInterface = "net.connman.iwd.Station"
NetworkInterface = "net.connman.iwd.Network"
AgentInterface = "net.connman.iwd.Agent"
AgentManagerInterface = "net.connman.iwd.AgentManager"
)
// NetworkInfo represents a network with its signal strength
type NetworkInfo struct {
Path dbus.ObjectPath
Signal int16 // 100 * dBm (0 to -10000)
}
// NetworkProperties holds network properties
type NetworkProperties struct {
Name string // SSID
Type string // "open", "wep", "psk", "8021x"
Connected bool
}
// StationState represents the connection state
type StationState string
const (
StateConnected StationState = "connected"
StateDisconnected StationState = "disconnected"
StateConnecting StationState = "connecting"
StateDisconnecting StationState = "disconnecting"
StateRoaming StationState = "roaming"
)

242
internal/wifi/wifi.go Normal file
View file

@ -0,0 +1,242 @@
package wifi
import (
"fmt"
"sort"
"strings"
"time"
"github.com/gorilla/websocket"
"github.com/nemunaire/repeater/internal/models"
"github.com/nemunaire/repeater/internal/wifi/backend"
)
var (
wifiBackend backend.WiFiBackend
wifiBroadcaster *WifiBroadcaster
)
// Initialize initializes the WiFi service with the specified backend
func Initialize(interfaceName string, backendName string) error {
// Create the appropriate backend using the factory
var err error
wifiBackend, err = createBackend(backendName)
if err != nil {
return err
}
// Initialize the backend
return wifiBackend.Initialize(interfaceName)
}
// Close closes the backend connection
func Close() {
if wifiBackend != nil {
wifiBackend.Close()
}
}
// GetCachedNetworks returns previously discovered networks without triggering a scan
func GetCachedNetworks() ([]models.WiFiNetwork, error) {
// Get ordered networks from backend
backendNetworks, err := wifiBackend.GetOrderedNetworks()
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err)
}
// Convert backend networks to models
networks := make([]models.WiFiNetwork, 0, len(backendNetworks))
for _, backendNet := range backendNetworks {
wifiNet := models.WiFiNetwork{
SSID: backendNet.SSID,
Signal: signalToStrength(int(backendNet.SignalDBm)),
Security: mapSecurityType(backendNet.SecurityType),
BSSID: backendNet.BSSID,
Channel: 0, // Not yet exposed by backends
}
networks = append(networks, wifiNet)
}
// Sort by signal strength (descending)
sort.Slice(networks, func(i, j int) bool {
return networks[i].Signal > networks[j].Signal
})
return networks, nil
}
// ScanNetworks scans for available WiFi networks
func ScanNetworks() ([]models.WiFiNetwork, error) {
// Check if already scanning
scanning, err := wifiBackend.IsScanning()
if err == nil && scanning {
time.Sleep(3 * time.Second)
} else {
// Trigger scan
err := wifiBackend.ScanNetworks()
if err != nil && !strings.Contains(err.Error(), "rejected") {
return nil, fmt.Errorf("erreur lors du scan: %v", err)
}
time.Sleep(2 * time.Second)
}
// Get ordered networks from backend
backendNetworks, err := wifiBackend.GetOrderedNetworks()
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err)
}
// Convert backend networks to models
networks := make([]models.WiFiNetwork, 0, len(backendNetworks))
for _, backendNet := range backendNetworks {
wifiNet := models.WiFiNetwork{
SSID: backendNet.SSID,
Signal: signalToStrength(int(backendNet.SignalDBm)),
Security: mapSecurityType(backendNet.SecurityType),
BSSID: backendNet.BSSID,
Channel: 0, // Not yet exposed by backends
}
networks = append(networks, wifiNet)
}
// Sort by signal strength (descending)
sort.Slice(networks, func(i, j int) bool {
return networks[i].Signal > networks[j].Signal
})
// Broadcast to WebSocket clients if available
if wifiBroadcaster != nil {
wifiBroadcaster.BroadcastScanUpdate(networks)
}
return networks, nil
}
// Connect connects to a WiFi network
func Connect(ssid, password string) error {
// Use backend to connect
if err := wifiBackend.Connect(ssid, password); err != nil {
return err
}
// Poll for connection
for i := 0; i < 20; i++ {
time.Sleep(500 * time.Millisecond)
if IsConnected() {
return nil
}
}
return fmt.Errorf("timeout lors de la connexion")
}
// Disconnect disconnects from the current WiFi network
func Disconnect() error {
return wifiBackend.Disconnect()
}
// IsConnected checks if WiFi is connected
func IsConnected() bool {
state, err := wifiBackend.GetConnectionState()
if err != nil {
return false
}
return state == backend.StateConnected
}
// GetConnectedSSID returns the SSID of the currently connected network
func GetConnectedSSID() string {
return wifiBackend.GetConnectedSSID()
}
// GetConnectionState returns the current WiFi connection state
func GetConnectionState() string {
state, err := wifiBackend.GetConnectionState()
if err != nil {
return string(backend.StateDisconnected)
}
return string(state)
}
// StartEventMonitoring initializes signal monitoring and WebSocket broadcasting
func StartEventMonitoring() error {
// Initialize broadcaster
wifiBroadcaster = NewWifiBroadcaster()
// Set up callbacks
callbacks := backend.EventCallbacks{
OnStateChange: handleStateChange,
OnScanComplete: handleScanComplete,
}
// Start backend monitoring
return wifiBackend.StartEventMonitoring(callbacks)
}
// StopEventMonitoring stops signal monitoring
func StopEventMonitoring() {
if wifiBackend != nil {
wifiBackend.StopEventMonitoring()
}
}
// RegisterWebSocketClient registers a new WebSocket client for WiFi events
func RegisterWebSocketClient(conn *websocket.Conn) {
if wifiBroadcaster != nil {
wifiBroadcaster.RegisterClient(conn)
}
}
// UnregisterWebSocketClient removes a WebSocket client
func UnregisterWebSocketClient(conn *websocket.Conn) {
if wifiBroadcaster != nil {
wifiBroadcaster.UnregisterClient(conn)
}
}
// handleStateChange is called when WiFi connection state changes
func handleStateChange(newState backend.ConnectionState, connectedSSID string) {
if wifiBroadcaster != nil {
wifiBroadcaster.BroadcastStateChange(string(newState), connectedSSID)
}
}
// handleScanComplete is called when a WiFi scan completes
func handleScanComplete() {
// Get updated network list
networks, err := GetCachedNetworks()
if err == nil && wifiBroadcaster != nil {
wifiBroadcaster.BroadcastScanUpdate(networks)
}
}
// mapSecurityType maps backend security types to display format
func mapSecurityType(securityType string) string {
switch securityType {
case "open":
return "Open"
case "wep":
return "WEP"
case "psk":
return "WPA2"
case "8021x":
return "WPA2"
default:
return "WPA2"
}
}
// signalToStrength converts signal level (dBm) to strength (1-5)
func signalToStrength(level int) int {
if level >= -30 {
return 5
} else if level >= -50 {
return 4
} else if level >= -60 {
return 3
} else if level >= -70 {
return 2
} else {
return 1
}
}

View file

@ -0,0 +1,265 @@
package wpasupplicant
import (
"fmt"
"time"
"github.com/godbus/dbus/v5"
"github.com/nemunaire/repeater/internal/wifi/backend"
)
// WPABackend implements the WiFiBackend interface for wpa_supplicant
type WPABackend struct {
conn *dbus.Conn
wpasupplicant dbus.BusObject
iface *WPAInterface
signalMonitor *SignalMonitor
interfaceName string
currentNetwork dbus.ObjectPath
}
// NewWPABackend creates a new wpa_supplicant backend instance
func NewWPABackend() *WPABackend {
return &WPABackend{}
}
// Initialize initializes the wpa_supplicant backend with the given interface name
func (b *WPABackend) Initialize(interfaceName string) error {
b.interfaceName = interfaceName
var err error
// Connect to D-Bus
b.conn, err = dbus.SystemBus()
if err != nil {
return fmt.Errorf("failed to connect to D-Bus: %v", err)
}
// Get wpa_supplicant root object
b.wpasupplicant = b.conn.Object(Service, dbus.ObjectPath(RootPath))
// Get interface path for the given interface name
interfacePath, err := b.getInterfacePath(interfaceName)
if err != nil {
return fmt.Errorf("failed to get interface for %s: %v", interfaceName, err)
}
b.iface = NewWPAInterface(b.conn, interfacePath)
return nil
}
// getInterfacePath gets or creates the wpa_supplicant Interface object path
func (b *WPABackend) getInterfacePath(interfaceName string) (dbus.ObjectPath, error) {
var interfacePath dbus.ObjectPath
// Try to get existing interface
err := b.wpasupplicant.Call(Service+".GetInterface", 0, interfaceName).Store(&interfacePath)
if err == nil {
return interfacePath, nil
}
// Interface doesn't exist, create it
args := map[string]dbus.Variant{
"Ifname": dbus.MakeVariant(interfaceName),
}
err = b.wpasupplicant.Call(Service+".CreateInterface", 0, args).Store(&interfacePath)
if err != nil {
return "", fmt.Errorf("failed to create interface: %v", err)
}
return interfacePath, nil
}
// Close closes the D-Bus connection
func (b *WPABackend) Close() error {
if b.conn != nil {
b.conn.Close()
}
return nil
}
// ScanNetworks triggers a network scan
func (b *WPABackend) ScanNetworks() error {
err := b.iface.Scan("active")
if err != nil {
return fmt.Errorf("failed to trigger scan: %v", err)
}
return nil
}
// GetOrderedNetworks returns networks sorted by signal strength in backend-agnostic format
func (b *WPABackend) GetOrderedNetworks() ([]backend.BackendNetwork, error) {
// Get BSS list
bssPaths, err := b.iface.GetBSSs()
if err != nil {
return nil, fmt.Errorf("failed to get BSSs: %v", err)
}
var networks []backend.BackendNetwork
seenSSIDs := make(map[string]bool)
// Iterate through BSSs and collect network info
for _, bssPath := range bssPaths {
bss := NewBSS(b.conn, bssPath)
props, err := bss.GetProperties()
if err != nil {
continue
}
ssid := string(props.SSID)
if ssid == "" || seenSSIDs[ssid] {
continue
}
seenSSIDs[ssid] = true
// Get BSSID string
bssidStr, err := bss.GetBSSIDString()
if err != nil {
bssidStr = ""
}
// Convert to backend-agnostic format
backendNet := backend.BackendNetwork{
SSID: ssid,
SignalDBm: props.Signal,
SecurityType: props.DetermineSecurityType(),
BSSID: bssidStr,
Frequency: props.Frequency,
}
networks = append(networks, backendNet)
}
// Sort by signal strength (descending)
// Note: This is a simple bubble sort for demonstration
// In production, use sort.Slice
for i := 0; i < len(networks); i++ {
for j := i + 1; j < len(networks); j++ {
if networks[j].SignalDBm > networks[i].SignalDBm {
networks[i], networks[j] = networks[j], networks[i]
}
}
}
return networks, nil
}
// IsScanning checks if a scan is currently in progress
func (b *WPABackend) IsScanning() (bool, error) {
return b.iface.GetScanning()
}
// Connect connects to a WiFi network
func (b *WPABackend) Connect(ssid, password string) error {
// Create network configuration
config := make(map[string]interface{})
config["ssid"] = fmt.Sprintf("\"%s\"", ssid) // wpa_supplicant expects quoted SSID
if password != "" {
// For WPA/WPA2-PSK networks
config["psk"] = fmt.Sprintf("\"%s\"", password)
} else {
// For open networks
config["key_mgmt"] = "NONE"
}
// Add network
networkPath, err := b.iface.AddNetwork(config)
if err != nil {
return fmt.Errorf("failed to add network: %v", err)
}
// Store current network path for cleanup
b.currentNetwork = networkPath
// Select (connect to) the network
err = b.iface.SelectNetwork(networkPath)
if err != nil {
// Clean up network on failure
b.iface.RemoveNetwork(networkPath)
return fmt.Errorf("failed to select network: %v", err)
}
return nil
}
// Disconnect disconnects from the current WiFi network
func (b *WPABackend) Disconnect() error {
// Disconnect from current network
if err := b.iface.Disconnect(); err != nil {
return fmt.Errorf("failed to disconnect: %v", err)
}
// Remove the network configuration if we have one
if b.currentNetwork != "" && b.currentNetwork != "/" {
b.iface.RemoveNetwork(b.currentNetwork)
b.currentNetwork = ""
}
return nil
}
// GetConnectionState returns the current WiFi connection state
func (b *WPABackend) GetConnectionState() (backend.ConnectionState, error) {
state, err := b.iface.GetState()
if err != nil {
return backend.StateDisconnected, err
}
return mapWPAState(state), nil
}
// GetConnectedSSID returns the SSID of the currently connected network
func (b *WPABackend) GetConnectedSSID() string {
// Get current BSS
bssPath, err := b.iface.GetCurrentBSS()
if err != nil || bssPath == "/" {
return ""
}
// Get BSS object
bss := NewBSS(b.conn, bssPath)
ssid, err := bss.GetSSIDString()
if err != nil {
return ""
}
return ssid
}
// StartEventMonitoring starts monitoring WiFi events
func (b *WPABackend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
// Create signal monitor
b.signalMonitor = NewSignalMonitor(b.conn, b.iface)
// Start monitoring
return b.signalMonitor.Start(callbacks)
}
// StopEventMonitoring stops monitoring WiFi events
func (b *WPABackend) StopEventMonitoring() {
if b.signalMonitor != nil {
b.signalMonitor.Stop()
}
}
// Wait for scan to complete (helper method)
func (b *WPABackend) waitForScanComplete(timeout time.Duration) error {
start := time.Now()
for {
if time.Since(start) > timeout {
return fmt.Errorf("scan timeout")
}
scanning, err := b.iface.GetScanning()
if err != nil {
return err
}
if !scanning {
return nil
}
time.Sleep(100 * time.Millisecond)
}
}

View file

@ -0,0 +1,157 @@
package wpasupplicant
import (
"fmt"
"github.com/godbus/dbus/v5"
)
// BSS represents a wpa_supplicant BSS (Basic Service Set) object
type BSS struct {
path dbus.ObjectPath
conn *dbus.Conn
obj dbus.BusObject
}
// BSSProperties holds the properties of a BSS
type BSSProperties struct {
SSID []byte
BSSID []byte
Signal int16 // Signal strength in dBm
Frequency uint32 // Frequency in MHz
Privacy bool // Whether encryption is enabled
RSN map[string]dbus.Variant
WPA map[string]dbus.Variant
}
// NewBSS creates a new BSS instance
func NewBSS(conn *dbus.Conn, path dbus.ObjectPath) *BSS {
return &BSS{
path: path,
conn: conn,
obj: conn.Object(Service, path),
}
}
// GetProperties returns all properties of the BSS
func (b *BSS) GetProperties() (*BSSProperties, error) {
props := &BSSProperties{}
// Get SSID
if ssidProp, err := b.obj.GetProperty(BSSInterface + ".SSID"); err == nil {
if ssid, ok := ssidProp.Value().([]byte); ok {
props.SSID = ssid
}
}
// Get BSSID
if bssidProp, err := b.obj.GetProperty(BSSInterface + ".BSSID"); err == nil {
if bssid, ok := bssidProp.Value().([]byte); ok {
props.BSSID = bssid
}
}
// Get Signal
if signalProp, err := b.obj.GetProperty(BSSInterface + ".Signal"); err == nil {
if signal, ok := signalProp.Value().(int16); ok {
props.Signal = signal
}
}
// Get Frequency
if freqProp, err := b.obj.GetProperty(BSSInterface + ".Frequency"); err == nil {
if freq, ok := freqProp.Value().(uint16); ok {
props.Frequency = uint32(freq)
}
}
// Get Privacy
if privacyProp, err := b.obj.GetProperty(BSSInterface + ".Privacy"); err == nil {
if privacy, ok := privacyProp.Value().(bool); ok {
props.Privacy = privacy
}
}
// Get RSN (WPA2) information
if rsnProp, err := b.obj.GetProperty(BSSInterface + ".RSN"); err == nil {
if rsn, ok := rsnProp.Value().(map[string]dbus.Variant); ok {
props.RSN = rsn
}
}
// Get WPA information
if wpaProp, err := b.obj.GetProperty(BSSInterface + ".WPA"); err == nil {
if wpa, ok := wpaProp.Value().(map[string]dbus.Variant); ok {
props.WPA = wpa
}
}
return props, nil
}
// GetSSIDString returns the SSID as a string
func (b *BSS) GetSSIDString() (string, error) {
prop, err := b.obj.GetProperty(BSSInterface + ".SSID")
if err != nil {
return "", fmt.Errorf("failed to get SSID property: %v", err)
}
ssid, ok := prop.Value().([]byte)
if !ok {
return "", fmt.Errorf("SSID property is not a byte array")
}
return string(ssid), nil
}
// GetBSSIDString returns the BSSID as a formatted MAC address string
func (b *BSS) GetBSSIDString() (string, error) {
prop, err := b.obj.GetProperty(BSSInterface + ".BSSID")
if err != nil {
return "", fmt.Errorf("failed to get BSSID property: %v", err)
}
bssid, ok := prop.Value().([]byte)
if !ok || len(bssid) != 6 {
return "", fmt.Errorf("BSSID property is not a valid MAC address")
}
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]), nil
}
// GetSignal returns the signal strength in dBm
func (b *BSS) GetSignal() (int16, error) {
prop, err := b.obj.GetProperty(BSSInterface + ".Signal")
if err != nil {
return 0, fmt.Errorf("failed to get Signal property: %v", err)
}
signal, ok := prop.Value().(int16)
if !ok {
return 0, fmt.Errorf("Signal property is not an int16")
}
return signal, nil
}
// DetermineSecurityType determines the security type based on BSS properties
func (p *BSSProperties) DetermineSecurityType() string {
// Check for WPA2 (RSN)
if len(p.RSN) > 0 {
return "psk"
}
// Check for WPA
if len(p.WPA) > 0 {
return "psk"
}
// Check for WEP (privacy but no WPA/RSN)
if p.Privacy {
return "wep"
}
// Open network
return "open"
}

View file

@ -0,0 +1,146 @@
package wpasupplicant
import (
"fmt"
"github.com/godbus/dbus/v5"
)
// WPAInterface represents a wpa_supplicant Interface object
type WPAInterface struct {
path dbus.ObjectPath
conn *dbus.Conn
obj dbus.BusObject
}
// NewWPAInterface creates a new WPAInterface instance
func NewWPAInterface(conn *dbus.Conn, path dbus.ObjectPath) *WPAInterface {
return &WPAInterface{
path: path,
conn: conn,
obj: conn.Object(Service, path),
}
}
// Scan triggers a network scan
func (i *WPAInterface) Scan(scanType string) error {
args := map[string]interface{}{
"Type": scanType, // "active" or "passive"
}
err := i.obj.Call(InterfaceInterface+".Scan", 0, args).Err
if err != nil {
return fmt.Errorf("scan failed: %v", err)
}
return nil
}
// GetBSSs returns a list of BSS (Basic Service Set) object paths
func (i *WPAInterface) GetBSSs() ([]dbus.ObjectPath, error) {
prop, err := i.obj.GetProperty(InterfaceInterface + ".BSSs")
if err != nil {
return nil, fmt.Errorf("failed to get BSSs property: %v", err)
}
bsss, ok := prop.Value().([]dbus.ObjectPath)
if !ok {
return nil, fmt.Errorf("BSSs property is not an array of ObjectPath")
}
return bsss, nil
}
// GetState returns the current connection state
func (i *WPAInterface) GetState() (WPAState, error) {
prop, err := i.obj.GetProperty(InterfaceInterface + ".State")
if err != nil {
return "", fmt.Errorf("failed to get State property: %v", err)
}
state, ok := prop.Value().(string)
if !ok {
return "", fmt.Errorf("State property is not a string")
}
return WPAState(state), nil
}
// GetCurrentBSS returns the currently connected BSS object path
func (i *WPAInterface) GetCurrentBSS() (dbus.ObjectPath, error) {
prop, err := i.obj.GetProperty(InterfaceInterface + ".CurrentBSS")
if err != nil {
return "", fmt.Errorf("failed to get CurrentBSS property: %v", err)
}
bss, ok := prop.Value().(dbus.ObjectPath)
if !ok {
return "", fmt.Errorf("CurrentBSS property is not an ObjectPath")
}
return bss, nil
}
// AddNetwork creates a new network configuration
func (i *WPAInterface) AddNetwork(config map[string]interface{}) (dbus.ObjectPath, error) {
var networkPath dbus.ObjectPath
// Convert config to proper DBus variant format
dbusConfig := make(map[string]dbus.Variant)
for key, value := range config {
dbusConfig[key] = dbus.MakeVariant(value)
}
err := i.obj.Call(InterfaceInterface+".AddNetwork", 0, dbusConfig).Store(&networkPath)
if err != nil {
return "", fmt.Errorf("failed to add network: %v", err)
}
return networkPath, nil
}
// SelectNetwork connects to a network
func (i *WPAInterface) SelectNetwork(networkPath dbus.ObjectPath) error {
err := i.obj.Call(InterfaceInterface+".SelectNetwork", 0, networkPath).Err
if err != nil {
return fmt.Errorf("failed to select network: %v", err)
}
return nil
}
// RemoveNetwork removes a network configuration
func (i *WPAInterface) RemoveNetwork(networkPath dbus.ObjectPath) error {
err := i.obj.Call(InterfaceInterface+".RemoveNetwork", 0, networkPath).Err
if err != nil {
return fmt.Errorf("failed to remove network: %v", err)
}
return nil
}
// Disconnect disconnects from the current network
func (i *WPAInterface) Disconnect() error {
err := i.obj.Call(InterfaceInterface+".Disconnect", 0).Err
if err != nil {
return fmt.Errorf("disconnect failed: %v", err)
}
return nil
}
// GetPath returns the D-Bus object path for this interface
func (i *WPAInterface) GetPath() dbus.ObjectPath {
return i.path
}
// GetScanning returns whether a scan is currently in progress
func (i *WPAInterface) GetScanning() (bool, error) {
prop, err := i.obj.GetProperty(InterfaceInterface + ".Scanning")
if err != nil {
return false, fmt.Errorf("failed to get Scanning property: %v", err)
}
scanning, ok := prop.Value().(bool)
if !ok {
return false, fmt.Errorf("Scanning property is not a boolean")
}
return scanning, nil
}

View file

@ -0,0 +1,41 @@
package wpasupplicant
import (
"github.com/godbus/dbus/v5"
)
// Network represents a wpa_supplicant Network configuration object
type Network struct {
path dbus.ObjectPath
conn *dbus.Conn
obj dbus.BusObject
}
// NewNetwork creates a new Network instance
func NewNetwork(conn *dbus.Conn, path dbus.ObjectPath) *Network {
return &Network{
path: path,
conn: conn,
obj: conn.Object(Service, path),
}
}
// GetPath returns the D-Bus object path for this network
func (n *Network) GetPath() dbus.ObjectPath {
return n.path
}
// GetProperties returns properties of the network configuration
func (n *Network) GetProperties() (map[string]dbus.Variant, error) {
prop, err := n.obj.GetProperty(NetworkInterface + ".Properties")
if err != nil {
return nil, err
}
props, ok := prop.Value().(map[string]dbus.Variant)
if !ok {
return nil, nil
}
return props, nil
}

View file

@ -0,0 +1,236 @@
package wpasupplicant
import (
"log"
"sync"
"github.com/godbus/dbus/v5"
"github.com/nemunaire/repeater/internal/wifi/backend"
)
// SignalMonitor monitors D-Bus signals from wpa_supplicant
type SignalMonitor struct {
conn *dbus.Conn
iface *WPAInterface
callbacks backend.EventCallbacks
// Signal channel
signalChan chan *dbus.Signal
// Control
stopChan chan struct{}
mu sync.RWMutex
running bool
// State tracking
lastState WPAState
}
// NewSignalMonitor creates a new signal monitor
func NewSignalMonitor(conn *dbus.Conn, iface *WPAInterface) *SignalMonitor {
return &SignalMonitor{
conn: conn,
iface: iface,
signalChan: make(chan *dbus.Signal, 100),
stopChan: make(chan struct{}),
}
}
// Start begins monitoring D-Bus signals
func (sm *SignalMonitor) Start(callbacks backend.EventCallbacks) error {
sm.mu.Lock()
if sm.running {
sm.mu.Unlock()
return nil
}
sm.running = true
sm.callbacks = callbacks
sm.mu.Unlock()
interfacePath := sm.iface.GetPath()
// Add signal match for PropertiesChanged on Interface
matchOptions := []dbus.MatchOption{
dbus.WithMatchObjectPath(interfacePath),
dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
dbus.WithMatchMember("PropertiesChanged"),
}
if err := sm.conn.AddMatchSignal(matchOptions...); err != nil {
sm.mu.Lock()
sm.running = false
sm.mu.Unlock()
return err
}
// Add signal match for ScanDone
scanDoneOptions := []dbus.MatchOption{
dbus.WithMatchObjectPath(interfacePath),
dbus.WithMatchInterface(InterfaceInterface),
dbus.WithMatchMember("ScanDone"),
}
if err := sm.conn.AddMatchSignal(scanDoneOptions...); err != nil {
sm.mu.Lock()
sm.running = false
sm.mu.Unlock()
return err
}
// Register signal channel
sm.conn.Signal(sm.signalChan)
// Get initial state
state, err := sm.iface.GetState()
if err == nil {
sm.lastState = state
}
// Start monitoring goroutine
go sm.monitor()
log.Printf("D-Bus signal monitoring started for wpa_supplicant interface %s", interfacePath)
return nil
}
// Stop stops monitoring D-Bus signals
func (sm *SignalMonitor) Stop() {
sm.mu.Lock()
if !sm.running {
sm.mu.Unlock()
return
}
sm.running = false
sm.mu.Unlock()
// Signal stop
close(sm.stopChan)
// Remove signal channel
sm.conn.RemoveSignal(sm.signalChan)
log.Printf("D-Bus signal monitoring stopped for wpa_supplicant")
}
// monitor is the main signal processing loop
func (sm *SignalMonitor) monitor() {
for {
select {
case sig := <-sm.signalChan:
sm.handleSignal(sig)
case <-sm.stopChan:
return
}
}
}
// handleSignal processes a D-Bus signal
func (sm *SignalMonitor) handleSignal(sig *dbus.Signal) {
// Handle ScanDone signal
if sig.Name == InterfaceInterface+".ScanDone" {
sm.handleScanDone(sig)
return
}
// Handle PropertiesChanged signals
if sig.Name != "org.freedesktop.DBus.Properties.PropertiesChanged" {
return
}
// Verify signal is from Interface
if len(sig.Body) < 2 {
return
}
interfaceName, ok := sig.Body[0].(string)
if !ok || interfaceName != InterfaceInterface {
return
}
// Parse changed properties
changedProps, ok := sig.Body[1].(map[string]dbus.Variant)
if !ok {
return
}
// Check for State property change
if stateVariant, ok := changedProps["State"]; ok {
if state, ok := stateVariant.Value().(string); ok {
sm.handleStateChange(WPAState(state))
}
}
// Check for CurrentBSS property change (connection status)
if _, ok := changedProps["CurrentBSS"]; ok {
// BSS changed, trigger state update
sm.handleConnectionChange()
}
}
// handleStateChange processes a state change
func (sm *SignalMonitor) handleStateChange(state WPAState) {
sm.lastState = state
sm.mu.RLock()
callback := sm.callbacks.OnStateChange
sm.mu.RUnlock()
if callback == nil {
return
}
// Map wpa_supplicant state to backend state
backendState := mapWPAState(state)
// Get connected SSID if connected
ssid := ""
if backendState == backend.StateConnected {
if bssPath, err := sm.iface.GetCurrentBSS(); err == nil && bssPath != "/" {
bss := NewBSS(sm.conn, bssPath)
if ssidStr, err := bss.GetSSIDString(); err == nil {
ssid = ssidStr
}
}
}
callback(backendState, ssid)
}
// handleConnectionChange processes connection changes
func (sm *SignalMonitor) handleConnectionChange() {
// Get current state and trigger state change callback
state, err := sm.iface.GetState()
if err != nil {
return
}
sm.handleStateChange(state)
}
// handleScanDone processes scan completion
func (sm *SignalMonitor) handleScanDone(sig *dbus.Signal) {
sm.mu.RLock()
callback := sm.callbacks.OnScanComplete
sm.mu.RUnlock()
if callback != nil {
callback()
}
}
// mapWPAState maps wpa_supplicant states to backend-agnostic states
func mapWPAState(state WPAState) backend.ConnectionState {
switch state {
case StateCompleted:
return backend.StateConnected
case StateAuthenticating, StateAssociating, StateAssociated, State4WayHandshake, StateGroupHandshake:
return backend.StateConnecting
case StateDisconnected, StateInactive, StateInterfaceDisabled:
return backend.StateDisconnected
case StateScanning:
// Keep as disconnected if just scanning
return backend.StateDisconnected
default:
return backend.StateDisconnected
}
}

View file

@ -0,0 +1,27 @@
package wpasupplicant
const (
// D-Bus service and interfaces
Service = "fi.w1.wpa_supplicant1"
RootPath = "/fi/w1/wpa_supplicant1"
InterfaceInterface = "fi.w1.wpa_supplicant1.Interface"
BSSInterface = "fi.w1.wpa_supplicant1.BSS"
NetworkInterface = "fi.w1.wpa_supplicant1.Network"
)
// WPAState represents the wpa_supplicant connection state
type WPAState string
const (
// wpa_supplicant state strings
StateDisconnected WPAState = "disconnected"
StateInactive WPAState = "inactive"
StateScanning WPAState = "scanning"
StateAuthenticating WPAState = "authenticating"
StateAssociating WPAState = "associating"
StateAssociated WPAState = "associated"
State4WayHandshake WPAState = "4way_handshake"
StateGroupHandshake WPAState = "group_handshake"
StateCompleted WPAState = "completed"
StateInterfaceDisabled WPAState = "interface_disabled"
)

826
main.go
View file

@ -1,826 +0,0 @@
package main
import (
"bufio"
"embed"
"encoding/json"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"os/exec"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/godbus/dbus/v5"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
)
//go:embed all:static
var _assets embed.FS
// Structures de données
type WiFiNetwork struct {
SSID string `json:"ssid"`
Signal int `json:"signal"`
Security string `json:"security"`
Channel int `json:"channel"`
BSSID string `json:"bssid"`
}
type ConnectedDevice struct {
Name string `json:"name"`
Type string `json:"type"`
MAC string `json:"mac"`
IP string `json:"ip"`
}
type HotspotConfig struct {
SSID string `json:"ssid"`
Password string `json:"password"`
Channel int `json:"channel"`
}
type SystemStatus struct {
Connected bool `json:"connected"`
ConnectedSSID string `json:"connectedSSID"`
HotspotEnabled bool `json:"hotspotEnabled"`
ConnectedCount int `json:"connectedCount"`
DataUsage float64 `json:"dataUsage"`
Uptime int64 `json:"uptime"`
ConnectedDevices []ConnectedDevice `json:"connectedDevices"`
}
type WiFiConnectRequest struct {
SSID string `json:"ssid"`
Password string `json:"password"`
}
type LogEntry struct {
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Message string `json:"message"`
}
// Variables globales
var (
currentStatus SystemStatus
statusMutex sync.RWMutex
logEntries []LogEntry
logMutex sync.RWMutex
websocketClients = make(map[*websocket.Conn]bool)
clientsMutex sync.RWMutex
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
startTime = time.Now()
dbusConn *dbus.Conn
wpaSupplicant dbus.BusObject
)
const (
WLAN_INTERFACE = "wlan0"
AP_INTERFACE = "wlan1"
HOSTAPD_CONF = "/etc/hostapd/hostapd.conf"
WPA_CONF = "/etc/wpa_supplicant/wpa_supplicant.conf"
// D-Bus constantes pour wpa_supplicant
WPA_SUPPLICANT_SERVICE = "fi.w1.wpa_supplicant1"
WPA_SUPPLICANT_PATH = "/fi/w1/wpa_supplicant1"
WPA_SUPPLICANT_IFACE = "fi.w1.wpa_supplicant1"
WPA_INTERFACE_IFACE = "fi.w1.wpa_supplicant1.Interface"
WPA_BSS_IFACE = "fi.w1.wpa_supplicant1.BSS"
WPA_NETWORK_IFACE = "fi.w1.wpa_supplicant1.Network"
)
func main() {
// Initialiser D-Bus
var err error
dbusConn, err = dbus.SystemBus()
if err != nil {
log.Fatalf("Erreur de connexion D-Bus: %v", err)
}
defer dbusConn.Close()
wpaSupplicant = dbusConn.Object(WPA_SUPPLICANT_SERVICE, dbus.ObjectPath(WPA_SUPPLICANT_PATH))
// Initialiser le statut système
initializeStatus()
// Démarrer les tâches périodiques
go periodicStatusUpdate()
go periodicDeviceUpdate()
// Configuration du routeur
r := mux.NewRouter()
// Routes API
api := r.PathPrefix("/api").Subrouter()
api.HandleFunc("/wifi/scan", scanWiFiHandler).Methods("GET")
api.HandleFunc("/wifi/connect", connectWiFiHandler).Methods("POST")
api.HandleFunc("/wifi/disconnect", disconnectWiFiHandler).Methods("POST")
api.HandleFunc("/hotspot/config", configureHotspotHandler).Methods("POST")
api.HandleFunc("/hotspot/toggle", toggleHotspotHandler).Methods("POST")
api.HandleFunc("/devices", getDevicesHandler).Methods("GET")
api.HandleFunc("/status", getStatusHandler).Methods("GET")
api.HandleFunc("/logs", getLogsHandler).Methods("GET")
api.HandleFunc("/logs", clearLogsHandler).Methods("DELETE")
// WebSocket pour les logs en temps réel
r.HandleFunc("/ws/logs", websocketHandler)
// Servir les fichiers statiques
sub, err := fs.Sub(_assets, "static")
if err != nil {
log.Fatal("Unable to cd to static/ directory:", err)
}
Assets := http.FS(sub)
r.PathPrefix("/").Handler(http.FileServer(Assets))
addLog("Système", "Serveur API démarré sur le port 8080")
log.Fatal(http.ListenAndServe(":8080", r))
}
func initializeStatus() {
statusMutex.Lock()
defer statusMutex.Unlock()
currentStatus = SystemStatus{
Connected: false,
ConnectedSSID: "",
HotspotEnabled: true,
ConnectedCount: 0,
DataUsage: 0.0,
Uptime: 0,
}
}
// Handlers API
func scanWiFiHandler(w http.ResponseWriter, r *http.Request) {
networks, err := scanWiFiNetworks()
if err != nil {
addLog("WiFi", fmt.Sprintf("Erreur lors du scan: %v", err))
http.Error(w, "Erreur lors du scan WiFi", http.StatusInternalServerError)
return
}
addLog("WiFi", fmt.Sprintf("Scan terminé - %d réseaux trouvés", len(networks)))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(networks)
}
func connectWiFiHandler(w http.ResponseWriter, r *http.Request) {
var req WiFiConnectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Données invalides", http.StatusBadRequest)
return
}
addLog("WiFi", fmt.Sprintf("Tentative de connexion à %s", req.SSID))
err := connectToWiFiDBus(req.SSID, req.Password)
if err != nil {
addLog("WiFi", fmt.Sprintf("Échec de connexion: %v", err))
http.Error(w, fmt.Sprintf("Échec de connexion: %v", err), http.StatusInternalServerError)
return
}
statusMutex.Lock()
currentStatus.Connected = true
currentStatus.ConnectedSSID = req.SSID
statusMutex.Unlock()
addLog("WiFi", fmt.Sprintf("Connexion réussie à %s", req.SSID))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
func disconnectWiFiHandler(w http.ResponseWriter, r *http.Request) {
addLog("WiFi", "Tentative de déconnexion")
err := disconnectWiFiDBus()
if err != nil {
addLog("WiFi", fmt.Sprintf("Erreur de déconnexion: %v", err))
http.Error(w, fmt.Sprintf("Erreur de déconnexion: %v", err), http.StatusInternalServerError)
return
}
statusMutex.Lock()
currentStatus.Connected = false
currentStatus.ConnectedSSID = ""
statusMutex.Unlock()
addLog("WiFi", "Déconnexion réussie")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
func configureHotspotHandler(w http.ResponseWriter, r *http.Request) {
var config HotspotConfig
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
http.Error(w, "Données invalides", http.StatusBadRequest)
return
}
err := configureHotspot(config)
if err != nil {
addLog("Hotspot", fmt.Sprintf("Erreur de configuration: %v", err))
http.Error(w, fmt.Sprintf("Erreur de configuration: %v", err), http.StatusInternalServerError)
return
}
addLog("Hotspot", fmt.Sprintf("Configuration mise à jour: %s (Canal %d)", config.SSID, config.Channel))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
func toggleHotspotHandler(w http.ResponseWriter, r *http.Request) {
statusMutex.Lock()
currentStatus.HotspotEnabled = !currentStatus.HotspotEnabled
enabled := currentStatus.HotspotEnabled
statusMutex.Unlock()
var err error
if enabled {
err = startHotspot()
addLog("Hotspot", "Hotspot activé")
} else {
err = stopHotspot()
addLog("Hotspot", "Hotspot désactivé")
}
if err != nil {
addLog("Hotspot", fmt.Sprintf("Erreur: %v", err))
http.Error(w, fmt.Sprintf("Erreur: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"enabled": enabled})
}
func getDevicesHandler(w http.ResponseWriter, r *http.Request) {
devices, err := getConnectedDevices()
if err != nil {
addLog("Système", fmt.Sprintf("Erreur lors de la récupération des appareils: %v", err))
http.Error(w, "Erreur lors de la récupération des appareils", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(devices)
}
func getStatusHandler(w http.ResponseWriter, r *http.Request) {
statusMutex.RLock()
status := currentStatus
status.Uptime = int64(time.Since(startTime).Seconds())
statusMutex.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
func getLogsHandler(w http.ResponseWriter, r *http.Request) {
logMutex.RLock()
logs := make([]LogEntry, len(logEntries))
copy(logs, logEntries)
logMutex.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(logs)
}
func clearLogsHandler(w http.ResponseWriter, r *http.Request) {
logMutex.Lock()
logEntries = []LogEntry{}
logMutex.Unlock()
addLog("Système", "Logs effacés")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
// Fonctions WiFi avec D-Bus
func scanWiFiNetworks() ([]WiFiNetwork, error) {
interfacePath, err := getWiFiInterfacePath()
if err != nil {
return nil, fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err)
}
// Déclencher un scan
wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
call := wifiInterface.Call(WPA_INTERFACE_IFACE+".Scan", 0, map[string]dbus.Variant{"Type": dbus.MakeVariant("active")})
if call.Err != nil {
return nil, fmt.Errorf("erreur lors du scan: %v", call.Err)
}
// Attendre un peu pour que le scan se termine
time.Sleep(2 * time.Second)
// Récupérer la liste des BSS
bssePaths, err := wifiInterface.GetProperty(WPA_INTERFACE_IFACE + ".BSSs")
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des BSS: %v", err)
}
var networks []WiFiNetwork
seenSSIDs := make(map[string]bool)
for _, bssPath := range bssePaths.Value().([]dbus.ObjectPath) {
bss := dbusConn.Object(WPA_SUPPLICANT_SERVICE, bssPath)
// Récupérer les propriétés du BSS
var props map[string]dbus.Variant
err = bss.Call("org.freedesktop.DBus.Properties.GetAll", 0, WPA_BSS_IFACE).Store(&props)
if err != nil {
continue
}
network := WiFiNetwork{}
// Extraire SSID
if ssidBytes, ok := props["SSID"].Value().([]byte); ok {
network.SSID = string(ssidBytes)
}
// Éviter les doublons
if network.SSID == "" || seenSSIDs[network.SSID] {
continue
}
seenSSIDs[network.SSID] = true
// Extraire BSSID
if bssidBytes, ok := props["BSSID"].Value().([]byte); ok {
network.BSSID = fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
bssidBytes[0], bssidBytes[1], bssidBytes[2], bssidBytes[3], bssidBytes[4], bssidBytes[5])
}
// Extraire la force du signal
if signal, ok := props["Signal"].Value().(int16); ok {
network.Signal = signalToStrength(int(signal))
}
// Extraire la fréquence et calculer le canal
if frequency, ok := props["Frequency"].Value().(uint16); ok {
network.Channel = frequencyToChannel(int(frequency))
}
// Déterminer la sécurité
if privacyVal, ok := props["Privacy"].Value().(bool); ok && privacyVal {
if wpaProps, ok := props["WPA"].Value().(map[string]dbus.Variant); ok && len(wpaProps) > 0 {
network.Security = "WPA"
} else if rsnProps, ok := props["RSN"].Value().(map[string]dbus.Variant); ok && len(rsnProps) > 0 {
network.Security = "WPA2"
} else {
network.Security = "WEP"
}
} else {
network.Security = "Open"
}
networks = append(networks, network)
}
// Trier par force du signal
sort.Slice(networks, func(i, j int) bool {
return networks[i].Signal > networks[j].Signal
})
return networks, nil
}
func connectToWiFiDBus(ssid, password string) error {
interfacePath, err := getWiFiInterfacePath()
if err != nil {
return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err)
}
wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
// Créer un nouveau réseau
networkConfig := map[string]dbus.Variant{
"ssid": dbus.MakeVariant(ssid),
}
if password != "" {
networkConfig["psk"] = dbus.MakeVariant(password)
}
var networkPath dbus.ObjectPath
err = wifiInterface.Call(WPA_INTERFACE_IFACE+".AddNetwork", 0, networkConfig).Store(&networkPath)
if err != nil {
return fmt.Errorf("erreur lors de l'ajout du réseau: %v", err)
}
// Sélectionner le réseau
err = wifiInterface.Call(WPA_INTERFACE_IFACE+".SelectNetwork", 0, networkPath).Err
if err != nil {
return fmt.Errorf("erreur lors de la sélection du réseau: %v", err)
}
// Attendre la connexion
for i := 0; i < 20; i++ {
time.Sleep(500 * time.Millisecond)
if isConnectedDBus() {
return nil
}
}
return fmt.Errorf("timeout lors de la connexion")
}
func disconnectWiFiDBus() error {
interfacePath, err := getWiFiInterfacePath()
if err != nil {
return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err)
}
wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
// Déconnecter
err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Disconnect", 0).Err
if err != nil {
return fmt.Errorf("erreur lors de la déconnexion: %v", err)
}
// Supprimer tous les réseaux
var networks []dbus.ObjectPath
err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "Networks").Store(&networks)
if err == nil {
for _, networkPath := range networks {
wifiInterface.Call(WPA_INTERFACE_IFACE+".RemoveNetwork", 0, networkPath)
}
}
return nil
}
func getWiFiInterfacePath() (dbus.ObjectPath, error) {
var interfacePath dbus.ObjectPath
err := wpaSupplicant.Call(WPA_SUPPLICANT_IFACE+".GetInterface", 0, WLAN_INTERFACE).Store(&interfacePath)
if err != nil {
return "", fmt.Errorf("erreur lors de la récupération des interfaces: %v", err)
}
return interfacePath, nil
}
func isConnectedDBus() bool {
interfacePath, err := getWiFiInterfacePath()
if err != nil {
return false
}
wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
var state string
err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "State").Store(&state)
if err != nil {
return false
}
return state == "completed"
}
func frequencyToChannel(frequency int) int {
if frequency >= 2412 && frequency <= 2484 {
if frequency == 2484 {
return 14
}
return (frequency-2412)/5 + 1
} else if frequency >= 5170 && frequency <= 5825 {
return (frequency - 5000) / 5
}
return 0
}
func signalToStrength(level int) int {
if level >= -30 {
return 5
} else if level >= -50 {
return 4
} else if level >= -60 {
return 3
} else if level >= -70 {
return 2
} else {
return 1
}
}
func connectToWiFi(ssid, password string) error {
// Créer la configuration wpa_supplicant
config := fmt.Sprintf(`ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1
country=FR
network={
ssid="%s"
psk="%s"
}
`, ssid, password)
err := os.WriteFile(WPA_CONF, []byte(config), 0600)
if err != nil {
return err
}
// Redémarrer wpa_supplicant
cmd := exec.Command("systemctl", "restart", "wpa_supplicant")
if err := cmd.Run(); err != nil {
return err
}
// Attendre la connexion
for i := 0; i < 10; i++ {
time.Sleep(1 * time.Second)
if isConnected() {
return nil
}
}
return fmt.Errorf("timeout lors de la connexion")
}
func isConnected() bool {
cmd := exec.Command("iwconfig", WLAN_INTERFACE)
output, err := cmd.Output()
if err != nil {
return false
}
return strings.Contains(string(output), "Access Point:")
}
// Fonctions Hotspot
func configureHotspot(config HotspotConfig) error {
hostapdConfig := fmt.Sprintf(`interface=%s
driver=nl80211
ssid=%s
hw_mode=g
channel=%d
wmm_enabled=0
macaddr_acl=0
auth_algs=1
ignore_broadcast_ssid=0
wpa=2
wpa_passphrase=%s
wpa_key_mgmt=WPA-PSK
wpa_pairwise=TKIP
rsn_pairwise=CCMP
`, AP_INTERFACE, config.SSID, config.Channel, config.Password)
return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0644)
}
func startHotspot() error {
cmd := exec.Command("systemctl", "start", "hostapd")
return cmd.Run()
}
func stopHotspot() error {
cmd := exec.Command("systemctl", "stop", "hostapd")
return cmd.Run()
}
// Fonctions pour les appareils connectés
func getConnectedDevices() ([]ConnectedDevice, error) {
var devices []ConnectedDevice
// Lire les baux DHCP
leases, err := parseDHCPLeases()
if err != nil {
return devices, err
}
// Obtenir les informations ARP
arpInfo, err := getARPInfo()
if err != nil {
return devices, err
}
for _, lease := range leases {
device := ConnectedDevice{
Name: lease.Hostname,
MAC: lease.MAC,
IP: lease.IP,
Type: guessDeviceType(lease.Hostname, lease.MAC),
}
// Vérifier si l'appareil est toujours connecté via ARP
if _, exists := arpInfo[lease.IP]; exists {
devices = append(devices, device)
}
}
return devices, nil
}
type DHCPLease struct {
IP string
MAC string
Hostname string
}
func parseDHCPLeases() ([]DHCPLease, error) {
var leases []DHCPLease
file, err := os.Open("/var/lib/dhcp/dhcpd.leases")
if err != nil {
return leases, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
var currentLease DHCPLease
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "lease ") {
ip := strings.Fields(line)[1]
currentLease = DHCPLease{IP: ip}
} else if strings.Contains(line, "hardware ethernet") {
mac := strings.Fields(line)[2]
mac = strings.TrimSuffix(mac, ";")
currentLease.MAC = mac
} else if strings.Contains(line, "client-hostname") {
hostname := strings.Fields(line)[1]
hostname = strings.Trim(hostname, `";`)
currentLease.Hostname = hostname
} else if line == "}" {
if currentLease.IP != "" && currentLease.MAC != "" {
leases = append(leases, currentLease)
}
currentLease = DHCPLease{}
}
}
return leases, nil
}
func getARPInfo() (map[string]string, error) {
arpInfo := make(map[string]string)
cmd := exec.Command("arp", "-a")
output, err := cmd.Output()
if err != nil {
return arpInfo, err
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 {
ip := matches[1]
mac := matches[2]
arpInfo[ip] = mac
}
}
return arpInfo, nil
}
func guessDeviceType(hostname, mac string) string {
hostname = strings.ToLower(hostname)
if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") {
return "mobile"
} else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") {
return "tablet"
} else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") {
return "laptop"
}
// Deviner par préfixe MAC (OUI)
macPrefix := strings.ToUpper(mac[:8])
switch macPrefix {
case "00:50:56", "00:0C:29", "00:05:69": // VMware
return "laptop"
case "08:00:27": // VirtualBox
return "laptop"
default:
return "mobile"
}
}
// Fonctions de logging
func addLog(source, message string) {
logMutex.Lock()
entry := LogEntry{
Timestamp: time.Now(),
Source: source,
Message: message,
}
logEntries = append(logEntries, entry)
// Garder seulement les 100 derniers logs
if len(logEntries) > 100 {
logEntries = logEntries[len(logEntries)-100:]
}
logMutex.Unlock()
// Envoyer aux clients WebSocket
broadcastToWebSockets(entry)
// Log vers la console
log.Printf("[%s] %s", source, message)
}
// WebSocket pour les logs en temps réel
func websocketHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Erreur WebSocket: %v", err)
return
}
defer conn.Close()
clientsMutex.Lock()
websocketClients[conn] = true
clientsMutex.Unlock()
defer func() {
clientsMutex.Lock()
delete(websocketClients, conn)
clientsMutex.Unlock()
}()
// Envoyer les logs existants
logMutex.RLock()
for _, entry := range logEntries {
conn.WriteJSON(entry)
}
logMutex.RUnlock()
// Maintenir la connexion
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}
func broadcastToWebSockets(entry LogEntry) {
clientsMutex.RLock()
defer clientsMutex.RUnlock()
for client := range websocketClients {
err := client.WriteJSON(entry)
if err != nil {
client.Close()
delete(websocketClients, client)
}
}
}
// Tâches périodiques
func periodicStatusUpdate() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
statusMutex.Lock()
currentStatus.Connected = isConnected()
if !currentStatus.Connected {
currentStatus.ConnectedSSID = ""
}
statusMutex.Unlock()
}
}
func periodicDeviceUpdate() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
devices, err := getConnectedDevices()
if err != nil {
continue
}
statusMutex.Lock()
currentStatus.ConnectedDevices = devices
currentStatus.ConnectedCount = len(devices)
statusMutex.Unlock()
}
}

5
oapi-gin.cfg.yaml Normal file
View file

@ -0,0 +1,5 @@
package: api
generate:
- gin
- embedded-spec
output: internal/api/routes.gen.go

4
oapi-types.cfg.yaml Normal file
View file

@ -0,0 +1,4 @@
package: api
generate:
- types
output: internal/api/types.gen.go

552
openapi.yaml Normal file
View file

@ -0,0 +1,552 @@
openapi: 3.0.3
info:
title: Travel Router Control API
description: |
API for controlling a mini travel router with dual WiFi interfaces and Ethernet connectivity.
The router can operate as a WiFi repeater, connecting to upstream networks while providing
a hotspot for client devices.
version: 1.0.0
contact:
name: API Support
license:
name: MIT
servers:
- url: http://localhost:8080
description: Local router API
tags:
- name: WiFi
description: WiFi client operations (upstream network connection)
- name: Hotspot
description: Access point operations (client-facing hotspot)
- name: Devices
description: Connected devices management
- name: System
description: System status and monitoring
- name: Logs
description: System logs and real-time monitoring
paths:
/api/wifi/networks:
get:
tags:
- WiFi
summary: Get discovered WiFi networks
description: |
Returns the list of WiFi networks from the last scan without triggering a new scan.
Returns an empty list if no scan has been performed yet.
operationId: getWiFiNetworks
responses:
'200':
description: List of discovered networks
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WiFiNetwork'
example:
- ssid: "Hotel-Guest"
signal: 5
security: "WPA2"
channel: 6
bssid: "aa:bb:cc:dd:ee:ff"
- ssid: "Public-WiFi"
signal: 3
security: "Open"
channel: 11
bssid: "11:22:33:44:55:66"
'500':
description: Error retrieving networks
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/wifi/scan:
get:
tags:
- WiFi
summary: Scan for available WiFi networks
description: |
Triggers a WiFi scan using wpa_supplicant via D-Bus and returns all discovered networks
sorted by signal strength. The scan takes approximately 2 seconds to complete.
operationId: scanWiFi
responses:
'200':
description: Successfully scanned networks
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WiFiNetwork'
example:
- ssid: "Hotel-Guest"
signal: 5
security: "WPA2"
channel: 6
bssid: "aa:bb:cc:dd:ee:ff"
- ssid: "Public-WiFi"
signal: 3
security: "Open"
channel: 11
bssid: "11:22:33:44:55:66"
'500':
description: Scan error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/wifi/connect:
post:
tags:
- WiFi
summary: Connect to a WiFi network
description: |
Connects the router to an upstream WiFi network using wpa_supplicant.
Supports both open and password-protected networks (WPA/WPA2).
operationId: connectWiFi
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WiFiConnectRequest'
examples:
protected:
summary: WPA2 protected network
value:
ssid: "Hotel-Guest"
password: "guest1234"
open:
summary: Open network
value:
ssid: "Public-WiFi"
password: ""
responses:
'200':
description: Successfully connected
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
'400':
description: Invalid request data
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Connection failed
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/wifi/disconnect:
post:
tags:
- WiFi
summary: Disconnect from WiFi network
description: |
Disconnects from the currently connected upstream WiFi network
and removes all saved network configurations.
operationId: disconnectWiFi
responses:
'200':
description: Successfully disconnected
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
'500':
description: Disconnection failed
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/hotspot/toggle:
post:
tags:
- Hotspot
summary: Toggle hotspot on/off
description: |
Enables or disables the hotspot (access point) by starting/stopping
the hostapd service. Returns the new enabled state and updates
the system status with current hostapd_cli information.
operationId: toggleHotspot
responses:
'200':
description: Hotspot state changed successfully
content:
application/json:
schema:
type: object
properties:
enabled:
type: boolean
description: Current hotspot state after toggle
required:
- enabled
example:
enabled: true
'500':
description: Failed to change hotspot state
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/devices:
get:
tags:
- Devices
summary: Get connected devices
description: |
Returns a list of all devices currently connected to the hotspot.
Device information is gathered from DHCP leases and ARP tables.
Only devices with active ARP entries are considered connected.
operationId: getDevices
responses:
'200':
description: List of connected devices
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ConnectedDevice'
example:
- name: "iPhone-12"
type: "mobile"
mac: "aa:bb:cc:11:22:33"
ip: "192.168.1.100"
- name: "MacBook-Pro"
type: "laptop"
mac: "dd:ee:ff:44:55:66"
ip: "192.168.1.101"
'500':
description: Failed to retrieve device list
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/status:
get:
tags:
- System
summary: Get system status
description: |
Returns comprehensive system status including WiFi connection state,
detailed hotspot status from hostapd_cli, connected device count,
data usage, and uptime.
operationId: getStatus
responses:
'200':
description: Current system status
content:
application/json:
schema:
$ref: '#/components/schemas/SystemStatus'
/api/logs:
get:
tags:
- Logs
summary: Get system logs
description: |
Returns the last 100 log entries from the system.
For real-time log streaming, use the WebSocket endpoint.
operationId: getLogs
responses:
'200':
description: List of log entries
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/LogEntry'
example:
- timestamp: "2025-10-28T14:32:10Z"
source: "WiFi"
message: "Scan terminé - 5 réseaux trouvés"
- timestamp: "2025-10-28T14:32:15Z"
source: "WiFi"
message: "Tentative de connexion à Hotel-Guest"
delete:
tags:
- Logs
summary: Clear system logs
description: Clears all stored log entries (keeps only the "logs cleared" entry)
operationId: clearLogs
responses:
'200':
description: Logs cleared successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
/ws/logs:
get:
tags:
- Logs
summary: WebSocket for real-time logs
description: |
WebSocket endpoint for receiving real-time log updates.
Upon connection, all existing logs are sent, followed by new logs as they occur.
This is a WebSocket endpoint - upgrade the HTTP connection to WebSocket protocol.
operationId: logsWebSocket
responses:
'101':
description: WebSocket connection established
'400':
description: WebSocket upgrade failed
components:
schemas:
WiFiNetwork:
type: object
description: Discovered WiFi network information
properties:
ssid:
type: string
description: Network SSID (name)
example: "Hotel-Guest"
signal:
type: integer
description: Signal strength (1-5 scale)
minimum: 1
maximum: 5
example: 4
security:
type: string
description: Security type
enum:
- Open
- WEP
- WPA
- WPA2
example: "WPA2"
channel:
type: integer
description: WiFi channel number
minimum: 1
maximum: 165
example: 6
bssid:
type: string
description: Access point MAC address
pattern: '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$'
example: "aa:bb:cc:dd:ee:ff"
required:
- ssid
- signal
- security
- channel
- bssid
WiFiConnectRequest:
type: object
description: Request to connect to a WiFi network
properties:
ssid:
type: string
description: Network SSID to connect to
example: "Hotel-Guest"
password:
type: string
description: Network password (empty string for open networks)
example: "guest1234"
required:
- ssid
- password
HotspotStatus:
type: object
description: Detailed hotspot status from hostapd_cli
properties:
state:
type: string
description: Hotspot state (ENABLED, DISABLED, etc.)
example: "ENABLED"
ssid:
type: string
description: Current SSID being broadcast
example: "TravelRouter"
bssid:
type: string
description: MAC address of the access point
pattern: '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$'
example: "4a:e3:4e:09:57:f8"
channel:
type: integer
description: Current WiFi channel
minimum: 1
maximum: 14
example: 11
frequency:
type: integer
description: Frequency in MHz
example: 2462
numStations:
type: integer
description: Number of connected stations
minimum: 0
example: 2
hwMode:
type: string
description: Hardware mode (g, a, n, ac, etc.)
example: "g"
countryCode:
type: string
description: Country code
example: "VN"
required:
- state
ConnectedDevice:
type: object
description: Device connected to the hotspot
properties:
name:
type: string
description: Device hostname
example: "iPhone-12"
type:
type: string
description: Detected device type
enum:
- mobile
- tablet
- laptop
- desktop
- unknown
example: "mobile"
mac:
type: string
description: Device MAC address
pattern: '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$'
example: "aa:bb:cc:11:22:33"
ip:
type: string
description: Assigned IP address
pattern: '^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$'
example: "192.168.1.100"
required:
- name
- type
- mac
- ip
SystemStatus:
type: object
description: Overall system status
properties:
connected:
type: boolean
description: Whether router is connected to upstream WiFi
example: true
connectionState:
type: string
description: Current WiFi connection state
enum:
- connected
- disconnected
- connecting
- disconnecting
- roaming
example: "connected"
connectedSSID:
type: string
description: SSID of connected upstream network (empty if not connected)
example: "Hotel-Guest"
hotspotStatus:
allOf:
- $ref: '#/components/schemas/HotspotStatus'
nullable: true
description: Detailed hotspot status (null if hotspot is not running)
connectedCount:
type: integer
description: Number of devices connected to hotspot
minimum: 0
example: 3
dataUsage:
type: number
format: double
description: Total data usage in MB
example: 145.7
uptime:
type: integer
format: int64
description: System uptime in seconds
example: 3600
connectedDevices:
type: array
description: List of devices connected to hotspot
items:
$ref: '#/components/schemas/ConnectedDevice'
required:
- connected
- connectionState
- connectedSSID
- connectedCount
- dataUsage
- uptime
- connectedDevices
LogEntry:
type: object
description: System log entry
properties:
timestamp:
type: string
format: date-time
description: When the log entry was created
example: "2025-10-28T14:32:10Z"
source:
type: string
description: Log source component
enum:
- Système
- WiFi
- Hotspot
example: "WiFi"
message:
type: string
description: Log message
example: "Scan terminé - 5 réseaux trouvés"
required:
- timestamp
- source
- message
SuccessResponse:
type: object
description: Generic success response
properties:
status:
type: string
enum:
- success
example: "success"
required:
- status
Error:
type: object
description: Error response
properties:
error:
type: string
description: Error message
example: "Erreur lors du scan WiFi"
required:
- error

View file

@ -1,239 +0,0 @@
// État global de l'application
let appState = {
selectedWifi: null,
hotspotEnabled: true,
connectedDevices: [],
wifiNetworks: [],
uptime: 0,
dataUsage: 0
};
// Simulation de données
const mockDevices = [
{ name: "iPhone 13", type: "mobile", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.101" },
{ name: "MacBook Pro", type: "laptop", mac: "11:22:33:44:55:66", ip: "192.168.1.102" },
{ name: "iPad", type: "tablet", mac: "77:88:99:AA:BB:CC", ip: "192.168.1.103" }
];
// Initialisation
document.addEventListener('DOMContentLoaded', function() {
initializeApp();
startPeriodicUpdates();
});
function initializeApp() {
updateWifiList();
updateDevicesList();
updateStats();
addLog("Système", "Interface web initialisée");
}
async function updateWifiList() {
const wifiList = document.getElementById('wifiList');
wifiList.innerHTML = '';
(await (await fetch('/api/wifi/scan')).json()).forEach((network, index) => {
const wifiItem = document.createElement('div');
wifiItem.className = 'wifi-item';
wifiItem.onclick = () => selectWifi(network, wifiItem);
wifiItem.innerHTML = `
<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);
}

View file

@ -1,135 +0,0 @@
<!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>

View file

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