Compare commits

...

11 commits

Author SHA1 Message Date
92b6113d72 app: Require link carrier to consider Ethernet uplink active
A static IPv4 (e.g. the hotspot gateway address on eth0) was wrongly
classified as a DHCP uplink on stacks where ip(8) omits the "dynamic"
flag, and a NO-CARRIER interface with any address was reported as up.
Gate probeEthernet on /sys/class/net/<iface>/carrier == 1, and accept
either the dynamic flag or a finite valid_lft as the DHCP signal so
BusyBox's ip output is handled too.
2026-05-02 11:37:28 +08:00
8b1debdddc app: Surface Ethernet uplink in the UI and gate wpa_supplicant access
When the configured Ethernet interface holds a DHCP-assigned IPv4 at
startup, the app now skips wifi.Initialize / StartEventMonitoring and
guards every wifi.* wrapper against a nil backend. This prevents D-Bus
calls to fi.w1.wpa_supplicant1 from re-activating the daemon via
dbus-activation, honoring the "do nothing" intent of the Ethernet path.

The probed state is exposed in SystemStatus and rendered in the header
as a third pill ("Ethernet · <IP>"); a new "disabled" connectionState
covers the WiFi pill in this mode.
2026-05-02 11:24:36 +08:00
5a3942f351 station/hostapd: Resolve station IPs via udhcpd leases and ARP fallback
The hostapd backend never populated IPs: NewDHCPCorrelator was defined
but never instantiated, and even when it was, the parser only handled
ISC dhcpd's text format. On a BusyBox-based router using udhcpd, every
device showed up with an empty IP.

Two fixes:

- Add a udhcpd binary lease parser. The format is documented in
  busybox/networking/udhcp/dhcpd.{h,c}: an 8-byte big-endian unix-time
  header followed by 36-byte dyn_lease records (expires, IP, MAC,
  20-byte hostname, 2-byte pad). ParseLeases auto-detects the format
  by inspecting the header so the same code path handles both udhcpd
  and ISC text leases.

- Wire the DHCPCorrelator into Backend.Initialize and have it merge
  two sources: ARP first (universal IP fallback for any station that
  has been talked to) and DHCP leases on top (authoritative, carries
  the hostname). ARP fills the gap when leases are missing or the
  station uses a static IP; DHCP wins on conflict.

Default DHCPLeasesPath updated to /var/lib/udhcpd/udhcpd.leases — the
common BusyBox path. Configurable as before.
2026-05-02 11:07:37 +08:00
249217d4ad app: Use service(8) instead of systemctl to start wpa_supplicant
The target system uses sysvinit-style service management, not systemd.
2026-05-01 22:35:59 +08:00
d57c08a6c4 app: Start wpa_supplicant only when Ethernet has no DHCP lease
Probe the configured Ethernet interface (default eth0, overridable via
-ethernet-interface) at startup. If no DHCP-assigned IPv4 is present,
start the wpa_supplicant service so the WiFi backend has something to
talk to; otherwise leave it alone and rely on the wired uplink.
2026-05-01 22:35:07 +08:00
70140bc289 station: Identify devices by MAC OUI vendor lookup
Embed the IEEE OUI registry (~1MB pre-processed text file) and resolve
the vendor for every station MAC. Locally administered MACs (U/L bit
set, used by iOS/Android private addresses and virtual interfaces) are
skipped so we don't return spurious matches against randomized prefixes.

The vendor name shows up in the device card as a secondary line, and
falls back to the title position when no DHCP hostname is available —
"Apple" with the IP and MAC is far more useful than "Sans nom".

The lookup table loads lazily (sync.Once) on the first call so the
~40k-entry parse only runs when the station discovery code is exercised.
2026-05-01 22:32:57 +08:00
950f73371c station: Surface signal, traffic and connection time in API and UI
Hostapd already parsed signal strength and rx/tx counters but the
station -> ConnectedDevice conversion threw them away. Add signalDbm,
rxBytes, txBytes and connectedAt to the OpenAPI schema and the
ConnectedDevice model, and centralise the conversion in
station.ToConnectedDevice so handlers, the periodic refresh and the
event callbacks all serialise the same shape.

Two follow-on bugs surfaced while wiring this up:

- The hostapd backend only stored station entries on first contact.
  Subsequent polls were dropped, so signal and byte counters never
  refreshed. Reconcile updates in checkStationChanges.
- ConnectedAt was reset to time.Now() on every conversion. Track
  FirstSeen on HostapdStation when the station joins, and preserve
  the timestamp across periodic refreshes in app.go so the UI's
  "connected since" badge is stable.

Frontend gains a metrics row on each device card with signal bars,
total traffic and a live duration. Falls back gracefully when a
backend (DHCP, ARP) doesn't expose these fields.
2026-05-01 22:26:43 +08:00
a758c331c0 station: Default device type to "unknown" and propagate DHCP hostname
GuessDeviceType silently returned "mobile" for any device whose hostname
or MAC OUI didn't match a known pattern, so the UI labelled every
unidentified device as a phone. Default to "unknown" instead and broaden
hostname matching (pixel/galaxy, thinkpad, imac/-pc, QEMU OUI).

The hostapd backend was also dropping DHCP hostnames on the floor: the
correlator only forwarded MAC->IP, and convertStation hard-coded the
hostname to "". Replace UpdateIPMapping with UpdateLeaseInfo that carries
both maps so hostnames flow through to ConnectedDevice.Name.

Frontend gains a "Sans nom" fallback when no hostname is available and
French labels for the device-type badge.
2026-05-01 22:21:14 +08:00
0797f7dd50 wpa_supplicant: Use log package and tolerate GetNetworks failures
Switch warning prints to the log package for consistent output, and
fall back to AddNetwork when listing existing networks fails instead
of aborting Connect entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:14:38 +08:00
07f8673f2f Harden API surface and station/wifi backends
Bind to localhost by default and stop echoing backend errors (which can
embed credentials or low-level details) back over the API and log
broadcast. Validate hotspot SSID/passphrase/channel before writing
hostapd.conf and tighten its mode to 0600 since it stores the WPA PSK.
Restrict WebSocket upgrades to same-origin so a LAN browser can't be
turned into a proxy for the API.

Guard shared state: status reads/writes go through StatusMutex (the
periodic updater races with the toggle and status handlers otherwise),
broadcastToWebSockets no longer mutates the client map under RLock, and
station-event callbacks now run under SafeGo so a panic in app code can't
take down the daemon. Stop channels in hostapd, dhcp, and iwd signal
monitors are now closed under sync.Once to survive concurrent Stop calls.

App.Shutdown is idempotent and waits for the periodic loops before
closing backends, so signal-driven and deferred shutdowns no longer race.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:56:50 +08:00
77370eff19 wpa_supplicant: Reuse existing networks and preserve saved ones on connect
Connect() called AddNetwork unconditionally, creating duplicate entries for
the same SSID, and SelectNetwork's side-effect of disabling all other
networks was being persisted by SaveConfig — making previously saved
networks appear erased. Disconnect() also removed the current network from
the config. Now reuse an existing network entry when the SSID matches,
re-enable other networks after SelectNetwork, and keep entries on
disconnect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:25:30 +08:00
32 changed files with 40661 additions and 276 deletions

View file

