repeater/internal/app/app.go
Pierre-Olivier Mercier 07f8673f2f 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>
2026-05-01 21:56:50 +08:00

317 lines
8.1 KiB
Go

package app
import (
"embed"
"log"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/nemunaire/repeater/internal/api"
"github.com/nemunaire/repeater/internal/config"
"github.com/nemunaire/repeater/internal/hotspot"
"github.com/nemunaire/repeater/internal/logging"
"github.com/nemunaire/repeater/internal/models"
"github.com/nemunaire/repeater/internal/station"
"github.com/nemunaire/repeater/internal/station/backend"
"github.com/nemunaire/repeater/internal/syslog"
"github.com/nemunaire/repeater/internal/wifi"
)
// App represents the application
type App struct {
Status models.SystemStatus
StatusMutex sync.RWMutex
StartTime time.Time
Assets embed.FS
Config *config.Config
SyslogTailer *syslog.SyslogTailer
stopCh chan struct{}
stopOnce sync.Once
wg sync.WaitGroup
}
// New creates a new application instance
func New(assets embed.FS) *App {
return &App{
Status: models.SystemStatus{
Connected: false,
ConnectionState: "disconnected",
ConnectedSSID: "",
HotspotStatus: nil,
ConnectedCount: 0,
DataUsage: 0.0,
Uptime: 0,
},
StartTime: time.Now(),
Assets: assets,
stopCh: make(chan struct{}),
}
}
// Initialize initializes the application
func (a *App) Initialize(cfg *config.Config) error {
// Store config reference
a.Config = cfg
// Initialize WiFi backend
if err := wifi.Initialize(cfg.WifiInterface, cfg.WifiBackend); err != nil {
return err
}
// Start WiFi event monitoring
if err := wifi.StartEventMonitoring(); err != nil {
log.Printf("Warning: WiFi event monitoring failed: %v", err)
// Don't fail - polling fallback still works
}
// Initialize station backend
stationConfig := backend.BackendConfig{
InterfaceName: cfg.HotspotInterface,
ARPTablePath: cfg.ARPTablePath,
DHCPLeasesPath: cfg.DHCPLeasesPath,
HostapdInterface: cfg.HotspotInterface,
}
if err := station.Initialize(cfg.StationBackend, stationConfig); err != nil {
log.Printf("Warning: Station backend initialization failed: %v", err)
// Don't fail - will continue without station discovery
} else {
// Start event monitoring for station events
if err := station.StartEventMonitoring(backend.EventCallbacks{
OnStationConnected: a.handleStationConnected,
OnStationDisconnected: a.handleStationDisconnected,
OnStationUpdated: a.handleStationUpdated,
}); err != nil {
log.Printf("Warning: Station event monitoring failed: %v", err)
// Don't fail - polling fallback still works
}
}
// Start syslog tailing if enabled
if cfg.SyslogEnabled {
a.SyslogTailer = syslog.NewSyslogTailer(
cfg.SyslogPath,
cfg.SyslogFilter,
cfg.SyslogSource,
)
if err := a.SyslogTailer.Start(); err != nil {
log.Printf("Warning: Failed to start syslog tailing: %v", err)
// Don't fail - app continues without syslog
}
}
// Start periodic tasks
a.wg.Add(2)
go a.periodicStatusUpdate()
go a.periodicDeviceUpdate()
logging.AddLog("Système", "Application initialisée")
return nil
}
// Run starts the HTTP server
func (a *App) Run(addr string) error {
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. 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() {
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 syslog tailing if running
if a.SyslogTailer != nil {
a.SyslogTailer.Stop()
}
// 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
func getSystemUptime() int64 {
data, err := os.ReadFile("/proc/uptime")
if err != nil {
log.Printf("Error reading /proc/uptime: %v", err)
return 0
}
fields := strings.Fields(string(data))
if len(fields) == 0 {
return 0
}
uptime, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
log.Printf("Error parsing uptime: %v", err)
return 0
}
return int64(uptime)
}
// getInterfaceBytes reads rx and tx bytes for a network interface
func getInterfaceBytes(interfaceName string) (rxBytes, txBytes int64) {
rxPath := "/sys/class/net/" + interfaceName + "/statistics/rx_bytes"
txPath := "/sys/class/net/" + interfaceName + "/statistics/tx_bytes"
// Read RX bytes
rxData, err := os.ReadFile(rxPath)
if err != nil {
log.Printf("Error reading rx_bytes for %s: %v", interfaceName, err)
} else {
rxBytes, _ = strconv.ParseInt(strings.TrimSpace(string(rxData)), 10, 64)
}
// Read TX bytes
txData, err := os.ReadFile(txPath)
if err != nil {
log.Printf("Error reading tx_bytes for %s: %v", interfaceName, err)
} else {
txBytes, _ = strconv.ParseInt(strings.TrimSpace(string(txData)), 10, 64)
}
return rxBytes, txBytes
}
// periodicStatusUpdate updates WiFi connection status periodically
func (a *App) periodicStatusUpdate() {
defer a.wg.Done()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-a.stopCh:
return
case <-ticker.C:
}
a.StatusMutex.Lock()
a.Status.Connected = wifi.IsConnected()
a.Status.ConnectionState = wifi.GetConnectionState()
a.Status.ConnectedSSID = wifi.GetConnectedSSID()
a.Status.Uptime = getSystemUptime()
// Get detailed hotspot status
a.Status.HotspotStatus = hotspot.GetDetailedStatus()
// Get network data usage for WiFi interface
if a.Config != nil {
rxBytes, txBytes := getInterfaceBytes(a.Config.WifiInterface)
// Convert to MB and sum rx + tx
a.Status.DataUsage = float64(rxBytes+txBytes) / (1024 * 1024)
}
a.StatusMutex.Unlock()
}
}
// periodicDeviceUpdate updates connected devices list periodically
func (a *App) periodicDeviceUpdate() {
defer a.wg.Done()
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-a.stopCh:
return
case <-ticker.C:
}
devices, err := station.GetStations()
if err != nil {
log.Printf("Error getting connected devices: %v", err)
}
a.StatusMutex.Lock()
a.Status.ConnectedDevices = devices
a.Status.ConnectedCount = len(devices)
a.StatusMutex.Unlock()
}
}
// handleStationConnected handles station connection events
func (a *App) handleStationConnected(st backend.Station) {
a.StatusMutex.Lock()
defer a.StatusMutex.Unlock()
// Convert backend.Station to models.ConnectedDevice
device := models.ConnectedDevice{
Name: st.Hostname,
Type: st.Type,
MAC: st.MAC,
IP: st.IP,
}
// Check if device already exists
found := false
for i, d := range a.Status.ConnectedDevices {
if d.MAC == device.MAC {
a.Status.ConnectedDevices[i] = device
found = true
break
}
}
// Add new device if not found
if !found {
a.Status.ConnectedDevices = append(a.Status.ConnectedDevices, device)
a.Status.ConnectedCount = len(a.Status.ConnectedDevices)
logging.AddLog("Stations", "Device connected: "+device.MAC+" ("+device.IP+")")
}
}
// handleStationDisconnected handles station disconnection events
func (a *App) handleStationDisconnected(mac string) {
a.StatusMutex.Lock()
defer a.StatusMutex.Unlock()
// Remove device from list
for i, d := range a.Status.ConnectedDevices {
if d.MAC == mac {
a.Status.ConnectedDevices = append(a.Status.ConnectedDevices[:i], a.Status.ConnectedDevices[i+1:]...)
a.Status.ConnectedCount = len(a.Status.ConnectedDevices)
logging.AddLog("Stations", "Device disconnected: "+mac)
break
}
}
}
// handleStationUpdated handles station update events
func (a *App) handleStationUpdated(st backend.Station) {
a.StatusMutex.Lock()
defer a.StatusMutex.Unlock()
// Update existing device
for i, d := range a.Status.ConnectedDevices {
if d.MAC == st.MAC {
a.Status.ConnectedDevices[i] = models.ConnectedDevice{
Name: st.Hostname,
Type: st.Type,
MAC: st.MAC,
IP: st.IP,
}
break
}
}
}