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{} stopOnce sync.Once 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), Vendor: backend.LookupVendor(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. Idempotent — see hostapd backend. 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.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 cb := b.callbacks.OnStationConnected; cb != nil { stCopy := st backend.SafeGo("OnStationConnected", func() { cb(stCopy) }) } } else { // Check for updates (IP change, hostname change, etc.) oldStation := b.lastStations[mac] if oldStation.IP != st.IP || oldStation.Hostname != st.Hostname { if cb := b.callbacks.OnStationUpdated; cb != nil { stCopy := st backend.SafeGo("OnStationUpdated", func() { cb(stCopy) }) } } } } // Check for disconnected stations for mac := range b.lastStations { if _, exists := currentMap[mac]; !exists { // Station disconnected if cb := b.callbacks.OnStationDisconnected; cb != nil { macCopy := mac backend.SafeGo("OnStationDisconnected", func() { cb(macCopy) }) } } } // Update last state b.lastStations = currentMap }