Add configuration system and ARP-based device discovery

Implement comprehensive configuration management with CLI flags for WiFi interface, device discovery method, and file paths. Add ARP table parsing as an alternative to DHCP leases for more reliable device detection. Improve WiFi scanning to handle concurrent scan requests gracefully.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
nemunaire 2025-12-28 18:51:15 +07:00
commit 2b3a5b89f8
8 changed files with 156 additions and 28 deletions

View file

@ -25,7 +25,7 @@ func main() {
application := app.New(assets) application := app.New(assets)
// Initialize the application // Initialize the application
if err := application.Initialize(); err != nil { if err := application.Initialize(cfg); err != nil {
log.Fatalf("Failed to initialize application: %v", err) log.Fatalf("Failed to initialize application: %v", err)
} }
defer application.Shutdown() defer application.Shutdown()

View file

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/nemunaire/repeater/internal/config"
"github.com/nemunaire/repeater/internal/device" "github.com/nemunaire/repeater/internal/device"
"github.com/nemunaire/repeater/internal/hotspot" "github.com/nemunaire/repeater/internal/hotspot"
"github.com/nemunaire/repeater/internal/logging" "github.com/nemunaire/repeater/internal/logging"
@ -103,8 +104,8 @@ func ToggleHotspot(c *gin.Context, status *models.SystemStatus) {
} }
// GetDevices returns connected devices // GetDevices returns connected devices
func GetDevices(c *gin.Context) { func GetDevices(c *gin.Context, cfg *config.Config) {
devices, err := device.GetConnectedDevices() devices, err := device.GetConnectedDevices(cfg)
if err != nil { if err != nil {
logging.AddLog("Système", "Erreur lors de la récupération des appareils: "+err.Error()) 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des appareils"})

View file

@ -7,11 +7,12 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/nemunaire/repeater/internal/api/handlers" "github.com/nemunaire/repeater/internal/api/handlers"
"github.com/nemunaire/repeater/internal/config"
"github.com/nemunaire/repeater/internal/models" "github.com/nemunaire/repeater/internal/models"
) )
// SetupRouter creates and configures the Gin router // SetupRouter creates and configures the Gin router
func SetupRouter(status *models.SystemStatus, assets embed.FS) *gin.Engine { func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.FS) *gin.Engine {
// Set Gin to release mode (can be overridden with GIN_MODE env var) // Set Gin to release mode (can be overridden with GIN_MODE env var)
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
@ -38,7 +39,9 @@ func SetupRouter(status *models.SystemStatus, assets embed.FS) *gin.Engine {
} }
// Device endpoints // Device endpoints
api.GET("/devices", handlers.GetDevices) api.GET("/devices", func(c *gin.Context) {
handlers.GetDevices(c, cfg)
})
// Status endpoint // Status endpoint
api.GET("/status", func(c *gin.Context) { api.GET("/status", func(c *gin.Context) {

View file

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/nemunaire/repeater/internal/api" "github.com/nemunaire/repeater/internal/api"
"github.com/nemunaire/repeater/internal/config"
"github.com/nemunaire/repeater/internal/device" "github.com/nemunaire/repeater/internal/device"
"github.com/nemunaire/repeater/internal/logging" "github.com/nemunaire/repeater/internal/logging"
"github.com/nemunaire/repeater/internal/models" "github.com/nemunaire/repeater/internal/models"
@ -19,6 +20,7 @@ type App struct {
StatusMutex sync.RWMutex StatusMutex sync.RWMutex
StartTime time.Time StartTime time.Time
Assets embed.FS Assets embed.FS
Config *config.Config
} }
// New creates a new application instance // New creates a new application instance
@ -38,9 +40,12 @@ func New(assets embed.FS) *App {
} }
// Initialize initializes the application // Initialize initializes the application
func (a *App) Initialize() error { func (a *App) Initialize(cfg *config.Config) error {
// Store config reference
a.Config = cfg
// Initialize WiFi D-Bus connection // Initialize WiFi D-Bus connection
if err := wifi.Initialize(); err != nil { if err := wifi.Initialize(cfg.WifiInterface); err != nil {
return err return err
} }
@ -54,7 +59,7 @@ func (a *App) Initialize() error {
// Run starts the HTTP server // Run starts the HTTP server
func (a *App) Run(addr string) error { func (a *App) Run(addr string) error {
router := api.SetupRouter(&a.Status, a.Assets) router := api.SetupRouter(&a.Status, a.Config, a.Assets)
logging.AddLog("Système", "Serveur API démarré sur "+addr) logging.AddLog("Système", "Serveur API démarré sur "+addr)
return router.Run(addr) return router.Run(addr)
@ -88,7 +93,7 @@ func (a *App) periodicDeviceUpdate() {
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for range ticker.C {
devices, err := device.GetConnectedDevices() devices, err := device.GetConnectedDevices(a.Config)
if err != nil { if err != nil {
log.Printf("Error getting connected devices: %v", err) log.Printf("Error getting connected devices: %v", err)
continue continue

View file

@ -7,6 +7,10 @@ import (
// declareFlags registers flags for the structure Options. // declareFlags registers flags for the structure Options.
func declareFlags(o *Config) { func declareFlags(o *Config) {
flag.StringVar(&o.Bind, "bind", ":8081", "Bind port/socket") flag.StringVar(&o.Bind, "bind", ":8081", "Bind port/socket")
flag.StringVar(&o.WifiInterface, "wifi-interface", "wlan0", "WiFi interface name")
flag.BoolVar(&o.UseARPDiscovery, "use-arp-discovery", true, "Use ARP table for device discovery instead of DHCP leases")
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")
} }
// parseCLI parse the flags and treats extra args as configuration filename. // parseCLI parse the flags and treats extra args as configuration filename.

View file

@ -10,6 +10,10 @@ import (
type Config struct { type Config struct {
Bind string Bind string
WifiInterface string
UseARPDiscovery bool
DHCPLeasesPath string
ARPTablePath string
} }
// ConsolidateConfig fills an Options struct by reading configuration from // ConsolidateConfig fills an Options struct by reading configuration from
@ -20,6 +24,10 @@ func ConsolidateConfig() (opts *Config, err error) {
// Define defaults options // Define defaults options
opts = &Config{ opts = &Config{
Bind: ":8080", Bind: ":8080",
WifiInterface: "wlan0",
UseARPDiscovery: true,
DHCPLeasesPath: "/var/lib/dhcp/dhcpd.leases",
ARPTablePath: "/proc/net/arp",
} }
declareFlags(opts) declareFlags(opts)

View file

@ -2,25 +2,71 @@ package device
import ( import (
"bufio" "bufio"
"fmt"
"net"
"os" "os"
"os/exec" "os/exec"
"regexp" "regexp"
"strings" "strings"
"github.com/nemunaire/repeater/internal/config"
"github.com/nemunaire/repeater/internal/models" "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 // GetConnectedDevices returns a list of connected devices
func GetConnectedDevices() ([]models.ConnectedDevice, error) { 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 var devices []models.ConnectedDevice
// Read DHCP leases arpEntries, err := parseARPTable(cfg.ARPTablePath)
leases, err := parseDHCPLeases()
if err != nil { if err != nil {
return devices, err return devices, err
} }
// Get ARP information 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() arpInfo, err := getARPInfo()
if err != nil { if err != nil {
return devices, err return devices, err
@ -43,11 +89,57 @@ func GetConnectedDevices() ([]models.ConnectedDevice, error) {
return devices, nil 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 // parseDHCPLeases reads and parses DHCP lease file
func parseDHCPLeases() ([]models.DHCPLease, error) { func parseDHCPLeases(path string) ([]models.DHCPLease, error) {
var leases []models.DHCPLease var leases []models.DHCPLease
file, err := os.Open("/var/lib/dhcp/dhcpd.leases") file, err := os.Open(path)
if err != nil { if err != nil {
return leases, err return leases, err
} }

View file

@ -12,7 +12,6 @@ import (
) )
const ( const (
WLAN_INTERFACE = "wlan0"
WPA_CONF = "/etc/wpa_supplicant/wpa_supplicant.conf" WPA_CONF = "/etc/wpa_supplicant/wpa_supplicant.conf"
// D-Bus constants for wpa_supplicant // D-Bus constants for wpa_supplicant
@ -25,12 +24,14 @@ const (
) )
var ( var (
wlanInterface string
dbusConn *dbus.Conn dbusConn *dbus.Conn
wpaSupplicant dbus.BusObject wpaSupplicant dbus.BusObject
) )
// Initialize initializes the WiFi service with D-Bus connection // Initialize initializes the WiFi service with D-Bus connection
func Initialize() error { func Initialize(interfaceName string) error {
wlanInterface = interfaceName
var err error var err error
dbusConn, err = dbus.SystemBus() dbusConn, err = dbus.SystemBus()
if err != nil { if err != nil {
@ -55,15 +56,29 @@ func ScanNetworks() ([]models.WiFiNetwork, error) {
return nil, fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) return nil, fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err)
} }
// Trigger a scan
wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
// Check current scanning state
scanning, err := wifiInterface.GetProperty(WPA_INTERFACE_IFACE + ".Scanning")
if err == nil && scanning.Value().(bool) {
// Scan already in progress, wait for it to complete
time.Sleep(3 * time.Second)
} else {
// Trigger a scan
call := wifiInterface.Call(WPA_INTERFACE_IFACE+".Scan", 0, map[string]dbus.Variant{"Type": dbus.MakeVariant("active")}) call := wifiInterface.Call(WPA_INTERFACE_IFACE+".Scan", 0, map[string]dbus.Variant{"Type": dbus.MakeVariant("active")})
if call.Err != nil { if call.Err != nil {
// If scan is rejected, it might be too soon after a previous scan
// Try to use cached results instead
if strings.Contains(call.Err.Error(), "rejected") {
// Continue to retrieve existing BSS list
} else {
return nil, fmt.Errorf("erreur lors du scan: %v", call.Err) return nil, fmt.Errorf("erreur lors du scan: %v", call.Err)
} }
} else {
// Wait for scan to complete // Wait for scan to complete
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
}
}
// Retrieve BSS list // Retrieve BSS list
bssePaths, err := wifiInterface.GetProperty(WPA_INTERFACE_IFACE + ".BSSs") bssePaths, err := wifiInterface.GetProperty(WPA_INTERFACE_IFACE + ".BSSs")
@ -224,7 +239,7 @@ func IsConnected() bool {
// IsConnectedLegacy checks if WiFi is connected using iwconfig (fallback) // IsConnectedLegacy checks if WiFi is connected using iwconfig (fallback)
func IsConnectedLegacy() bool { func IsConnectedLegacy() bool {
cmd := exec.Command("iwconfig", WLAN_INTERFACE) cmd := exec.Command("iwconfig", wlanInterface)
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return false return false
@ -236,7 +251,7 @@ func IsConnectedLegacy() bool {
// getWiFiInterfacePath retrieves the D-Bus path for the WiFi interface // getWiFiInterfacePath retrieves the D-Bus path for the WiFi interface
func getWiFiInterfacePath() (dbus.ObjectPath, error) { func getWiFiInterfacePath() (dbus.ObjectPath, error) {
var interfacePath dbus.ObjectPath var interfacePath dbus.ObjectPath
err := wpaSupplicant.Call(WPA_SUPPLICANT_IFACE+".GetInterface", 0, WLAN_INTERFACE).Store(&interfacePath) err := wpaSupplicant.Call(WPA_SUPPLICANT_IFACE+".GetInterface", 0, wlanInterface).Store(&interfacePath)
if err != nil { if err != nil {
return "", fmt.Errorf("erreur lors de la récupération des interfaces: %v", err) return "", fmt.Errorf("erreur lors de la récupération des interfaces: %v", err)
} }