Compare commits

..

No commits in common. "92b6113d726c44881c89e0fb0fec54ea735b76a0" and "08cbe4f9ff7263e65053714a985e049f8846871f" have entirely different histories.

32 changed files with 276 additions and 40661 deletions

View file

@ -273,10 +273,6 @@ 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';
@ -284,16 +280,6 @@ 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');
@ -415,112 +401,20 @@ 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(displayName)}</div>
<div class="device-type">${escapeHtml(formatDeviceType(device.type))}</div>
${showVendor ? `<div class="device-vendor">${escapeHtml(device.vendor)}</div>` : ''}
<div class="device-name">${escapeHtml(device.name)}</div>
<div class="device-type">${device.type}</div>
<div class="device-info">
<div>${escapeHtml(device.ip || '—')}</div>
<div class="device-mac">${escapeHtml(device.mac)}</div>
<div>${device.ip}</div>
<div style="font-size: 0.75rem;">${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');

View file

@ -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,10 +25,6 @@
<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>

View file

@ -553,16 +553,7 @@ 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 {
@ -571,49 +562,6 @@ 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;

View file

@ -1,10 +1,7 @@
package handlers
import (
"log"
"net/http"
"strconv"
"sync"
"github.com/gin-gonic/gin"
"github.com/nemunaire/repeater/internal/config"
@ -31,13 +28,12 @@ func GetWiFiNetworks(c *gin.Context) {
func ScanWiFi(c *gin.Context) {
networks, err := wifi.ScanNetworks()
if err != nil {
log.Printf("WiFi scan error: %v", err)
logging.AddLog("WiFi", "Erreur lors du scan")
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é - "+strconv.Itoa(len(networks))+" réseaux trouvés")
logging.AddLog("WiFi", "Scan terminé - "+string(rune(len(networks)))+" réseaux trouvés")
c.JSON(http.StatusOK, networks)
}
@ -49,19 +45,16 @@ func ConnectWiFi(c *gin.Context) {
return
}
logging.AddLog("WiFi", "Tentative de connexion")
logging.AddLog("WiFi", "Tentative de connexion à "+req.SSID)
err := wifi.Connect(req.SSID, req.Password)
if err != nil {
// 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"})
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")
logging.AddLog("WiFi", "Connexion réussie à "+req.SSID)
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
@ -71,9 +64,8 @@ func DisconnectWiFi(c *gin.Context) {
err := wifi.Disconnect()
if err != nil {
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"})
logging.AddLog("WiFi", "Erreur de déconnexion: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de déconnexion: " + err.Error()})
return
}
@ -91,24 +83,19 @@ func ConfigureHotspot(c *gin.Context) {
err := hotspot.Configure(config)
if err != nil {
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()})
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")
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, statusMu *sync.RWMutex) {
// Determine current state under read lock to avoid racing with the
// periodic status updater that mutates HotspotStatus.
statusMu.RLock()
func ToggleHotspot(c *gin.Context, status *models.SystemStatus) {
// Determine current state
isEnabled := status.HotspotStatus != nil && status.HotspotStatus.State == "ENABLED"
statusMu.RUnlock()
var err error
if !isEnabled {
@ -120,18 +107,13 @@ func ToggleHotspot(c *gin.Context, status *models.SystemStatus, statusMu *sync.R
}
if err != nil {
log.Printf("Hotspot toggle error: %v", err)
logging.AddLog("Hotspot", "Erreur")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur"})
logging.AddLog("Hotspot", "Erreur: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur: " + err.Error()})
return
}
// 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()
// Update status immediately
status.HotspotStatus = hotspot.GetDetailedStatus()
c.JSON(http.StatusOK, gin.H{"enabled": !isEnabled})
}
@ -140,8 +122,7 @@ func ToggleHotspot(c *gin.Context, status *models.SystemStatus, statusMu *sync.R
func GetDevices(c *gin.Context, cfg *config.Config) {
devices, err := station.GetStations()
if err != nil {
log.Printf("GetDevices error: %v", err)
logging.AddLog("Système", "Erreur lors de la récupération des appareils")
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
}
@ -149,13 +130,9 @@ func GetDevices(c *gin.Context, cfg *config.Config) {
c.JSON(http.StatusOK, devices)
}
// 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)
// GetStatus returns system status
func GetStatus(c *gin.Context, status *models.SystemStatus) {
c.JSON(http.StatusOK, status)
}
// GetLogs returns system logs

View file

@ -3,7 +3,6 @@ package handlers
import (
"log"
"net/http"
"net/url"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
@ -11,23 +10,9 @@ import (
)
var upgrader = websocket.Upgrader{
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.
CheckOrigin: func(r *http.Request) bool {
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

View file

@ -4,7 +4,6 @@ import (
"embed"
"io/fs"
"net/http"
"sync"
"github.com/gin-gonic/gin"
"github.com/nemunaire/repeater/internal/api/handlers"
@ -13,7 +12,7 @@ import (
)
// SetupRouter creates and configures the Gin router
func SetupRouter(status *models.SystemStatus, statusMu *sync.RWMutex, cfg *config.Config, assets embed.FS) *gin.Engine {
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)
@ -36,7 +35,7 @@ func SetupRouter(status *models.SystemStatus, statusMu *sync.RWMutex, cfg *confi
{
hotspot.POST("/config", handlers.ConfigureHotspot)
hotspot.POST("/toggle", func(c *gin.Context) {
handlers.ToggleHotspot(c, status, statusMu)
handlers.ToggleHotspot(c, status)
})
}
@ -47,7 +46,7 @@ func SetupRouter(status *models.SystemStatus, statusMu *sync.RWMutex, cfg *confi
// Status endpoint
api.GET("/status", func(c *gin.Context) {
handlers.GetStatus(c, status, statusMu)
handlers.GetStatus(c, status)
})
// Log endpoints

View file

@ -28,10 +28,6 @@ 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
@ -48,7 +44,6 @@ func New(assets embed.FS) *App {
},
StartTime: time.Now(),
Assets: assets,
stopCh: make(chan struct{}),
}
}
@ -57,28 +52,15 @@ func (a *App) Initialize(cfg *config.Config) error {
// Store config reference
a.Config = cfg
// 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()
// Initialize WiFi backend
if err := wifi.Initialize(cfg.WifiInterface, cfg.WifiBackend); err != nil {
return err
}
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)
// 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
@ -117,7 +99,6 @@ func (a *App) Initialize(cfg *config.Config) error {
}
// Start periodic tasks
a.wg.Add(2)
go a.periodicStatusUpdate()
go a.periodicDeviceUpdate()
@ -127,36 +108,26 @@ 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.StatusMutex, a.Config, a.Assets)
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. 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).
// Shutdown gracefully shuts down the application
func (a *App) Shutdown() {
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 syslog tailing if running
if a.SyslogTailer != nil {
a.SyslogTailer.Stop()
}
// Stop syslog tailing if running
if a.SyslogTailer != nil {
a.SyslogTailer.Stop()
}
// Stop station monitoring and close backend
station.StopEventMonitoring()
station.Close()
// Stop station monitoring and close backend
station.StopEventMonitoring()
station.Close()
wifi.StopEventMonitoring()
wifi.Close()
logging.AddLog("Système", "Application arrêtée")
})
wifi.StopEventMonitoring()
wifi.Close()
logging.AddLog("Système", "Application arrêtée")
}
// getSystemUptime reads system uptime from /proc/uptime
@ -207,43 +178,14 @@ 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 {
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
}
}
for range ticker.C {
a.StatusMutex.Lock()
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.Connected = wifi.IsConnected()
a.Status.ConnectionState = wifi.GetConnectionState()
a.Status.ConnectedSSID = wifi.GetConnectedSSID()
a.Status.Uptime = getSystemUptime()
// Get detailed hotspot status
@ -262,37 +204,16 @@ 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 {
select {
case <-a.stopCh:
return
case <-ticker.C:
}
for range 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()
@ -304,7 +225,13 @@ func (a *App) handleStationConnected(st backend.Station) {
a.StatusMutex.Lock()
defer a.StatusMutex.Unlock()
device := station.ToConnectedDevice(st)
// 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
@ -345,16 +272,15 @@ func (a *App) handleStationUpdated(st backend.Station) {
a.StatusMutex.Lock()
defer a.StatusMutex.Unlock()
// 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.
// Update existing device
for i, d := range a.Status.ConnectedDevices {
if d.MAC == st.MAC {
updated := station.ToConnectedDevice(st)
if !d.ConnectedAt.IsZero() {
updated.ConnectedAt = d.ConnectedAt
a.Status.ConnectedDevices[i] = models.ConnectedDevice{
Name: st.Hostname,
Type: st.Type,
MAC: st.MAC,
IP: st.IP,
}
a.Status.ConnectedDevices[i] = updated
break
}
}

View file

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

View file

@ -6,13 +6,12 @@ import (
// declareFlags registers flags for the structure Options.
func declareFlags(o *Config) {
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.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.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/udhcpd/udhcpd.leases", "Path to DHCP leases file (udhcpd binary or ISC dhcpd text format — auto-detected)")
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")

View file

@ -9,18 +9,17 @@ import (
)
type Config struct {
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
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
@ -30,16 +29,15 @@ type Config struct {
func ConsolidateConfig() (opts *Config, err error) {
// Define defaults options
opts = &Config{
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",
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)

View file

@ -1,9 +1,7 @@
package hotspot
import (
"errors"
"fmt"
"log"
"os"
"os/exec"
"strconv"
@ -17,36 +15,8 @@ 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
@ -63,7 +33,7 @@ wpa_pairwise=TKIP
rsn_pairwise=CCMP
`, AP_INTERFACE, config.SSID, config.Channel, config.Password)
return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0600)
return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0644)
}
// Start starts the hotspot
@ -86,17 +56,11 @@ func Status() error {
}
// GetDetailedStatus retrieves detailed status information from hostapd_cli.
// 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.
// 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 {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
log.Printf("hostapd_cli status: %v", err)
}
return nil
}

View file

@ -79,31 +79,16 @@ func UnregisterWebSocketClient(conn *websocket.Conn) {
clientsMutex.Unlock()
}
// 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.
// broadcastToWebSockets sends a log entry to all connected WebSocket clients
func broadcastToWebSockets(entry models.LogEntry) {
clientsMutex.RLock()
clients := make([]*websocket.Conn, 0, len(websocketClients))
defer clientsMutex.RUnlock()
for client := range websocketClients {
clients = append(clients, client)
}
clientsMutex.RUnlock()
var dead []*websocket.Conn
for _, client := range clients {
if err := client.WriteJSON(entry); err != nil {
err := client.WriteJSON(entry)
if err != nil {
client.Close()
dead = append(dead, client)
delete(websocketClients, client)
}
}
if len(dead) > 0 {
clientsMutex.Lock()
for _, c := range dead {
delete(websocketClients, c)
}
clientsMutex.Unlock()
}
}

View file

@ -13,15 +13,10 @@ 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"`
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"`
Name string `json:"name"`
Type string `json:"type"`
MAC string `json:"mac"`
IP string `json:"ip"`
}
// HotspotConfig represents hotspot configuration
@ -31,14 +26,6 @@ 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.
@ -57,7 +44,6 @@ 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"`

View file

@ -59,13 +59,11 @@ 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: mac,
MAC: entry.HWAddress.String(),
IP: entry.IP.String(),
Hostname: "", // No hostname available from ARP
Type: backend.GuessDeviceType("", mac),
Vendor: backend.LookupVendor(mac),
Type: backend.GuessDeviceType("", entry.HWAddress.String()),
Signal: 0, // Not available from ARP
RxBytes: 0, // Not available from ARP
TxBytes: 0, // Not available from ARP

View file

@ -17,13 +17,6 @@ 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
@ -33,7 +26,7 @@ func parseARPTable(path string) ([]ARPEntry, error) {
return entries, err
}
for line := range strings.SplitSeq(string(content), "\n") {
for _, line := range strings.Split(string(content), "\n") {
fields := strings.Fields(line)
if len(fields) > 5 {
var entry ARPEntry

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,27 +1,10 @@
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 {
@ -52,7 +35,6 @@ 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)
@ -88,26 +70,19 @@ type BackendConfig struct {
HostapdInterface string // Hostapd interface name for DBus
}
// 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".
// 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") ||
strings.Contains(hostname, "pixel") || strings.Contains(hostname, "galaxy") {
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") ||
strings.Contains(hostname, "thinkpad") {
} else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") {
return "laptop"
} else if strings.Contains(hostname, "imac") || strings.Contains(hostname, "desktop") ||
strings.Contains(hostname, "-pc") {
return "desktop"
}
// Guess by MAC prefix (OUI) — VM hypervisor MACs are almost always laptops
// Guess by MAC prefix (OUI)
if len(mac) >= 8 {
macPrefix := strings.ToUpper(mac[:8])
switch macPrefix {
@ -115,8 +90,8 @@ func GuessDeviceType(hostname, mac string) string {
return "laptop"
case "08:00:27": // VirtualBox
return "laptop"
case "52:54:00": // QEMU/KVM
return "laptop"
default:
return "mobile"
}
}

View file

@ -13,7 +13,6 @@ 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
}
@ -33,7 +32,7 @@ func (b *Backend) Initialize(config backend.BackendConfig) error {
b.dhcpLeasesPath = config.DHCPLeasesPath
if b.dhcpLeasesPath == "" {
b.dhcpLeasesPath = "/var/lib/udhcpd/udhcpd.leases"
b.dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases"
}
return nil
@ -72,7 +71,6 @@ 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
@ -103,7 +101,7 @@ func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
return nil
}
// StopEventMonitoring stops event monitoring. Idempotent — see hostapd backend.
// StopEventMonitoring stops event monitoring
func (b *Backend) StopEventMonitoring() {
b.mu.Lock()
if !b.running {
@ -113,7 +111,7 @@ func (b *Backend) StopEventMonitoring() {
b.running = false
b.mu.Unlock()
b.stopOnce.Do(func() { close(b.stopChan) })
close(b.stopChan)
}
// SupportsRealTimeEvents returns false (DHCP is polling-based)
@ -157,17 +155,15 @@ func (b *Backend) checkForChanges() {
for mac, st := range currentMap {
if _, exists := b.lastStations[mac]; !exists {
// New station connected
if cb := b.callbacks.OnStationConnected; cb != nil {
stCopy := st
backend.SafeGo("OnStationConnected", func() { cb(stCopy) })
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 cb := b.callbacks.OnStationUpdated; cb != nil {
stCopy := st
backend.SafeGo("OnStationUpdated", func() { cb(stCopy) })
if b.callbacks.OnStationUpdated != nil {
go b.callbacks.OnStationUpdated(st)
}
}
}
@ -177,9 +173,8 @@ func (b *Backend) checkForChanges() {
for mac := range b.lastStations {
if _, exists := currentMap[mac]; !exists {
// Station disconnected
if cb := b.callbacks.OnStationDisconnected; cb != nil {
macCopy := mac
backend.SafeGo("OnStationDisconnected", func() { cb(macCopy) })
if b.callbacks.OnStationDisconnected != nil {
go b.callbacks.OnStationDisconnected(mac)
}
}
}

View file

@ -2,11 +2,6 @@ package dhcp
import (
"bufio"
"bytes"
"encoding/binary"
"fmt"
"io"
"net"
"os"
"os/exec"
"regexp"
@ -15,143 +10,34 @@ import (
"github.com/nemunaire/repeater/internal/models"
)
// 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.
// parseDHCPLeases reads and parses DHCP lease file
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
scanner := bufio.NewScanner(r)
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())
fields := strings.Fields(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 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)
}
@ -159,7 +45,7 @@ func parseISCLeases(r io.Reader) ([]models.DHCPLease, error) {
}
}
return leases, scanner.Err()
return leases, nil
}
// getARPInfo retrieves ARP table information using arp command
@ -173,7 +59,8 @@ func getARPInfo() (map[string]string, error) {
return arpInfo, err
}
for line := range strings.SplitSeq(string(output), "\n") {
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]

View file

@ -1,75 +0,0 @@
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")
}
}

View file

@ -22,26 +22,21 @@ type Backend struct {
stations map[string]*HostapdStation // Key: MAC address
callbacks backend.EventCallbacks
mu sync.RWMutex
running bool
stopCh chan struct{}
stopOnce sync.Once
mu sync.RWMutex
running bool
stopCh chan struct{}
// 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
// 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),
hostnameByMAC: make(map[string]string),
hostapdCLI: "hostapd_cli",
stopCh: make(chan struct{}),
stations: make(map[string]*HostapdStation),
ipByMAC: make(map[string]string),
hostapdCLI: "hostapd_cli",
stopCh: make(chan struct{}),
}
}
@ -72,20 +67,11 @@ 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
}
@ -143,9 +129,7 @@ func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
return nil
}
// 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.
// StopEventMonitoring stops event monitoring
func (b *Backend) StopEventMonitoring() {
b.mu.Lock()
if !b.running {
@ -155,7 +139,7 @@ func (b *Backend) StopEventMonitoring() {
b.running = false
b.mu.Unlock()
b.stopOnce.Do(func() { close(b.stopCh) })
close(b.stopCh)
}
// SupportsRealTimeEvents returns false (hostapd_cli uses polling)
@ -188,8 +172,6 @@ func (b *Backend) checkStationChanges() error {
return err
}
now := time.Now()
b.mu.Lock()
defer b.mu.Unlock()
@ -199,24 +181,17 @@ func (b *Backend) checkStationChanges() error {
currentMACs[mac] = true
}
// Reconcile current poll with cached state
// Check for new stations
for mac, station := range currentStations {
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
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)
}
// 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
@ -225,9 +200,8 @@ func (b *Backend) checkStationChanges() error {
// Station disconnected
delete(b.stations, mac)
delete(b.ipByMAC, mac)
if cb := b.callbacks.OnStationDisconnected; cb != nil {
macCopy := mac
backend.SafeGo("OnStationDisconnected", func() { cb(macCopy) })
if b.callbacks.OnStationDisconnected != nil {
go b.callbacks.OnStationDisconnected(mac)
}
log.Printf("Station disconnected: %s", mac)
}
@ -243,12 +217,6 @@ 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
@ -326,60 +294,51 @@ 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]
connectedAt := hs.FirstSeen
if connectedAt.IsZero() {
connectedAt = time.Now()
}
// 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),
Vendor: backend.LookupVendor(mac),
Signal: hs.Signal,
RxBytes: hs.RxBytes,
TxBytes: hs.TxBytes,
ConnectedAt: connectedAt,
ConnectedAt: time.Now(), // We don't have exact connection time
}
}
// 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) {
// 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 {
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
}
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
// Trigger update callbacks for stations that got new/changed IPs
for mac := range updated {
if station, exists := b.stations[mac]; exists {
if cb := b.callbacks.OnStationUpdated; cb != nil {
if b.callbacks.OnStationUpdated != nil {
st := b.convertStation(mac, station)
backend.SafeGo("OnStationUpdated", func() { cb(st) })
go b.callbacks.OnStationUpdated(st)
}
}
}

View file

@ -1,38 +1,32 @@
package hostapd
import (
"bufio"
"log"
"os"
"strings"
"time"
"github.com/nemunaire/repeater/internal/station/arp"
"github.com/nemunaire/repeater/internal/station/dhcp"
"github.com/nemunaire/repeater/internal/models"
)
// 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.
// DHCPCorrelator helps correlate hostapd stations with DHCP leases to discover IP addresses
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, arpTablePath string) *DHCPCorrelator {
func NewDHCPCorrelator(backend *Backend, dhcpLeasesPath string) *DHCPCorrelator {
if dhcpLeasesPath == "" {
dhcpLeasesPath = "/var/lib/misc/udhcpd.leases"
}
if arpTablePath == "" {
arpTablePath = "/proc/net/arp"
dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases"
}
return &DHCPCorrelator{
backend: backend,
dhcpLeasesPath: dhcpLeasesPath,
arpTablePath: arpTablePath,
stopChan: make(chan struct{}),
}
}
@ -45,7 +39,7 @@ func (dc *DHCPCorrelator) Start() {
dc.running = true
go dc.correlationLoop()
log.Printf("DHCP/ARP correlation started (leases=%s, arp=%s)", dc.dhcpLeasesPath, dc.arpTablePath)
log.Printf("DHCP correlation started for hostapd backend")
}
// Stop stops the correlation loop
@ -78,54 +72,59 @@ func (dc *DHCPCorrelator) correlationLoop() {
}
}
// 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.
// 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)
macToHostname := make(map[string]string)
// 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
}
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)
for _, lease := range leases {
macToIP[lease.MAC] = lease.IP
}
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)
// 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

@ -1,13 +1,10 @@
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
FirstSeen time.Time // When the station was first observed (best effort)
Signal int32 // Signal in dBm
}

View file

@ -51,36 +51,20 @@ 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] = toConnectedDevice(s)
devices[i] = models.ConnectedDevice{
Name: s.Hostname,
Type: s.Type,
MAC: s.MAC,
IP: s.IP,
}
}
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()

View file

@ -21,7 +21,6 @@ type SignalMonitor struct {
// Control
stopChan chan struct{}
stopOnce sync.Once
mu sync.RWMutex
running bool
@ -96,8 +95,7 @@ func (sm *SignalMonitor) Start() error {
return nil
}
// Stop stops monitoring D-Bus signals. Idempotent: stopOnce guards the
// channel close so concurrent Stop() callers do not panic on double-close.
// Stop stops monitoring D-Bus signals
func (sm *SignalMonitor) Stop() {
sm.mu.Lock()
if !sm.running {
@ -107,7 +105,8 @@ func (sm *SignalMonitor) Stop() {
sm.running = false
sm.mu.Unlock()
sm.stopOnce.Do(func() { close(sm.stopChan) })
// Signal stop
close(sm.stopChan)
// Remove signal channel
sm.conn.RemoveSignal(sm.signalChan)

View file

@ -1,7 +1,6 @@
package wifi
import (
"errors"
"fmt"
"sort"
"strings"
@ -17,13 +16,6 @@ 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
@ -46,9 +38,6 @@ 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 {
@ -78,9 +67,6 @@ 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 {
@ -128,9 +114,6 @@ 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
@ -149,17 +132,11 @@ 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
@ -169,17 +146,11 @@ 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)
@ -189,9 +160,6 @@ func GetConnectionState() string {
// StartEventMonitoring initializes signal monitoring and WebSocket broadcasting
func StartEventMonitoring() error {
if wifiBackend == nil {
return nil
}
// Initialize broadcaster
wifiBroadcaster = NewWifiBroadcaster()

View file

@ -2,7 +2,6 @@ package wpasupplicant
import (
"fmt"
"log"
"time"
"github.com/godbus/dbus/v5"
@ -153,79 +152,39 @@ func (b *WPABackend) IsScanning() (bool, error) {
// Connect connects to a WiFi network
func (b *WPABackend) Connect(ssid, password string) error {
// 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()
// 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)
if err != nil {
log.Printf("wpa_supplicant: failed to list configured networks: %v", err)
existingNetworks = nil
return fmt.Errorf("failed to add network: %v", err)
}
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
// Store current network path for cleanup
b.currentNetwork = 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)
}
// 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)
}
// 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.
// Save the configuration to persist it across reboots
if err := b.iface.SaveConfig(); err != nil {
log.Printf("wpa_supplicant: SaveConfig failed: %v", err)
} else {
log.Printf("wpa_supplicant: configuration saved for SSID %q", ssid)
// Log warning but don't fail - connection still works
fmt.Printf("Warning: failed to save config: %v\n", err)
}
return nil
@ -238,7 +197,11 @@ func (b *WPABackend) Disconnect() error {
return fmt.Errorf("failed to disconnect: %v", err)
}
b.currentNetwork = ""
// Remove the network configuration if we have one
if b.currentNetwork != "" && b.currentNetwork != "/" {
b.iface.RemoveNetwork(b.currentNetwork)
b.currentNetwork = ""
}
return nil
}

View file

@ -107,16 +107,6 @@ 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
@ -126,21 +116,6 @@ 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

View file

@ -1,8 +1,6 @@
package wpasupplicant
import (
"strings"
"github.com/godbus/dbus/v5"
)
@ -41,25 +39,3 @@ 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
}

View file

@ -369,26 +369,6 @@ 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
@ -438,7 +418,7 @@ components:
properties:
name:
type: string
description: Device hostname (may be empty when DHCP did not provide one)
description: Device hostname
example: "iPhone-12"
type:
type: string
@ -460,30 +440,6 @@ 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
@ -500,14 +456,13 @@ components:
example: true
connectionState:
type: string
description: Current WiFi connection state ("disabled" when Ethernet uplink is active and the WiFi backend is intentionally not initialized)
description: Current WiFi connection state
enum:
- connected
- disconnected
- connecting
- disconnecting
- roaming
- disabled
example: "connected"
connectedSSID:
type: string
@ -518,11 +473,6 @@ 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