diff --git a/cmd/repeater/static/app.js b/cmd/repeater/static/app.js index d2a0012..75240c1 100644 --- a/cmd/repeater/static/app.js +++ b/cmd/repeater/static/app.js @@ -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'); diff --git a/cmd/repeater/static/index.html b/cmd/repeater/static/index.html index 203e607..b0f96b8 100644 --- a/cmd/repeater/static/index.html +++ b/cmd/repeater/static/index.html @@ -25,6 +25,10 @@ Disconnected +
Hotspot actif diff --git a/internal/app/app.go b/internal/app/app.go index 8c3d315..b7106a3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -57,20 +57,28 @@ func (a *App) Initialize(cfg *config.Config) error { // Store config reference a.Config = cfg - // If Ethernet uplink is not already providing connectivity (no DHCP lease - // on the configured interface), bring up wpa_supplicant so the WiFi - // backend has something to talk to. - ensureUplink(cfg.EthernetInterface) + // 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() - // Initialize WiFi backend - if err := wifi.Initialize(cfg.WifiInterface, cfg.WifiBackend); err != nil { - return err - } + 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 + // 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 @@ -210,10 +218,32 @@ func (a *App) periodicStatusUpdate() { 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 diff --git a/internal/app/network.go b/internal/app/network.go index b13dc85..557dffb 100644 --- a/internal/app/network.go +++ b/internal/app/network.go @@ -5,50 +5,64 @@ import ( "log" "os/exec" "strings" + + "github.com/nemunaire/repeater/internal/models" ) -// hasDHCPAddress reports whether iface holds an IPv4 address that ip(8) marks -// as "dynamic" — the flag set on addresses with a finite valid_lft, which is -// how DHCP-assigned leases appear (static addresses are "permanent"). -func hasDHCPAddress(iface string) (bool, error) { +// probeEthernet reports the wired uplink state of iface by parsing +// `ip -4 -o addr show dev `. An IPv4 line carrying the "dynamic" flag +// (set by ip(8) on addresses with a finite valid_lft) is treated as a +// DHCP-assigned lease — that's the signal we use to decide whether the box +// already has connectivity through Ethernet. +func probeEthernet(iface string) (*models.EthernetStatus, error) { out, err := exec.Command("ip", "-4", "-o", "addr", "show", "dev", iface).Output() if err != nil { - return false, fmt.Errorf("ip addr show %s: %w", iface, err) + 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 hasDynamic := false - for _, f := range fields { + for i, f := range fields { switch f { case "inet": hasInet = true + if i+1 < len(fields) { + addr = fields[i+1] + } case "dynamic": hasDynamic = true } } if hasInet && hasDynamic { - return true, nil + if slash := strings.IndexByte(addr, '/'); slash >= 0 { + addr = addr[:slash] + } + return &models.EthernetStatus{Active: true, Interface: iface, IPv4: addr}, nil } } - return false, nil + return &models.EthernetStatus{Active: false, Interface: iface}, nil } -// ensureUplink boots the WiFi uplink only if Ethernet is not already providing -// connectivity: when iface has no DHCP-assigned IPv4, start wpa_supplicant so -// the WiFi backend can take over. -func ensureUplink(iface string) { - has, err := hasDHCPAddress(iface) +// 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 for DHCP address (%v); starting wpa_supplicant as fallback", iface, err) - } else if has { - log.Printf("DHCP address present on %s, leaving wpa_supplicant alone", iface) - return - } else { - log.Printf("No DHCP address on %s, starting wpa_supplicant", iface) + 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 DHCP address on %s, 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 } diff --git a/internal/models/models.go b/internal/models/models.go index 138aeb1..3925a68 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -31,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. @@ -49,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"` diff --git a/internal/wifi/wifi.go b/internal/wifi/wifi.go index dac04c3..9365987 100644 --- a/internal/wifi/wifi.go +++ b/internal/wifi/wifi.go @@ -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() diff --git a/openapi.yaml b/openapi.yaml index f3132c4..a6bfa9b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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 @@ -480,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 @@ -497,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