Refactor stations discovery and add hostapd discovery
This commit is contained in:
parent
69594c2fe4
commit
2922a03724
15 changed files with 1339 additions and 249 deletions
345
internal/station/hostapd/backend.go
Normal file
345
internal/station/hostapd/backend.go
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
130
internal/station/hostapd/correlation.go
Normal file
130
internal/station/hostapd/correlation.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package hostapd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nemunaire/repeater/internal/models"
|
||||
)
|
||||
|
||||
// DHCPCorrelator helps correlate hostapd stations with DHCP leases to discover IP addresses
|
||||
type DHCPCorrelator struct {
|
||||
backend *Backend
|
||||
dhcpLeasesPath string
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewDHCPCorrelator creates a new DHCP correlator
|
||||
func NewDHCPCorrelator(backend *Backend, dhcpLeasesPath string) *DHCPCorrelator {
|
||||
if dhcpLeasesPath == "" {
|
||||
dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases"
|
||||
}
|
||||
|
||||
return &DHCPCorrelator{
|
||||
backend: backend,
|
||||
dhcpLeasesPath: dhcpLeasesPath,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins periodic correlation of DHCP leases with hostapd stations
|
||||
func (dc *DHCPCorrelator) Start() {
|
||||
if dc.running {
|
||||
return
|
||||
}
|
||||
|
||||
dc.running = true
|
||||
go dc.correlationLoop()
|
||||
log.Printf("DHCP correlation started for hostapd backend")
|
||||
}
|
||||
|
||||
// Stop stops the correlation loop
|
||||
func (dc *DHCPCorrelator) Stop() {
|
||||
if !dc.running {
|
||||
return
|
||||
}
|
||||
|
||||
dc.running = false
|
||||
close(dc.stopChan)
|
||||
log.Printf("DHCP correlation stopped")
|
||||
}
|
||||
|
||||
// correlationLoop periodically correlates DHCP leases with stations
|
||||
func (dc *DHCPCorrelator) correlationLoop() {
|
||||
// Do an initial correlation immediately
|
||||
dc.correlate()
|
||||
|
||||
// Then correlate every 10 seconds
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
dc.correlate()
|
||||
case <-dc.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// correlate performs one correlation cycle
|
||||
func (dc *DHCPCorrelator) correlate() {
|
||||
// Parse DHCP leases
|
||||
leases, err := parseDHCPLeases(dc.dhcpLeasesPath)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to parse DHCP leases: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build MAC -> IP mapping
|
||||
macToIP := make(map[string]string)
|
||||
for _, lease := range leases {
|
||||
macToIP[lease.MAC] = lease.IP
|
||||
}
|
||||
|
||||
// Update backend with IP mappings
|
||||
dc.backend.UpdateIPMapping(macToIP)
|
||||
}
|
||||
|
||||
// parseDHCPLeases reads and parses DHCP lease file
|
||||
func parseDHCPLeases(path string) ([]models.DHCPLease, error) {
|
||||
var leases []models.DHCPLease
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return leases, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
var currentLease models.DHCPLease
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
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 == "}" {
|
||||
if currentLease.IP != "" && currentLease.MAC != "" {
|
||||
leases = append(leases, currentLease)
|
||||
}
|
||||
currentLease = models.DHCPLease{}
|
||||
}
|
||||
}
|
||||
|
||||
return leases, scanner.Err()
|
||||
}
|
||||
10
internal/station/hostapd/types.go
Normal file
10
internal/station/hostapd/types.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package hostapd
|
||||
|
||||
// HostapdStation represents station properties from hostapd_cli
|
||||
type HostapdStation struct {
|
||||
RxPackets uint64
|
||||
TxPackets uint64
|
||||
RxBytes uint64
|
||||
TxBytes uint64
|
||||
Signal int32 // Signal in dBm
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue