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>
349 lines
8.8 KiB
Go
349 lines
8.8 KiB
Go
package hostapd
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"log"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nemunaire/repeater/internal/station/backend"
|
|
)
|
|
|
|
// Backend implements StationBackend using hostapd_cli
|
|
type Backend struct {
|
|
interfaceName string
|
|
hostapdCLI string // Path to hostapd_cli executable
|
|
|
|
stations map[string]*HostapdStation // Key: MAC address
|
|
callbacks backend.EventCallbacks
|
|
|
|
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
|
|
}
|
|
|
|
// NewBackend creates a new hostapd backend
|
|
func NewBackend() *Backend {
|
|
return &Backend{
|
|
stations: make(map[string]*HostapdStation),
|
|
ipByMAC: make(map[string]string),
|
|
hostapdCLI: "hostapd_cli",
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Initialize initializes the hostapd backend
|
|
func (b *Backend) Initialize(config backend.BackendConfig) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
b.interfaceName = config.InterfaceName
|
|
if b.interfaceName == "" {
|
|
b.interfaceName = "wlan1" // Default AP interface
|
|
}
|
|
|
|
// Check if hostapd_cli is available
|
|
if _, err := exec.LookPath(b.hostapdCLI); err != nil {
|
|
return fmt.Errorf("hostapd_cli not found in PATH: %w", err)
|
|
}
|
|
|
|
// Verify we can communicate with hostapd
|
|
if err := b.runCommand("ping"); err != nil {
|
|
return fmt.Errorf("failed to communicate with hostapd on interface %s: %w", b.interfaceName, err)
|
|
}
|
|
|
|
log.Printf("Hostapd backend initialized for interface %s", b.interfaceName)
|
|
|
|
// Load initial station list
|
|
if err := b.loadStations(); err != nil {
|
|
log.Printf("Warning: Failed to load initial stations: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close cleans up backend resources
|
|
func (b *Backend) Close() error {
|
|
b.StopEventMonitoring()
|
|
return nil
|
|
}
|
|
|
|
// runCommand executes a hostapd_cli command and returns the output
|
|
func (b *Backend) runCommand(args ...string) error {
|
|
cmdArgs := []string{"-i", b.interfaceName}
|
|
cmdArgs = append(cmdArgs, args...)
|
|
cmd := exec.Command(b.hostapdCLI, cmdArgs...)
|
|
return cmd.Run()
|
|
}
|
|
|
|
// runCommandOutput executes a hostapd_cli command and returns the output
|
|
func (b *Backend) runCommandOutput(args ...string) (string, error) {
|
|
cmdArgs := []string{"-i", b.interfaceName}
|
|
cmdArgs = append(cmdArgs, args...)
|
|
cmd := exec.Command(b.hostapdCLI, cmdArgs...)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
// GetStations returns all connected stations
|
|
func (b *Backend) GetStations() ([]backend.Station, error) {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
|
|
stations := make([]backend.Station, 0, len(b.stations))
|
|
for mac, hs := range b.stations {
|
|
station := b.convertStation(mac, hs)
|
|
stations = append(stations, station)
|
|
}
|
|
|
|
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.pollStations()
|
|
|
|
log.Printf("Hostapd event monitoring started (polling mode)")
|
|
return nil
|
|
}
|
|
|
|
// 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 {
|
|
b.mu.Unlock()
|
|
return
|
|
}
|
|
b.running = false
|
|
b.mu.Unlock()
|
|
|
|
b.stopOnce.Do(func() { close(b.stopCh) })
|
|
}
|
|
|
|
// SupportsRealTimeEvents returns false (hostapd_cli uses polling)
|
|
func (b *Backend) SupportsRealTimeEvents() bool {
|
|
return false
|
|
}
|
|
|
|
// pollStations periodically polls for station changes
|
|
func (b *Backend) pollStations() {
|
|
ticker := time.NewTicker(2 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-b.stopCh:
|
|
return
|
|
case <-ticker.C:
|
|
if err := b.checkStationChanges(); err != nil {
|
|
log.Printf("Error polling stations: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkStationChanges checks for station connect/disconnect events
|
|
func (b *Backend) checkStationChanges() error {
|
|
// Get current stations from hostapd
|
|
currentStations, err := b.fetchStations()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
// Build a map of current MACs
|
|
currentMACs := make(map[string]bool)
|
|
for mac := range currentStations {
|
|
currentMACs[mac] = true
|
|
}
|
|
|
|
// Check for new stations
|
|
for mac, station := range currentStations {
|
|
if _, exists := b.stations[mac]; !exists {
|
|
// New station connected
|
|
b.stations[mac] = station
|
|
if cb := b.callbacks.OnStationConnected; cb != nil {
|
|
st := b.convertStation(mac, station)
|
|
backend.SafeGo("OnStationConnected", func() { cb(st) })
|
|
}
|
|
log.Printf("Station connected: %s", mac)
|
|
}
|
|
}
|
|
|
|
// Check for removed stations
|
|
for mac := range b.stations {
|
|
if !currentMACs[mac] {
|
|
// Station disconnected
|
|
delete(b.stations, mac)
|
|
delete(b.ipByMAC, mac)
|
|
if cb := b.callbacks.OnStationDisconnected; cb != nil {
|
|
macCopy := mac
|
|
backend.SafeGo("OnStationDisconnected", func() { cb(macCopy) })
|
|
}
|
|
log.Printf("Station disconnected: %s", mac)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadStations loads the initial list of stations from hostapd
|
|
func (b *Backend) loadStations() error {
|
|
stations, err := b.fetchStations()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
b.stations = stations
|
|
log.Printf("Loaded %d initial stations from hostapd", len(b.stations))
|
|
return nil
|
|
}
|
|
|
|
// fetchStations fetches all stations using hostapd_cli all_sta command
|
|
func (b *Backend) fetchStations() (map[string]*HostapdStation, error) {
|
|
output, err := b.runCommandOutput("all_sta")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get stations: %w", err)
|
|
}
|
|
|
|
return b.parseAllStaOutput(output), nil
|
|
}
|
|
|
|
// parseAllStaOutput parses the output of "hostapd_cli all_sta"
|
|
func (b *Backend) parseAllStaOutput(output string) map[string]*HostapdStation {
|
|
stations := make(map[string]*HostapdStation)
|
|
scanner := bufio.NewScanner(bytes.NewBufferString(output))
|
|
|
|
var currentMAC string
|
|
var currentStation *HostapdStation
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Check if this is a MAC address line (starts the station block)
|
|
if !strings.Contains(line, "=") && len(line) == 17 && strings.Count(line, ":") == 5 {
|
|
// Save previous station if exists
|
|
if currentMAC != "" && currentStation != nil {
|
|
stations[currentMAC] = currentStation
|
|
}
|
|
// Start new station
|
|
currentMAC = strings.ToLower(line)
|
|
currentStation = &HostapdStation{}
|
|
continue
|
|
}
|
|
|
|
// Parse key=value pairs
|
|
if currentStation != nil && strings.Contains(line, "=") {
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(parts[0])
|
|
value := strings.TrimSpace(parts[1])
|
|
|
|
switch key {
|
|
case "signal":
|
|
if v, err := strconv.Atoi(value); err == nil {
|
|
currentStation.Signal = int32(v)
|
|
}
|
|
case "rx_bytes":
|
|
if v, err := strconv.ParseUint(value, 10, 64); err == nil {
|
|
currentStation.RxBytes = v
|
|
}
|
|
case "tx_bytes":
|
|
if v, err := strconv.ParseUint(value, 10, 64); err == nil {
|
|
currentStation.TxBytes = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save last station
|
|
if currentMAC != "" && currentStation != nil {
|
|
stations[currentMAC] = currentStation
|
|
}
|
|
|
|
return stations
|
|
}
|
|
|
|
// convertStation converts HostapdStation to backend.Station
|
|
func (b *Backend) convertStation(mac string, hs *HostapdStation) backend.Station {
|
|
// Get IP address if available from correlation
|
|
ip := b.ipByMAC[mac]
|
|
|
|
// Attempt hostname resolution if we have an IP
|
|
hostname := ""
|
|
// TODO: Could do reverse DNS lookup here if needed
|
|
|
|
return backend.Station{
|
|
MAC: mac,
|
|
IP: ip,
|
|
Hostname: hostname,
|
|
Type: backend.GuessDeviceType(hostname, mac),
|
|
Signal: hs.Signal,
|
|
RxBytes: hs.RxBytes,
|
|
TxBytes: hs.TxBytes,
|
|
ConnectedAt: time.Now(), // We don't have exact connection time
|
|
}
|
|
}
|
|
|
|
// UpdateIPMapping updates the MAC -> IP mapping from external source (e.g., DHCP)
|
|
// This should be called periodically to correlate hostapd stations with IP addresses
|
|
func (b *Backend) UpdateIPMapping(macToIP map[string]string) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
// Track which stations got IP updates
|
|
updated := make(map[string]bool)
|
|
|
|
for mac, ip := range macToIP {
|
|
if oldIP, exists := b.ipByMAC[mac]; exists && oldIP != ip {
|
|
// IP changed
|
|
updated[mac] = true
|
|
} else if !exists {
|
|
// New IP mapping
|
|
updated[mac] = true
|
|
}
|
|
b.ipByMAC[mac] = ip
|
|
}
|
|
|
|
// Trigger update callbacks for stations that got new/changed IPs
|
|
for mac := range updated {
|
|
if station, exists := b.stations[mac]; exists {
|
|
if cb := b.callbacks.OnStationUpdated; cb != nil {
|
|
st := b.convertStation(mac, station)
|
|
backend.SafeGo("OnStationUpdated", func() { cb(st) })
|
|
}
|
|
}
|
|
}
|
|
}
|