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

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

View file

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