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