@ -273,6 +273,10 @@ function updateStatusDisplay(status) {
wifiDot.className = 'status-dot active';
wifiText.textContent = `Roaming: ${status.connectedSSID}`;
break;
case 'disabled':
wifiDot.className = 'status-dot offline';
wifiText.textContent = 'WiFi désactivé';
break;
case 'disconnected':
default:
wifiDot.className = 'status-dot offline';
@ -280,6 +284,16 @@ function updateStatusDisplay(status) {
break;
}
// Update Ethernet uplink badge
const ethernetStatus = document.getElementById('ethernetStatus');
const ethernetText = ethernetStatus.querySelector('.status-text');
if (status.ethernetStatus?.active) {
ethernetText.textContent = `Ethernet · ${status.ethernetStatus.ipv4 || status.ethernetStatus.interface}`;
ethernetStatus.hidden = false;
} else {
ethernetStatus.hidden = true;
}
// Update hotspot status badge
const hotspotStatus = document.getElementById('hotspotStatus');
const hotspotDot = hotspotStatus.querySelector('.status-dot');
@ -401,20 +415,112 @@ function displayDevices(devices) {
const deviceCard = document.createElement('div');
deviceCard.className = 'device-card';
const displayName = device.name && device.name.trim() !== ''
? device.name
: (device.vendor ? device.vendor : 'Sans nom');
const showVendor = device.vendor && device.vendor !== displayName;
const metrics = buildDeviceMetrics(device);
deviceCard.innerHTML = `
${getDeviceIcon(device.type)}
<div class="device-name">${escapeHtml(device.name)}</div>
<div class="device-type">${device.type}</div>
<div class="device-name">${escapeHtml(displayName)}</div>
<div class="device-type">${escapeHtml(formatDeviceType(device.type))}</div>
${showVendor ? `<div class="device-vendor">${escapeHtml(device.vendor)}</div>` : ''}
<div class="device-info">
<div>${device.ip}</div>
<div style="font-size: 0.75rem;">${device.mac}</div>
<div>${escapeHtml(device.ip || '—')}</div>
<div class="device-mac">${escapeHtml(device.mac)}</div>
</div>
${metrics ? `<div class="device-metrics">${metrics}</div>` : ''}
`;
devicesList.appendChild(deviceCard);
});
}
function formatDeviceType(type) {
const labels = {
mobile: 'Mobile',
tablet: 'Tablette',
laptop: 'Portable',
desktop: 'Ordinateur',
unknown: 'Inconnu'
};
return labels[type] || labels.unknown;
}
function buildDeviceMetrics(device) {
const parts = [];
if (typeof device.signalDbm === 'number' && device.signalDbm < 0) {
const level = signalLevelFromDbm(device.signalDbm);
parts.push(`
<span class="device-metric" title="${device.signalDbm} dBm">
${generateSignalBars(level)}
<span class="device-metric-value">${device.signalDbm} dBm</span>
</span>
`);
}
const total = (device.rxBytes || 0) + (device.txBytes || 0);
if (total > 0) {
parts.push(`
<span class="device-metric" title="Reçu ${formatBytes(device.rxBytes || 0)} / Émis ${formatBytes(device.txBytes || 0)}">
<span class="device-metric-label">Trafic</span>
<span class="device-metric-value">${formatBytes(total)}</span>
</span>
`);
}
if (device.connectedAt) {
const since = new Date(device.connectedAt);
if (!isNaN(since.getTime())) {
const elapsed = Math.max(0, Math.floor((Date.now() - since.getTime()) / 1000));
parts.push(`
<span class="device-metric" title="Connecté depuis ${since.toLocaleTimeString('fr-FR')}">
<span class="device-metric-label">Depuis</span>
<span class="device-metric-value">${formatDuration(elapsed)}</span>
</span>
`);
}
}
return parts.join('');
}
function signalLevelFromDbm(dbm) {
// dBm to 1-4 bars: -50+ excellent, -60 good, -70 fair, <=-80 weak
if (dbm >= -55) return 4;
if (dbm >= -65) return 3;
if (dbm >= -75) return 2;
return 1;
}
function formatBytes(bytes) {
if (!bytes || bytes < 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let value = bytes;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
const precision = value >= 100 || i === 0 ? 0 : 1;
return `${value.toFixed(precision)} ${units[i]}`;
}
function formatDuration(seconds) {
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60);
const remM = m % 60;
if (h < 24) return remM > 0 ? `${h}h${remM.toString().padStart(2, '0')}` : `${h}h`;
const d = Math.floor(h / 24);
const remH = h % 24;
return remH > 0 ? `${d}j ${remH}h` : `${d}j`;
}
function addLogEntry(log) {
const logContainer = document.getElementById('logContainer');

View file

@ -6,7 +6,7 @@
<title>Travel Router</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,400&display=swap" rel="stylesheet">
<!--link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,400&display=swap" rel="stylesheet"-->
<link rel="stylesheet" href="/style.css">
</head>
<body>
@ -25,6 +25,10 @@
<span class="status-dot offline"></span>
<span class="status-text">Disconnected</span>
</div>
<div class="status-badge" id="ethernetStatus" hidden>
<span class="status-dot active"></span>
<span class="status-text">Ethernet</span>
</div>
<div class="status-badge" id="hotspotStatus">
<span class="status-dot active"></span>
<span class="status-text">Hotspot actif</span>

View file

@ -553,7 +553,16 @@ body {
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.125rem;
}
.device-vendor {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.375rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-info {
@ -562,6 +571,49 @@ body {
font-variant-numeric: tabular-nums;
}
.device-mac {
font-size: 0.6875rem;
color: var(--text-tertiary);
letter-spacing: 0.02em;
margin-top: 0.125rem;
}
.device-metrics {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem 0.75rem;
margin-top: 0.625rem;
padding-top: 0.625rem;
border-top: 1px solid var(--border-light);
}
.device-metric {
display: inline-flex;
align-items: center;
gap: 0.3125rem;
font-size: 0.6875rem;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.device-metric-label {
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.625rem;
}
.device-metric-value {
color: var(--text);
font-weight: 500;
}
.device-metric .signal-bars {
transform: scale(0.85);
transform-origin: left center;
}
.device-placeholder {
grid-column: 1 / -1;
text-align: center;

View file

@ -1,7 +1,10 @@
package handlers
import (
"log"
"net/http"
"strconv"
"sync"
"github.com/gin-gonic/gin"
"github.com/nemunaire/repeater/internal/config"
@ -28,12 +31,13 @@ func GetWiFiNetworks(c *gin.Context) {
func ScanWiFi(c *gin.Context) {
networks, err := wifi.ScanNetworks()
if err != nil {
logging.AddLog("WiFi", "Erreur lors du scan: "+err.Error())
log.Printf("WiFi scan error: %v", err)
logging.AddLog("WiFi", "Erreur lors du scan")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du scan WiFi"})
return
}
logging.AddLog("WiFi", "Scan terminé - "+string(rune(len(networks)))+" réseaux trouvés")
logging.AddLog("WiFi", "Scan terminé - "+strconv.Itoa(len(networks))+" réseaux trouvés")
c.JSON(http.StatusOK, networks)
}
@ -45,16 +49,19 @@ func ConnectWiFi(c *gin.Context) {
return
}
logging.AddLog("WiFi", "Tentative de connexion à "+req.SSID)
logging.AddLog("WiFi", "Tentative de connexion")
err := wifi.Connect(req.SSID, req.Password)
if err != nil {
logging.AddLog("WiFi", "Échec de connexion: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "Échec de connexion: " + err.Error()})
// Backend errors may include credentials or low-level details
// (dbus paths, kernel messages); keep them in the server log only.
log.Printf("WiFi connect error: %v", err)
logging.AddLog("WiFi", "Échec de connexion")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Échec de connexion"})
return
}
logging.AddLog("WiFi", "Connexion réussie à "+req.SSID)
logging.AddLog("WiFi", "Connexion réussie")
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
@ -64,8 +71,9 @@ func DisconnectWiFi(c *gin.Context) {
err := wifi.Disconnect()
if err != nil {
logging.AddLog("WiFi", "Erreur de déconnexion: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de déconnexion: " + err.Error()})
log.Printf("WiFi disconnect error: %v", err)
logging.AddLog("WiFi", "Erreur de déconnexion")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de déconnexion"})
return
}
@ -83,19 +91,24 @@ func ConfigureHotspot(c *gin.Context) {
err := hotspot.Configure(config)
if err != nil {
logging.AddLog("Hotspot", "Erreur de configuration: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de configuration: " + err.Error()})
log.Printf("Hotspot configure error: %v", err)
logging.AddLog("Hotspot", "Erreur de configuration")
// Validation errors are user-actionable; keep them on the wire.
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
logging.AddLog("Hotspot", "Configuration mise à jour: "+config.SSID)
logging.AddLog("Hotspot", "Configuration mise à jour")
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
// ToggleHotspot handles hotspot enable/disable
func ToggleHotspot(c *gin.Context, status *models.SystemStatus) {
// Determine current state
func ToggleHotspot(c *gin.Context, status *models.SystemStatus, statusMu *sync.RWMutex) {
// Determine current state under read lock to avoid racing with the
// periodic status updater that mutates HotspotStatus.
statusMu.RLock()
isEnabled := status.HotspotStatus != nil && status.HotspotStatus.State == "ENABLED"
statusMu.RUnlock()
var err error
if !isEnabled {
@ -107,13 +120,18 @@ func ToggleHotspot(c *gin.Context, status *models.SystemStatus) {
}
if err != nil {
logging.AddLog("Hotspot", "Erreur: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur: " + err.Error()})
log.Printf("Hotspot toggle error: %v", err)
logging.AddLog("Hotspot", "Erreur")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur"})
return
}
// Update status immediately
status.HotspotStatus = hotspot.GetDetailedStatus()
// Refresh status under the write lock; the periodic updater touches
// the same field on a 5s ticker.
newStatus := hotspot.GetDetailedStatus()
statusMu.Lock()
status.HotspotStatus = newStatus
statusMu.Unlock()
c.JSON(http.StatusOK, gin.H{"enabled": !isEnabled})
}
@ -122,7 +140,8 @@ func ToggleHotspot(c *gin.Context, status *models.SystemStatus) {
func GetDevices(c *gin.Context, cfg *config.Config) {
devices, err := station.GetStations()
if err != nil {
logging.AddLog("Système", "Erreur lors de la récupération des appareils: "+err.Error())
log.Printf("GetDevices error: %v", err)
logging.AddLog("Système", "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"})
return
}
@ -130,9 +149,13 @@ func GetDevices(c *gin.Context, cfg *config.Config) {
c.JSON(http.StatusOK, devices)
}
// GetStatus returns system status
func GetStatus(c *gin.Context, status *models.SystemStatus) {
c.JSON(http.StatusOK, status)
// GetStatus returns system status. Reads under the shared lock so the JSON
// encoder doesn't observe a torn ConnectedDevices slice mid-update.
func GetStatus(c *gin.Context, status *models.SystemStatus, statusMu *sync.RWMutex) {
statusMu.RLock()
snapshot := *status
statusMu.RUnlock()
c.JSON(http.StatusOK, snapshot)
}
// GetLogs returns system logs

View file

@ -3,6 +3,7 @@ package handlers
import (
"log"
"net/http"
"net/url"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
@ -10,9 +11,23 @@ import (
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
CheckOrigin: checkSameOrigin,
}
// checkSameOrigin only accepts WebSocket upgrades whose Origin header matches
// the request's Host. Without this, any web page a LAN user visits could open
// a WebSocket against the router's API.
func checkSameOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
// Non-browser clients (curl, native apps) do not send Origin.
return true
},
}
u, err := url.Parse(origin)
if err != nil {
return false
}
return u.Host == r.Host
}
// WebSocketLogs handles WebSocket connections for real-time logs

View file

@ -4,6 +4,7 @@ import (
"embed"
"io/fs"
"net/http"
"sync"
"github.com/gin-gonic/gin"
"github.com/nemunaire/repeater/internal/api/handlers"
@ -12,7 +13,7 @@ import (
)
// SetupRouter creates and configures the Gin router
func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.FS) *gin.Engine {
func SetupRouter(status *models.SystemStatus, statusMu *sync.RWMutex, cfg *config.Config, assets embed.FS) *gin.Engine {
// Set Gin to release mode (can be overridden with GIN_MODE env var)
gin.SetMode(gin.ReleaseMode)
@ -35,7 +36,7 @@ func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.F
{
hotspot.POST("/config", handlers.ConfigureHotspot)
hotspot.POST("/toggle", func(c *gin.Context) {
handlers.ToggleHotspot(c, status)
handlers.ToggleHotspot(c, status, statusMu)
})
}
@ -46,7 +47,7 @@ func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.F
// Status endpoint
api.GET("/status", func(c *gin.Context) {
handlers.GetStatus(c, status)
handlers.GetStatus(c, status, statusMu)
})
// Log endpoints

View file

@ -28,6 +28,10 @@ type App struct {
Assets embed.FS
Config *config.Config
SyslogTailer *syslog.SyslogTailer
stopCh chan struct{}
stopOnce sync.Once
wg sync.WaitGroup
}
// New creates a new application instance
@ -44,6 +48,7 @@ func New(assets embed.FS) *App {
},
StartTime: time.Now(),
Assets: assets,
stopCh: make(chan struct{}),
}
}
@ -52,15 +57,28 @@ func (a *App) Initialize(cfg *config.Config) error {
// Store config reference
a.Config = cfg
// Initialize WiFi backend
if err := wifi.Initialize(cfg.WifiInterface, cfg.WifiBackend); err != nil {
return err
}
// Decide whether the Ethernet uplink already provides connectivity. When
// it does we deliberately skip every wpa_supplicant interaction below —
// the daemon is dbus-activatable, so any call into the WiFi backend
// would re-spawn it and undo this choice.
eth := ensureUplink(cfg.EthernetInterface)
a.StatusMutex.Lock()
a.Status.EthernetStatus = eth
a.StatusMutex.Unlock()
// Start WiFi event monitoring
if err := wifi.StartEventMonitoring(); err != nil {
log.Printf("Warning: WiFi event monitoring failed: %v", err)
// Don't fail - polling fallback still works
if !eth.Active {
// Initialize WiFi backend
if err := wifi.Initialize(cfg.WifiInterface, cfg.WifiBackend); err != nil {
return err
}
// Start WiFi event monitoring
if err := wifi.StartEventMonitoring(); err != nil {
log.Printf("Warning: WiFi event monitoring failed: %v", err)
// Don't fail - polling fallback still works
}
} else {
log.Printf("Skipping WiFi backend init: Ethernet uplink active on %s", eth.Interface)
}
// Initialize station backend
@ -99,6 +117,7 @@ func (a *App) Initialize(cfg *config.Config) error {
}
// Start periodic tasks
a.wg.Add(2)
go a.periodicStatusUpdate()
go a.periodicDeviceUpdate()
@ -108,26 +127,36 @@ func (a *App) Initialize(cfg *config.Config) error {
// Run starts the HTTP server
func (a *App) Run(addr string) error {
router := api.SetupRouter(&a.Status, a.Config, a.Assets)
router := api.SetupRouter(&a.Status, &a.StatusMutex, a.Config, a.Assets)
logging.AddLog("Système", "Serveur API démarré sur "+addr)
return router.Run(addr)
}
// Shutdown gracefully shuts down the application
// Shutdown gracefully shuts down the application. Idempotent — main wires
// it both to a signal handler and a defer, and the second call must be a
// no-op (channel close panics otherwise; backend Close paths assume single
// invocation).
func (a *App) Shutdown() {
// Stop syslog tailing if running
if a.SyslogTailer != nil {
a.SyslogTailer.Stop()
}
a.stopOnce.Do(func() {
// Signal periodic loops to exit, then wait for them so we don't
// race with backends being closed below.
close(a.stopCh)
a.wg.Wait()
// Stop station monitoring and close backend
station.StopEventMonitoring()
station.Close()
// Stop syslog tailing if running
if a.SyslogTailer != nil {
a.SyslogTailer.Stop()
}
wifi.StopEventMonitoring()
wifi.Close()
logging.AddLog("Système", "Application arrêtée")
// Stop station monitoring and close backend
station.StopEventMonitoring()
station.Close()
wifi.StopEventMonitoring()
wifi.Close()
logging.AddLog("Système", "Application arrêtée")
})
}
// getSystemUptime reads system uptime from /proc/uptime
@ -178,14 +207,43 @@ func getInterfaceBytes(interfaceName string) (rxBytes, txBytes int64) {
// periodicStatusUpdate updates WiFi connection status periodically
func (a *App) periodicStatusUpdate() {
defer a.wg.Done()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
for {
select {
case <-a.stopCh:
return
case <-ticker.C:
}
var eth *models.EthernetStatus
if a.Config != nil {
if e, err := probeEthernet(a.Config.EthernetInterface); err == nil {
eth = e
}
}
a.StatusMutex.Lock()
a.Status.Connected = wifi.IsConnected()
a.Status.ConnectionState = wifi.GetConnectionState()
a.Status.ConnectedSSID = wifi.GetConnectedSSID()
if eth != nil {
a.Status.EthernetStatus = eth
}
// Skip every wifi.* call when the Ethernet uplink is the chosen
// path: the WiFi backend isn't initialized in that mode and the
// wrappers would otherwise return zero values; either way we don't
// want to risk dbus-activating wpa_supplicant from this hot loop.
ethActive := a.Status.EthernetStatus != nil && a.Status.EthernetStatus.Active
if !ethActive {
a.Status.Connected = wifi.IsConnected()
a.Status.ConnectionState = wifi.GetConnectionState()
a.Status.ConnectedSSID = wifi.GetConnectedSSID()
} else {
a.Status.Connected = false
a.Status.ConnectionState = "disabled"
a.Status.ConnectedSSID = ""
}
a.Status.Uptime = getSystemUptime()
// Get detailed hotspot status
@ -204,16 +262,37 @@ func (a *App) periodicStatusUpdate() {
// periodicDeviceUpdate updates connected devices list periodically
func (a *App) periodicDeviceUpdate() {
defer a.wg.Done()
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
for {
select {
case <-a.stopCh:
return
case <-ticker.C:
}
devices, err := station.GetStations()
if err != nil {
log.Printf("Error getting connected devices: %v", err)
}
a.StatusMutex.Lock()
// Preserve ConnectedAt across refreshes so the UI's "connected since"
// timestamp doesn't reset every poll. Backends that don't track a
// real connection time return time.Now() each call.
previous := make(map[string]time.Time, len(a.Status.ConnectedDevices))
for _, d := range a.Status.ConnectedDevices {
if !d.ConnectedAt.IsZero() {
previous[d.MAC] = d.ConnectedAt
}
}
for i := range devices {
if t, ok := previous[devices[i].MAC]; ok {
devices[i].ConnectedAt = t
}
}
a.Status.ConnectedDevices = devices
a.Status.ConnectedCount = len(devices)
a.StatusMutex.Unlock()
@ -225,13 +304,7 @@ 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,
}
device := station.ToConnectedDevice(st)
// Check if device already exists
found := false
@ -272,15 +345,16 @@ func (a *App) handleStationUpdated(st backend.Station) {
a.StatusMutex.Lock()
defer a.StatusMutex.Unlock()
// Update existing device
// Update existing device. Preserve the original ConnectedAt so the
// device card doesn't reset its "connected since" badge each time the
// IP or signal updates.
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,
updated := station.ToConnectedDevice(st)
if !d.ConnectedAt.IsZero() {
updated.ConnectedAt = d.ConnectedAt
}
a.Status.ConnectedDevices[i] = updated
break
}
}

87
internal/app/network.go Normal file
View file

@ -0,0 +1,87 @@
package app
import (
"fmt"
"log"
"os"
"os/exec"
"strings"
"github.com/nemunaire/repeater/internal/models"
)
// hasCarrier reports whether the kernel sees an active link on iface.
// `/sys/class/net/<iface>/carrier` is "1" when the link layer is up; reading
// it on an admin-down interface yields EINVAL, which we treat the same way.
func hasCarrier(iface string) bool {
data, err := os.ReadFile("/sys/class/net/" + iface + "/carrier")
if err != nil {
return false
}
return strings.TrimSpace(string(data)) == "1"
}
// probeEthernet reports the wired uplink state of iface. Active requires both
// a live link (`carrier == 1`) and a DHCP-assigned IPv4 — detected either by
// the `dynamic` flag emitted by full iproute2, or by a finite `valid_lft`
// (which BusyBox's `ip` does emit even when it omits the flag).
func probeEthernet(iface string) (*models.EthernetStatus, error) {
if !hasCarrier(iface) {
return &models.EthernetStatus{Active: false, Interface: iface}, nil
}
out, err := exec.Command("ip", "-4", "-o", "addr", "show", "dev", iface).Output()
if err != nil {
return nil, fmt.Errorf("ip addr show %s: %w", iface, err)
}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
var addr string
hasInet := false
isDHCP := false
for i, f := range fields {
switch f {
case "inet":
hasInet = true
if i+1 < len(fields) {
addr = fields[i+1]
}
case "dynamic":
isDHCP = true
case "valid_lft":
if i+1 < len(fields) && fields[i+1] != "forever" {
isDHCP = true
}
}
}
if hasInet && isDHCP {
if slash := strings.IndexByte(addr, '/'); slash >= 0 {
addr = addr[:slash]
}
return &models.EthernetStatus{Active: true, Interface: iface, IPv4: addr}, nil
}
}
return &models.EthernetStatus{Active: false, Interface: iface}, nil
}
// ensureUplink returns the current Ethernet status, and starts wpa_supplicant
// when no DHCP-assigned address is present. When Ethernet is already
// providing connectivity the WiFi backend is left alone — and crucially the
// caller must avoid any D-Bus call to fi.w1.wpa_supplicant1, which would
// otherwise re-activate the daemon via dbus-activation.
func ensureUplink(iface string) *models.EthernetStatus {
eth, err := probeEthernet(iface)
if err != nil {
log.Printf("Could not probe %s (%v); starting wpa_supplicant as fallback", iface, err)
eth = &models.EthernetStatus{Active: false, Interface: iface}
}
if eth.Active {
log.Printf("DHCP address %s on %s, leaving wpa_supplicant alone", eth.IPv4, iface)
return eth
}
log.Printf("No usable DHCP uplink on %s (carrier or lease missing), starting wpa_supplicant", iface)
if out, err := exec.Command("service", "wpa_supplicant", "start").CombinedOutput(); err != nil {
log.Printf("Failed to start wpa_supplicant: %v: %s", err, strings.TrimSpace(string(out)))
}
return eth
}

View file

@ -6,12 +6,13 @@ import (
// declareFlags registers flags for the structure Options.
func declareFlags(o *Config) {
flag.StringVar(&o.Bind, "bind", ":8081", "Bind port/socket")
flag.StringVar(&o.Bind, "bind", "127.0.0.1:8080", "Bind address (host:port). Defaults to localhost; set to ':8080' to expose on the LAN — but note: there is no built-in authentication.")
flag.StringVar(&o.WifiInterface, "wifi-interface", "wlan0", "WiFi interface name")
flag.StringVar(&o.HotspotInterface, "hotspot-interface", "wlan1", "Hotspot WiFi interface name")
flag.StringVar(&o.EthernetInterface, "ethernet-interface", "eth0", "Ethernet interface to probe for a DHCP-assigned address at startup; if no DHCP address is present, wpa_supplicant is started")
flag.StringVar(&o.WifiBackend, "wifi-backend", "", "WiFi backend to use: 'iwd' or 'wpasupplicant' (required)")
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.DHCPLeasesPath, "dhcp-leases-path", "/var/lib/udhcpd/udhcpd.leases", "Path to DHCP leases file (udhcpd binary or ISC dhcpd text format — auto-detected)")
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")
flag.StringVar(&o.SyslogPath, "syslog-path", "/var/log/messages", "Path to syslog file")

View file

@ -9,17 +9,18 @@ import (
)
type Config struct {
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
Bind string
WifiInterface string
HotspotInterface string
EthernetInterface 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
@ -29,15 +30,16 @@ type Config struct {
func ConsolidateConfig() (opts *Config, err error) {
// Define defaults options
opts = &Config{
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",
Bind: "127.0.0.1:8080",
WifiInterface: "wlan0",
HotspotInterface: "wlan1",
EthernetInterface: "eth0",
DHCPLeasesPath: "/var/lib/udhcpd/udhcpd.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)

View file

@ -1,7 +1,9 @@
package hotspot
import (
"errors"
"fmt"
"log"
"os"
"os/exec"
"strconv"
@ -15,8 +17,36 @@ const (
HOSTAPD_CONF = "/etc/hostapd/hostapd.conf"
)
// validateHotspotConfig rejects values that could break out of the
// key=value lines in hostapd.conf or violate WPA-PSK constraints.
func validateHotspotConfig(c models.HotspotConfig) error {
if l := len(c.SSID); l < 1 || l > 32 {
return fmt.Errorf("ssid must be 1-32 bytes (got %d)", l)
}
if strings.ContainsAny(c.SSID, "\r\n\x00") {
return fmt.Errorf("ssid contains forbidden control characters")
}
if l := len(c.Password); l < 8 || l > 63 {
return fmt.Errorf("password must be 8-63 ASCII characters (got %d)", l)
}
for _, r := range c.Password {
if r < 0x20 || r > 0x7e {
return fmt.Errorf("password must be printable ASCII")
}
}
// 2.4 GHz channels only — hw_mode is hardcoded to "g" below.
if c.Channel < 1 || c.Channel > 14 {
return fmt.Errorf("channel must be in 1-14 for 2.4GHz (got %d)", c.Channel)
}
return nil
}
// Configure updates the hotspot configuration
func Configure(config models.HotspotConfig) error {
if err := validateHotspotConfig(config); err != nil {
return err
}
hostapdConfig := fmt.Sprintf(`interface=%s
driver=nl80211
ssid=%s
@ -33,7 +63,7 @@ wpa_pairwise=TKIP
rsn_pairwise=CCMP
`, AP_INTERFACE, config.SSID, config.Channel, config.Password)
return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0644)
return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0600)
}
// Start starts the hotspot
@ -56,11 +86,17 @@ func Status() error {
}
// GetDetailedStatus retrieves detailed status information from hostapd_cli.
// Returns nil if hostapd is not running or if there's an error.
// Returns nil if hostapd is not running. Distinguishes a non-zero exit
// (expected: daemon stopped) from environmental failures (binary missing,
// permission denied) which are logged at most once per call.
func GetDetailedStatus() *models.HotspotStatus {
cmd := exec.Command("hostapd_cli", "status")
output, err := cmd.Output()
if err != nil {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
log.Printf("hostapd_cli status: %v", err)
}
return nil
}

View file

@ -79,16 +79,31 @@ func UnregisterWebSocketClient(conn *websocket.Conn) {
clientsMutex.Unlock()
}
// broadcastToWebSockets sends a log entry to all connected WebSocket clients
// broadcastToWebSockets sends a log entry to all connected WebSocket clients.
// Dead clients are collected during the read pass and pruned under the write
// lock afterwards — mutating the map while only holding RLock would race
// with concurrent Register/Unregister and panic Go's map runtime.
func broadcastToWebSockets(entry models.LogEntry) {
clientsMutex.RLock()
defer clientsMutex.RUnlock()
clients := make([]*websocket.Conn, 0, len(websocketClients))
for client := range websocketClients {
err := client.WriteJSON(entry)
if err != nil {
clients = append(clients, client)
}
clientsMutex.RUnlock()
var dead []*websocket.Conn
for _, client := range clients {
if err := client.WriteJSON(entry); err != nil {
client.Close()
delete(websocketClients, client)
dead = append(dead, client)
}
}
if len(dead) > 0 {
clientsMutex.Lock()
for _, c := range dead {
delete(websocketClients, c)
}
clientsMutex.Unlock()
}
}

View file

@ -13,10 +13,15 @@ type WiFiNetwork struct {
// ConnectedDevice represents a device connected to the hotspot
type ConnectedDevice struct {
Name string `json:"name"`
Type string `json:"type"`
MAC string `json:"mac"`
IP string `json:"ip"`
Name string `json:"name"`
Type string `json:"type"`
MAC string `json:"mac"`
IP string `json:"ip"`
Vendor string `json:"vendor,omitempty"`
SignalDbm int32 `json:"signalDbm,omitempty"`
RxBytes uint64 `json:"rxBytes,omitempty"`
TxBytes uint64 `json:"txBytes,omitempty"`
ConnectedAt time.Time `json:"connectedAt,omitzero"`
}
// HotspotConfig represents hotspot configuration
@ -26,6 +31,14 @@ type HotspotConfig struct {
Channel int `json:"channel"`
}
// EthernetStatus represents the state of the wired uplink interface.
// Active is true when the interface holds a DHCP-assigned IPv4 address.
type EthernetStatus struct {
Active bool `json:"active"`
Interface string `json:"interface"`
IPv4 string `json:"ipv4,omitempty"`
}
// HotspotStatus represents detailed hotspot status
type HotspotStatus struct {
State string `json:"state"` // ENABLED, DISABLED, etc.
@ -44,6 +57,7 @@ type SystemStatus struct {
ConnectionState string `json:"connectionState"` // Connection state: connected, disconnected, connecting, disconnecting, roaming
ConnectedSSID string `json:"connectedSSID"`
HotspotStatus *HotspotStatus `json:"hotspotStatus,omitempty"` // Detailed hotspot status
EthernetStatus *EthernetStatus `json:"ethernetStatus,omitempty"` // Wired uplink state, when probed
ConnectedCount int `json:"connectedCount"`
DataUsage float64 `json:"dataUsage"`
Uptime int64 `json:"uptime"`

View file

@ -59,11 +59,13 @@ func (b *Backend) GetStations() ([]backend.Station, error) {
for _, entry := range arpEntries {
// Only include entries with valid flags (2 = COMPLETE, 6 = COMPLETE|PERM)
if entry.Flags == 2 || entry.Flags == 6 {
mac := entry.HWAddress.String()
st := backend.Station{
MAC: entry.HWAddress.String(),
MAC: mac,
IP: entry.IP.String(),
Hostname: "", // No hostname available from ARP
Type: backend.GuessDeviceType("", entry.HWAddress.String()),
Type: backend.GuessDeviceType("", mac),
Vendor: backend.LookupVendor(mac),
Signal: 0, // Not available from ARP
RxBytes: 0, // Not available from ARP
TxBytes: 0, // Not available from ARP

View file

@ -17,6 +17,13 @@ type ARPEntry struct {
Device string
}
// ParseTable is the exported entry point for the /proc/net/arp parser.
// Other packages (e.g. the hostapd correlator) use it as a universal IP
// source for stations whose DHCP lease isn't available.
func ParseTable(path string) ([]ARPEntry, error) {
return parseARPTable(path)
}
// parseARPTable reads and parses ARP table from /proc/net/arp format
func parseARPTable(path string) ([]ARPEntry, error) {
var entries []ARPEntry
@ -26,7 +33,7 @@ func parseARPTable(path string) ([]ARPEntry, error) {
return entries, err
}
for _, line := range strings.Split(string(content), "\n") {
for line := range strings.SplitSeq(string(content), "\n") {
fields := strings.Fields(line)
if len(fields) > 5 {
var entry ARPEntry

View file

@ -0,0 +1,98 @@
package backend
import (
_ "embed"
"strings"
"sync"
)
// IEEE OUI registry as a compact text file: each line is 6 hex characters
// (uppercase, no separators) followed immediately by the organization name.
// Sourced from https://standards-oui.ieee.org/oui/oui.csv and pre-processed
// to strip the address column and common corporate suffixes (Inc, Ltd, ...).
//
//go:embed oui_data.txt
var ouiData string
var (
ouiOnce sync.Once
ouiTable map[string]string
)
func loadOUITable() {
ouiTable = make(map[string]string, 40000)
for line := range strings.SplitSeq(ouiData, "\n") {
if len(line) < 7 {
continue
}
ouiTable[line[:6]] = line[6:]
}
}
// LookupVendor returns the organization assigned to the OUI prefix of mac,
// or an empty string when the prefix is unknown or mac is malformed. Locally
// administered MACs (the U/L bit is set) are explicitly skipped — they are
// randomized by the device, not assigned to a vendor, so any match would be
// a false positive.
func LookupVendor(mac string) string {
ouiOnce.Do(loadOUITable)
prefix := normalizeOUI(mac)
if prefix == "" {
return ""
}
return ouiTable[prefix]
}
// normalizeOUI extracts the 6-hex-char OUI from a MAC address (any of
// "aa:bb:cc:...", "aa-bb-cc-...", "aabb.ccdd.eeff", "aabbcc...") and returns
// it uppercased. Returns "" for randomized MACs (locally administered bit
// set) or anything that doesn't parse.
func normalizeOUI(mac string) string {
var hex strings.Builder
hex.Grow(12)
for i := 0; i < len(mac) && hex.Len() < 6; i++ {
c := mac[i]
switch {
case c >= '0' && c <= '9', c >= 'A' && c <= 'F':
hex.WriteByte(c)
case c >= 'a' && c <= 'f':
hex.WriteByte(c - 32)
}
}
if hex.Len() < 6 {
return ""
}
out := hex.String()
// Skip locally administered MACs. The U/L bit is bit 1 of the first
// octet — when set, the MAC was generated by the device (private
// address randomization on iOS/Android, virtual interfaces, ...), not
// assigned by IEEE, so OUI lookup is meaningless.
first, ok := hexByte(out[0], out[1])
if !ok || first&0x02 != 0 {
return ""
}
return out
}
func hexByte(hi, lo byte) (byte, bool) {
h, ok1 := hexNibble(hi)
l, ok2 := hexNibble(lo)
if !ok1 || !ok2 {
return 0, false
}
return h<<4 | l, true
}
func hexNibble(c byte) (byte, bool) {
switch {
case c >= '0' && c <= '9':
return c - '0', true
case c >= 'A' && c <= 'F':
return c - 'A' + 10, true
case c >= 'a' && c <= 'f':
return c - 'a' + 10, true
}
return 0, false
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,57 @@
package backend
import "testing"
func TestLookupVendor(t *testing.T) {
cases := []struct {
mac string
want string
exact bool // when true, want must match exactly; otherwise non-empty is enough
comment string
}{
{"00:1c:b3:11:22:33", "Apple", true, "Apple OUI"},
{"f4:0f:24:11:22:33", "Apple", true, "Apple OUI uppercase"},
{"B8:27:EB:AA:BB:CC", "", false, "Raspberry Pi Foundation"},
{"dc:a6:32:11:22:33", "", false, "Raspberry Pi Trading"},
{"02:11:22:33:44:55", "", true, "locally administered (U/L bit set)"},
{"aa:bb:cc:dd:ee:ff", "", true, "locally administered randomized"},
{"", "", true, "empty mac"},
{"not a mac", "", true, "garbage"},
}
for _, tc := range cases {
got := LookupVendor(tc.mac)
if tc.exact {
if got != tc.want {
t.Errorf("%s: LookupVendor(%q) = %q, want %q", tc.comment, tc.mac, got, tc.want)
}
continue
}
if got == "" {
t.Errorf("%s: LookupVendor(%q) returned empty, expected non-empty", tc.comment, tc.mac)
}
}
}
func TestNormalizeOUI(t *testing.T) {
cases := []struct {
in string
want string
}{
{"00:1c:b3:11:22:33", "001CB3"},
{"00-1c-b3-11-22-33", "001CB3"},
{"001c.b311.2233", "001CB3"},
{"001cb3112233", "001CB3"},
{"02:11:22:33:44:55", ""}, // U/L bit set
{"03:11:22:33:44:55", ""}, // U/L bit set
{"06:11:22:33:44:55", ""}, // U/L bit set
{"01:00:5e:00:00:00", "01005E"}, // bit 0 (multicast) is fine, only bit 1 matters
{"abcd", ""}, // too short
}
for _, tc := range cases {
if got := normalizeOUI(tc.in); got != tc.want {
t.Errorf("normalizeOUI(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}

View file

@ -1,10 +1,27 @@
package backend
import (
"log"
"runtime/debug"
"strings"
"time"
)
// SafeGo runs fn in a goroutine and recovers from panics so a misbehaving
// callback (registered by app code) cannot bring down the daemon. Backends
// dispatch event callbacks asynchronously; without recovery, a single
// panic in user code crashes the whole process.
func SafeGo(name string, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("panic in %s: %v\n%s", name, r, debug.Stack())
}
}()
fn()
}()
}
// StationBackend defines the interface for station/device discovery backends.
// Implementations include ARP-based, DHCP-based, and Hostapd DBus-based discovery.
type StationBackend interface {
@ -35,6 +52,7 @@ type Station struct {
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"
Vendor string // Vendor inferred from MAC OUI (empty when unknown or randomized)
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)
@ -70,19 +88,26 @@ type BackendConfig struct {
HostapdInterface string // Hostapd interface name for DBus
}
// GuessDeviceType attempts to guess device type from hostname and MAC address
// GuessDeviceType attempts to guess device type from hostname and MAC address.
// Returns "unknown" when no signal is strong enough — the frontend renders that
// as a neutral icon rather than mislabeling unknown devices as "mobile".
func GuessDeviceType(hostname, mac string) string {
hostname = strings.ToLower(hostname)
if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") {
if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") ||
strings.Contains(hostname, "pixel") || strings.Contains(hostname, "galaxy") {
return "mobile"
} else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") {
return "tablet"
} else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") {
} else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") ||
strings.Contains(hostname, "thinkpad") {
return "laptop"
} else if strings.Contains(hostname, "imac") || strings.Contains(hostname, "desktop") ||
strings.Contains(hostname, "-pc") {
return "desktop"
}
// Guess by MAC prefix (OUI)
// Guess by MAC prefix (OUI) — VM hypervisor MACs are almost always laptops
if len(mac) >= 8 {
macPrefix := strings.ToUpper(mac[:8])
switch macPrefix {
@ -90,8 +115,8 @@ func GuessDeviceType(hostname, mac string) string {
return "laptop"
case "08:00:27": // VirtualBox
return "laptop"
default:
return "mobile"
case "52:54:00": // QEMU/KVM
return "laptop"
}
}

View file

@ -13,6 +13,7 @@ type Backend struct {
lastStations map[string]backend.Station // Key: MAC address
callbacks backend.EventCallbacks
stopChan chan struct{}
stopOnce sync.Once
mu sync.RWMutex
running bool
}
@ -32,7 +33,7 @@ func (b *Backend) Initialize(config backend.BackendConfig) error {
b.dhcpLeasesPath = config.DHCPLeasesPath
if b.dhcpLeasesPath == "" {
b.dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases"
b.dhcpLeasesPath = "/var/lib/udhcpd/udhcpd.leases"
}
return nil
@ -71,6 +72,7 @@ func (b *Backend) GetStations() ([]backend.Station, error) {
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
@ -101,7 +103,7 @@ func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
return nil
}
// StopEventMonitoring stops event monitoring
// StopEventMonitoring stops event monitoring. Idempotent — see hostapd backend.
func (b *Backend) StopEventMonitoring() {
b.mu.Lock()
if !b.running {
@ -111,7 +113,7 @@ func (b *Backend) StopEventMonitoring() {
b.running = false
b.mu.Unlock()
close(b.stopChan)
b.stopOnce.Do(func() { close(b.stopChan) })
}
// SupportsRealTimeEvents returns false (DHCP is polling-based)
@ -155,15 +157,17 @@ func (b *Backend) checkForChanges() {
for mac, st := range currentMap {
if _, exists := b.lastStations[mac]; !exists {
// New station connected
if b.callbacks.OnStationConnected != nil {
go b.callbacks.OnStationConnected(st)
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 b.callbacks.OnStationUpdated != nil {
go b.callbacks.OnStationUpdated(st)
if cb := b.callbacks.OnStationUpdated; cb != nil {
stCopy := st
backend.SafeGo("OnStationUpdated", func() { cb(stCopy) })
}
}
}
@ -173,8 +177,9 @@ func (b *Backend) checkForChanges() {
for mac := range b.lastStations {
if _, exists := currentMap[mac]; !exists {
// Station disconnected
if b.callbacks.OnStationDisconnected != nil {
go b.callbacks.OnStationDisconnected(mac)
if cb := b.callbacks.OnStationDisconnected; cb != nil {
macCopy := mac
backend.SafeGo("OnStationDisconnected", func() { cb(macCopy) })
}
}
}

View file

@ -2,6 +2,11 @@ package dhcp
import (
"bufio"
"bytes"
"encoding/binary"
"fmt"
"io"
"net"
"os"
"os/exec"
"regexp"
@ -10,34 +15,143 @@ import (
"github.com/nemunaire/repeater/internal/models"
)
// parseDHCPLeases reads and parses DHCP lease file
// ParseLeases reads a DHCP lease file and returns the active leases. The
// format is auto-detected: udhcpd (BusyBox) writes a small binary file with
// fixed-size records, while ISC dhcpd uses a text format. Most embedded /
// router systems use one or the other.
func ParseLeases(path string) ([]models.DHCPLease, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if looksLikeUdhcpd(data) {
return parseUdhcpdLeases(data)
}
return parseISCLeases(bytes.NewReader(data))
}
// parseDHCPLeases is kept for backwards compatibility with callers in this
// package. New code should call ParseLeases.
func parseDHCPLeases(path string) ([]models.DHCPLease, error) {
return ParseLeases(path)
}
// looksLikeUdhcpd returns true when the file looks like a BusyBox udhcpd
// lease file. The format is: 8-byte big-endian timestamp header followed by
// 36-byte records. Detection rule: total size minus 8 is a positive multiple
// of 36 and the header decodes to a plausible Unix time.
func looksLikeUdhcpd(data []byte) bool {
const headerSize = 8
const recordSize = 36
if len(data) < headerSize+recordSize {
return false
}
body := len(data) - headerSize
if body <= 0 || body%recordSize != 0 {
return false
}
ts := int64(binary.BigEndian.Uint64(data[:headerSize]))
// 2001-01-01 to 2100-01-01: anything outside this range is almost
// certainly not a Unix timestamp and we're looking at text instead.
const minTs = 978307200 // 2001-01-01
const maxTs = 4102444800 // 2100-01-01
return ts >= minTs && ts <= maxTs
}
// parseUdhcpdLeases parses BusyBox's udhcpd lease file format.
//
// File layout (see networking/udhcp/dhcpd.c in BusyBox):
//
// [ 8 bytes: written_at — int64 big-endian Unix timestamp ]
// [ struct dyn_lease × N, 36 bytes each, packed:
// uint32 expires (big-endian, seconds remaining at write time)
// uint32 lease_nip (network-order IPv4)
// uint8 lease_mac[6]
// char hostname[20] (NUL-terminated, padded)
// uint8 pad[2]
// ]
func parseUdhcpdLeases(data []byte) ([]models.DHCPLease, error) {
const headerSize = 8
const recordSize = 36
if len(data) < headerSize {
return nil, fmt.Errorf("udhcpd lease file too short: %d bytes", len(data))
}
body := data[headerSize:]
leases := make([]models.DHCPLease, 0, len(body)/recordSize)
for off := 0; off+recordSize <= len(body); off += recordSize {
rec := body[off : off+recordSize]
// expires: rec[0:4] — we keep records even when expired so the UI
// keeps showing the IP for a station whose DHCP lease just lapsed
// (the station is still associated with hostapd).
ipBytes := rec[4:8]
mac := net.HardwareAddr(rec[8:14])
hostname := nullTerminated(rec[14:34])
// Skip the all-zero "empty slot" entries udhcpd leaves in the file.
if isAllZero(rec[4:14]) {
continue
}
ip := net.IPv4(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]).String()
leases = append(leases, models.DHCPLease{
IP: ip,
MAC: mac.String(),
Hostname: hostname,
})
}
return leases, nil
}
func nullTerminated(b []byte) string {
if i := bytes.IndexByte(b, 0); i >= 0 {
b = b[:i]
}
return strings.TrimSpace(string(b))
}
func isAllZero(b []byte) bool {
for _, c := range b {
if c != 0 {
return false
}
}
return true
}
// parseISCLeases parses ISC dhcpd's text lease format.
func parseISCLeases(r io.Reader) ([]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)
scanner := bufio.NewScanner(r)
var currentLease models.DHCPLease
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
fields := strings.Fields(line)
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 == "}" {
switch {
case strings.HasPrefix(line, "lease "):
if len(fields) < 2 {
continue
}
currentLease = models.DHCPLease{IP: fields[1]}
case strings.Contains(line, "hardware ethernet"):
if len(fields) < 3 {
continue
}
currentLease.MAC = strings.TrimSuffix(fields[2], ";")
case strings.Contains(line, "client-hostname"):
if len(fields) < 2 {
continue
}
currentLease.Hostname = strings.Trim(fields[1], `";`)
case line == "}":
if currentLease.IP != "" && currentLease.MAC != "" {
leases = append(leases, currentLease)
}
@ -45,7 +159,7 @@ func parseDHCPLeases(path string) ([]models.DHCPLease, error) {
}
}
return leases, nil
return leases, scanner.Err()
}
// getARPInfo retrieves ARP table information using arp command
@ -59,8 +173,7 @@ func getARPInfo() (map[string]string, error) {
return arpInfo, err
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
for line := range strings.SplitSeq(string(output), "\n") {
if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 {
ip := matches[1]
mac := matches[2]

View file

@ -0,0 +1,75 @@
package dhcp
import (
"encoding/binary"
"testing"
)
func TestParseUdhcpdLeases(t *testing.T) {
// Build a synthetic udhcpd lease file: 8-byte big-endian timestamp
// header, then two 36-byte records.
buf := make([]byte, 0, 8+36*3)
header := make([]byte, 8)
binary.BigEndian.PutUint64(header, 1714560000) // 2024-05-01
buf = append(buf, header...)
// Record 1: 192.168.1.42, aa:bb:cc:dd:ee:ff, "iPhone"
r1 := make([]byte, 36)
binary.BigEndian.PutUint32(r1[0:4], 3600) // expires
r1[4], r1[5], r1[6], r1[7] = 192, 168, 1, 42 // IP
r1[8], r1[9], r1[10] = 0xaa, 0xbb, 0xcc // MAC
r1[11], r1[12], r1[13] = 0xdd, 0xee, 0xff //
copy(r1[14:34], "iPhone\x00") // hostname (NUL-padded)
buf = append(buf, r1...)
// Record 2: empty slot (all zero MAC/IP) — should be skipped
r2 := make([]byte, 36)
buf = append(buf, r2...)
// Record 3: 10.0.0.1, b8:27:eb:11:22:33, no hostname
r3 := make([]byte, 36)
binary.BigEndian.PutUint32(r3[0:4], 7200)
r3[4], r3[5], r3[6], r3[7] = 10, 0, 0, 1
r3[8], r3[9], r3[10] = 0xb8, 0x27, 0xeb
r3[11], r3[12], r3[13] = 0x11, 0x22, 0x33
buf = append(buf, r3...)
if !looksLikeUdhcpd(buf) {
t.Fatalf("synthetic file not detected as udhcpd")
}
leases, err := parseUdhcpdLeases(buf)
if err != nil {
t.Fatalf("parseUdhcpdLeases: %v", err)
}
if len(leases) != 2 {
t.Fatalf("expected 2 leases, got %d: %+v", len(leases), leases)
}
if leases[0].IP != "192.168.1.42" {
t.Errorf("lease[0].IP = %q, want %q", leases[0].IP, "192.168.1.42")
}
if leases[0].MAC != "aa:bb:cc:dd:ee:ff" {
t.Errorf("lease[0].MAC = %q, want %q", leases[0].MAC, "aa:bb:cc:dd:ee:ff")
}
if leases[0].Hostname != "iPhone" {
t.Errorf("lease[0].Hostname = %q, want %q", leases[0].Hostname, "iPhone")
}
if leases[1].IP != "10.0.0.1" {
t.Errorf("lease[1].IP = %q, want %q", leases[1].IP, "10.0.0.1")
}
if leases[1].MAC != "b8:27:eb:11:22:33" {
t.Errorf("lease[1].MAC = %q, want %q", leases[1].MAC, "b8:27:eb:11:22:33")
}
if leases[1].Hostname != "" {
t.Errorf("lease[1].Hostname = %q, want empty", leases[1].Hostname)
}
}
func TestLooksLikeUdhcpdRejectsText(t *testing.T) {
text := []byte("# The format of this file is documented in dhcpd.leases(5).\nlease 192.168.1.42 {\n starts 0 2024/05/01 00:00:00;\n hardware ethernet aa:bb:cc:dd:ee:ff;\n client-hostname \"iPhone\";\n}\n")
if looksLikeUdhcpd(text) {
t.Errorf("ISC text file misdetected as udhcpd")
}
}

View file

@ -22,21 +22,26 @@ type Backend struct {
stations map[string]*HostapdStation // Key: MAC address
callbacks backend.EventCallbacks
mu sync.RWMutex
running bool
stopCh chan struct{}
mu sync.RWMutex
running bool
stopCh chan struct{}
stopOnce sync.Once
// IP correlation - will be populated by periodic DHCP lease correlation
ipByMAC map[string]string // MAC -> IP mapping
// IP / hostname correlation - populated by periodic DHCP lease correlation
ipByMAC map[string]string // MAC -> IP mapping
hostnameByMAC map[string]string // MAC -> hostname mapping
correlator *DHCPCorrelator
}
// 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{}),
stations: make(map[string]*HostapdStation),
ipByMAC: make(map[string]string),
hostnameByMAC: make(map[string]string),
hostapdCLI: "hostapd_cli",
stopCh: make(chan struct{}),
}
}
@ -67,11 +72,20 @@ func (b *Backend) Initialize(config backend.BackendConfig) error {
log.Printf("Warning: Failed to load initial stations: %v", err)
}
// Hostapd only knows MACs. Pair every station with its IP/hostname by
// running a background correlator that polls the DHCP lease file and
// the ARP table. Without this, the device list shows MACs only.
b.correlator = NewDHCPCorrelator(b, config.DHCPLeasesPath, config.ARPTablePath)
b.correlator.Start()
return nil
}
// Close cleans up backend resources
func (b *Backend) Close() error {
if b.correlator != nil {
b.correlator.Stop()
}
b.StopEventMonitoring()
return nil
}
@ -129,7 +143,9 @@ func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
return nil
}
// StopEventMonitoring stops event monitoring
// StopEventMonitoring stops event monitoring. Safe to call multiple times —
// without sync.Once, racing callers could each pass the running guard before
// either closed the channel, panicking on the second close.
func (b *Backend) StopEventMonitoring() {
b.mu.Lock()
if !b.running {
@ -139,7 +155,7 @@ func (b *Backend) StopEventMonitoring() {
b.running = false
b.mu.Unlock()
close(b.stopCh)
b.stopOnce.Do(func() { close(b.stopCh) })
}
// SupportsRealTimeEvents returns false (hostapd_cli uses polling)
@ -172,6 +188,8 @@ func (b *Backend) checkStationChanges() error {
return err
}
now := time.Now()
b.mu.Lock()
defer b.mu.Unlock()
@ -181,17 +199,24 @@ func (b *Backend) checkStationChanges() error {
currentMACs[mac] = true
}
// Check for new stations
// Reconcile current poll with cached state
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)
if existing, exists := b.stations[mac]; exists {
// Refresh signal/byte counters in place — without this, GetStations
// would keep returning the values from the very first poll.
existing.Signal = station.Signal
existing.RxBytes = station.RxBytes
existing.TxBytes = station.TxBytes
continue
}
// New station connected
station.FirstSeen = now
b.stations[mac] = station
if cb := b.callbacks.OnStationConnected; cb != nil {
st := b.convertStation(mac, station)
backend.SafeGo("OnStationConnected", func() { cb(st) })
}
log.Printf("Station connected: %s", mac)
}
// Check for removed stations
@ -200,8 +225,9 @@ func (b *Backend) checkStationChanges() error {
// Station disconnected
delete(b.stations, mac)
delete(b.ipByMAC, mac)
if b.callbacks.OnStationDisconnected != nil {
go b.callbacks.OnStationDisconnected(mac)
if cb := b.callbacks.OnStationDisconnected; cb != nil {
macCopy := mac
backend.SafeGo("OnStationDisconnected", func() { cb(macCopy) })
}
log.Printf("Station disconnected: %s", mac)
}
@ -217,6 +243,12 @@ func (b *Backend) loadStations() error {
return err
}
now := time.Now()
for _, st := range stations {
// We can't tell when these stations actually connected; treat the
// daemon-start time as a lower bound rather than leaving it zero.
st.FirstSeen = now
}
b.stations = stations
log.Printf("Loaded %d initial stations from hostapd", len(b.stations))
return nil
@ -294,51 +326,60 @@ func (b *Backend) parseAllStaOutput(output string) map[string]*HostapdStation {
// 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]
hostname := b.hostnameByMAC[mac]
// Attempt hostname resolution if we have an IP
hostname := ""
// TODO: Could do reverse DNS lookup here if needed
connectedAt := hs.FirstSeen
if connectedAt.IsZero() {
connectedAt = time.Now()
}
return backend.Station{
MAC: mac,
IP: ip,
Hostname: hostname,
Type: backend.GuessDeviceType(hostname, mac),
Vendor: backend.LookupVendor(mac),
Signal: hs.Signal,
RxBytes: hs.RxBytes,
TxBytes: hs.TxBytes,
ConnectedAt: time.Now(), // We don't have exact connection time
ConnectedAt: connectedAt,
}
}
// 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) {
// UpdateLeaseInfo updates MAC -> IP and MAC -> hostname mappings from an
// external source (e.g., DHCP lease file). Called periodically by the
// correlator. An empty hostname leaves the existing one in place — DHCP
// leases sometimes drop the hostname between renewals.
func (b *Backend) UpdateLeaseInfo(macToIP, macToHostname 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
if oldIP, exists := b.ipByMAC[mac]; !exists || oldIP != ip {
updated[mac] = true
}
b.ipByMAC[mac] = ip
}
// Trigger update callbacks for stations that got new/changed IPs
for mac, hostname := range macToHostname {
if hostname == "" {
continue
}
if oldName, exists := b.hostnameByMAC[mac]; !exists || oldName != hostname {
updated[mac] = true
}
b.hostnameByMAC[mac] = hostname
}
// Trigger update callbacks for stations whose info changed
for mac := range updated {
if station, exists := b.stations[mac]; exists {
if b.callbacks.OnStationUpdated != nil {
if cb := b.callbacks.OnStationUpdated; cb != nil {
st := b.convertStation(mac, station)
go b.callbacks.OnStationUpdated(st)
backend.SafeGo("OnStationUpdated", func() { cb(st) })
}
}
}

View file

@ -1,32 +1,38 @@
package hostapd
import (
"bufio"
"log"
"os"
"strings"
"time"
"github.com/nemunaire/repeater/internal/models"
"github.com/nemunaire/repeater/internal/station/arp"
"github.com/nemunaire/repeater/internal/station/dhcp"
)
// DHCPCorrelator helps correlate hostapd stations with DHCP leases to discover IP addresses
// DHCPCorrelator helps correlate hostapd stations with DHCP leases (and the
// ARP table as a fallback) so the UI gets an IP and hostname for every
// station, not just a MAC. Hostapd itself only knows MACs.
type DHCPCorrelator struct {
backend *Backend
dhcpLeasesPath string
arpTablePath string
stopChan chan struct{}
running bool
}
// NewDHCPCorrelator creates a new DHCP correlator
func NewDHCPCorrelator(backend *Backend, dhcpLeasesPath string) *DHCPCorrelator {
func NewDHCPCorrelator(backend *Backend, dhcpLeasesPath, arpTablePath string) *DHCPCorrelator {
if dhcpLeasesPath == "" {
dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases"
dhcpLeasesPath = "/var/lib/misc/udhcpd.leases"
}
if arpTablePath == "" {
arpTablePath = "/proc/net/arp"
}
return &DHCPCorrelator{
backend: backend,
dhcpLeasesPath: dhcpLeasesPath,
arpTablePath: arpTablePath,
stopChan: make(chan struct{}),
}
}
@ -39,7 +45,7 @@ func (dc *DHCPCorrelator) Start() {
dc.running = true
go dc.correlationLoop()
log.Printf("DHCP correlation started for hostapd backend")
log.Printf("DHCP/ARP correlation started (leases=%s, arp=%s)", dc.dhcpLeasesPath, dc.arpTablePath)
}
// Stop stops the correlation loop
@ -72,59 +78,54 @@ func (dc *DHCPCorrelator) correlationLoop() {
}
}
// correlate performs one correlation cycle
// correlate performs one correlation cycle. We pull from two sources:
//
// - The DHCP lease file (udhcpd binary or ISC text — auto-detected) is the
// authoritative source for hostnames and the IP that was actually
// assigned.
// - The ARP table is a universal IP fallback. If the lease file is missing
// or the station uses a static IP, ARP still gives us its current IPv4.
//
// The two are merged: ARP fills gaps, DHCP wins on conflict because it
// carries the hostname and is less prone to stale entries.
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
}
macToHostname := make(map[string]string)
// 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)
// ARP first, so DHCP overrides on conflict.
if entries, err := arp.ParseTable(dc.arpTablePath); err == nil {
for _, entry := range entries {
// Flags 2 (COMPLETE) and 6 (COMPLETE|PERM) — incomplete
// entries have a zero MAC and would pollute the mapping.
if entry.Flags != 2 && entry.Flags != 6 {
continue
}
currentLease = models.DHCPLease{}
mac := strings.ToLower(entry.HWAddress.String())
if mac == "" || mac == "00:00:00:00:00:00" {
continue
}
macToIP[mac] = entry.IP.String()
}
} else {
log.Printf("ARP table read failed (%s): %v", dc.arpTablePath, err)
}
return leases, scanner.Err()
if leases, err := dhcp.ParseLeases(dc.dhcpLeasesPath); err == nil {
for _, lease := range leases {
mac := strings.ToLower(lease.MAC)
if mac == "" {
continue
}
if lease.IP != "" {
macToIP[mac] = lease.IP
}
if lease.Hostname != "" {
macToHostname[mac] = lease.Hostname
}
}
} else {
log.Printf("DHCP lease parse failed (%s): %v", dc.dhcpLeasesPath, err)
}
dc.backend.UpdateLeaseInfo(macToIP, macToHostname)
}

View file

@ -1,10 +1,13 @@
package hostapd
import "time"
// HostapdStation represents station properties from hostapd_cli
type HostapdStation struct {
RxPackets uint64
TxPackets uint64
RxBytes uint64
TxBytes uint64
Signal int32 // Signal in dBm
Signal int32 // Signal in dBm
FirstSeen time.Time // When the station was first observed (best effort)
}

View file

@ -51,20 +51,36 @@ func GetStations() ([]models.ConnectedDevice, error) {
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,
}
devices[i] = toConnectedDevice(s)
}
return devices, nil
}
// toConnectedDevice converts a backend Station to the API model. Centralised
// so handlers, the periodic refresh and the event callbacks all serialise the
// same shape.
func toConnectedDevice(s backend.Station) models.ConnectedDevice {
return models.ConnectedDevice{
Name: s.Hostname,
Type: s.Type,
MAC: s.MAC,
IP: s.IP,
Vendor: s.Vendor,
SignalDbm: s.Signal,
RxBytes: s.RxBytes,
TxBytes: s.TxBytes,
ConnectedAt: s.ConnectedAt,
}
}
// ToConnectedDevice exposes the conversion for callers in other packages.
func ToConnectedDevice(s backend.Station) models.ConnectedDevice {
return toConnectedDevice(s)
}
// StartEventMonitoring starts monitoring for station events
func StartEventMonitoring(callbacks backend.EventCallbacks) error {
mu.RLock()

View file

@ -21,6 +21,7 @@ type SignalMonitor struct {
// Control
stopChan chan struct{}
stopOnce sync.Once
mu sync.RWMutex
running bool
@ -95,7 +96,8 @@ func (sm *SignalMonitor) Start() error {
return nil
}
// Stop stops monitoring D-Bus signals
// Stop stops monitoring D-Bus signals. Idempotent: stopOnce guards the
// channel close so concurrent Stop() callers do not panic on double-close.
func (sm *SignalMonitor) Stop() {
sm.mu.Lock()
if !sm.running {
@ -105,8 +107,7 @@ func (sm *SignalMonitor) Stop() {
sm.running = false
sm.mu.Unlock()
// Signal stop
close(sm.stopChan)
sm.stopOnce.Do(func() { close(sm.stopChan) })
// Remove signal channel
sm.conn.RemoveSignal(sm.signalChan)

View file

@ -1,6 +1,7 @@
package wifi
import (
"errors"
"fmt"
"sort"
"strings"
@ -16,6 +17,13 @@ var (
wifiBroadcaster *WifiBroadcaster
)
// errWifiDisabled is returned by every wifi.* wrapper when no backend has
// been initialized. This happens when the application chose not to start
// wpa_supplicant because the Ethernet uplink is already providing
// connectivity — touching the D-Bus interface in that mode would re-activate
// the daemon via dbus-activation, defeating the intent.
var errWifiDisabled = errors.New("wifi backend disabled (Ethernet uplink active)")
// Initialize initializes the WiFi service with the specified backend
func Initialize(interfaceName string, backendName string) error {
// Create the appropriate backend using the factory
@ -38,6 +46,9 @@ func Close() {
// GetCachedNetworks returns previously discovered networks without triggering a scan
func GetCachedNetworks() ([]models.WiFiNetwork, error) {
if wifiBackend == nil {
return nil, errWifiDisabled
}
// Get ordered networks from backend
backendNetworks, err := wifiBackend.GetOrderedNetworks()
if err != nil {
@ -67,6 +78,9 @@ func GetCachedNetworks() ([]models.WiFiNetwork, error) {
// ScanNetworks scans for available WiFi networks
func ScanNetworks() ([]models.WiFiNetwork, error) {
if wifiBackend == nil {
return nil, errWifiDisabled
}
// Check if already scanning
scanning, err := wifiBackend.IsScanning()
if err == nil && scanning {
@ -114,6 +128,9 @@ func ScanNetworks() ([]models.WiFiNetwork, error) {
// Connect connects to a WiFi network
func Connect(ssid, password string) error {
if wifiBackend == nil {
return errWifiDisabled
}
// Use backend to connect
if err := wifiBackend.Connect(ssid, password); err != nil {
return err
@ -132,11 +149,17 @@ func Connect(ssid, password string) error {
// Disconnect disconnects from the current WiFi network
func Disconnect() error {
if wifiBackend == nil {
return errWifiDisabled
}
return wifiBackend.Disconnect()
}
// IsConnected checks if WiFi is connected
func IsConnected() bool {
if wifiBackend == nil {
return false
}
state, err := wifiBackend.GetConnectionState()
if err != nil {
return false
@ -146,11 +169,17 @@ func IsConnected() bool {
// GetConnectedSSID returns the SSID of the currently connected network
func GetConnectedSSID() string {
if wifiBackend == nil {
return ""
}
return wifiBackend.GetConnectedSSID()
}
// GetConnectionState returns the current WiFi connection state
func GetConnectionState() string {
if wifiBackend == nil {
return string(backend.StateDisconnected)
}
state, err := wifiBackend.GetConnectionState()
if err != nil {
return string(backend.StateDisconnected)
@ -160,6 +189,9 @@ func GetConnectionState() string {
// StartEventMonitoring initializes signal monitoring and WebSocket broadcasting
func StartEventMonitoring() error {
if wifiBackend == nil {
return nil
}
// Initialize broadcaster
wifiBroadcaster = NewWifiBroadcaster()

View file

@ -2,6 +2,7 @@ package wpasupplicant
import (
"fmt"
"log"
"time"
"github.com/godbus/dbus/v5"
@ -152,39 +153,79 @@ func (b *WPABackend) IsScanning() (bool, error) {
// Connect connects to a WiFi network
func (b *WPABackend) Connect(ssid, password string) error {
// Create network configuration
config := make(map[string]interface{})
config["ssid"] = ssid // Raw SSID string, no quotes
if password != "" {
// For WPA/WPA2-PSK networks
config["psk"] = password // Raw password string, no quotes
} else {
// For open networks
config["key_mgmt"] = "NONE"
}
// Add network
networkPath, err := b.iface.AddNetwork(config)
// Best-effort lookup of existing networks, used both to avoid creating
// duplicate entries for the same SSID and to re-enable other entries
// after SelectNetwork's "disable all others" side-effect. If listing
// fails, fall back to plain AddNetwork — saving still works.
existingNetworks, err := b.iface.GetNetworks()
if err != nil {
return fmt.Errorf("failed to add network: %v", err)
log.Printf("wpa_supplicant: failed to list configured networks: %v", err)
existingNetworks = nil
}
// Store current network path for cleanup
var networkPath dbus.ObjectPath
createdNew := false
for _, p := range existingNetworks {
net := NewNetwork(b.conn, p)
netSSID, err := net.GetSSID()
if err != nil || netSSID != ssid {
continue
}
networkPath = p
break
}
if networkPath == "" {
// Create network configuration
config := make(map[string]interface{})
config["ssid"] = ssid // Raw SSID string, no quotes
if password != "" {
// For WPA/WPA2-PSK networks
config["psk"] = password // Raw password string, no quotes
} else {
// For open networks
config["key_mgmt"] = "NONE"
}
networkPath, err = b.iface.AddNetwork(config)
if err != nil {
return fmt.Errorf("failed to add network: %v", err)
}
createdNew = true
}
// Store current network path
b.currentNetwork = networkPath
// Select (connect to) the network
err = b.iface.SelectNetwork(networkPath)
if err != nil {
// Clean up network on failure
b.iface.RemoveNetwork(networkPath)
// Select (connect to) the network. Note: SelectNetwork disables every
// other configured network as a side-effect.
if err := b.iface.SelectNetwork(networkPath); err != nil {
if createdNew {
b.iface.RemoveNetwork(networkPath)
}
return fmt.Errorf("failed to select network: %v", err)
}
// Save the configuration to persist it across reboots
// Re-enable previously configured networks so SelectNetwork's side-effect
// doesn't mark them all as disabled in the persisted config.
for _, p := range existingNetworks {
if p == networkPath {
continue
}
if err := b.iface.EnableNetwork(p); err != nil {
log.Printf("wpa_supplicant: failed to re-enable network %s: %v", p, err)
}
}
// Save the configuration to persist it across reboots. Requires
// update_config=1 in the wpa_supplicant.conf wpa_supplicant was started
// with, and that file being writable by the wpa_supplicant process.
if err := b.iface.SaveConfig(); err != nil {
// Log warning but don't fail - connection still works
fmt.Printf("Warning: failed to save config: %v\n", err)
log.Printf("wpa_supplicant: SaveConfig failed: %v", err)
} else {
log.Printf("wpa_supplicant: configuration saved for SSID %q", ssid)
}
return nil
@ -197,11 +238,7 @@ func (b *WPABackend) Disconnect() error {
return fmt.Errorf("failed to disconnect: %v", err)
}
// Remove the network configuration if we have one
if b.currentNetwork != "" && b.currentNetwork != "/" {
b.iface.RemoveNetwork(b.currentNetwork)
b.currentNetwork = ""
}
b.currentNetwork = ""
return nil
}

View file

@ -107,6 +107,16 @@ func (i *WPAInterface) SelectNetwork(networkPath dbus.ObjectPath) error {
return nil
}
// EnableNetwork marks a network configuration as enabled (eligible for auto-connect)
func (i *WPAInterface) EnableNetwork(networkPath dbus.ObjectPath) error {
netObj := i.conn.Object(Service, networkPath)
err := netObj.SetProperty(NetworkInterface+".Enabled", dbus.MakeVariant(true))
if err != nil {
return fmt.Errorf("failed to enable network: %v", err)
}
return nil
}
// RemoveNetwork removes a network configuration
func (i *WPAInterface) RemoveNetwork(networkPath dbus.ObjectPath) error {
err := i.obj.Call(InterfaceInterface+".RemoveNetwork", 0, networkPath).Err
@ -116,6 +126,21 @@ func (i *WPAInterface) RemoveNetwork(networkPath dbus.ObjectPath) error {
return nil
}
// GetNetworks returns the object paths of all configured networks
func (i *WPAInterface) GetNetworks() ([]dbus.ObjectPath, error) {
prop, err := i.obj.GetProperty(InterfaceInterface + ".Networks")
if err != nil {
return nil, fmt.Errorf("failed to get Networks property: %v", err)
}
networks, ok := prop.Value().([]dbus.ObjectPath)
if !ok {
return nil, fmt.Errorf("Networks property is not an array of ObjectPath")
}
return networks, nil
}
// Disconnect disconnects from the current network
func (i *WPAInterface) Disconnect() error {
err := i.obj.Call(InterfaceInterface+".Disconnect", 0).Err

View file

@ -1,6 +1,8 @@
package wpasupplicant
import (
"strings"
"github.com/godbus/dbus/v5"
)
@ -39,3 +41,25 @@ func (n *Network) GetProperties() (map[string]dbus.Variant, error) {
return props, nil
}
// GetSSID returns the configured SSID, stripping the wrapping quotes
// that wpa_supplicant stores around string-form SSIDs.
func (n *Network) GetSSID() (string, error) {
props, err := n.GetProperties()
if err != nil {
return "", err
}
ssidVariant, ok := props["ssid"]
if !ok {
return "", nil
}
ssid, ok := ssidVariant.Value().(string)
if !ok {
return "", nil
}
// wpa_supplicant returns quoted SSIDs like "MyNetwork"
return strings.Trim(ssid, `"`), nil
}

View file

@ -369,6 +369,26 @@ components:
- ssid
- password
EthernetStatus:
type: object
description: State of the wired uplink interface (probed via ip(8))
properties:
active:
type: boolean
description: True when the interface holds a DHCP-assigned IPv4
example: true
interface:
type: string
description: Probed interface name
example: "eth0"
ipv4:
type: string
description: DHCP-assigned IPv4 address (empty when no lease)
example: "192.168.1.42"
required:
- active
- interface
HotspotStatus:
type: object
description: Detailed hotspot status from hostapd_cli
@ -418,7 +438,7 @@ components:
properties:
name:
type: string
description: Device hostname
description: Device hostname (may be empty when DHCP did not provide one)
example: "iPhone-12"
type:
type: string
@ -440,6 +460,30 @@ components:
description: Assigned IP address
pattern: '^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$'
example: "192.168.1.100"
vendor:
type: string
description: Hardware vendor inferred from the MAC OUI prefix (empty when unknown)
example: "Apple"
signalDbm:
type: integer
format: int32
description: Wi-Fi signal strength in dBm (0 when unavailable, e.g. for non-hostapd backends)
example: -54
rxBytes:
type: integer
format: int64
description: Total bytes received from the device since connection (0 when unavailable)
example: 1048576
txBytes:
type: integer
format: int64
description: Total bytes sent to the device since connection (0 when unavailable)
example: 524288
connectedAt:
type: string
format: date-time
description: Timestamp when the station first appeared (best effort)
example: "2026-05-01T08:30:00Z"
required:
- name
- type
@ -456,13 +500,14 @@ components:
example: true
connectionState:
type: string
description: Current WiFi connection state
description: Current WiFi connection state ("disabled" when Ethernet uplink is active and the WiFi backend is intentionally not initialized)
enum:
- connected
- disconnected
- connecting
- disconnecting
- roaming
- disabled
example: "connected"
connectedSSID:
type: string
@ -473,6 +518,11 @@ components:
- $ref: '#/components/schemas/HotspotStatus'
nullable: true
description: Detailed hotspot status (null if hotspot is not running)
ethernetStatus:
allOf:
- $ref: '#/components/schemas/EthernetStatus'
nullable: true
description: Wired uplink state (null when not yet probed)
connectedCount:
type: integer
description: Number of devices connected to hotspot