repeater/internal/station/hostapd/backend.go

345 lines
8.4 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{}
// 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
func (b *Backend) StopEventMonitoring() {
b.mu.Lock()
if !b.running {
b.mu.Unlock()
return
}
b.running = false
b.mu.Unlock()
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 b.callbacks.OnStationConnected != nil {
st := b.convertStation(mac, station)
go b.callbacks.OnStationConnected(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 b.callbacks.OnStationDisconnected != nil {
go b.callbacks.OnStationDisconnected(mac)
}
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 b.callbacks.OnStationUpdated != nil {
st := b.convertStation(mac, station)
go b.callbacks.OnStationUpdated(st)
}
}
}
}