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>
188 lines
4.2 KiB
Go
188 lines
4.2 KiB
Go
package dhcp
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nemunaire/repeater/internal/station/backend"
|
|
)
|
|
|
|
// Backend implements StationBackend using DHCP lease discovery
|
|
type Backend struct {
|
|
dhcpLeasesPath string
|
|
lastStations map[string]backend.Station // Key: MAC address
|
|
callbacks backend.EventCallbacks
|
|
stopChan chan struct{}
|
|
stopOnce sync.Once
|
|
mu sync.RWMutex
|
|
running bool
|
|
}
|
|
|
|
// NewBackend creates a new DHCP backend
|
|
func NewBackend() *Backend {
|
|
return &Backend{
|
|
lastStations: make(map[string]backend.Station),
|
|
stopChan: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Initialize initializes the DHCP backend
|
|
func (b *Backend) Initialize(config backend.BackendConfig) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
b.dhcpLeasesPath = config.DHCPLeasesPath
|
|
if b.dhcpLeasesPath == "" {
|
|
b.dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases"
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close cleans up backend resources
|
|
func (b *Backend) Close() error {
|
|
b.StopEventMonitoring()
|
|
return nil
|
|
}
|
|
|
|
// GetStations returns all connected stations from DHCP leases validated by ARP
|
|
func (b *Backend) GetStations() ([]backend.Station, error) {
|
|
b.mu.RLock()
|
|
dhcpLeasesPath := b.dhcpLeasesPath
|
|
b.mu.RUnlock()
|
|
|
|
// Read DHCP leases
|
|
leases, err := parseDHCPLeases(dhcpLeasesPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get ARP information for validation
|
|
arpInfo, err := getARPInfo()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var stations []backend.Station
|
|
for _, lease := range leases {
|
|
// Check if the device is still connected via ARP
|
|
if _, exists := arpInfo[lease.IP]; exists {
|
|
st := backend.Station{
|
|
MAC: lease.MAC,
|
|
IP: lease.IP,
|
|
Hostname: lease.Hostname,
|
|
Type: backend.GuessDeviceType(lease.Hostname, lease.MAC),
|
|
Signal: 0, // Not available from DHCP
|
|
RxBytes: 0, // Not available from DHCP
|
|
TxBytes: 0, // Not available from DHCP
|
|
ConnectedAt: time.Now(),
|
|
}
|
|
stations = append(stations, st)
|
|
}
|
|
}
|
|
|
|
return stations, nil
|
|
}
|
|
|
|
// StartEventMonitoring starts monitoring for station events via polling
|
|
func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if b.running {
|
|
return nil
|
|
}
|
|
|
|
b.callbacks = callbacks
|
|
b.running = true
|
|
|
|
// Start polling goroutine
|
|
go b.pollLoop()
|
|
|
|
return nil
|
|
}
|
|
|
|
// StopEventMonitoring stops event monitoring. Idempotent — see hostapd backend.
|
|
func (b *Backend) StopEventMonitoring() {
|
|
b.mu.Lock()
|
|
if !b.running {
|
|
b.mu.Unlock()
|
|
return
|
|
}
|
|
b.running = false
|
|
b.mu.Unlock()
|
|
|
|
b.stopOnce.Do(func() { close(b.stopChan) })
|
|
}
|
|
|
|
// SupportsRealTimeEvents returns false (DHCP is polling-based)
|
|
func (b *Backend) SupportsRealTimeEvents() bool {
|
|
return false
|
|
}
|
|
|
|
// pollLoop polls DHCP leases and simulates events
|
|
func (b *Backend) pollLoop() {
|
|
ticker := time.NewTicker(10 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
b.checkForChanges()
|
|
case <-b.stopChan:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkForChanges compares current state with last state and triggers callbacks
|
|
func (b *Backend) checkForChanges() {
|
|
// Get current stations
|
|
current, err := b.GetStations()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Build map of current stations
|
|
currentMap := make(map[string]backend.Station)
|
|
for _, st := range current {
|
|
currentMap[st.MAC] = st
|
|
}
|
|
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
// Check for new stations (connected)
|
|
for mac, st := range currentMap {
|
|
if _, exists := b.lastStations[mac]; !exists {
|
|
// New station connected
|
|
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 cb := b.callbacks.OnStationUpdated; cb != nil {
|
|
stCopy := st
|
|
backend.SafeGo("OnStationUpdated", func() { cb(stCopy) })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for disconnected stations
|
|
for mac := range b.lastStations {
|
|
if _, exists := currentMap[mac]; !exists {
|
|
// Station disconnected
|
|
if cb := b.callbacks.OnStationDisconnected; cb != nil {
|
|
macCopy := mac
|
|
backend.SafeGo("OnStationDisconnected", func() { cb(macCopy) })
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update last state
|
|
b.lastStations = currentMap
|
|
}
|