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)