From 07f8673f2fbae31a1b520ac305a01c0b53ed9aaf Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 1 May 2026 21:56:50 +0800 Subject: [PATCH] Harden API surface and station/wifi backends Bind to localhost by default and stop echoing backend errors (which can embed credentials or low-level details) back over the API and log broadcast. Validate hotspot SSID/passphrase/channel before writing hostapd.conf and tighten its mode to 0600 since it stores the WPA PSK. Restrict WebSocket upgrades to same-origin so a LAN browser can't be turned into a proxy for the API. Guard shared state: status reads/writes go through StatusMutex (the periodic updater races with the toggle and status handlers otherwise), broadcastToWebSockets no longer mutates the client map under RLock, and station-event callbacks now run under SafeGo so a panic in app code can't take down the daemon. Stop channels in hostapd, dhcp, and iwd signal monitors are now closed under sync.Once to survive concurrent Stop calls. App.Shutdown is idempotent and waits for the periodic loops before closing backends, so signal-driven and deferred shutdowns no longer race. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/repeater/static/index.html | 2 +- internal/api/handlers/handlers.go | 65 +++++++++++++++++++---------- internal/api/handlers/websocket.go | 19 ++++++++- internal/api/router.go | 7 ++-- internal/app/app.go | 58 ++++++++++++++++++------- internal/config/cli.go | 2 +- internal/config/config.go | 2 +- internal/hotspot/hotspot.go | 40 +++++++++++++++++- internal/logging/logging.go | 27 +++++++++--- internal/station/backend/types.go | 17 ++++++++ internal/station/dhcp/backend.go | 20 +++++---- internal/station/dhcp/parser.go | 30 +++++++------ internal/station/hostapd/backend.go | 26 +++++++----- internal/wifi/iwd/signals.go | 7 ++-- 14 files changed, 237 insertions(+), 85 deletions(-) diff --git a/cmd/repeater/static/index.html b/cmd/repeater/static/index.html index 9ded9ad..203e607 100644 --- a/cmd/repeater/static/index.html +++ b/cmd/repeater/static/index.html @@ -6,7 +6,7 @@ Travel Router - + diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go index 7c87a7c..9963120 100644 --- a/internal/api/handlers/handlers.go +++ b/internal/api/handlers/handlers.go @@ -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 diff --git a/internal/api/handlers/websocket.go b/internal/api/handlers/websocket.go index 87d2823..ed4d2d3 100644 --- a/internal/api/handlers/websocket.go +++ b/internal/api/handlers/websocket.go @@ -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 diff --git a/internal/api/router.go b/internal/api/router.go index 956661e..78e91e2 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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 diff --git a/internal/app/app.go b/internal/app/app.go index 3e55b49..4efcf3d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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{}), } } @@ -99,6 +104,7 @@ func (a *App) Initialize(cfg *config.Config) error { } // Start periodic tasks + a.wg.Add(2) go a.periodicStatusUpdate() go a.periodicDeviceUpdate() @@ -108,26 +114,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,10 +194,17 @@ 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: + } + a.StatusMutex.Lock() a.Status.Connected = wifi.IsConnected() a.Status.ConnectionState = wifi.GetConnectionState() @@ -204,10 +227,17 @@ 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) diff --git a/internal/config/cli.go b/internal/config/cli.go index 49013b3..4dad1b0 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -6,7 +6,7 @@ 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.WifiBackend, "wifi-backend", "", "WiFi backend to use: 'iwd' or 'wpasupplicant' (required)") diff --git a/internal/config/config.go b/internal/config/config.go index 3cd2fa7..14570c7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,7 +29,7 @@ type Config struct { func ConsolidateConfig() (opts *Config, err error) { // Define defaults options opts = &Config{ - Bind: ":8080", + Bind: "127.0.0.1:8080", WifiInterface: "wlan0", HotspotInterface: "wlan1", DHCPLeasesPath: "/var/lib/dhcp/dhcpd.leases", diff --git a/internal/hotspot/hotspot.go b/internal/hotspot/hotspot.go index 793352f..000ae9f 100644 --- a/internal/hotspot/hotspot.go +++ b/internal/hotspot/hotspot.go @@ -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 } diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 064a813..3d463bf 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -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() + } } diff --git a/internal/station/backend/types.go b/internal/station/backend/types.go index 9874d5c..f4900ef 100644 --- a/internal/station/backend/types.go +++ b/internal/station/backend/types.go @@ -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 { diff --git a/internal/station/dhcp/backend.go b/internal/station/dhcp/backend.go index 54abf95..33fb28d 100644 --- a/internal/station/dhcp/backend.go +++ b/internal/station/dhcp/backend.go @@ -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 } @@ -101,7 +102,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 +112,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 +156,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 +176,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) }) } } } diff --git a/internal/station/dhcp/parser.go b/internal/station/dhcp/parser.go index efcd583..831d09e 100644 --- a/internal/station/dhcp/parser.go +++ b/internal/station/dhcp/parser.go @@ -25,19 +25,25 @@ func parseDHCPLeases(path string) ([]models.DHCPLease, error) { 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) } diff --git a/internal/station/hostapd/backend.go b/internal/station/hostapd/backend.go index ce5708a..c78c0be 100644 --- a/internal/station/hostapd/backend.go +++ b/internal/station/hostapd/backend.go @@ -22,9 +22,10 @@ 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 @@ -129,7 +130,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 +142,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) @@ -186,9 +189,9 @@ func (b *Backend) checkStationChanges() error { if _, exists := b.stations[mac]; !exists { // New station connected b.stations[mac] = station - if b.callbacks.OnStationConnected != nil { + if cb := b.callbacks.OnStationConnected; cb != nil { st := b.convertStation(mac, station) - go b.callbacks.OnStationConnected(st) + backend.SafeGo("OnStationConnected", func() { cb(st) }) } log.Printf("Station connected: %s", mac) } @@ -200,8 +203,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) } @@ -336,9 +340,9 @@ func (b *Backend) UpdateIPMapping(macToIP map[string]string) { // Trigger update callbacks for stations that got new/changed IPs for mac := range updated { if station, exists := b.stations[mac]; exists { - if b.callbacks.OnStationUpdated != nil { + if cb := b.callbacks.OnStationUpdated; cb != nil { st := b.convertStation(mac, station) - go b.callbacks.OnStationUpdated(st) + backend.SafeGo("OnStationUpdated", func() { cb(st) }) } } } diff --git a/internal/wifi/iwd/signals.go b/internal/wifi/iwd/signals.go index 90ed32e..5c077e4 100644 --- a/internal/wifi/iwd/signals.go +++ b/internal/wifi/iwd/signals.go @@ -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)