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)