Compare commits
11 commits
08cbe4f9ff
...
92b6113d72
| Author | SHA1 | Date | |
|---|---|---|---|
| 92b6113d72 | |||
| 8b1debdddc | |||
| 5a3942f351 | |||
| 249217d4ad | |||
| d57c08a6c4 | |||
| 70140bc289 | |||
| 950f73371c | |||
| a758c331c0 | |||
| 0797f7dd50 | |||
| 07f8673f2f | |||
| 77370eff19 |
32 changed files with 40661 additions and 276 deletions
|
|
@ -273,6 +273,10 @@ function updateStatusDisplay(status) {
|
|||
wifiDot.className = 'status-dot active';
|
||||
wifiText.textContent = `Roaming: ${status.connectedSSID}`;
|
||||
break;
|
||||
case 'disabled':
|
||||
wifiDot.className = 'status-dot offline';
|
||||
wifiText.textContent = 'WiFi désactivé';
|
||||
break;
|
||||
case 'disconnected':
|
||||
default:
|
||||
wifiDot.className = 'status-dot offline';
|
||||
|
|
@ -280,6 +284,16 @@ function updateStatusDisplay(status) {
|
|||
break;
|
||||
}
|
||||
|
||||
// Update Ethernet uplink badge
|
||||
const ethernetStatus = document.getElementById('ethernetStatus');
|
||||
const ethernetText = ethernetStatus.querySelector('.status-text');
|
||||
if (status.ethernetStatus?.active) {
|
||||
ethernetText.textContent = `Ethernet · ${status.ethernetStatus.ipv4 || status.ethernetStatus.interface}`;
|
||||
ethernetStatus.hidden = false;
|
||||
} else {
|
||||
ethernetStatus.hidden = true;
|
||||
}
|
||||
|
||||
// Update hotspot status badge
|
||||
const hotspotStatus = document.getElementById('hotspotStatus');
|
||||
const hotspotDot = hotspotStatus.querySelector('.status-dot');
|
||||
|
|
@ -401,20 +415,112 @@ function displayDevices(devices) {
|
|||
const deviceCard = document.createElement('div');
|
||||
deviceCard.className = 'device-card';
|
||||
|
||||
const displayName = device.name && device.name.trim() !== ''
|
||||
? device.name
|
||||
: (device.vendor ? device.vendor : 'Sans nom');
|
||||
|
||||
const showVendor = device.vendor && device.vendor !== displayName;
|
||||
const metrics = buildDeviceMetrics(device);
|
||||
|
||||
deviceCard.innerHTML = `
|
||||
${getDeviceIcon(device.type)}
|
||||
<div class="device-name">${escapeHtml(device.name)}</div>
|
||||
<div class="device-type">${device.type}</div>
|
||||
<div class="device-name">${escapeHtml(displayName)}</div>
|
||||
<div class="device-type">${escapeHtml(formatDeviceType(device.type))}</div>
|
||||
${showVendor ? `<div class="device-vendor">${escapeHtml(device.vendor)}</div>` : ''}
|
||||
<div class="device-info">
|
||||
<div>${device.ip}</div>
|
||||
<div style="font-size: 0.75rem;">${device.mac}</div>
|
||||
<div>${escapeHtml(device.ip || '—')}</div>
|
||||
<div class="device-mac">${escapeHtml(device.mac)}</div>
|
||||
</div>
|
||||
${metrics ? `<div class="device-metrics">${metrics}</div>` : ''}
|
||||
`;
|
||||
|
||||
devicesList.appendChild(deviceCard);
|
||||
});
|
||||
}
|
||||
|
||||
function formatDeviceType(type) {
|
||||
const labels = {
|
||||
mobile: 'Mobile',
|
||||
tablet: 'Tablette',
|
||||
laptop: 'Portable',
|
||||
desktop: 'Ordinateur',
|
||||
unknown: 'Inconnu'
|
||||
};
|
||||
return labels[type] || labels.unknown;
|
||||
}
|
||||
|
||||
function buildDeviceMetrics(device) {
|
||||
const parts = [];
|
||||
|
||||
if (typeof device.signalDbm === 'number' && device.signalDbm < 0) {
|
||||
const level = signalLevelFromDbm(device.signalDbm);
|
||||
parts.push(`
|
||||
<span class="device-metric" title="${device.signalDbm} dBm">
|
||||
${generateSignalBars(level)}
|
||||
<span class="device-metric-value">${device.signalDbm} dBm</span>
|
||||
</span>
|
||||
`);
|
||||
}
|
||||
|
||||
const total = (device.rxBytes || 0) + (device.txBytes || 0);
|
||||
if (total > 0) {
|
||||
parts.push(`
|
||||
<span class="device-metric" title="Reçu ${formatBytes(device.rxBytes || 0)} / Émis ${formatBytes(device.txBytes || 0)}">
|
||||
<span class="device-metric-label">Trafic</span>
|
||||
<span class="device-metric-value">${formatBytes(total)}</span>
|
||||
</span>
|
||||
`);
|
||||
}
|
||||
|
||||
if (device.connectedAt) {
|
||||
const since = new Date(device.connectedAt);
|
||||
if (!isNaN(since.getTime())) {
|
||||
const elapsed = Math.max(0, Math.floor((Date.now() - since.getTime()) / 1000));
|
||||
parts.push(`
|
||||
<span class="device-metric" title="Connecté depuis ${since.toLocaleTimeString('fr-FR')}">
|
||||
<span class="device-metric-label">Depuis</span>
|
||||
<span class="device-metric-value">${formatDuration(elapsed)}</span>
|
||||
</span>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function signalLevelFromDbm(dbm) {
|
||||
// dBm to 1-4 bars: -50+ excellent, -60 good, -70 fair, <=-80 weak
|
||||
if (dbm >= -55) return 4;
|
||||
if (dbm >= -65) return 3;
|
||||
if (dbm >= -75) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes || bytes < 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
let value = bytes;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i++;
|
||||
}
|
||||
const precision = value >= 100 || i === 0 ? 0 : 1;
|
||||
return `${value.toFixed(precision)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
if (m < 60) return `${m} min`;
|
||||
const h = Math.floor(m / 60);
|
||||
const remM = m % 60;
|
||||
if (h < 24) return remM > 0 ? `${h}h${remM.toString().padStart(2, '0')}` : `${h}h`;
|
||||
const d = Math.floor(h / 24);
|
||||
const remH = h % 24;
|
||||
return remH > 0 ? `${d}j ${remH}h` : `${d}j`;
|
||||
}
|
||||
|
||||
function addLogEntry(log) {
|
||||
const logContainer = document.getElementById('logContainer');
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<title>Travel Router</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,400&display=swap" rel="stylesheet">
|
||||
<!--link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,400&display=swap" rel="stylesheet"-->
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -25,6 +25,10 @@
|
|||
<span class="status-dot offline"></span>
|
||||
<span class="status-text">Disconnected</span>
|
||||
</div>
|
||||
<div class="status-badge" id="ethernetStatus" hidden>
|
||||
<span class="status-dot active"></span>
|
||||
<span class="status-text">Ethernet</span>
|
||||
</div>
|
||||
<div class="status-badge" id="hotspotStatus">
|
||||
<span class="status-dot active"></span>
|
||||
<span class="status-text">Hotspot actif</span>
|
||||
|
|
|
|||
|
|
@ -553,7 +553,16 @@ body {
|
|||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.device-vendor {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.375rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
|
|
@ -562,6 +571,49 @@ body {
|
|||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.device-mac {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.02em;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.device-metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin-top: 0.625rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.device-metric {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3125rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.device-metric-label {
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.device-metric-value {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.device-metric .signal-bars {
|
||||
transform: scale(0.85);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.device-placeholder {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/nemunaire/repeater/internal/config"
|
||||
|
|
@ -28,12 +31,13 @@ func GetWiFiNetworks(c *gin.Context) {
|
|||
func ScanWiFi(c *gin.Context) {
|
||||
networks, err := wifi.ScanNetworks()
|
||||
if err != nil {
|
||||
logging.AddLog("WiFi", "Erreur lors du scan: "+err.Error())
|
||||
log.Printf("WiFi scan error: %v", err)
|
||||
logging.AddLog("WiFi", "Erreur lors du scan")
|
||||
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")
|
||||
logging.AddLog("WiFi", "Scan terminé - "+strconv.Itoa(len(networks))+" réseaux trouvés")
|
||||
c.JSON(http.StatusOK, networks)
|
||||
}
|
||||
|
||||
|
|
@ -45,16 +49,19 @@ func ConnectWiFi(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
logging.AddLog("WiFi", "Tentative de connexion à "+req.SSID)
|
||||
logging.AddLog("WiFi", "Tentative de connexion")
|
||||
|
||||
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()})
|
||||
// Backend errors may include credentials or low-level details
|
||||
// (dbus paths, kernel messages); keep them in the server log only.
|
||||
log.Printf("WiFi connect error: %v", err)
|
||||
logging.AddLog("WiFi", "Échec de connexion")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Échec de connexion"})
|
||||
return
|
||||
}
|
||||
|
||||
logging.AddLog("WiFi", "Connexion réussie à "+req.SSID)
|
||||
logging.AddLog("WiFi", "Connexion réussie")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
|
|
@ -64,8 +71,9 @@ func DisconnectWiFi(c *gin.Context) {
|
|||
|
||||
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()})
|
||||
log.Printf("WiFi disconnect error: %v", err)
|
||||
logging.AddLog("WiFi", "Erreur de déconnexion")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de déconnexion"})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -83,19 +91,24 @@ func ConfigureHotspot(c *gin.Context) {
|
|||
|
||||
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()})
|
||||
log.Printf("Hotspot configure error: %v", err)
|
||||
logging.AddLog("Hotspot", "Erreur de configuration")
|
||||
// Validation errors are user-actionable; keep them on the wire.
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
logging.AddLog("Hotspot", "Configuration mise à jour: "+config.SSID)
|
||||
logging.AddLog("Hotspot", "Configuration mise à jour")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
// ToggleHotspot handles hotspot enable/disable
|
||||
func ToggleHotspot(c *gin.Context, status *models.SystemStatus) {
|
||||
// Determine current state
|
||||
func ToggleHotspot(c *gin.Context, status *models.SystemStatus, statusMu *sync.RWMutex) {
|
||||
// Determine current state under read lock to avoid racing with the
|
||||
// periodic status updater that mutates HotspotStatus.
|
||||
statusMu.RLock()
|
||||
isEnabled := status.HotspotStatus != nil && status.HotspotStatus.State == "ENABLED"
|
||||
statusMu.RUnlock()
|
||||
|
||||
var err error
|
||||
if !isEnabled {
|
||||
|
|
@ -107,13 +120,18 @@ func ToggleHotspot(c *gin.Context, status *models.SystemStatus) {
|
|||
}
|
||||
|
||||
if err != nil {
|
||||
logging.AddLog("Hotspot", "Erreur: "+err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur: " + err.Error()})
|
||||
log.Printf("Hotspot toggle error: %v", err)
|
||||
logging.AddLog("Hotspot", "Erreur")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update status immediately
|
||||
status.HotspotStatus = hotspot.GetDetailedStatus()
|
||||
// Refresh status under the write lock; the periodic updater touches
|
||||
// the same field on a 5s ticker.
|
||||
newStatus := hotspot.GetDetailedStatus()
|
||||
statusMu.Lock()
|
||||
status.HotspotStatus = newStatus
|
||||
statusMu.Unlock()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": !isEnabled})
|
||||
}
|
||||
|
|
@ -122,7 +140,8 @@ func ToggleHotspot(c *gin.Context, status *models.SystemStatus) {
|
|||
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())
|
||||
log.Printf("GetDevices error: %v", err)
|
||||
logging.AddLog("Système", "Erreur lors de la récupération des appareils")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des appareils"})
|
||||
return
|
||||
}
|
||||
|
|
@ -130,9 +149,13 @@ func GetDevices(c *gin.Context, cfg *config.Config) {
|
|||
c.JSON(http.StatusOK, devices)
|
||||
}
|
||||
|
||||
// GetStatus returns system status
|
||||
func GetStatus(c *gin.Context, status *models.SystemStatus) {
|
||||
c.JSON(http.StatusOK, status)
|
||||
// GetStatus returns system status. Reads under the shared lock so the JSON
|
||||
// encoder doesn't observe a torn ConnectedDevices slice mid-update.
|
||||
func GetStatus(c *gin.Context, status *models.SystemStatus, statusMu *sync.RWMutex) {
|
||||
statusMu.RLock()
|
||||
snapshot := *status
|
||||
statusMu.RUnlock()
|
||||
c.JSON(http.StatusOK, snapshot)
|
||||
}
|
||||
|
||||
// GetLogs returns system logs
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package handlers
|
|||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
|
|
@ -10,9 +11,23 @@ import (
|
|||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
CheckOrigin: checkSameOrigin,
|
||||
}
|
||||
|
||||
// checkSameOrigin only accepts WebSocket upgrades whose Origin header matches
|
||||
// the request's Host. Without this, any web page a LAN user visits could open
|
||||
// a WebSocket against the router's API.
|
||||
func checkSameOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
// Non-browser clients (curl, native apps) do not send Origin.
|
||||
return true
|
||||
},
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Host == r.Host
|
||||
}
|
||||
|
||||
// WebSocketLogs handles WebSocket connections for real-time logs
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/nemunaire/repeater/internal/api/handlers"
|
||||
|
|
@ -12,7 +13,7 @@ import (
|
|||
)
|
||||
|
||||
// SetupRouter creates and configures the Gin router
|
||||
func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.FS) *gin.Engine {
|
||||
func SetupRouter(status *models.SystemStatus, statusMu *sync.RWMutex, 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)
|
||||
|
||||
|
|
@ -35,7 +36,7 @@ func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.F
|
|||
{
|
||||
hotspot.POST("/config", handlers.ConfigureHotspot)
|
||||
hotspot.POST("/toggle", func(c *gin.Context) {
|
||||
handlers.ToggleHotspot(c, status)
|
||||
handlers.ToggleHotspot(c, status, statusMu)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +47,7 @@ func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.F
|
|||
|
||||
// Status endpoint
|
||||
api.GET("/status", func(c *gin.Context) {
|
||||
handlers.GetStatus(c, status)
|
||||
handlers.GetStatus(c, status, statusMu)
|
||||
})
|
||||
|
||||
// Log endpoints
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ type App struct {
|
|||
Assets embed.FS
|
||||
Config *config.Config
|
||||
SyslogTailer *syslog.SyslogTailer
|
||||
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// New creates a new application instance
|
||||
|
|
@ -44,6 +48,7 @@ func New(assets embed.FS) *App {
|
|||
},
|
||||
StartTime: time.Now(),
|
||||
Assets: assets,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -52,15 +57,28 @@ 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
|
||||
}
|
||||
// Decide whether the Ethernet uplink already provides connectivity. When
|
||||
// it does we deliberately skip every wpa_supplicant interaction below —
|
||||
// the daemon is dbus-activatable, so any call into the WiFi backend
|
||||
// would re-spawn it and undo this choice.
|
||||
eth := ensureUplink(cfg.EthernetInterface)
|
||||
a.StatusMutex.Lock()
|
||||
a.Status.EthernetStatus = eth
|
||||
a.StatusMutex.Unlock()
|
||||
|
||||
// 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
|
||||
if !eth.Active {
|
||||
// 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
|
||||
}
|
||||
} else {
|
||||
log.Printf("Skipping WiFi backend init: Ethernet uplink active on %s", eth.Interface)
|
||||
}
|
||||
|
||||
// Initialize station backend
|
||||
|
|
@ -99,6 +117,7 @@ func (a *App) Initialize(cfg *config.Config) error {
|
|||
}
|
||||
|
||||
// Start periodic tasks
|
||||
a.wg.Add(2)
|
||||
go a.periodicStatusUpdate()
|
||||
go a.periodicDeviceUpdate()
|
||||
|
||||
|
|
@ -108,26 +127,36 @@ func (a *App) Initialize(cfg *config.Config) error {
|
|||
|
||||
// Run starts the HTTP server
|
||||
func (a *App) Run(addr string) error {
|
||||
router := api.SetupRouter(&a.Status, a.Config, a.Assets)
|
||||
router := api.SetupRouter(&a.Status, &a.StatusMutex, a.Config, a.Assets)
|
||||
|
||||
logging.AddLog("Système", "Serveur API démarré sur "+addr)
|
||||
return router.Run(addr)
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the application
|
||||
// Shutdown gracefully shuts down the application. Idempotent — main wires
|
||||
// it both to a signal handler and a defer, and the second call must be a
|
||||
// no-op (channel close panics otherwise; backend Close paths assume single
|
||||
// invocation).
|
||||
func (a *App) Shutdown() {
|
||||
// Stop syslog tailing if running
|
||||
if a.SyslogTailer != nil {
|
||||
a.SyslogTailer.Stop()
|
||||
}
|
||||
a.stopOnce.Do(func() {
|
||||
// Signal periodic loops to exit, then wait for them so we don't
|
||||
// race with backends being closed below.
|
||||
close(a.stopCh)
|
||||
a.wg.Wait()
|
||||
|
||||
// Stop station monitoring and close backend
|
||||
station.StopEventMonitoring()
|
||||
station.Close()
|
||||
// Stop syslog tailing if running
|
||||
if a.SyslogTailer != nil {
|
||||
a.SyslogTailer.Stop()
|
||||
}
|
||||
|
||||
wifi.StopEventMonitoring()
|
||||
wifi.Close()
|
||||
logging.AddLog("Système", "Application arrêtée")
|
||||
// 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
|
||||
|
|
@ -178,14 +207,43 @@ func getInterfaceBytes(interfaceName string) (rxBytes, txBytes int64) {
|
|||
|
||||
// periodicStatusUpdate updates WiFi connection status periodically
|
||||
func (a *App) periodicStatusUpdate() {
|
||||
defer a.wg.Done()
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
for {
|
||||
select {
|
||||
case <-a.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
var eth *models.EthernetStatus
|
||||
if a.Config != nil {
|
||||
if e, err := probeEthernet(a.Config.EthernetInterface); err == nil {
|
||||
eth = e
|
||||
}
|
||||
}
|
||||
|
||||
a.StatusMutex.Lock()
|
||||
a.Status.Connected = wifi.IsConnected()
|
||||
a.Status.ConnectionState = wifi.GetConnectionState()
|
||||
a.Status.ConnectedSSID = wifi.GetConnectedSSID()
|
||||
if eth != nil {
|
||||
a.Status.EthernetStatus = eth
|
||||
}
|
||||
|
||||
// Skip every wifi.* call when the Ethernet uplink is the chosen
|
||||
// path: the WiFi backend isn't initialized in that mode and the
|
||||
// wrappers would otherwise return zero values; either way we don't
|
||||
// want to risk dbus-activating wpa_supplicant from this hot loop.
|
||||
ethActive := a.Status.EthernetStatus != nil && a.Status.EthernetStatus.Active
|
||||
if !ethActive {
|
||||
a.Status.Connected = wifi.IsConnected()
|
||||
a.Status.ConnectionState = wifi.GetConnectionState()
|
||||
a.Status.ConnectedSSID = wifi.GetConnectedSSID()
|
||||
} else {
|
||||
a.Status.Connected = false
|
||||
a.Status.ConnectionState = "disabled"
|
||||
a.Status.ConnectedSSID = ""
|
||||
}
|
||||
a.Status.Uptime = getSystemUptime()
|
||||
|
||||
// Get detailed hotspot status
|
||||
|
|
@ -204,16 +262,37 @@ func (a *App) periodicStatusUpdate() {
|
|||
|
||||
// periodicDeviceUpdate updates connected devices list periodically
|
||||
func (a *App) periodicDeviceUpdate() {
|
||||
defer a.wg.Done()
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
for {
|
||||
select {
|
||||
case <-a.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
devices, err := station.GetStations()
|
||||
if err != nil {
|
||||
log.Printf("Error getting connected devices: %v", err)
|
||||
}
|
||||
|
||||
a.StatusMutex.Lock()
|
||||
// Preserve ConnectedAt across refreshes so the UI's "connected since"
|
||||
// timestamp doesn't reset every poll. Backends that don't track a
|
||||
// real connection time return time.Now() each call.
|
||||
previous := make(map[string]time.Time, len(a.Status.ConnectedDevices))
|
||||
for _, d := range a.Status.ConnectedDevices {
|
||||
if !d.ConnectedAt.IsZero() {
|
||||
previous[d.MAC] = d.ConnectedAt
|
||||
}
|
||||
}
|
||||
for i := range devices {
|
||||
if t, ok := previous[devices[i].MAC]; ok {
|
||||
devices[i].ConnectedAt = t
|
||||
}
|
||||
}
|
||||
a.Status.ConnectedDevices = devices
|
||||
a.Status.ConnectedCount = len(devices)
|
||||
a.StatusMutex.Unlock()
|
||||
|
|
@ -225,13 +304,7 @@ 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,
|
||||
}
|
||||
device := station.ToConnectedDevice(st)
|
||||
|
||||
// Check if device already exists
|
||||
found := false
|
||||
|
|
@ -272,15 +345,16 @@ func (a *App) handleStationUpdated(st backend.Station) {
|
|||
a.StatusMutex.Lock()
|
||||
defer a.StatusMutex.Unlock()
|
||||
|
||||
// Update existing device
|
||||
// Update existing device. Preserve the original ConnectedAt so the
|
||||
// device card doesn't reset its "connected since" badge each time the
|
||||
// IP or signal updates.
|
||||
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,
|
||||
updated := station.ToConnectedDevice(st)
|
||||
if !d.ConnectedAt.IsZero() {
|
||||
updated.ConnectedAt = d.ConnectedAt
|
||||
}
|
||||
a.Status.ConnectedDevices[i] = updated
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
|
|||
87
internal/app/network.go
Normal file
87
internal/app/network.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/nemunaire/repeater/internal/models"
|
||||
)
|
||||
|
||||
// hasCarrier reports whether the kernel sees an active link on iface.
|
||||
// `/sys/class/net/<iface>/carrier` is "1" when the link layer is up; reading
|
||||
// it on an admin-down interface yields EINVAL, which we treat the same way.
|
||||
func hasCarrier(iface string) bool {
|
||||
data, err := os.ReadFile("/sys/class/net/" + iface + "/carrier")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(data)) == "1"
|
||||
}
|
||||
|
||||
// probeEthernet reports the wired uplink state of iface. Active requires both
|
||||
// a live link (`carrier == 1`) and a DHCP-assigned IPv4 — detected either by
|
||||
// the `dynamic` flag emitted by full iproute2, or by a finite `valid_lft`
|
||||
// (which BusyBox's `ip` does emit even when it omits the flag).
|
||||
func probeEthernet(iface string) (*models.EthernetStatus, error) {
|
||||
if !hasCarrier(iface) {
|
||||
return &models.EthernetStatus{Active: false, Interface: iface}, nil
|
||||
}
|
||||
|
||||
out, err := exec.Command("ip", "-4", "-o", "addr", "show", "dev", iface).Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ip addr show %s: %w", iface, err)
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
var addr string
|
||||
hasInet := false
|
||||
isDHCP := false
|
||||
for i, f := range fields {
|
||||
switch f {
|
||||
case "inet":
|
||||
hasInet = true
|
||||
if i+1 < len(fields) {
|
||||
addr = fields[i+1]
|
||||
}
|
||||
case "dynamic":
|
||||
isDHCP = true
|
||||
case "valid_lft":
|
||||
if i+1 < len(fields) && fields[i+1] != "forever" {
|
||||
isDHCP = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasInet && isDHCP {
|
||||
if slash := strings.IndexByte(addr, '/'); slash >= 0 {
|
||||
addr = addr[:slash]
|
||||
}
|
||||
return &models.EthernetStatus{Active: true, Interface: iface, IPv4: addr}, nil
|
||||
}
|
||||
}
|
||||
return &models.EthernetStatus{Active: false, Interface: iface}, nil
|
||||
}
|
||||
|
||||
// ensureUplink returns the current Ethernet status, and starts wpa_supplicant
|
||||
// when no DHCP-assigned address is present. When Ethernet is already
|
||||
// providing connectivity the WiFi backend is left alone — and crucially the
|
||||
// caller must avoid any D-Bus call to fi.w1.wpa_supplicant1, which would
|
||||
// otherwise re-activate the daemon via dbus-activation.
|
||||
func ensureUplink(iface string) *models.EthernetStatus {
|
||||
eth, err := probeEthernet(iface)
|
||||
if err != nil {
|
||||
log.Printf("Could not probe %s (%v); starting wpa_supplicant as fallback", iface, err)
|
||||
eth = &models.EthernetStatus{Active: false, Interface: iface}
|
||||
}
|
||||
if eth.Active {
|
||||
log.Printf("DHCP address %s on %s, leaving wpa_supplicant alone", eth.IPv4, iface)
|
||||
return eth
|
||||
}
|
||||
log.Printf("No usable DHCP uplink on %s (carrier or lease missing), starting wpa_supplicant", iface)
|
||||
if out, err := exec.Command("service", "wpa_supplicant", "start").CombinedOutput(); err != nil {
|
||||
log.Printf("Failed to start wpa_supplicant: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return eth
|
||||
}
|
||||
|
|
@ -6,12 +6,13 @@ import (
|
|||
|
||||
// declareFlags registers flags for the structure Options.
|
||||
func declareFlags(o *Config) {
|
||||
flag.StringVar(&o.Bind, "bind", ":8081", "Bind port/socket")
|
||||
flag.StringVar(&o.Bind, "bind", "127.0.0.1:8080", "Bind address (host:port). Defaults to localhost; set to ':8080' to expose on the LAN — but note: there is no built-in authentication.")
|
||||
flag.StringVar(&o.WifiInterface, "wifi-interface", "wlan0", "WiFi interface name")
|
||||
flag.StringVar(&o.HotspotInterface, "hotspot-interface", "wlan1", "Hotspot WiFi interface name")
|
||||
flag.StringVar(&o.EthernetInterface, "ethernet-interface", "eth0", "Ethernet interface to probe for a DHCP-assigned address at startup; if no DHCP address is present, wpa_supplicant is started")
|
||||
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.DHCPLeasesPath, "dhcp-leases-path", "/var/lib/udhcpd/udhcpd.leases", "Path to DHCP leases file (udhcpd binary or ISC dhcpd text format — auto-detected)")
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -9,17 +9,18 @@ import (
|
|||
)
|
||||
|
||||
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
|
||||
Bind string
|
||||
WifiInterface string
|
||||
HotspotInterface string
|
||||
EthernetInterface 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
|
||||
|
|
@ -29,15 +30,16 @@ type Config struct {
|
|||
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",
|
||||
Bind: "127.0.0.1:8080",
|
||||
WifiInterface: "wlan0",
|
||||
HotspotInterface: "wlan1",
|
||||
EthernetInterface: "eth0",
|
||||
DHCPLeasesPath: "/var/lib/udhcpd/udhcpd.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)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package hotspot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
|
|
@ -15,8 +17,36 @@ const (
|
|||
HOSTAPD_CONF = "/etc/hostapd/hostapd.conf"
|
||||
)
|
||||
|
||||
// validateHotspotConfig rejects values that could break out of the
|
||||
// key=value lines in hostapd.conf or violate WPA-PSK constraints.
|
||||
func validateHotspotConfig(c models.HotspotConfig) error {
|
||||
if l := len(c.SSID); l < 1 || l > 32 {
|
||||
return fmt.Errorf("ssid must be 1-32 bytes (got %d)", l)
|
||||
}
|
||||
if strings.ContainsAny(c.SSID, "\r\n\x00") {
|
||||
return fmt.Errorf("ssid contains forbidden control characters")
|
||||
}
|
||||
if l := len(c.Password); l < 8 || l > 63 {
|
||||
return fmt.Errorf("password must be 8-63 ASCII characters (got %d)", l)
|
||||
}
|
||||
for _, r := range c.Password {
|
||||
if r < 0x20 || r > 0x7e {
|
||||
return fmt.Errorf("password must be printable ASCII")
|
||||
}
|
||||
}
|
||||
// 2.4 GHz channels only — hw_mode is hardcoded to "g" below.
|
||||
if c.Channel < 1 || c.Channel > 14 {
|
||||
return fmt.Errorf("channel must be in 1-14 for 2.4GHz (got %d)", c.Channel)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Configure updates the hotspot configuration
|
||||
func Configure(config models.HotspotConfig) error {
|
||||
if err := validateHotspotConfig(config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hostapdConfig := fmt.Sprintf(`interface=%s
|
||||
driver=nl80211
|
||||
ssid=%s
|
||||
|
|
@ -33,7 +63,7 @@ wpa_pairwise=TKIP
|
|||
rsn_pairwise=CCMP
|
||||
`, AP_INTERFACE, config.SSID, config.Channel, config.Password)
|
||||
|
||||
return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0644)
|
||||
return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0600)
|
||||
}
|
||||
|
||||
// Start starts the hotspot
|
||||
|
|
@ -56,11 +86,17 @@ func Status() error {
|
|||
}
|
||||
|
||||
// GetDetailedStatus retrieves detailed status information from hostapd_cli.
|
||||
// Returns nil if hostapd is not running or if there's an error.
|
||||
// Returns nil if hostapd is not running. Distinguishes a non-zero exit
|
||||
// (expected: daemon stopped) from environmental failures (binary missing,
|
||||
// permission denied) which are logged at most once per call.
|
||||
func GetDetailedStatus() *models.HotspotStatus {
|
||||
cmd := exec.Command("hostapd_cli", "status")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
log.Printf("hostapd_cli status: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,16 +79,31 @@ func UnregisterWebSocketClient(conn *websocket.Conn) {
|
|||
clientsMutex.Unlock()
|
||||
}
|
||||
|
||||
// broadcastToWebSockets sends a log entry to all connected WebSocket clients
|
||||
// broadcastToWebSockets sends a log entry to all connected WebSocket clients.
|
||||
// Dead clients are collected during the read pass and pruned under the write
|
||||
// lock afterwards — mutating the map while only holding RLock would race
|
||||
// with concurrent Register/Unregister and panic Go's map runtime.
|
||||
func broadcastToWebSockets(entry models.LogEntry) {
|
||||
clientsMutex.RLock()
|
||||
defer clientsMutex.RUnlock()
|
||||
|
||||
clients := make([]*websocket.Conn, 0, len(websocketClients))
|
||||
for client := range websocketClients {
|
||||
err := client.WriteJSON(entry)
|
||||
if err != nil {
|
||||
clients = append(clients, client)
|
||||
}
|
||||
clientsMutex.RUnlock()
|
||||
|
||||
var dead []*websocket.Conn
|
||||
for _, client := range clients {
|
||||
if err := client.WriteJSON(entry); err != nil {
|
||||
client.Close()
|
||||
delete(websocketClients, client)
|
||||
dead = append(dead, client)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dead) > 0 {
|
||||
clientsMutex.Lock()
|
||||
for _, c := range dead {
|
||||
delete(websocketClients, c)
|
||||
}
|
||||
clientsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,15 @@ type WiFiNetwork struct {
|
|||
|
||||
// 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"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
MAC string `json:"mac"`
|
||||
IP string `json:"ip"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
SignalDbm int32 `json:"signalDbm,omitempty"`
|
||||
RxBytes uint64 `json:"rxBytes,omitempty"`
|
||||
TxBytes uint64 `json:"txBytes,omitempty"`
|
||||
ConnectedAt time.Time `json:"connectedAt,omitzero"`
|
||||
}
|
||||
|
||||
// HotspotConfig represents hotspot configuration
|
||||
|
|
@ -26,6 +31,14 @@ type HotspotConfig struct {
|
|||
Channel int `json:"channel"`
|
||||
}
|
||||
|
||||
// EthernetStatus represents the state of the wired uplink interface.
|
||||
// Active is true when the interface holds a DHCP-assigned IPv4 address.
|
||||
type EthernetStatus struct {
|
||||
Active bool `json:"active"`
|
||||
Interface string `json:"interface"`
|
||||
IPv4 string `json:"ipv4,omitempty"`
|
||||
}
|
||||
|
||||
// HotspotStatus represents detailed hotspot status
|
||||
type HotspotStatus struct {
|
||||
State string `json:"state"` // ENABLED, DISABLED, etc.
|
||||
|
|
@ -44,6 +57,7 @@ type SystemStatus struct {
|
|||
ConnectionState string `json:"connectionState"` // Connection state: connected, disconnected, connecting, disconnecting, roaming
|
||||
ConnectedSSID string `json:"connectedSSID"`
|
||||
HotspotStatus *HotspotStatus `json:"hotspotStatus,omitempty"` // Detailed hotspot status
|
||||
EthernetStatus *EthernetStatus `json:"ethernetStatus,omitempty"` // Wired uplink state, when probed
|
||||
ConnectedCount int `json:"connectedCount"`
|
||||
DataUsage float64 `json:"dataUsage"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
|
|
|
|||
|
|
@ -59,11 +59,13 @@ func (b *Backend) GetStations() ([]backend.Station, error) {
|
|||
for _, entry := range arpEntries {
|
||||
// Only include entries with valid flags (2 = COMPLETE, 6 = COMPLETE|PERM)
|
||||
if entry.Flags == 2 || entry.Flags == 6 {
|
||||
mac := entry.HWAddress.String()
|
||||
st := backend.Station{
|
||||
MAC: entry.HWAddress.String(),
|
||||
MAC: mac,
|
||||
IP: entry.IP.String(),
|
||||
Hostname: "", // No hostname available from ARP
|
||||
Type: backend.GuessDeviceType("", entry.HWAddress.String()),
|
||||
Type: backend.GuessDeviceType("", mac),
|
||||
Vendor: backend.LookupVendor(mac),
|
||||
Signal: 0, // Not available from ARP
|
||||
RxBytes: 0, // Not available from ARP
|
||||
TxBytes: 0, // Not available from ARP
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ type ARPEntry struct {
|
|||
Device string
|
||||
}
|
||||
|
||||
// ParseTable is the exported entry point for the /proc/net/arp parser.
|
||||
// Other packages (e.g. the hostapd correlator) use it as a universal IP
|
||||
// source for stations whose DHCP lease isn't available.
|
||||
func ParseTable(path string) ([]ARPEntry, error) {
|
||||
return parseARPTable(path)
|
||||
}
|
||||
|
||||
// parseARPTable reads and parses ARP table from /proc/net/arp format
|
||||
func parseARPTable(path string) ([]ARPEntry, error) {
|
||||
var entries []ARPEntry
|
||||
|
|
@ -26,7 +33,7 @@ func parseARPTable(path string) ([]ARPEntry, error) {
|
|||
return entries, err
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(content), "\n") {
|
||||
for line := range strings.SplitSeq(string(content), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 5 {
|
||||
var entry ARPEntry
|
||||
|
|
|
|||
98
internal/station/backend/oui.go
Normal file
98
internal/station/backend/oui.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package backend
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// IEEE OUI registry as a compact text file: each line is 6 hex characters
|
||||
// (uppercase, no separators) followed immediately by the organization name.
|
||||
// Sourced from https://standards-oui.ieee.org/oui/oui.csv and pre-processed
|
||||
// to strip the address column and common corporate suffixes (Inc, Ltd, ...).
|
||||
//
|
||||
//go:embed oui_data.txt
|
||||
var ouiData string
|
||||
|
||||
var (
|
||||
ouiOnce sync.Once
|
||||
ouiTable map[string]string
|
||||
)
|
||||
|
||||
func loadOUITable() {
|
||||
ouiTable = make(map[string]string, 40000)
|
||||
for line := range strings.SplitSeq(ouiData, "\n") {
|
||||
if len(line) < 7 {
|
||||
continue
|
||||
}
|
||||
ouiTable[line[:6]] = line[6:]
|
||||
}
|
||||
}
|
||||
|
||||
// LookupVendor returns the organization assigned to the OUI prefix of mac,
|
||||
// or an empty string when the prefix is unknown or mac is malformed. Locally
|
||||
// administered MACs (the U/L bit is set) are explicitly skipped — they are
|
||||
// randomized by the device, not assigned to a vendor, so any match would be
|
||||
// a false positive.
|
||||
func LookupVendor(mac string) string {
|
||||
ouiOnce.Do(loadOUITable)
|
||||
|
||||
prefix := normalizeOUI(mac)
|
||||
if prefix == "" {
|
||||
return ""
|
||||
}
|
||||
return ouiTable[prefix]
|
||||
}
|
||||
|
||||
// normalizeOUI extracts the 6-hex-char OUI from a MAC address (any of
|
||||
// "aa:bb:cc:...", "aa-bb-cc-...", "aabb.ccdd.eeff", "aabbcc...") and returns
|
||||
// it uppercased. Returns "" for randomized MACs (locally administered bit
|
||||
// set) or anything that doesn't parse.
|
||||
func normalizeOUI(mac string) string {
|
||||
var hex strings.Builder
|
||||
hex.Grow(12)
|
||||
for i := 0; i < len(mac) && hex.Len() < 6; i++ {
|
||||
c := mac[i]
|
||||
switch {
|
||||
case c >= '0' && c <= '9', c >= 'A' && c <= 'F':
|
||||
hex.WriteByte(c)
|
||||
case c >= 'a' && c <= 'f':
|
||||
hex.WriteByte(c - 32)
|
||||
}
|
||||
}
|
||||
if hex.Len() < 6 {
|
||||
return ""
|
||||
}
|
||||
out := hex.String()
|
||||
|
||||
// Skip locally administered MACs. The U/L bit is bit 1 of the first
|
||||
// octet — when set, the MAC was generated by the device (private
|
||||
// address randomization on iOS/Android, virtual interfaces, ...), not
|
||||
// assigned by IEEE, so OUI lookup is meaningless.
|
||||
first, ok := hexByte(out[0], out[1])
|
||||
if !ok || first&0x02 != 0 {
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hexByte(hi, lo byte) (byte, bool) {
|
||||
h, ok1 := hexNibble(hi)
|
||||
l, ok2 := hexNibble(lo)
|
||||
if !ok1 || !ok2 {
|
||||
return 0, false
|
||||
}
|
||||
return h<<4 | l, true
|
||||
}
|
||||
|
||||
func hexNibble(c byte) (byte, bool) {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return c - '0', true
|
||||
case c >= 'A' && c <= 'F':
|
||||
return c - 'A' + 10, true
|
||||
case c >= 'a' && c <= 'f':
|
||||
return c - 'a' + 10, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
39343
internal/station/backend/oui_data.txt
Normal file
39343
internal/station/backend/oui_data.txt
Normal file
File diff suppressed because it is too large
Load diff
57
internal/station/backend/oui_test.go
Normal file
57
internal/station/backend/oui_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package backend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLookupVendor(t *testing.T) {
|
||||
cases := []struct {
|
||||
mac string
|
||||
want string
|
||||
exact bool // when true, want must match exactly; otherwise non-empty is enough
|
||||
comment string
|
||||
}{
|
||||
{"00:1c:b3:11:22:33", "Apple", true, "Apple OUI"},
|
||||
{"f4:0f:24:11:22:33", "Apple", true, "Apple OUI uppercase"},
|
||||
{"B8:27:EB:AA:BB:CC", "", false, "Raspberry Pi Foundation"},
|
||||
{"dc:a6:32:11:22:33", "", false, "Raspberry Pi Trading"},
|
||||
{"02:11:22:33:44:55", "", true, "locally administered (U/L bit set)"},
|
||||
{"aa:bb:cc:dd:ee:ff", "", true, "locally administered randomized"},
|
||||
{"", "", true, "empty mac"},
|
||||
{"not a mac", "", true, "garbage"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := LookupVendor(tc.mac)
|
||||
if tc.exact {
|
||||
if got != tc.want {
|
||||
t.Errorf("%s: LookupVendor(%q) = %q, want %q", tc.comment, tc.mac, got, tc.want)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got == "" {
|
||||
t.Errorf("%s: LookupVendor(%q) returned empty, expected non-empty", tc.comment, tc.mac)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOUI(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"00:1c:b3:11:22:33", "001CB3"},
|
||||
{"00-1c-b3-11-22-33", "001CB3"},
|
||||
{"001c.b311.2233", "001CB3"},
|
||||
{"001cb3112233", "001CB3"},
|
||||
{"02:11:22:33:44:55", ""}, // U/L bit set
|
||||
{"03:11:22:33:44:55", ""}, // U/L bit set
|
||||
{"06:11:22:33:44:55", ""}, // U/L bit set
|
||||
{"01:00:5e:00:00:00", "01005E"}, // bit 0 (multicast) is fine, only bit 1 matters
|
||||
{"abcd", ""}, // too short
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := normalizeOUI(tc.in); got != tc.want {
|
||||
t.Errorf("normalizeOUI(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,27 @@
|
|||
package backend
|
||||
|
||||
import (
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SafeGo runs fn in a goroutine and recovers from panics so a misbehaving
|
||||
// callback (registered by app code) cannot bring down the daemon. Backends
|
||||
// dispatch event callbacks asynchronously; without recovery, a single
|
||||
// panic in user code crashes the whole process.
|
||||
func SafeGo(name string, fn func()) {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("panic in %s: %v\n%s", name, r, debug.Stack())
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
|
||||
// StationBackend defines the interface for station/device discovery backends.
|
||||
// Implementations include ARP-based, DHCP-based, and Hostapd DBus-based discovery.
|
||||
type StationBackend interface {
|
||||
|
|
@ -35,6 +52,7 @@ type Station struct {
|
|||
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"
|
||||
Vendor string // Vendor inferred from MAC OUI (empty when unknown or randomized)
|
||||
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)
|
||||
|
|
@ -70,19 +88,26 @@ type BackendConfig struct {
|
|||
HostapdInterface string // Hostapd interface name for DBus
|
||||
}
|
||||
|
||||
// GuessDeviceType attempts to guess device type from hostname and MAC address
|
||||
// GuessDeviceType attempts to guess device type from hostname and MAC address.
|
||||
// Returns "unknown" when no signal is strong enough — the frontend renders that
|
||||
// as a neutral icon rather than mislabeling unknown devices as "mobile".
|
||||
func GuessDeviceType(hostname, mac string) string {
|
||||
hostname = strings.ToLower(hostname)
|
||||
|
||||
if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") {
|
||||
if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") ||
|
||||
strings.Contains(hostname, "pixel") || strings.Contains(hostname, "galaxy") {
|
||||
return "mobile"
|
||||
} else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") {
|
||||
return "tablet"
|
||||
} else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") {
|
||||
} else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") ||
|
||||
strings.Contains(hostname, "thinkpad") {
|
||||
return "laptop"
|
||||
} else if strings.Contains(hostname, "imac") || strings.Contains(hostname, "desktop") ||
|
||||
strings.Contains(hostname, "-pc") {
|
||||
return "desktop"
|
||||
}
|
||||
|
||||
// Guess by MAC prefix (OUI)
|
||||
// Guess by MAC prefix (OUI) — VM hypervisor MACs are almost always laptops
|
||||
if len(mac) >= 8 {
|
||||
macPrefix := strings.ToUpper(mac[:8])
|
||||
switch macPrefix {
|
||||
|
|
@ -90,8 +115,8 @@ func GuessDeviceType(hostname, mac string) string {
|
|||
return "laptop"
|
||||
case "08:00:27": // VirtualBox
|
||||
return "laptop"
|
||||
default:
|
||||
return "mobile"
|
||||
case "52:54:00": // QEMU/KVM
|
||||
return "laptop"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ type Backend struct {
|
|||
lastStations map[string]backend.Station // Key: MAC address
|
||||
callbacks backend.EventCallbacks
|
||||
stopChan chan struct{}
|
||||
stopOnce sync.Once
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
}
|
||||
|
|
@ -32,7 +33,7 @@ func (b *Backend) Initialize(config backend.BackendConfig) error {
|
|||
|
||||
b.dhcpLeasesPath = config.DHCPLeasesPath
|
||||
if b.dhcpLeasesPath == "" {
|
||||
b.dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases"
|
||||
b.dhcpLeasesPath = "/var/lib/udhcpd/udhcpd.leases"
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -71,6 +72,7 @@ func (b *Backend) GetStations() ([]backend.Station, error) {
|
|||
IP: lease.IP,
|
||||
Hostname: lease.Hostname,
|
||||
Type: backend.GuessDeviceType(lease.Hostname, lease.MAC),
|
||||
Vendor: backend.LookupVendor(lease.MAC),
|
||||
Signal: 0, // Not available from DHCP
|
||||
RxBytes: 0, // Not available from DHCP
|
||||
TxBytes: 0, // Not available from DHCP
|
||||
|
|
@ -101,7 +103,7 @@ func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// StopEventMonitoring stops event monitoring
|
||||
// StopEventMonitoring stops event monitoring. Idempotent — see hostapd backend.
|
||||
func (b *Backend) StopEventMonitoring() {
|
||||
b.mu.Lock()
|
||||
if !b.running {
|
||||
|
|
@ -111,7 +113,7 @@ func (b *Backend) StopEventMonitoring() {
|
|||
b.running = false
|
||||
b.mu.Unlock()
|
||||
|
||||
close(b.stopChan)
|
||||
b.stopOnce.Do(func() { close(b.stopChan) })
|
||||
}
|
||||
|
||||
// SupportsRealTimeEvents returns false (DHCP is polling-based)
|
||||
|
|
@ -155,15 +157,17 @@ func (b *Backend) checkForChanges() {
|
|||
for mac, st := range currentMap {
|
||||
if _, exists := b.lastStations[mac]; !exists {
|
||||
// New station connected
|
||||
if b.callbacks.OnStationConnected != nil {
|
||||
go b.callbacks.OnStationConnected(st)
|
||||
if cb := b.callbacks.OnStationConnected; cb != nil {
|
||||
stCopy := st
|
||||
backend.SafeGo("OnStationConnected", func() { cb(stCopy) })
|
||||
}
|
||||
} 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)
|
||||
if cb := b.callbacks.OnStationUpdated; cb != nil {
|
||||
stCopy := st
|
||||
backend.SafeGo("OnStationUpdated", func() { cb(stCopy) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -173,8 +177,9 @@ func (b *Backend) checkForChanges() {
|
|||
for mac := range b.lastStations {
|
||||
if _, exists := currentMap[mac]; !exists {
|
||||
// Station disconnected
|
||||
if b.callbacks.OnStationDisconnected != nil {
|
||||
go b.callbacks.OnStationDisconnected(mac)
|
||||
if cb := b.callbacks.OnStationDisconnected; cb != nil {
|
||||
macCopy := mac
|
||||
backend.SafeGo("OnStationDisconnected", func() { cb(macCopy) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ package dhcp
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
|
|
@ -10,34 +15,143 @@ import (
|
|||
"github.com/nemunaire/repeater/internal/models"
|
||||
)
|
||||
|
||||
// parseDHCPLeases reads and parses DHCP lease file
|
||||
// ParseLeases reads a DHCP lease file and returns the active leases. The
|
||||
// format is auto-detected: udhcpd (BusyBox) writes a small binary file with
|
||||
// fixed-size records, while ISC dhcpd uses a text format. Most embedded /
|
||||
// router systems use one or the other.
|
||||
func ParseLeases(path string) ([]models.DHCPLease, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if looksLikeUdhcpd(data) {
|
||||
return parseUdhcpdLeases(data)
|
||||
}
|
||||
return parseISCLeases(bytes.NewReader(data))
|
||||
}
|
||||
|
||||
// parseDHCPLeases is kept for backwards compatibility with callers in this
|
||||
// package. New code should call ParseLeases.
|
||||
func parseDHCPLeases(path string) ([]models.DHCPLease, error) {
|
||||
return ParseLeases(path)
|
||||
}
|
||||
|
||||
// looksLikeUdhcpd returns true when the file looks like a BusyBox udhcpd
|
||||
// lease file. The format is: 8-byte big-endian timestamp header followed by
|
||||
// 36-byte records. Detection rule: total size minus 8 is a positive multiple
|
||||
// of 36 and the header decodes to a plausible Unix time.
|
||||
func looksLikeUdhcpd(data []byte) bool {
|
||||
const headerSize = 8
|
||||
const recordSize = 36
|
||||
|
||||
if len(data) < headerSize+recordSize {
|
||||
return false
|
||||
}
|
||||
body := len(data) - headerSize
|
||||
if body <= 0 || body%recordSize != 0 {
|
||||
return false
|
||||
}
|
||||
ts := int64(binary.BigEndian.Uint64(data[:headerSize]))
|
||||
// 2001-01-01 to 2100-01-01: anything outside this range is almost
|
||||
// certainly not a Unix timestamp and we're looking at text instead.
|
||||
const minTs = 978307200 // 2001-01-01
|
||||
const maxTs = 4102444800 // 2100-01-01
|
||||
return ts >= minTs && ts <= maxTs
|
||||
}
|
||||
|
||||
// parseUdhcpdLeases parses BusyBox's udhcpd lease file format.
|
||||
//
|
||||
// File layout (see networking/udhcp/dhcpd.c in BusyBox):
|
||||
//
|
||||
// [ 8 bytes: written_at — int64 big-endian Unix timestamp ]
|
||||
// [ struct dyn_lease × N, 36 bytes each, packed:
|
||||
// uint32 expires (big-endian, seconds remaining at write time)
|
||||
// uint32 lease_nip (network-order IPv4)
|
||||
// uint8 lease_mac[6]
|
||||
// char hostname[20] (NUL-terminated, padded)
|
||||
// uint8 pad[2]
|
||||
// ]
|
||||
func parseUdhcpdLeases(data []byte) ([]models.DHCPLease, error) {
|
||||
const headerSize = 8
|
||||
const recordSize = 36
|
||||
|
||||
if len(data) < headerSize {
|
||||
return nil, fmt.Errorf("udhcpd lease file too short: %d bytes", len(data))
|
||||
}
|
||||
|
||||
body := data[headerSize:]
|
||||
leases := make([]models.DHCPLease, 0, len(body)/recordSize)
|
||||
|
||||
for off := 0; off+recordSize <= len(body); off += recordSize {
|
||||
rec := body[off : off+recordSize]
|
||||
|
||||
// expires: rec[0:4] — we keep records even when expired so the UI
|
||||
// keeps showing the IP for a station whose DHCP lease just lapsed
|
||||
// (the station is still associated with hostapd).
|
||||
ipBytes := rec[4:8]
|
||||
mac := net.HardwareAddr(rec[8:14])
|
||||
hostname := nullTerminated(rec[14:34])
|
||||
|
||||
// Skip the all-zero "empty slot" entries udhcpd leaves in the file.
|
||||
if isAllZero(rec[4:14]) {
|
||||
continue
|
||||
}
|
||||
|
||||
ip := net.IPv4(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]).String()
|
||||
leases = append(leases, models.DHCPLease{
|
||||
IP: ip,
|
||||
MAC: mac.String(),
|
||||
Hostname: hostname,
|
||||
})
|
||||
}
|
||||
|
||||
return leases, nil
|
||||
}
|
||||
|
||||
func nullTerminated(b []byte) string {
|
||||
if i := bytes.IndexByte(b, 0); i >= 0 {
|
||||
b = b[:i]
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func isAllZero(b []byte) bool {
|
||||
for _, c := range b {
|
||||
if c != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parseISCLeases parses ISC dhcpd's text lease format.
|
||||
func parseISCLeases(r io.Reader) ([]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)
|
||||
scanner := bufio.NewScanner(r)
|
||||
var currentLease models.DHCPLease
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
fields := strings.Fields(line)
|
||||
|
||||
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 == "}" {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "lease "):
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
currentLease = models.DHCPLease{IP: fields[1]}
|
||||
case strings.Contains(line, "hardware ethernet"):
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
currentLease.MAC = strings.TrimSuffix(fields[2], ";")
|
||||
case strings.Contains(line, "client-hostname"):
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
currentLease.Hostname = strings.Trim(fields[1], `";`)
|
||||
case line == "}":
|
||||
if currentLease.IP != "" && currentLease.MAC != "" {
|
||||
leases = append(leases, currentLease)
|
||||
}
|
||||
|
|
@ -45,7 +159,7 @@ func parseDHCPLeases(path string) ([]models.DHCPLease, error) {
|
|||
}
|
||||
}
|
||||
|
||||
return leases, nil
|
||||
return leases, scanner.Err()
|
||||
}
|
||||
|
||||
// getARPInfo retrieves ARP table information using arp command
|
||||
|
|
@ -59,8 +173,7 @@ func getARPInfo() (map[string]string, error) {
|
|||
return arpInfo, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 {
|
||||
ip := matches[1]
|
||||
mac := matches[2]
|
||||
|
|
|
|||
75
internal/station/dhcp/parser_test.go
Normal file
75
internal/station/dhcp/parser_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package dhcp
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseUdhcpdLeases(t *testing.T) {
|
||||
// Build a synthetic udhcpd lease file: 8-byte big-endian timestamp
|
||||
// header, then two 36-byte records.
|
||||
buf := make([]byte, 0, 8+36*3)
|
||||
header := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(header, 1714560000) // 2024-05-01
|
||||
buf = append(buf, header...)
|
||||
|
||||
// Record 1: 192.168.1.42, aa:bb:cc:dd:ee:ff, "iPhone"
|
||||
r1 := make([]byte, 36)
|
||||
binary.BigEndian.PutUint32(r1[0:4], 3600) // expires
|
||||
r1[4], r1[5], r1[6], r1[7] = 192, 168, 1, 42 // IP
|
||||
r1[8], r1[9], r1[10] = 0xaa, 0xbb, 0xcc // MAC
|
||||
r1[11], r1[12], r1[13] = 0xdd, 0xee, 0xff //
|
||||
copy(r1[14:34], "iPhone\x00") // hostname (NUL-padded)
|
||||
buf = append(buf, r1...)
|
||||
|
||||
// Record 2: empty slot (all zero MAC/IP) — should be skipped
|
||||
r2 := make([]byte, 36)
|
||||
buf = append(buf, r2...)
|
||||
|
||||
// Record 3: 10.0.0.1, b8:27:eb:11:22:33, no hostname
|
||||
r3 := make([]byte, 36)
|
||||
binary.BigEndian.PutUint32(r3[0:4], 7200)
|
||||
r3[4], r3[5], r3[6], r3[7] = 10, 0, 0, 1
|
||||
r3[8], r3[9], r3[10] = 0xb8, 0x27, 0xeb
|
||||
r3[11], r3[12], r3[13] = 0x11, 0x22, 0x33
|
||||
buf = append(buf, r3...)
|
||||
|
||||
if !looksLikeUdhcpd(buf) {
|
||||
t.Fatalf("synthetic file not detected as udhcpd")
|
||||
}
|
||||
|
||||
leases, err := parseUdhcpdLeases(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("parseUdhcpdLeases: %v", err)
|
||||
}
|
||||
if len(leases) != 2 {
|
||||
t.Fatalf("expected 2 leases, got %d: %+v", len(leases), leases)
|
||||
}
|
||||
|
||||
if leases[0].IP != "192.168.1.42" {
|
||||
t.Errorf("lease[0].IP = %q, want %q", leases[0].IP, "192.168.1.42")
|
||||
}
|
||||
if leases[0].MAC != "aa:bb:cc:dd:ee:ff" {
|
||||
t.Errorf("lease[0].MAC = %q, want %q", leases[0].MAC, "aa:bb:cc:dd:ee:ff")
|
||||
}
|
||||
if leases[0].Hostname != "iPhone" {
|
||||
t.Errorf("lease[0].Hostname = %q, want %q", leases[0].Hostname, "iPhone")
|
||||
}
|
||||
|
||||
if leases[1].IP != "10.0.0.1" {
|
||||
t.Errorf("lease[1].IP = %q, want %q", leases[1].IP, "10.0.0.1")
|
||||
}
|
||||
if leases[1].MAC != "b8:27:eb:11:22:33" {
|
||||
t.Errorf("lease[1].MAC = %q, want %q", leases[1].MAC, "b8:27:eb:11:22:33")
|
||||
}
|
||||
if leases[1].Hostname != "" {
|
||||
t.Errorf("lease[1].Hostname = %q, want empty", leases[1].Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeUdhcpdRejectsText(t *testing.T) {
|
||||
text := []byte("# The format of this file is documented in dhcpd.leases(5).\nlease 192.168.1.42 {\n starts 0 2024/05/01 00:00:00;\n hardware ethernet aa:bb:cc:dd:ee:ff;\n client-hostname \"iPhone\";\n}\n")
|
||||
if looksLikeUdhcpd(text) {
|
||||
t.Errorf("ISC text file misdetected as udhcpd")
|
||||
}
|
||||
}
|
||||
|
|
@ -22,21 +22,26 @@ type Backend struct {
|
|||
stations map[string]*HostapdStation // Key: MAC address
|
||||
callbacks backend.EventCallbacks
|
||||
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
stopCh chan struct{}
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
|
||||
// IP correlation - will be populated by periodic DHCP lease correlation
|
||||
ipByMAC map[string]string // MAC -> IP mapping
|
||||
// IP / hostname correlation - populated by periodic DHCP lease correlation
|
||||
ipByMAC map[string]string // MAC -> IP mapping
|
||||
hostnameByMAC map[string]string // MAC -> hostname mapping
|
||||
|
||||
correlator *DHCPCorrelator
|
||||
}
|
||||
|
||||
// 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{}),
|
||||
stations: make(map[string]*HostapdStation),
|
||||
ipByMAC: make(map[string]string),
|
||||
hostnameByMAC: make(map[string]string),
|
||||
hostapdCLI: "hostapd_cli",
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,11 +72,20 @@ func (b *Backend) Initialize(config backend.BackendConfig) error {
|
|||
log.Printf("Warning: Failed to load initial stations: %v", err)
|
||||
}
|
||||
|
||||
// Hostapd only knows MACs. Pair every station with its IP/hostname by
|
||||
// running a background correlator that polls the DHCP lease file and
|
||||
// the ARP table. Without this, the device list shows MACs only.
|
||||
b.correlator = NewDHCPCorrelator(b, config.DHCPLeasesPath, config.ARPTablePath)
|
||||
b.correlator.Start()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close cleans up backend resources
|
||||
func (b *Backend) Close() error {
|
||||
if b.correlator != nil {
|
||||
b.correlator.Stop()
|
||||
}
|
||||
b.StopEventMonitoring()
|
||||
return nil
|
||||
}
|
||||
|
|
@ -129,7 +143,9 @@ func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// StopEventMonitoring stops event monitoring
|
||||
// StopEventMonitoring stops event monitoring. Safe to call multiple times —
|
||||
// without sync.Once, racing callers could each pass the running guard before
|
||||
// either closed the channel, panicking on the second close.
|
||||
func (b *Backend) StopEventMonitoring() {
|
||||
b.mu.Lock()
|
||||
if !b.running {
|
||||
|
|
@ -139,7 +155,7 @@ func (b *Backend) StopEventMonitoring() {
|
|||
b.running = false
|
||||
b.mu.Unlock()
|
||||
|
||||
close(b.stopCh)
|
||||
b.stopOnce.Do(func() { close(b.stopCh) })
|
||||
}
|
||||
|
||||
// SupportsRealTimeEvents returns false (hostapd_cli uses polling)
|
||||
|
|
@ -172,6 +188,8 @@ func (b *Backend) checkStationChanges() error {
|
|||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
|
|
@ -181,17 +199,24 @@ func (b *Backend) checkStationChanges() error {
|
|||
currentMACs[mac] = true
|
||||
}
|
||||
|
||||
// Check for new stations
|
||||
// Reconcile current poll with cached state
|
||||
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)
|
||||
if existing, exists := b.stations[mac]; exists {
|
||||
// Refresh signal/byte counters in place — without this, GetStations
|
||||
// would keep returning the values from the very first poll.
|
||||
existing.Signal = station.Signal
|
||||
existing.RxBytes = station.RxBytes
|
||||
existing.TxBytes = station.TxBytes
|
||||
continue
|
||||
}
|
||||
// New station connected
|
||||
station.FirstSeen = now
|
||||
b.stations[mac] = station
|
||||
if cb := b.callbacks.OnStationConnected; cb != nil {
|
||||
st := b.convertStation(mac, station)
|
||||
backend.SafeGo("OnStationConnected", func() { cb(st) })
|
||||
}
|
||||
log.Printf("Station connected: %s", mac)
|
||||
}
|
||||
|
||||
// Check for removed stations
|
||||
|
|
@ -200,8 +225,9 @@ func (b *Backend) checkStationChanges() error {
|
|||
// Station disconnected
|
||||
delete(b.stations, mac)
|
||||
delete(b.ipByMAC, mac)
|
||||
if b.callbacks.OnStationDisconnected != nil {
|
||||
go b.callbacks.OnStationDisconnected(mac)
|
||||
if cb := b.callbacks.OnStationDisconnected; cb != nil {
|
||||
macCopy := mac
|
||||
backend.SafeGo("OnStationDisconnected", func() { cb(macCopy) })
|
||||
}
|
||||
log.Printf("Station disconnected: %s", mac)
|
||||
}
|
||||
|
|
@ -217,6 +243,12 @@ func (b *Backend) loadStations() error {
|
|||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, st := range stations {
|
||||
// We can't tell when these stations actually connected; treat the
|
||||
// daemon-start time as a lower bound rather than leaving it zero.
|
||||
st.FirstSeen = now
|
||||
}
|
||||
b.stations = stations
|
||||
log.Printf("Loaded %d initial stations from hostapd", len(b.stations))
|
||||
return nil
|
||||
|
|
@ -294,51 +326,60 @@ func (b *Backend) parseAllStaOutput(output string) map[string]*HostapdStation {
|
|||
|
||||
// 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]
|
||||
hostname := b.hostnameByMAC[mac]
|
||||
|
||||
// Attempt hostname resolution if we have an IP
|
||||
hostname := ""
|
||||
// TODO: Could do reverse DNS lookup here if needed
|
||||
connectedAt := hs.FirstSeen
|
||||
if connectedAt.IsZero() {
|
||||
connectedAt = time.Now()
|
||||
}
|
||||
|
||||
return backend.Station{
|
||||
MAC: mac,
|
||||
IP: ip,
|
||||
Hostname: hostname,
|
||||
Type: backend.GuessDeviceType(hostname, mac),
|
||||
Vendor: backend.LookupVendor(mac),
|
||||
Signal: hs.Signal,
|
||||
RxBytes: hs.RxBytes,
|
||||
TxBytes: hs.TxBytes,
|
||||
ConnectedAt: time.Now(), // We don't have exact connection time
|
||||
ConnectedAt: connectedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// UpdateLeaseInfo updates MAC -> IP and MAC -> hostname mappings from an
|
||||
// external source (e.g., DHCP lease file). Called periodically by the
|
||||
// correlator. An empty hostname leaves the existing one in place — DHCP
|
||||
// leases sometimes drop the hostname between renewals.
|
||||
func (b *Backend) UpdateLeaseInfo(macToIP, macToHostname 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
|
||||
if oldIP, exists := b.ipByMAC[mac]; !exists || oldIP != ip {
|
||||
updated[mac] = true
|
||||
}
|
||||
b.ipByMAC[mac] = ip
|
||||
}
|
||||
|
||||
// Trigger update callbacks for stations that got new/changed IPs
|
||||
for mac, hostname := range macToHostname {
|
||||
if hostname == "" {
|
||||
continue
|
||||
}
|
||||
if oldName, exists := b.hostnameByMAC[mac]; !exists || oldName != hostname {
|
||||
updated[mac] = true
|
||||
}
|
||||
b.hostnameByMAC[mac] = hostname
|
||||
}
|
||||
|
||||
// Trigger update callbacks for stations whose info changed
|
||||
for mac := range updated {
|
||||
if station, exists := b.stations[mac]; exists {
|
||||
if b.callbacks.OnStationUpdated != nil {
|
||||
if cb := b.callbacks.OnStationUpdated; cb != nil {
|
||||
st := b.convertStation(mac, station)
|
||||
go b.callbacks.OnStationUpdated(st)
|
||||
backend.SafeGo("OnStationUpdated", func() { cb(st) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,38 @@
|
|||
package hostapd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nemunaire/repeater/internal/models"
|
||||
"github.com/nemunaire/repeater/internal/station/arp"
|
||||
"github.com/nemunaire/repeater/internal/station/dhcp"
|
||||
)
|
||||
|
||||
// DHCPCorrelator helps correlate hostapd stations with DHCP leases to discover IP addresses
|
||||
// DHCPCorrelator helps correlate hostapd stations with DHCP leases (and the
|
||||
// ARP table as a fallback) so the UI gets an IP and hostname for every
|
||||
// station, not just a MAC. Hostapd itself only knows MACs.
|
||||
type DHCPCorrelator struct {
|
||||
backend *Backend
|
||||
dhcpLeasesPath string
|
||||
arpTablePath string
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewDHCPCorrelator creates a new DHCP correlator
|
||||
func NewDHCPCorrelator(backend *Backend, dhcpLeasesPath string) *DHCPCorrelator {
|
||||
func NewDHCPCorrelator(backend *Backend, dhcpLeasesPath, arpTablePath string) *DHCPCorrelator {
|
||||
if dhcpLeasesPath == "" {
|
||||
dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases"
|
||||
dhcpLeasesPath = "/var/lib/misc/udhcpd.leases"
|
||||
}
|
||||
if arpTablePath == "" {
|
||||
arpTablePath = "/proc/net/arp"
|
||||
}
|
||||
|
||||
return &DHCPCorrelator{
|
||||
backend: backend,
|
||||
dhcpLeasesPath: dhcpLeasesPath,
|
||||
arpTablePath: arpTablePath,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +45,7 @@ func (dc *DHCPCorrelator) Start() {
|
|||
|
||||
dc.running = true
|
||||
go dc.correlationLoop()
|
||||
log.Printf("DHCP correlation started for hostapd backend")
|
||||
log.Printf("DHCP/ARP correlation started (leases=%s, arp=%s)", dc.dhcpLeasesPath, dc.arpTablePath)
|
||||
}
|
||||
|
||||
// Stop stops the correlation loop
|
||||
|
|
@ -72,59 +78,54 @@ func (dc *DHCPCorrelator) correlationLoop() {
|
|||
}
|
||||
}
|
||||
|
||||
// correlate performs one correlation cycle
|
||||
// correlate performs one correlation cycle. We pull from two sources:
|
||||
//
|
||||
// - The DHCP lease file (udhcpd binary or ISC text — auto-detected) is the
|
||||
// authoritative source for hostnames and the IP that was actually
|
||||
// assigned.
|
||||
// - The ARP table is a universal IP fallback. If the lease file is missing
|
||||
// or the station uses a static IP, ARP still gives us its current IPv4.
|
||||
//
|
||||
// The two are merged: ARP fills gaps, DHCP wins on conflict because it
|
||||
// carries the hostname and is less prone to stale entries.
|
||||
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
|
||||
}
|
||||
macToHostname := make(map[string]string)
|
||||
|
||||
// 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)
|
||||
// ARP first, so DHCP overrides on conflict.
|
||||
if entries, err := arp.ParseTable(dc.arpTablePath); err == nil {
|
||||
for _, entry := range entries {
|
||||
// Flags 2 (COMPLETE) and 6 (COMPLETE|PERM) — incomplete
|
||||
// entries have a zero MAC and would pollute the mapping.
|
||||
if entry.Flags != 2 && entry.Flags != 6 {
|
||||
continue
|
||||
}
|
||||
currentLease = models.DHCPLease{}
|
||||
mac := strings.ToLower(entry.HWAddress.String())
|
||||
if mac == "" || mac == "00:00:00:00:00:00" {
|
||||
continue
|
||||
}
|
||||
macToIP[mac] = entry.IP.String()
|
||||
}
|
||||
} else {
|
||||
log.Printf("ARP table read failed (%s): %v", dc.arpTablePath, err)
|
||||
}
|
||||
|
||||
return leases, scanner.Err()
|
||||
if leases, err := dhcp.ParseLeases(dc.dhcpLeasesPath); err == nil {
|
||||
for _, lease := range leases {
|
||||
mac := strings.ToLower(lease.MAC)
|
||||
if mac == "" {
|
||||
continue
|
||||
}
|
||||
if lease.IP != "" {
|
||||
macToIP[mac] = lease.IP
|
||||
}
|
||||
if lease.Hostname != "" {
|
||||
macToHostname[mac] = lease.Hostname
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("DHCP lease parse failed (%s): %v", dc.dhcpLeasesPath, err)
|
||||
}
|
||||
|
||||
dc.backend.UpdateLeaseInfo(macToIP, macToHostname)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
package hostapd
|
||||
|
||||
import "time"
|
||||
|
||||
// HostapdStation represents station properties from hostapd_cli
|
||||
type HostapdStation struct {
|
||||
RxPackets uint64
|
||||
TxPackets uint64
|
||||
RxBytes uint64
|
||||
TxBytes uint64
|
||||
Signal int32 // Signal in dBm
|
||||
Signal int32 // Signal in dBm
|
||||
FirstSeen time.Time // When the station was first observed (best effort)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,20 +51,36 @@ func GetStations() ([]models.ConnectedDevice, error) {
|
|||
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,
|
||||
}
|
||||
devices[i] = toConnectedDevice(s)
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// toConnectedDevice converts a backend Station to the API model. Centralised
|
||||
// so handlers, the periodic refresh and the event callbacks all serialise the
|
||||
// same shape.
|
||||
func toConnectedDevice(s backend.Station) models.ConnectedDevice {
|
||||
return models.ConnectedDevice{
|
||||
Name: s.Hostname,
|
||||
Type: s.Type,
|
||||
MAC: s.MAC,
|
||||
IP: s.IP,
|
||||
Vendor: s.Vendor,
|
||||
SignalDbm: s.Signal,
|
||||
RxBytes: s.RxBytes,
|
||||
TxBytes: s.TxBytes,
|
||||
ConnectedAt: s.ConnectedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ToConnectedDevice exposes the conversion for callers in other packages.
|
||||
func ToConnectedDevice(s backend.Station) models.ConnectedDevice {
|
||||
return toConnectedDevice(s)
|
||||
}
|
||||
|
||||
// StartEventMonitoring starts monitoring for station events
|
||||
func StartEventMonitoring(callbacks backend.EventCallbacks) error {
|
||||
mu.RLock()
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ type SignalMonitor struct {
|
|||
|
||||
// Control
|
||||
stopChan chan struct{}
|
||||
stopOnce sync.Once
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
|
||||
|
|
@ -95,7 +96,8 @@ func (sm *SignalMonitor) Start() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Stop stops monitoring D-Bus signals
|
||||
// Stop stops monitoring D-Bus signals. Idempotent: stopOnce guards the
|
||||
// channel close so concurrent Stop() callers do not panic on double-close.
|
||||
func (sm *SignalMonitor) Stop() {
|
||||
sm.mu.Lock()
|
||||
if !sm.running {
|
||||
|
|
@ -105,8 +107,7 @@ func (sm *SignalMonitor) Stop() {
|
|||
sm.running = false
|
||||
sm.mu.Unlock()
|
||||
|
||||
// Signal stop
|
||||
close(sm.stopChan)
|
||||
sm.stopOnce.Do(func() { close(sm.stopChan) })
|
||||
|
||||
// Remove signal channel
|
||||
sm.conn.RemoveSignal(sm.signalChan)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package wifi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
|
@ -16,6 +17,13 @@ var (
|
|||
wifiBroadcaster *WifiBroadcaster
|
||||
)
|
||||
|
||||
// errWifiDisabled is returned by every wifi.* wrapper when no backend has
|
||||
// been initialized. This happens when the application chose not to start
|
||||
// wpa_supplicant because the Ethernet uplink is already providing
|
||||
// connectivity — touching the D-Bus interface in that mode would re-activate
|
||||
// the daemon via dbus-activation, defeating the intent.
|
||||
var errWifiDisabled = errors.New("wifi backend disabled (Ethernet uplink active)")
|
||||
|
||||
// Initialize initializes the WiFi service with the specified backend
|
||||
func Initialize(interfaceName string, backendName string) error {
|
||||
// Create the appropriate backend using the factory
|
||||
|
|
@ -38,6 +46,9 @@ func Close() {
|
|||
|
||||
// GetCachedNetworks returns previously discovered networks without triggering a scan
|
||||
func GetCachedNetworks() ([]models.WiFiNetwork, error) {
|
||||
if wifiBackend == nil {
|
||||
return nil, errWifiDisabled
|
||||
}
|
||||
// Get ordered networks from backend
|
||||
backendNetworks, err := wifiBackend.GetOrderedNetworks()
|
||||
if err != nil {
|
||||
|
|
@ -67,6 +78,9 @@ func GetCachedNetworks() ([]models.WiFiNetwork, error) {
|
|||
|
||||
// ScanNetworks scans for available WiFi networks
|
||||
func ScanNetworks() ([]models.WiFiNetwork, error) {
|
||||
if wifiBackend == nil {
|
||||
return nil, errWifiDisabled
|
||||
}
|
||||
// Check if already scanning
|
||||
scanning, err := wifiBackend.IsScanning()
|
||||
if err == nil && scanning {
|
||||
|
|
@ -114,6 +128,9 @@ func ScanNetworks() ([]models.WiFiNetwork, error) {
|
|||
|
||||
// Connect connects to a WiFi network
|
||||
func Connect(ssid, password string) error {
|
||||
if wifiBackend == nil {
|
||||
return errWifiDisabled
|
||||
}
|
||||
// Use backend to connect
|
||||
if err := wifiBackend.Connect(ssid, password); err != nil {
|
||||
return err
|
||||
|
|
@ -132,11 +149,17 @@ func Connect(ssid, password string) error {
|
|||
|
||||
// Disconnect disconnects from the current WiFi network
|
||||
func Disconnect() error {
|
||||
if wifiBackend == nil {
|
||||
return errWifiDisabled
|
||||
}
|
||||
return wifiBackend.Disconnect()
|
||||
}
|
||||
|
||||
// IsConnected checks if WiFi is connected
|
||||
func IsConnected() bool {
|
||||
if wifiBackend == nil {
|
||||
return false
|
||||
}
|
||||
state, err := wifiBackend.GetConnectionState()
|
||||
if err != nil {
|
||||
return false
|
||||
|
|
@ -146,11 +169,17 @@ func IsConnected() bool {
|
|||
|
||||
// GetConnectedSSID returns the SSID of the currently connected network
|
||||
func GetConnectedSSID() string {
|
||||
if wifiBackend == nil {
|
||||
return ""
|
||||
}
|
||||
return wifiBackend.GetConnectedSSID()
|
||||
}
|
||||
|
||||
// GetConnectionState returns the current WiFi connection state
|
||||
func GetConnectionState() string {
|
||||
if wifiBackend == nil {
|
||||
return string(backend.StateDisconnected)
|
||||
}
|
||||
state, err := wifiBackend.GetConnectionState()
|
||||
if err != nil {
|
||||
return string(backend.StateDisconnected)
|
||||
|
|
@ -160,6 +189,9 @@ func GetConnectionState() string {
|
|||
|
||||
// StartEventMonitoring initializes signal monitoring and WebSocket broadcasting
|
||||
func StartEventMonitoring() error {
|
||||
if wifiBackend == nil {
|
||||
return nil
|
||||
}
|
||||
// Initialize broadcaster
|
||||
wifiBroadcaster = NewWifiBroadcaster()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package wpasupplicant
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
|
|
@ -152,39 +153,79 @@ func (b *WPABackend) IsScanning() (bool, error) {
|
|||
|
||||
// Connect connects to a WiFi network
|
||||
func (b *WPABackend) Connect(ssid, password string) error {
|
||||
// Create network configuration
|
||||
config := make(map[string]interface{})
|
||||
config["ssid"] = ssid // Raw SSID string, no quotes
|
||||
|
||||
if password != "" {
|
||||
// For WPA/WPA2-PSK networks
|
||||
config["psk"] = password // Raw password string, no quotes
|
||||
} else {
|
||||
// For open networks
|
||||
config["key_mgmt"] = "NONE"
|
||||
}
|
||||
|
||||
// Add network
|
||||
networkPath, err := b.iface.AddNetwork(config)
|
||||
// Best-effort lookup of existing networks, used both to avoid creating
|
||||
// duplicate entries for the same SSID and to re-enable other entries
|
||||
// after SelectNetwork's "disable all others" side-effect. If listing
|
||||
// fails, fall back to plain AddNetwork — saving still works.
|
||||
existingNetworks, err := b.iface.GetNetworks()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add network: %v", err)
|
||||
log.Printf("wpa_supplicant: failed to list configured networks: %v", err)
|
||||
existingNetworks = nil
|
||||
}
|
||||
|
||||
// Store current network path for cleanup
|
||||
var networkPath dbus.ObjectPath
|
||||
createdNew := false
|
||||
|
||||
for _, p := range existingNetworks {
|
||||
net := NewNetwork(b.conn, p)
|
||||
netSSID, err := net.GetSSID()
|
||||
if err != nil || netSSID != ssid {
|
||||
continue
|
||||
}
|
||||
networkPath = p
|
||||
break
|
||||
}
|
||||
|
||||
if networkPath == "" {
|
||||
// Create network configuration
|
||||
config := make(map[string]interface{})
|
||||
config["ssid"] = ssid // Raw SSID string, no quotes
|
||||
|
||||
if password != "" {
|
||||
// For WPA/WPA2-PSK networks
|
||||
config["psk"] = password // Raw password string, no quotes
|
||||
} else {
|
||||
// For open networks
|
||||
config["key_mgmt"] = "NONE"
|
||||
}
|
||||
|
||||
networkPath, err = b.iface.AddNetwork(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add network: %v", err)
|
||||
}
|
||||
createdNew = true
|
||||
}
|
||||
|
||||
// Store current network path
|
||||
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)
|
||||
// Select (connect to) the network. Note: SelectNetwork disables every
|
||||
// other configured network as a side-effect.
|
||||
if err := b.iface.SelectNetwork(networkPath); err != nil {
|
||||
if createdNew {
|
||||
b.iface.RemoveNetwork(networkPath)
|
||||
}
|
||||
return fmt.Errorf("failed to select network: %v", err)
|
||||
}
|
||||
|
||||
// Save the configuration to persist it across reboots
|
||||
// Re-enable previously configured networks so SelectNetwork's side-effect
|
||||
// doesn't mark them all as disabled in the persisted config.
|
||||
for _, p := range existingNetworks {
|
||||
if p == networkPath {
|
||||
continue
|
||||
}
|
||||
if err := b.iface.EnableNetwork(p); err != nil {
|
||||
log.Printf("wpa_supplicant: failed to re-enable network %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the configuration to persist it across reboots. Requires
|
||||
// update_config=1 in the wpa_supplicant.conf wpa_supplicant was started
|
||||
// with, and that file being writable by the wpa_supplicant process.
|
||||
if err := b.iface.SaveConfig(); err != nil {
|
||||
// Log warning but don't fail - connection still works
|
||||
fmt.Printf("Warning: failed to save config: %v\n", err)
|
||||
log.Printf("wpa_supplicant: SaveConfig failed: %v", err)
|
||||
} else {
|
||||
log.Printf("wpa_supplicant: configuration saved for SSID %q", ssid)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -197,11 +238,7 @@ func (b *WPABackend) Disconnect() error {
|
|||
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 = ""
|
||||
}
|
||||
b.currentNetwork = ""
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,16 @@ func (i *WPAInterface) SelectNetwork(networkPath dbus.ObjectPath) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// EnableNetwork marks a network configuration as enabled (eligible for auto-connect)
|
||||
func (i *WPAInterface) EnableNetwork(networkPath dbus.ObjectPath) error {
|
||||
netObj := i.conn.Object(Service, networkPath)
|
||||
err := netObj.SetProperty(NetworkInterface+".Enabled", dbus.MakeVariant(true))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to enable 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
|
||||
|
|
@ -116,6 +126,21 @@ func (i *WPAInterface) RemoveNetwork(networkPath dbus.ObjectPath) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// GetNetworks returns the object paths of all configured networks
|
||||
func (i *WPAInterface) GetNetworks() ([]dbus.ObjectPath, error) {
|
||||
prop, err := i.obj.GetProperty(InterfaceInterface + ".Networks")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Networks property: %v", err)
|
||||
}
|
||||
|
||||
networks, ok := prop.Value().([]dbus.ObjectPath)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Networks property is not an array of ObjectPath")
|
||||
}
|
||||
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
// Disconnect disconnects from the current network
|
||||
func (i *WPAInterface) Disconnect() error {
|
||||
err := i.obj.Call(InterfaceInterface+".Disconnect", 0).Err
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package wpasupplicant
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
|
|
@ -39,3 +41,25 @@ func (n *Network) GetProperties() (map[string]dbus.Variant, error) {
|
|||
|
||||
return props, nil
|
||||
}
|
||||
|
||||
// GetSSID returns the configured SSID, stripping the wrapping quotes
|
||||
// that wpa_supplicant stores around string-form SSIDs.
|
||||
func (n *Network) GetSSID() (string, error) {
|
||||
props, err := n.GetProperties()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ssidVariant, ok := props["ssid"]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
ssid, ok := ssidVariant.Value().(string)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// wpa_supplicant returns quoted SSIDs like "MyNetwork"
|
||||
return strings.Trim(ssid, `"`), nil
|
||||
}
|
||||
|
|
|
|||
54
openapi.yaml
54
openapi.yaml
|
|
@ -369,6 +369,26 @@ components:
|
|||
- ssid
|
||||
- password
|
||||
|
||||
EthernetStatus:
|
||||
type: object
|
||||
description: State of the wired uplink interface (probed via ip(8))
|
||||
properties:
|
||||
active:
|
||||
type: boolean
|
||||
description: True when the interface holds a DHCP-assigned IPv4
|
||||
example: true
|
||||
interface:
|
||||
type: string
|
||||
description: Probed interface name
|
||||
example: "eth0"
|
||||
ipv4:
|
||||
type: string
|
||||
description: DHCP-assigned IPv4 address (empty when no lease)
|
||||
example: "192.168.1.42"
|
||||
required:
|
||||
- active
|
||||
- interface
|
||||
|
||||
HotspotStatus:
|
||||
type: object
|
||||
description: Detailed hotspot status from hostapd_cli
|
||||
|
|
@ -418,7 +438,7 @@ components:
|
|||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Device hostname
|
||||
description: Device hostname (may be empty when DHCP did not provide one)
|
||||
example: "iPhone-12"
|
||||
type:
|
||||
type: string
|
||||
|
|
@ -440,6 +460,30 @@ components:
|
|||
description: Assigned IP address
|
||||
pattern: '^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$'
|
||||
example: "192.168.1.100"
|
||||
vendor:
|
||||
type: string
|
||||
description: Hardware vendor inferred from the MAC OUI prefix (empty when unknown)
|
||||
example: "Apple"
|
||||
signalDbm:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Wi-Fi signal strength in dBm (0 when unavailable, e.g. for non-hostapd backends)
|
||||
example: -54
|
||||
rxBytes:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Total bytes received from the device since connection (0 when unavailable)
|
||||
example: 1048576
|
||||
txBytes:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Total bytes sent to the device since connection (0 when unavailable)
|
||||
example: 524288
|
||||
connectedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp when the station first appeared (best effort)
|
||||
example: "2026-05-01T08:30:00Z"
|
||||
required:
|
||||
- name
|
||||
- type
|
||||
|
|
@ -456,13 +500,14 @@ components:
|
|||
example: true
|
||||
connectionState:
|
||||
type: string
|
||||
description: Current WiFi connection state
|
||||
description: Current WiFi connection state ("disabled" when Ethernet uplink is active and the WiFi backend is intentionally not initialized)
|
||||
enum:
|
||||
- connected
|
||||
- disconnected
|
||||
- connecting
|
||||
- disconnecting
|
||||
- roaming
|
||||
- disabled
|
||||
example: "connected"
|
||||
connectedSSID:
|
||||
type: string
|
||||
|
|
@ -473,6 +518,11 @@ components:
|
|||
- $ref: '#/components/schemas/HotspotStatus'
|
||||
nullable: true
|
||||
description: Detailed hotspot status (null if hotspot is not running)
|
||||
ethernetStatus:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/EthernetStatus'
|
||||
nullable: true
|
||||
description: Wired uplink state (null when not yet probed)
|
||||
connectedCount:
|
||||
type: integer
|
||||
description: Number of devices connected to hotspot
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue