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) <noreply@anthropic.com>
This commit is contained in:
parent
77370eff19
commit
07f8673f2f
14 changed files with 237 additions and 85 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/nemunaire/repeater/internal/config"
|
||||
|
|
@ -28,12 +31,13 @@ func GetWiFiNetworks(c *gin.Context) {
|
|||
func ScanWiFi(c *gin.Context) {
|
||||
networks, err := wifi.ScanNetworks()
|
||||
if err != nil {
|
||||
logging.AddLog("WiFi", "Erreur lors du scan: "+err.Error())
|
||||
log.Printf("WiFi scan error: %v", err)
|
||||
logging.AddLog("WiFi", "Erreur lors du scan")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du scan WiFi"})
|
||||
return
|
||||
}
|
||||
|
||||
logging.AddLog("WiFi", "Scan terminé - "+string(rune(len(networks)))+" réseaux trouvés")
|
||||
logging.AddLog("WiFi", "Scan terminé - "+strconv.Itoa(len(networks))+" réseaux trouvés")
|
||||
c.JSON(http.StatusOK, networks)
|
||||
}
|
||||
|
||||
|
|
@ -45,16 +49,19 @@ func ConnectWiFi(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
logging.AddLog("WiFi", "Tentative de connexion à "+req.SSID)
|
||||
logging.AddLog("WiFi", "Tentative de connexion")
|
||||
|
||||
err := wifi.Connect(req.SSID, req.Password)
|
||||
if err != nil {
|
||||
logging.AddLog("WiFi", "Échec de connexion: "+err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Échec de connexion: " + err.Error()})
|
||||
// Backend errors may include credentials or low-level details
|
||||
// (dbus paths, kernel messages); keep them in the server log only.
|
||||
log.Printf("WiFi connect error: %v", err)
|
||||
logging.AddLog("WiFi", "Échec de connexion")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Échec de connexion"})
|
||||
return
|
||||
}
|
||||
|
||||
logging.AddLog("WiFi", "Connexion réussie à "+req.SSID)
|
||||
logging.AddLog("WiFi", "Connexion réussie")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
|
|
@ -64,8 +71,9 @@ func DisconnectWiFi(c *gin.Context) {
|
|||
|
||||
err := wifi.Disconnect()
|
||||
if err != nil {
|
||||
logging.AddLog("WiFi", "Erreur de déconnexion: "+err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de déconnexion: " + err.Error()})
|
||||
log.Printf("WiFi disconnect error: %v", err)
|
||||
logging.AddLog("WiFi", "Erreur de déconnexion")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de déconnexion"})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -83,19 +91,24 @@ func ConfigureHotspot(c *gin.Context) {
|
|||
|
||||
err := hotspot.Configure(config)
|
||||
if err != nil {
|
||||
logging.AddLog("Hotspot", "Erreur de configuration: "+err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de configuration: " + err.Error()})
|
||||
log.Printf("Hotspot configure error: %v", err)
|
||||
logging.AddLog("Hotspot", "Erreur de configuration")
|
||||
// Validation errors are user-actionable; keep them on the wire.
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
logging.AddLog("Hotspot", "Configuration mise à jour: "+config.SSID)
|
||||
logging.AddLog("Hotspot", "Configuration mise à jour")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
// ToggleHotspot handles hotspot enable/disable
|
||||
func ToggleHotspot(c *gin.Context, status *models.SystemStatus) {
|
||||
// Determine current state
|
||||
func ToggleHotspot(c *gin.Context, status *models.SystemStatus, statusMu *sync.RWMutex) {
|
||||
// Determine current state under read lock to avoid racing with the
|
||||
// periodic status updater that mutates HotspotStatus.
|
||||
statusMu.RLock()
|
||||
isEnabled := status.HotspotStatus != nil && status.HotspotStatus.State == "ENABLED"
|
||||
statusMu.RUnlock()
|
||||
|
||||
var err error
|
||||
if !isEnabled {
|
||||
|
|
@ -107,13 +120,18 @@ func ToggleHotspot(c *gin.Context, status *models.SystemStatus) {
|
|||
}
|
||||
|
||||
if err != nil {
|
||||
logging.AddLog("Hotspot", "Erreur: "+err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur: " + err.Error()})
|
||||
log.Printf("Hotspot toggle error: %v", err)
|
||||
logging.AddLog("Hotspot", "Erreur")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update status immediately
|
||||
status.HotspotStatus = hotspot.GetDetailedStatus()
|
||||
// Refresh status under the write lock; the periodic updater touches
|
||||
// the same field on a 5s ticker.
|
||||
newStatus := hotspot.GetDetailedStatus()
|
||||
statusMu.Lock()
|
||||
status.HotspotStatus = newStatus
|
||||
statusMu.Unlock()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": !isEnabled})
|
||||
}
|
||||
|
|
@ -122,7 +140,8 @@ func ToggleHotspot(c *gin.Context, status *models.SystemStatus) {
|
|||
func GetDevices(c *gin.Context, cfg *config.Config) {
|
||||
devices, err := station.GetStations()
|
||||
if err != nil {
|
||||
logging.AddLog("Système", "Erreur lors de la récupération des appareils: "+err.Error())
|
||||
log.Printf("GetDevices error: %v", err)
|
||||
logging.AddLog("Système", "Erreur lors de la récupération des appareils")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des appareils"})
|
||||
return
|
||||
}
|
||||
|
|
@ -130,9 +149,13 @@ func GetDevices(c *gin.Context, cfg *config.Config) {
|
|||
c.JSON(http.StatusOK, devices)
|
||||
}
|
||||
|
||||
// GetStatus returns system status
|
||||
func GetStatus(c *gin.Context, status *models.SystemStatus) {
|
||||
c.JSON(http.StatusOK, status)
|
||||
// GetStatus returns system status. Reads under the shared lock so the JSON
|
||||
// encoder doesn't observe a torn ConnectedDevices slice mid-update.
|
||||
func GetStatus(c *gin.Context, status *models.SystemStatus, statusMu *sync.RWMutex) {
|
||||
statusMu.RLock()
|
||||
snapshot := *status
|
||||
statusMu.RUnlock()
|
||||
c.JSON(http.StatusOK, snapshot)
|
||||
}
|
||||
|
||||
// GetLogs returns system logs
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package handlers
|
|||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
|
|
@ -10,9 +11,23 @@ import (
|
|||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
CheckOrigin: checkSameOrigin,
|
||||
}
|
||||
|
||||
// checkSameOrigin only accepts WebSocket upgrades whose Origin header matches
|
||||
// the request's Host. Without this, any web page a LAN user visits could open
|
||||
// a WebSocket against the router's API.
|
||||
func checkSameOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
// Non-browser clients (curl, native apps) do not send Origin.
|
||||
return true
|
||||
},
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Host == r.Host
|
||||
}
|
||||
|
||||
// WebSocketLogs handles WebSocket connections for real-time logs
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/nemunaire/repeater/internal/api/handlers"
|
||||
|
|
@ -12,7 +13,7 @@ import (
|
|||
)
|
||||
|
||||
// SetupRouter creates and configures the Gin router
|
||||
func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.FS) *gin.Engine {
|
||||
func SetupRouter(status *models.SystemStatus, statusMu *sync.RWMutex, cfg *config.Config, assets embed.FS) *gin.Engine {
|
||||
// Set Gin to release mode (can be overridden with GIN_MODE env var)
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
|
|
@ -35,7 +36,7 @@ func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.F
|
|||
{
|
||||
hotspot.POST("/config", handlers.ConfigureHotspot)
|
||||
hotspot.POST("/toggle", func(c *gin.Context) {
|
||||
handlers.ToggleHotspot(c, status)
|
||||
handlers.ToggleHotspot(c, status, statusMu)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +47,7 @@ func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.F
|
|||
|
||||
// Status endpoint
|
||||
api.GET("/status", func(c *gin.Context) {
|
||||
handlers.GetStatus(c, status)
|
||||
handlers.GetStatus(c, status, statusMu)
|
||||
})
|
||||
|
||||
// Log endpoints
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ type App struct {
|
|||
Assets embed.FS
|
||||
Config *config.Config
|
||||
SyslogTailer *syslog.SyslogTailer
|
||||
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// New creates a new application instance
|
||||
|
|
@ -44,6 +48,7 @@ func New(assets embed.FS) *App {
|
|||
},
|
||||
StartTime: time.Now(),
|
||||
Assets: assets,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package hotspot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
|
|
@ -15,8 +17,36 @@ const (
|
|||
HOSTAPD_CONF = "/etc/hostapd/hostapd.conf"
|
||||
)
|
||||
|
||||
// validateHotspotConfig rejects values that could break out of the
|
||||
// key=value lines in hostapd.conf or violate WPA-PSK constraints.
|
||||
func validateHotspotConfig(c models.HotspotConfig) error {
|
||||
if l := len(c.SSID); l < 1 || l > 32 {
|
||||
return fmt.Errorf("ssid must be 1-32 bytes (got %d)", l)
|
||||
}
|
||||
if strings.ContainsAny(c.SSID, "\r\n\x00") {
|
||||
return fmt.Errorf("ssid contains forbidden control characters")
|
||||
}
|
||||
if l := len(c.Password); l < 8 || l > 63 {
|
||||
return fmt.Errorf("password must be 8-63 ASCII characters (got %d)", l)
|
||||
}
|
||||
for _, r := range c.Password {
|
||||
if r < 0x20 || r > 0x7e {
|
||||
return fmt.Errorf("password must be printable ASCII")
|
||||
}
|
||||
}
|
||||
// 2.4 GHz channels only — hw_mode is hardcoded to "g" below.
|
||||
if c.Channel < 1 || c.Channel > 14 {
|
||||
return fmt.Errorf("channel must be in 1-14 for 2.4GHz (got %d)", c.Channel)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Configure updates the hotspot configuration
|
||||
func Configure(config models.HotspotConfig) error {
|
||||
if err := validateHotspotConfig(config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hostapdConfig := fmt.Sprintf(`interface=%s
|
||||
driver=nl80211
|
||||
ssid=%s
|
||||
|
|
@ -33,7 +63,7 @@ wpa_pairwise=TKIP
|
|||
rsn_pairwise=CCMP
|
||||
`, AP_INTERFACE, config.SSID, config.Channel, config.Password)
|
||||
|
||||
return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0644)
|
||||
return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0600)
|
||||
}
|
||||
|
||||
// Start starts the hotspot
|
||||
|
|
@ -56,11 +86,17 @@ func Status() error {
|
|||
}
|
||||
|
||||
// GetDetailedStatus retrieves detailed status information from hostapd_cli.
|
||||
// Returns nil if hostapd is not running or if there's an error.
|
||||
// Returns nil if hostapd is not running. Distinguishes a non-zero exit
|
||||
// (expected: daemon stopped) from environmental failures (binary missing,
|
||||
// permission denied) which are logged at most once per call.
|
||||
func GetDetailedStatus() *models.HotspotStatus {
|
||||
cmd := exec.Command("hostapd_cli", "status")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
log.Printf("hostapd_cli status: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,16 +79,31 @@ func UnregisterWebSocketClient(conn *websocket.Conn) {
|
|||
clientsMutex.Unlock()
|
||||
}
|
||||
|
||||
// broadcastToWebSockets sends a log entry to all connected WebSocket clients
|
||||
// broadcastToWebSockets sends a log entry to all connected WebSocket clients.
|
||||
// Dead clients are collected during the read pass and pruned under the write
|
||||
// lock afterwards — mutating the map while only holding RLock would race
|
||||
// with concurrent Register/Unregister and panic Go's map runtime.
|
||||
func broadcastToWebSockets(entry models.LogEntry) {
|
||||
clientsMutex.RLock()
|
||||
defer clientsMutex.RUnlock()
|
||||
|
||||
clients := make([]*websocket.Conn, 0, len(websocketClients))
|
||||
for client := range websocketClients {
|
||||
err := client.WriteJSON(entry)
|
||||
if err != nil {
|
||||
clients = append(clients, client)
|
||||
}
|
||||
clientsMutex.RUnlock()
|
||||
|
||||
var dead []*websocket.Conn
|
||||
for _, client := range clients {
|
||||
if err := client.WriteJSON(entry); err != nil {
|
||||
client.Close()
|
||||
delete(websocketClients, client)
|
||||
dead = append(dead, client)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dead) > 0 {
|
||||
clientsMutex.Lock()
|
||||
for _, c := range dead {
|
||||
delete(websocketClients, c)
|
||||
}
|
||||
clientsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue