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:
nemunaire 2026-05-01 21:56:50 +08:00
commit 07f8673f2f
14 changed files with 237 additions and 85 deletions

View file

@ -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)