From 2922a037243549cf70cc00a42a36ea1f885eb34f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 1 Jan 2026 23:29:34 +0700 Subject: [PATCH] Refactor stations discovery and add hostapd discovery --- internal/api/handlers/handlers.go | 4 +- internal/app/app.go | 98 ++++++- internal/config/cli.go | 3 +- internal/config/config.go | 43 +-- internal/device/device.go | 224 --------------- internal/station/arp/backend.go | 177 ++++++++++++ internal/station/arp/parser.go | 64 +++++ internal/station/backend/types.go | 99 +++++++ internal/station/dhcp/backend.go | 184 +++++++++++++ internal/station/dhcp/parser.go | 72 +++++ internal/station/factory.go | 24 ++ internal/station/hostapd/backend.go | 345 ++++++++++++++++++++++++ internal/station/hostapd/correlation.go | 130 +++++++++ internal/station/hostapd/types.go | 10 + internal/station/station.go | 111 ++++++++ 15 files changed, 1339 insertions(+), 249 deletions(-) delete mode 100644 internal/device/device.go create mode 100644 internal/station/arp/backend.go create mode 100644 internal/station/arp/parser.go create mode 100644 internal/station/backend/types.go create mode 100644 internal/station/dhcp/backend.go create mode 100644 internal/station/dhcp/parser.go create mode 100644 internal/station/factory.go create mode 100644 internal/station/hostapd/backend.go create mode 100644 internal/station/hostapd/correlation.go create mode 100644 internal/station/hostapd/types.go create mode 100644 internal/station/station.go diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go index 6ef8ec1..7c87a7c 100644 --- a/internal/api/handlers/handlers.go +++ b/internal/api/handlers/handlers.go @@ -5,10 +5,10 @@ import ( "github.com/gin-gonic/gin" "github.com/nemunaire/repeater/internal/config" - "github.com/nemunaire/repeater/internal/device" "github.com/nemunaire/repeater/internal/hotspot" "github.com/nemunaire/repeater/internal/logging" "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/station" "github.com/nemunaire/repeater/internal/wifi" ) @@ -120,7 +120,7 @@ func ToggleHotspot(c *gin.Context, status *models.SystemStatus) { // GetDevices returns connected devices func GetDevices(c *gin.Context, cfg *config.Config) { - devices, err := device.GetConnectedDevices(cfg) + devices, err := station.GetStations() if err != nil { logging.AddLog("Système", "Erreur lors de la récupération des appareils: "+err.Error()) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des appareils"}) diff --git a/internal/app/app.go b/internal/app/app.go index 27299df..3e55b49 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,10 +11,11 @@ import ( "github.com/nemunaire/repeater/internal/api" "github.com/nemunaire/repeater/internal/config" - "github.com/nemunaire/repeater/internal/device" "github.com/nemunaire/repeater/internal/hotspot" "github.com/nemunaire/repeater/internal/logging" "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/station" + "github.com/nemunaire/repeater/internal/station/backend" "github.com/nemunaire/repeater/internal/syslog" "github.com/nemunaire/repeater/internal/wifi" ) @@ -62,6 +63,28 @@ func (a *App) Initialize(cfg *config.Config) error { // Don't fail - polling fallback still works } + // Initialize station backend + stationConfig := backend.BackendConfig{ + InterfaceName: cfg.HotspotInterface, + ARPTablePath: cfg.ARPTablePath, + DHCPLeasesPath: cfg.DHCPLeasesPath, + HostapdInterface: cfg.HotspotInterface, + } + if err := station.Initialize(cfg.StationBackend, stationConfig); err != nil { + log.Printf("Warning: Station backend initialization failed: %v", err) + // Don't fail - will continue without station discovery + } else { + // Start event monitoring for station events + if err := station.StartEventMonitoring(backend.EventCallbacks{ + OnStationConnected: a.handleStationConnected, + OnStationDisconnected: a.handleStationDisconnected, + OnStationUpdated: a.handleStationUpdated, + }); err != nil { + log.Printf("Warning: Station event monitoring failed: %v", err) + // Don't fail - polling fallback still works + } + } + // Start syslog tailing if enabled if cfg.SyslogEnabled { a.SyslogTailer = syslog.NewSyslogTailer( @@ -98,6 +121,10 @@ func (a *App) Shutdown() { a.SyslogTailer.Stop() } + // Stop station monitoring and close backend + station.StopEventMonitoring() + station.Close() + wifi.StopEventMonitoring() wifi.Close() logging.AddLog("Système", "Application arrêtée") @@ -181,10 +208,9 @@ func (a *App) periodicDeviceUpdate() { defer ticker.Stop() for range ticker.C { - devices, err := device.GetConnectedDevices(a.Config) + devices, err := station.GetStations() if err != nil { log.Printf("Error getting connected devices: %v", err) - continue } a.StatusMutex.Lock() @@ -193,3 +219,69 @@ func (a *App) periodicDeviceUpdate() { a.StatusMutex.Unlock() } } + +// handleStationConnected handles station connection events +func (a *App) handleStationConnected(st backend.Station) { + a.StatusMutex.Lock() + defer a.StatusMutex.Unlock() + + // Convert backend.Station to models.ConnectedDevice + device := models.ConnectedDevice{ + Name: st.Hostname, + Type: st.Type, + MAC: st.MAC, + IP: st.IP, + } + + // Check if device already exists + found := false + for i, d := range a.Status.ConnectedDevices { + if d.MAC == device.MAC { + a.Status.ConnectedDevices[i] = device + found = true + break + } + } + + // Add new device if not found + if !found { + a.Status.ConnectedDevices = append(a.Status.ConnectedDevices, device) + a.Status.ConnectedCount = len(a.Status.ConnectedDevices) + logging.AddLog("Stations", "Device connected: "+device.MAC+" ("+device.IP+")") + } +} + +// handleStationDisconnected handles station disconnection events +func (a *App) handleStationDisconnected(mac string) { + a.StatusMutex.Lock() + defer a.StatusMutex.Unlock() + + // Remove device from list + for i, d := range a.Status.ConnectedDevices { + if d.MAC == mac { + a.Status.ConnectedDevices = append(a.Status.ConnectedDevices[:i], a.Status.ConnectedDevices[i+1:]...) + a.Status.ConnectedCount = len(a.Status.ConnectedDevices) + logging.AddLog("Stations", "Device disconnected: "+mac) + break + } + } +} + +// handleStationUpdated handles station update events +func (a *App) handleStationUpdated(st backend.Station) { + a.StatusMutex.Lock() + defer a.StatusMutex.Unlock() + + // Update existing device + for i, d := range a.Status.ConnectedDevices { + if d.MAC == st.MAC { + a.Status.ConnectedDevices[i] = models.ConnectedDevice{ + Name: st.Hostname, + Type: st.Type, + MAC: st.MAC, + IP: st.IP, + } + break + } + } +} diff --git a/internal/config/cli.go b/internal/config/cli.go index c5be56d..49013b3 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -8,8 +8,9 @@ import ( func declareFlags(o *Config) { flag.StringVar(&o.Bind, "bind", ":8081", "Bind port/socket") flag.StringVar(&o.WifiInterface, "wifi-interface", "wlan0", "WiFi interface name") + flag.StringVar(&o.HotspotInterface, "hotspot-interface", "wlan1", "Hotspot WiFi interface name") flag.StringVar(&o.WifiBackend, "wifi-backend", "", "WiFi backend to use: 'iwd' or 'wpasupplicant' (required)") - flag.BoolVar(&o.UseARPDiscovery, "use-arp-discovery", true, "Use ARP table for device discovery instead of DHCP leases") + flag.StringVar(&o.StationBackend, "station-backend", "hostapd", "Station discovery backend: 'arp', 'dhcp', or 'hostapd'") flag.StringVar(&o.DHCPLeasesPath, "dhcp-leases-path", "/var/lib/dhcp/dhcpd.leases", "Path to DHCP leases file") flag.StringVar(&o.ARPTablePath, "arp-table-path", "/proc/net/arp", "Path to ARP table file") flag.BoolVar(&o.SyslogEnabled, "syslog-enabled", false, "Enable syslog tailing for iwd messages") diff --git a/internal/config/config.go b/internal/config/config.go index 48f66f6..3cd2fa7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,16 +9,17 @@ import ( ) type Config struct { - Bind string - WifiInterface string - WifiBackend string - UseARPDiscovery bool - DHCPLeasesPath string - ARPTablePath string - SyslogEnabled bool - SyslogPath string - SyslogFilter []string - SyslogSource string + Bind string + WifiInterface string + HotspotInterface string + WifiBackend string + StationBackend string // "arp", "dhcp", or "hostapd" + DHCPLeasesPath string + ARPTablePath string + SyslogEnabled bool + SyslogPath string + SyslogFilter []string + SyslogSource string } // ConsolidateConfig fills an Options struct by reading configuration from @@ -28,15 +29,15 @@ type Config struct { func ConsolidateConfig() (opts *Config, err error) { // Define defaults options opts = &Config{ - Bind: ":8080", - WifiInterface: "wlan0", - UseARPDiscovery: true, - DHCPLeasesPath: "/var/lib/dhcp/dhcpd.leases", - ARPTablePath: "/proc/net/arp", - SyslogEnabled: false, - SyslogPath: "/var/log/messages", - SyslogFilter: []string{"daemon.info wpa_supplicant:", "daemon.info iwd:", "daemon.info hostapd:"}, - SyslogSource: "iwd", + Bind: ":8080", + WifiInterface: "wlan0", + HotspotInterface: "wlan1", + DHCPLeasesPath: "/var/lib/dhcp/dhcpd.leases", + ARPTablePath: "/proc/net/arp", + SyslogEnabled: false, + SyslogPath: "/var/log/messages", + SyslogFilter: []string{"daemon.info wpa_supplicant:", "daemon.info iwd:", "daemon.info hostapd:"}, + SyslogSource: "iwd", } declareFlags(opts) @@ -81,6 +82,10 @@ func ConsolidateConfig() (opts *Config, err error) { log.Fatalf("wifi-backend must be set to 'iwd' or 'wpasupplicant' (got: '%s')", opts.WifiBackend) } + if opts.StationBackend != "arp" && opts.StationBackend != "dhcp" && opts.StationBackend != "hostapd" { + log.Fatalf("station-backend must be set to 'arp', 'dhcp', or 'hostapd' (got: '%s')", opts.StationBackend) + } + return } diff --git a/internal/device/device.go b/internal/device/device.go deleted file mode 100644 index f57dfa5..0000000 --- a/internal/device/device.go +++ /dev/null @@ -1,224 +0,0 @@ -package device - -import ( - "bufio" - "fmt" - "net" - "os" - "os/exec" - "regexp" - "strings" - - "github.com/nemunaire/repeater/internal/config" - "github.com/nemunaire/repeater/internal/models" -) - -// ARPEntry represents an entry in the ARP table -type ARPEntry struct { - IP net.IP - HWType int - Flags int - HWAddress net.HardwareAddr - Mask string - Device string -} - -// GetConnectedDevices returns a list of connected devices -func GetConnectedDevices(cfg *config.Config) ([]models.ConnectedDevice, error) { - if cfg.UseARPDiscovery { - return getDevicesFromARP(cfg) - } - return getDevicesFromDHCP(cfg) -} - -// getDevicesFromARP discovers devices using ARP table -func getDevicesFromARP(cfg *config.Config) ([]models.ConnectedDevice, error) { - var devices []models.ConnectedDevice - - arpEntries, err := parseARPTable(cfg.ARPTablePath) - if err != nil { - return devices, err - } - - for _, entry := range arpEntries { - // Only include entries with valid flags (2 = COMPLETE, 6 = COMPLETE|PERM) - if entry.Flags == 2 || entry.Flags == 6 { - device := models.ConnectedDevice{ - Name: "", // No hostname available from ARP - MAC: entry.HWAddress.String(), - IP: entry.IP.String(), - Type: guessDeviceType("", entry.HWAddress.String()), - } - devices = append(devices, device) - } - } - - return devices, nil -} - -// getDevicesFromDHCP discovers devices using DHCP leases and ARP validation -func getDevicesFromDHCP(cfg *config.Config) ([]models.ConnectedDevice, error) { - var devices []models.ConnectedDevice - - // Read DHCP leases - leases, err := parseDHCPLeases(cfg.DHCPLeasesPath) - if err != nil { - return devices, err - } - - // Get ARP information for validation - arpInfo, err := getARPInfo() - if err != nil { - return devices, err - } - - for _, lease := range leases { - device := models.ConnectedDevice{ - Name: lease.Hostname, - MAC: lease.MAC, - IP: lease.IP, - Type: guessDeviceType(lease.Hostname, lease.MAC), - } - - // Check if the device is still connected via ARP - if _, exists := arpInfo[lease.IP]; exists { - devices = append(devices, device) - } - } - - return devices, nil -} - -// parseARPTable reads and parses ARP table from /proc/net/arp format -func parseARPTable(path string) ([]ARPEntry, error) { - var entries []ARPEntry - - content, err := os.ReadFile(path) - if err != nil { - return entries, err - } - - for _, line := range strings.Split(string(content), "\n") { - fields := strings.Fields(line) - if len(fields) > 5 { - var entry ARPEntry - - // Parse HWType (hex format) - if _, err := fmt.Sscanf(fields[1], "0x%x", &entry.HWType); err != nil { - continue - } - - // Parse Flags (hex format) - if _, err := fmt.Sscanf(fields[2], "0x%x", &entry.Flags); err != nil { - continue - } - - // Parse IP address - entry.IP = net.ParseIP(fields[0]) - if entry.IP == nil { - continue - } - - // Parse MAC address - entry.HWAddress, err = net.ParseMAC(fields[3]) - if err != nil { - continue - } - - entry.Mask = fields[4] - entry.Device = fields[5] - - entries = append(entries, entry) - } - } - - return entries, nil -} - -// 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, nil -} - -// getARPInfo retrieves ARP table information -func getARPInfo() (map[string]string, error) { - arpInfo := make(map[string]string) - - cmd := exec.Command("arp", "-a") - output, err := cmd.Output() - if err != nil { - return arpInfo, err - } - - lines := strings.Split(string(output), "\n") - for _, line := range lines { - if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 { - ip := matches[1] - mac := matches[2] - arpInfo[ip] = mac - } - } - - return arpInfo, nil -} - -// guessDeviceType attempts to guess device type from hostname and MAC address -func guessDeviceType(hostname, mac string) string { - hostname = strings.ToLower(hostname) - - if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") { - return "mobile" - } else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") { - return "tablet" - } else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") { - return "laptop" - } - - // Guess by MAC prefix (OUI) - if len(mac) >= 8 { - macPrefix := strings.ToUpper(mac[:8]) - switch macPrefix { - case "00:50:56", "00:0C:29", "00:05:69": // VMware - return "laptop" - case "08:00:27": // VirtualBox - return "laptop" - default: - return "mobile" - } - } - - return "unknown" -} diff --git a/internal/station/arp/backend.go b/internal/station/arp/backend.go new file mode 100644 index 0000000..5b4e975 --- /dev/null +++ b/internal/station/arp/backend.go @@ -0,0 +1,177 @@ +package arp + +import ( + "sync" + "time" + + "github.com/nemunaire/repeater/internal/station/backend" +) + +// Backend implements StationBackend using ARP table discovery +type Backend struct { + arpTablePath string + lastStations map[string]backend.Station // Key: MAC address + callbacks backend.EventCallbacks + stopChan chan struct{} + mu sync.RWMutex + running bool +} + +// NewBackend creates a new ARP backend +func NewBackend() *Backend { + return &Backend{ + lastStations: make(map[string]backend.Station), + stopChan: make(chan struct{}), + } +} + +// Initialize initializes the ARP backend +func (b *Backend) Initialize(config backend.BackendConfig) error { + b.mu.Lock() + defer b.mu.Unlock() + + b.arpTablePath = config.ARPTablePath + if b.arpTablePath == "" { + b.arpTablePath = "/proc/net/arp" + } + + return nil +} + +// Close cleans up backend resources +func (b *Backend) Close() error { + b.StopEventMonitoring() + return nil +} + +// GetStations returns all connected stations from ARP table +func (b *Backend) GetStations() ([]backend.Station, error) { + b.mu.RLock() + arpTablePath := b.arpTablePath + b.mu.RUnlock() + + arpEntries, err := parseARPTable(arpTablePath) + if err != nil { + return nil, err + } + + var stations []backend.Station + for _, entry := range arpEntries { + // Only include entries with valid flags (2 = COMPLETE, 6 = COMPLETE|PERM) + if entry.Flags == 2 || entry.Flags == 6 { + st := backend.Station{ + MAC: entry.HWAddress.String(), + IP: entry.IP.String(), + Hostname: "", // No hostname available from ARP + Type: backend.GuessDeviceType("", entry.HWAddress.String()), + Signal: 0, // Not available from ARP + RxBytes: 0, // Not available from ARP + TxBytes: 0, // Not available from ARP + 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 +func (b *Backend) StopEventMonitoring() { + b.mu.Lock() + if !b.running { + b.mu.Unlock() + return + } + b.running = false + b.mu.Unlock() + + close(b.stopChan) +} + +// SupportsRealTimeEvents returns false (ARP is polling-based) +func (b *Backend) SupportsRealTimeEvents() bool { + return false +} + +// pollLoop polls the ARP table 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 _, station := range current { + currentMap[station.MAC] = station + } + + b.mu.Lock() + defer b.mu.Unlock() + + // Check for new stations (connected) + for mac, station := range currentMap { + if _, exists := b.lastStations[mac]; !exists { + // New station connected + if b.callbacks.OnStationConnected != nil { + go b.callbacks.OnStationConnected(station) + } + } else { + // Check for updates (IP change, etc.) + oldStation := b.lastStations[mac] + if oldStation.IP != station.IP || oldStation.Hostname != station.Hostname { + if b.callbacks.OnStationUpdated != nil { + go b.callbacks.OnStationUpdated(station) + } + } + } + } + + // Check for disconnected stations + for mac := range b.lastStations { + if _, exists := currentMap[mac]; !exists { + // Station disconnected + if b.callbacks.OnStationDisconnected != nil { + go b.callbacks.OnStationDisconnected(mac) + } + } + } + + // Update last state + b.lastStations = currentMap +} diff --git a/internal/station/arp/parser.go b/internal/station/arp/parser.go new file mode 100644 index 0000000..ca40f01 --- /dev/null +++ b/internal/station/arp/parser.go @@ -0,0 +1,64 @@ +package arp + +import ( + "fmt" + "net" + "os" + "strings" +) + +// ARPEntry represents an entry in the ARP table +type ARPEntry struct { + IP net.IP + HWType int + Flags int + HWAddress net.HardwareAddr + Mask string + Device string +} + +// parseARPTable reads and parses ARP table from /proc/net/arp format +func parseARPTable(path string) ([]ARPEntry, error) { + var entries []ARPEntry + + content, err := os.ReadFile(path) + if err != nil { + return entries, err + } + + for _, line := range strings.Split(string(content), "\n") { + fields := strings.Fields(line) + if len(fields) > 5 { + var entry ARPEntry + + // Parse HWType (hex format) + if _, err := fmt.Sscanf(fields[1], "0x%x", &entry.HWType); err != nil { + continue + } + + // Parse Flags (hex format) + if _, err := fmt.Sscanf(fields[2], "0x%x", &entry.Flags); err != nil { + continue + } + + // Parse IP address + entry.IP = net.ParseIP(fields[0]) + if entry.IP == nil { + continue + } + + // Parse MAC address + entry.HWAddress, err = net.ParseMAC(fields[3]) + if err != nil { + continue + } + + entry.Mask = fields[4] + entry.Device = fields[5] + + entries = append(entries, entry) + } + } + + return entries, nil +} diff --git a/internal/station/backend/types.go b/internal/station/backend/types.go new file mode 100644 index 0000000..9874d5c --- /dev/null +++ b/internal/station/backend/types.go @@ -0,0 +1,99 @@ +package backend + +import ( + "strings" + "time" +) + +// StationBackend defines the interface for station/device discovery backends. +// Implementations include ARP-based, DHCP-based, and Hostapd DBus-based discovery. +type StationBackend interface { + // Initialize initializes the backend with the given configuration + Initialize(config BackendConfig) error + + // Close cleans up backend resources + Close() error + + // GetStations returns all currently connected stations + GetStations() ([]Station, error) + + // StartEventMonitoring starts monitoring for station events + // Backends that don't support real-time events will poll and simulate events + StartEventMonitoring(callbacks EventCallbacks) error + + // StopEventMonitoring stops event monitoring + StopEventMonitoring() + + // SupportsRealTimeEvents returns true if backend supports real-time events (e.g., DBus) + // Returns false for polling-based backends (ARP, DHCP) + SupportsRealTimeEvents() bool +} + +// Station represents a connected device in a backend-agnostic format +type Station struct { + MAC string // Hardware MAC address (required, primary identifier) + IP string // IP address (may be empty for some backends initially) + Hostname string // Device hostname (may be empty) + Type string // Device type: "mobile", "laptop", "tablet", "unknown" + Signal int32 // Signal strength in dBm (0 if not available) + RxBytes uint64 // Received bytes (0 if not available) + TxBytes uint64 // Transmitted bytes (0 if not available) + ConnectedAt time.Time // When station connected (best effort) +} + +// EventCallbacks defines callback functions for station events. +// Backends call these when stations connect, disconnect, or update. +type EventCallbacks struct { + // OnStationConnected is called when a new station connects + OnStationConnected func(station Station) + + // OnStationDisconnected is called when a station disconnects + OnStationDisconnected func(mac string) + + // OnStationUpdated is called when station information changes + // (e.g., IP discovered, signal strength changed) + OnStationUpdated func(station Station) +} + +// BackendConfig provides configuration for backend initialization +type BackendConfig struct { + // Common + InterfaceName string // Network interface (e.g., "wlan1") + + // ARP-specific + ARPTablePath string // Path to /proc/net/arp (default: "/proc/net/arp") + + // DHCP-specific + DHCPLeasesPath string // Path to DHCP leases file + + // Hostapd-specific + HostapdInterface string // Hostapd interface name for DBus +} + +// GuessDeviceType attempts to guess device type from hostname and MAC address +func GuessDeviceType(hostname, mac string) string { + hostname = strings.ToLower(hostname) + + if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") { + return "mobile" + } else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") { + return "tablet" + } else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") { + return "laptop" + } + + // Guess by MAC prefix (OUI) + if len(mac) >= 8 { + macPrefix := strings.ToUpper(mac[:8]) + switch macPrefix { + case "00:50:56", "00:0C:29", "00:05:69": // VMware + return "laptop" + case "08:00:27": // VirtualBox + return "laptop" + default: + return "mobile" + } + } + + return "unknown" +} diff --git a/internal/station/dhcp/backend.go b/internal/station/dhcp/backend.go new file mode 100644 index 0000000..54abf95 --- /dev/null +++ b/internal/station/dhcp/backend.go @@ -0,0 +1,184 @@ +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{} + 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 +func (b *Backend) StopEventMonitoring() { + b.mu.Lock() + if !b.running { + b.mu.Unlock() + return + } + b.running = false + b.mu.Unlock() + + 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 b.callbacks.OnStationConnected != nil { + go b.callbacks.OnStationConnected(st) + } + } else { + // Check for updates (IP change, hostname change, etc.) + oldStation := b.lastStations[mac] + if oldStation.IP != st.IP || oldStation.Hostname != st.Hostname { + if b.callbacks.OnStationUpdated != nil { + go b.callbacks.OnStationUpdated(st) + } + } + } + } + + // Check for disconnected stations + for mac := range b.lastStations { + if _, exists := currentMap[mac]; !exists { + // Station disconnected + if b.callbacks.OnStationDisconnected != nil { + go b.callbacks.OnStationDisconnected(mac) + } + } + } + + // Update last state + b.lastStations = currentMap +} diff --git a/internal/station/dhcp/parser.go b/internal/station/dhcp/parser.go new file mode 100644 index 0000000..efcd583 --- /dev/null +++ b/internal/station/dhcp/parser.go @@ -0,0 +1,72 @@ +package dhcp + +import ( + "bufio" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/nemunaire/repeater/internal/models" +) + +// 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, nil +} + +// getARPInfo retrieves ARP table information using arp command +// Returns a map of IP -> MAC address +func getARPInfo() (map[string]string, error) { + arpInfo := make(map[string]string) + + cmd := exec.Command("arp", "-a") + output, err := cmd.Output() + if err != nil { + return arpInfo, err + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 { + ip := matches[1] + mac := matches[2] + arpInfo[ip] = mac + } + } + + return arpInfo, nil +} diff --git a/internal/station/factory.go b/internal/station/factory.go new file mode 100644 index 0000000..94bfc2b --- /dev/null +++ b/internal/station/factory.go @@ -0,0 +1,24 @@ +package station + +import ( + "fmt" + + "github.com/nemunaire/repeater/internal/station/arp" + "github.com/nemunaire/repeater/internal/station/backend" + "github.com/nemunaire/repeater/internal/station/dhcp" + "github.com/nemunaire/repeater/internal/station/hostapd" +) + +// createBackend creates a station backend based on the backend name +func createBackend(backendName string) (backend.StationBackend, error) { + switch backendName { + case "arp": + return arp.NewBackend(), nil + case "dhcp": + return dhcp.NewBackend(), nil + case "hostapd": + return hostapd.NewBackend(), nil + default: + return nil, fmt.Errorf("invalid station backend: %s (must be 'arp', 'dhcp', or 'hostapd')", backendName) + } +} diff --git a/internal/station/hostapd/backend.go b/internal/station/hostapd/backend.go new file mode 100644 index 0000000..ce5708a --- /dev/null +++ b/internal/station/hostapd/backend.go @@ -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) + } + } + } +} diff --git a/internal/station/hostapd/correlation.go b/internal/station/hostapd/correlation.go new file mode 100644 index 0000000..fe4361a --- /dev/null +++ b/internal/station/hostapd/correlation.go @@ -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() +} diff --git a/internal/station/hostapd/types.go b/internal/station/hostapd/types.go new file mode 100644 index 0000000..2521c1f --- /dev/null +++ b/internal/station/hostapd/types.go @@ -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 +} diff --git a/internal/station/station.go b/internal/station/station.go new file mode 100644 index 0000000..e6acaf1 --- /dev/null +++ b/internal/station/station.go @@ -0,0 +1,111 @@ +package station + +import ( + "sync" + + "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/station/backend" +) + +var ( + currentBackend backend.StationBackend + mu sync.RWMutex +) + +// Initialize initializes the station discovery backend +func Initialize(backendName string, config backend.BackendConfig) error { + mu.Lock() + defer mu.Unlock() + + // Close existing backend if any + if currentBackend != nil { + currentBackend.Close() + } + + // Create new backend + b, err := createBackend(backendName) + if err != nil { + return err + } + + // Initialize the backend + if err := b.Initialize(config); err != nil { + return err + } + + currentBackend = b + return nil +} + +// GetStations returns all connected stations as ConnectedDevice models +func GetStations() ([]models.ConnectedDevice, error) { + mu.RLock() + defer mu.RUnlock() + + if currentBackend == nil { + return nil, nil + } + + stations, err := currentBackend.GetStations() + if err != nil { + return nil, err + } + + // Convert backend.Station to models.ConnectedDevice + devices := make([]models.ConnectedDevice, len(stations)) + for i, s := range stations { + devices[i] = models.ConnectedDevice{ + Name: s.Hostname, + Type: s.Type, + MAC: s.MAC, + IP: s.IP, + } + } + + return devices, nil +} + +// StartEventMonitoring starts monitoring for station events +func StartEventMonitoring(callbacks backend.EventCallbacks) error { + mu.RLock() + defer mu.RUnlock() + + if currentBackend == nil { + return nil + } + + return currentBackend.StartEventMonitoring(callbacks) +} + +// StopEventMonitoring stops monitoring for station events +func StopEventMonitoring() { + mu.RLock() + defer mu.RUnlock() + + if currentBackend != nil { + currentBackend.StopEventMonitoring() + } +} + +// Close closes the current backend +func Close() { + mu.Lock() + defer mu.Unlock() + + if currentBackend != nil { + currentBackend.Close() + currentBackend = nil + } +} + +// SupportsRealTimeEvents returns true if the current backend supports real-time events +func SupportsRealTimeEvents() bool { + mu.RLock() + defer mu.RUnlock() + + if currentBackend == nil { + return false + } + + return currentBackend.SupportsRealTimeEvents() +}