Refactor stations discovery and add hostapd discovery

This commit is contained in:
nemunaire 2026-01-01 23:29:34 +07:00
commit 2922a03724
15 changed files with 1339 additions and 249 deletions

View file

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

View file

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