527 lines
17 KiB
Go
527 lines
17 KiB
Go
// Package network provides a high-level utility for managing network
|
||
// connections via the system's D-Bus and NetworkManager.
|
||
package network
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/godbus/dbus/v5"
|
||
"github.com/google/uuid"
|
||
"rhemawavetech.com/rhemawave/internal/logger"
|
||
)
|
||
|
||
// --- D-Bus NetworkManager Constants ---
|
||
const (
|
||
nmService = "org.freedesktop.NetworkManager"
|
||
nmPath = "/org/freedesktop/NetworkManager"
|
||
nmInterface = "org.freedesktop.NetworkManager"
|
||
nmDeviceInterface = "org.freedesktop.NetworkManager.Device"
|
||
nmWirelessDeviceInterface = "org.freedesktop.NetworkManager.Device.Wireless"
|
||
nmAPInterface = "org.freedesktop.NetworkManager.AccessPoint"
|
||
|
||
nmDeviceTypeEthernet uint32 = 1
|
||
nmDeviceTypeWifi uint32 = 2
|
||
nmDeviceStateActivated uint32 = 100
|
||
APFlagsPrivacy uint32 = 0x00000001 // Equivalent to NM_802_11_AP_FLAGS_PRIVACY
|
||
|
||
// Device states (from NM spec)
|
||
// nmDeviceStateUnknown uint32 = 0
|
||
nmDeviceStateUnmanaged uint32 = 10
|
||
nmDeviceStateUnavailable uint32 = 20
|
||
// nmDeviceStateDisconnected uint32 = 30
|
||
nmDeviceStateFailed uint32 = 120
|
||
|
||
// AP Security Flags (from NM_802_11_AP_SEC_*)
|
||
APFlagsWpaPsk uint32 = 0x00000100 // WPA-Personal
|
||
APFlagsWpaEap uint32 = 0x00000200 // WPA-Enterprise
|
||
APFlagsRsnPsk uint32 = 0x00000100 // WPA2-Personal (same flag as WPA-PSK)
|
||
APFlagsRsnSae uint32 = 0x00000400 // WPA3-Personal (SAE)
|
||
APFlagsRsnEap uint32 = 0x00000200 // WPA2-Enterprise (same flag as WPA-EAP)
|
||
APFlagsRsnOwe uint32 = 0x00000800 // OWE (Opportunistic Wireless Encryption)
|
||
APFlagsRsnEapSuiteB uint32 = 0x00001000 // WPA3-Enterprise
|
||
)
|
||
|
||
// ErrScanAborted is returned when a scan is cancelled via the stop channel.
|
||
var ErrScanAborted = errors.New("network scan aborted by caller")
|
||
|
||
// Status defines the type for various connection status codes.
|
||
type Status int
|
||
|
||
const (
|
||
StatusError Status = iota
|
||
StatusNoDevicesFound
|
||
StatusWifiAvailableNotConnected
|
||
StatusWifiConnectedNoInternet
|
||
StatusWifiConnectedInternetUp
|
||
StatusEthernetNotConnected
|
||
StatusEthernetConnectedNoInternet
|
||
StatusEthernetConnectedInternetUp
|
||
)
|
||
|
||
// ConnectionStatus holds the detailed result of a connectivity check.
|
||
type ConnectionStatus struct {
|
||
StatusCode Status
|
||
Message string
|
||
}
|
||
|
||
// DeviceType defines the kind of network device.
|
||
type DeviceType int
|
||
|
||
const (
|
||
TypeUnknown DeviceType = iota
|
||
TypeEthernet
|
||
TypeWifi
|
||
)
|
||
|
||
// Device holds information about a single network device.
|
||
type Device struct {
|
||
Path dbus.ObjectPath
|
||
InterfaceName string
|
||
Type DeviceType
|
||
State uint32
|
||
}
|
||
|
||
// Manager handles all D-Bus communication with NetworkManager.
|
||
type Manager struct {
|
||
conn *dbus.Conn
|
||
nmObject dbus.BusObject
|
||
Devices []Device
|
||
}
|
||
|
||
// AccessPoint holds essential information about a discovered Wi-Fi network.
|
||
type AccessPoint struct {
|
||
ObjectPath dbus.ObjectPath
|
||
SSID string
|
||
Strength byte
|
||
IsProtected bool
|
||
IsActive bool
|
||
KeyMgmt string
|
||
}
|
||
|
||
// New creates a new network Manager and connects to the system D-Bus.
|
||
func New() (*Manager, error) {
|
||
logger.Info("[NET-001] Initializing NetworkManager client")
|
||
logger.Debug("[NET-002] Connecting to system D-Bus")
|
||
conn, err := dbus.ConnectSystemBus()
|
||
if err != nil {
|
||
logger.Error("failed to connect to system bus: %v", err)
|
||
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
|
||
}
|
||
nmObject := conn.Object(nmService, nmPath)
|
||
logger.Debug("network manager created")
|
||
return &Manager{conn: conn, nmObject: nmObject}, nil
|
||
}
|
||
|
||
// Conn returns the underlying dbus.Conn for advanced queries.
|
||
func (m *Manager) Conn() *dbus.Conn {
|
||
logger.Debug("[NET-010] Returning underlying D-Bus connection")
|
||
logger.Debug("Conn() returning m.conn")
|
||
return m.conn
|
||
}
|
||
|
||
// Close disconnects from the D-Bus.
|
||
func (m *Manager) Close() {
|
||
logger.Info("[NET-020] Closing NetworkManager client")
|
||
logger.Debug("Disconnecting from the D-Bus")
|
||
if m.conn != nil {
|
||
m.conn.Close()
|
||
logger.Debug("Disconnected")
|
||
}
|
||
logger.Debug("Nothing to disconnect!")
|
||
}
|
||
|
||
// FindDevices scans the system for all available network devices (Ethernet and Wi-Fi).
|
||
func (m *Manager) FindDevices() error {
|
||
logger.Info("[NET-030] Starting network device discovery")
|
||
logger.Debug("Searching for network devices")
|
||
var devicePaths []dbus.ObjectPath
|
||
call := m.nmObject.Call(nmInterface+".GetDevices", 0)
|
||
if call.Err != nil {
|
||
logger.Error("GetDevices D-Bus call failed: %w", call.Err)
|
||
return fmt.Errorf("GetDevices D-Bus call failed: %w", call.Err)
|
||
}
|
||
if err := call.Store(&devicePaths); err != nil {
|
||
return fmt.Errorf("could not parse device list: %w", err)
|
||
}
|
||
|
||
m.Devices = []Device{}
|
||
for _, path := range devicePaths {
|
||
deviceObj := m.conn.Object(nmService, path)
|
||
typeVariant, err := deviceObj.GetProperty(nmDeviceInterface + ".DeviceType")
|
||
if err != nil {
|
||
logger.Warning("[NET-031] Skipping device %s because DeviceType could not be read: %v", path, err)
|
||
continue
|
||
}
|
||
deviceType, ok := typeVariant.Value().(uint32)
|
||
if !ok {
|
||
logger.Warning("[NET-032] Skipping device %s because DeviceType was invalid", path)
|
||
continue
|
||
}
|
||
|
||
var newDevice Device
|
||
switch deviceType {
|
||
case nmDeviceTypeEthernet:
|
||
newDevice.Type = TypeEthernet
|
||
case nmDeviceTypeWifi:
|
||
newDevice.Type = TypeWifi
|
||
default:
|
||
continue
|
||
}
|
||
|
||
newDevice.Path = path
|
||
|
||
// Safely get Interface property
|
||
ifaceVariant, err := deviceObj.GetProperty(nmDeviceInterface + ".Interface")
|
||
if err != nil {
|
||
logger.Warning("[NET-033] Skipping device %s because Interface property could not be read: %v", path, err)
|
||
continue
|
||
}
|
||
if newDevice.InterfaceName, ok = ifaceVariant.Value().(string); !ok {
|
||
logger.Warning("[NET-034] Skipping device %s because Interface property was invalid", path)
|
||
continue
|
||
}
|
||
|
||
// Safely get State property
|
||
stateVariant, err := deviceObj.GetProperty(nmDeviceInterface + ".State")
|
||
if err != nil {
|
||
logger.Warning("[NET-035] Skipping device %s because State property could not be read: %v", path, err)
|
||
continue
|
||
}
|
||
if newDevice.State, ok = stateVariant.Value().(uint32); !ok {
|
||
logger.Warning("[NET-036] Skipping device %s because State property was invalid", path)
|
||
continue
|
||
}
|
||
|
||
m.Devices = append(m.Devices, newDevice)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CheckInternetConnection performs a check for an active internet connection.
|
||
func (m *Manager) CheckInternetConnection() (*ConnectionStatus, error) {
|
||
logger.Debug("[NET-040] Checking internet connectivity")
|
||
if err := m.FindDevices(); err != nil {
|
||
return &ConnectionStatus{StatusError, "Failed to scan devices"}, err
|
||
}
|
||
|
||
// Check Ethernet first (Priority)
|
||
for _, device := range m.Devices {
|
||
if device.Type == TypeEthernet && device.State == nmDeviceStateActivated {
|
||
if pingTest() {
|
||
return &ConnectionStatus{StatusEthernetConnectedInternetUp, "Ethernet: Online"}, nil
|
||
}
|
||
return &ConnectionStatus{StatusEthernetConnectedNoInternet, "Ethernet: No Internet"}, nil
|
||
}
|
||
}
|
||
|
||
// Check Wifi
|
||
for _, device := range m.Devices {
|
||
if device.Type == TypeWifi && device.State == nmDeviceStateActivated {
|
||
if pingTest() {
|
||
return &ConnectionStatus{StatusWifiConnectedInternetUp, "Wi-Fi: Online"}, nil
|
||
}
|
||
// Even if ping fails, the state IS activated (connected to AP)
|
||
return &ConnectionStatus{StatusWifiConnectedNoInternet, "Wi-Fi: Connected, checking internet..."}, nil
|
||
}
|
||
}
|
||
|
||
return &ConnectionStatus{StatusWifiAvailableNotConnected, "Ready to connect"}, nil
|
||
}
|
||
|
||
// StartScan performs a Wi-Fi scan asynchronously. The optional stopChan can be used to abort.
|
||
func (m *Manager) StartScan(resultsChan chan<- []AccessPoint, errorChan chan<- error, stopChan <-chan struct{}) {
|
||
logger.Info("[NET-050] Starting Wi‑Fi scan")
|
||
var wifiDevice *Device
|
||
for i := range m.Devices {
|
||
if m.Devices[i].Type == TypeWifi {
|
||
wifiDevice = &m.Devices[i]
|
||
break
|
||
}
|
||
}
|
||
if wifiDevice == nil {
|
||
errorChan <- fmt.Errorf("no Wi-Fi device available for scanning")
|
||
return
|
||
}
|
||
|
||
// Ensure Wi-Fi is enabled
|
||
nmObj := m.conn.Object(nmService, nmPath)
|
||
prop, err := nmObj.GetProperty(nmInterface + ".WirelessEnabled")
|
||
if err != nil {
|
||
errorChan <- fmt.Errorf("could not read WirelessEnabled property: %w", err)
|
||
return
|
||
}
|
||
if !prop.Value().(bool) {
|
||
call := nmObj.Call("org.freedesktop.DBus.Properties.Set", 0,
|
||
nmInterface,
|
||
"WirelessEnabled",
|
||
dbus.MakeVariant(true),
|
||
)
|
||
if call.Err != nil {
|
||
errorChan <- fmt.Errorf("could not enable Wi-Fi: %w", call.Err)
|
||
return
|
||
}
|
||
time.Sleep(2 * time.Second) // Give NetworkManager time to bring up the device
|
||
}
|
||
|
||
// Request a scan
|
||
wifiDeviceWireless := m.conn.Object(nmService, wifiDevice.Path)
|
||
call := wifiDeviceWireless.Call(nmWirelessDeviceInterface+".RequestScan", 0, make(map[string]dbus.Variant))
|
||
if call.Err != nil {
|
||
errorChan <- fmt.Errorf("scan request failed: %w", call.Err)
|
||
return
|
||
}
|
||
|
||
select {
|
||
case <-time.After(3 * time.Second):
|
||
case <-stopChan:
|
||
errorChan <- ErrScanAborted
|
||
return
|
||
}
|
||
|
||
var apPaths []dbus.ObjectPath
|
||
call = wifiDeviceWireless.Call(nmWirelessDeviceInterface+".GetAllAccessPoints", 0)
|
||
if call.Err != nil {
|
||
errorChan <- fmt.Errorf("could not retrieve network list: %w", call.Err)
|
||
return
|
||
}
|
||
if err := call.Store(&apPaths); err != nil {
|
||
errorChan <- fmt.Errorf("could not parse network list: %w", err)
|
||
return
|
||
}
|
||
|
||
processedAPs, err := m.scanAccessPoints(apPaths)
|
||
if err != nil {
|
||
errorChan <- err
|
||
return
|
||
}
|
||
resultsChan <- processedAPs
|
||
}
|
||
|
||
// scanAccessPoints filters and sorts the raw list of D-Bus AP paths.
|
||
func (m *Manager) scanAccessPoints(apPaths []dbus.ObjectPath) ([]AccessPoint, error) {
|
||
logger.Debug("[NET-060] Processing %d access points", len(apPaths))
|
||
tempNetworkMap := make(map[string]AccessPoint)
|
||
for _, apPath := range apPaths {
|
||
apObj := m.conn.Object(nmService, apPath)
|
||
props, err := m.getAllProperties(apObj, nmAPInterface)
|
||
if err != nil {
|
||
logger.Warning("[NET-061] Failed to read access point properties for %s: %v", apPath, err)
|
||
continue
|
||
}
|
||
|
||
ssidBytes, ok := props["Ssid"].Value().([]byte)
|
||
if !ok || len(ssidBytes) == 0 {
|
||
continue
|
||
}
|
||
|
||
ssidStr := string(ssidBytes)
|
||
strength, _ := props["Strength"].Value().(byte)
|
||
flags, _ := props["Flags"].Value().(uint32)
|
||
wpaFlags, _ := props["WpaFlags"].Value().(uint32)
|
||
rsnFlags, _ := props["RsnFlags"].Value().(uint32)
|
||
|
||
isProtected := (flags&APFlagsPrivacy != 0) || (wpaFlags != 0) || (rsnFlags != 0)
|
||
|
||
// Determine key management type
|
||
var keyMgmt string
|
||
if !isProtected {
|
||
keyMgmt = "none"
|
||
} else if wpaFlags != 0 {
|
||
// WPA (not WPA2/WPA3)
|
||
if wpaFlags&APFlagsWpaPsk != 0 {
|
||
keyMgmt = "wpa-psk"
|
||
} else if wpaFlags&APFlagsWpaEap != 0 {
|
||
keyMgmt = "wpa-eap"
|
||
} else {
|
||
keyMgmt = "wpa-psk" // Fallback
|
||
}
|
||
} else if rsnFlags != 0 {
|
||
// WPA2 or WPA3 (RSN)
|
||
if rsnFlags&APFlagsRsnSae != 0 {
|
||
keyMgmt = "sae" // WPA3-Personal
|
||
} else if rsnFlags&APFlagsRsnEapSuiteB != 0 {
|
||
keyMgmt = "eap-suite-b-192" // WPA3-Enterprise
|
||
} else if rsnFlags&APFlagsRsnEap != 0 {
|
||
keyMgmt = "wpa-eap" // WPA2-Enterprise
|
||
} else {
|
||
keyMgmt = "wpa-psk" // WPA2-Personal (default for RSN)
|
||
}
|
||
} else {
|
||
keyMgmt = "none"
|
||
}
|
||
|
||
currentAP := AccessPoint{
|
||
ObjectPath: apPath,
|
||
SSID: ssidStr,
|
||
Strength: strength,
|
||
IsProtected: isProtected,
|
||
KeyMgmt: keyMgmt,
|
||
}
|
||
if existingAP, ok := tempNetworkMap[ssidStr]; !ok || strength > existingAP.Strength {
|
||
tempNetworkMap[ssidStr] = currentAP
|
||
}
|
||
}
|
||
|
||
networkList := make([]AccessPoint, 0, len(tempNetworkMap))
|
||
for _, ap := range tempNetworkMap {
|
||
networkList = append(networkList, ap)
|
||
}
|
||
sort.Slice(networkList, func(i, j int) bool {
|
||
if networkList[i].Strength != networkList[j].Strength {
|
||
return networkList[i].Strength > networkList[j].Strength
|
||
}
|
||
return strings.ToLower(networkList[i].SSID) < strings.ToLower(networkList[j].SSID)
|
||
})
|
||
return networkList, nil
|
||
}
|
||
|
||
// Connect attempts to connect a Wi-Fi device to a given access point.
|
||
func (m *Manager) Connect(wifiDevice Device, ap AccessPoint, passphrase string) error {
|
||
logger.Info("[NET-070] Connection requested for SSID %q using adapter %q", ap.SSID, wifiDevice.InterfaceName)
|
||
if wifiDevice.Type != TypeWifi {
|
||
return fmt.Errorf("cannot connect: provided device is not a Wi-Fi device")
|
||
}
|
||
|
||
if wifiDevice.State == nmDeviceStateFailed ||
|
||
wifiDevice.State == nmDeviceStateUnmanaged ||
|
||
wifiDevice.State == nmDeviceStateUnavailable {
|
||
return fmt.Errorf("cannot connect: device is in an error state (state=%d)", wifiDevice.State)
|
||
}
|
||
|
||
settings := map[string]map[string]dbus.Variant{
|
||
"connection": {
|
||
"type": dbus.MakeVariant("802-11-wireless"),
|
||
"uuid": dbus.MakeVariant(uuid.NewString()),
|
||
"id": dbus.MakeVariant(ap.SSID),
|
||
"interface-name": dbus.MakeVariant(wifiDevice.InterfaceName),
|
||
"permissions": dbus.MakeVariant([]string{}), // Shared connection
|
||
"autoconnect": dbus.MakeVariant(true),
|
||
},
|
||
"802-11-wireless": {
|
||
"ssid": dbus.MakeVariant([]byte(ap.SSID)),
|
||
"mode": dbus.MakeVariant("infrastructure"),
|
||
},
|
||
"ipv4": {"method": dbus.MakeVariant("auto")},
|
||
"ipv6": {"method": dbus.MakeVariant("auto")},
|
||
}
|
||
|
||
if ap.IsProtected {
|
||
settings["802-11-wireless-security"] = map[string]dbus.Variant{
|
||
"key-mgmt": dbus.MakeVariant(ap.KeyMgmt),
|
||
"auth-alg": dbus.MakeVariant("open"),
|
||
"psk": dbus.MakeVariant(passphrase),
|
||
}
|
||
// 0 means "The secret is not saved or is managed by an external agent"
|
||
// In a kiosk, we want NM to just handle it.
|
||
settings["802-11-wireless-security"]["psk-flags"] = dbus.MakeVariant(uint32(0))
|
||
}
|
||
|
||
var connectionPath dbus.ObjectPath
|
||
var activeConnectionPath dbus.ObjectPath
|
||
|
||
// Capture the two return values from AddAndActivateConnection
|
||
err := m.nmObject.Call(nmInterface+".AddAndActivateConnection", 0, settings, wifiDevice.Path, ap.ObjectPath).Store(&connectionPath, &activeConnectionPath)
|
||
|
||
if err != nil {
|
||
// THIS IS THE LOG YOU NEED TO SEE IN SSH
|
||
logger.Error("[NET-071] NetworkManager rejected the connection request: %v", err)
|
||
return fmt.Errorf("AddAndActivateConnection failed: %w", err)
|
||
}
|
||
|
||
logger.Info("[NET-072] Connection created successfully. Connection=%s Active=%s", connectionPath, activeConnectionPath)
|
||
|
||
// Wait for the connection to be fully established
|
||
if _, err := m.WaitForInternet(30*time.Second, 5*time.Second); err != nil {
|
||
return fmt.Errorf("failed to establish internet connection: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// pingTest with a short timeout to prevent the UI loop from hanging
|
||
func pingTest() bool {
|
||
logger.Debug("[NET-080] Beginning internet reachability test")
|
||
targets := []string{
|
||
"1.1.1.1:53", // Cloudflare DNS
|
||
"8.8.8.8:53", // Google DNS
|
||
"9.9.9.9:53", // Quad9 DNS
|
||
"149.112.112.112:53", // Quad9 Alternate
|
||
}
|
||
timeout := 2 * time.Second
|
||
|
||
for _, target := range targets {
|
||
conn, err := net.DialTimeout("tcp", target, timeout)
|
||
if err == nil {
|
||
conn.Close()
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// getAllProperties is a helper to fetch all properties for a D-Bus object interface.
|
||
func (m *Manager) getAllProperties(obj dbus.BusObject, iface string) (map[string]dbus.Variant, error) {
|
||
logger.Debug("[NET-090] Fetching all D‑Bus properties for interface %s", iface)
|
||
var props map[string]dbus.Variant
|
||
call := obj.Call("org.freedesktop.DBus.Properties.GetAll", 0, iface)
|
||
if call.Err != nil {
|
||
return nil, call.Err
|
||
}
|
||
if err := call.Store(&props); err != nil {
|
||
return nil, err
|
||
}
|
||
return props, nil
|
||
}
|
||
|
||
// WaitForInternet blocks until CheckInternetConnection reports Internet up,
|
||
// or until maxWait elapses. When it first sees "up", it requires that state
|
||
// to be stable for at least 'stableFor' before returning success to avoid flapping.
|
||
func (m *Manager) WaitForInternet(maxWait, stableFor time.Duration) (*ConnectionStatus, error) {
|
||
logger.Info("[NET-100] Waiting for internet connectivity (maxWait=%s stableFor=%s)", maxWait, stableFor)
|
||
deadline := time.Now().Add(maxWait)
|
||
var lastErr error
|
||
var lastStatus *ConnectionStatus
|
||
var upSince time.Time
|
||
|
||
// Reasonable poll cadence; NM typically completes within a few seconds.
|
||
const poll = 500 * time.Millisecond
|
||
|
||
for time.Now().Before(deadline) {
|
||
st, err := m.CheckInternetConnection()
|
||
if err != nil {
|
||
lastErr = err
|
||
lastStatus = &ConnectionStatus{StatusError, "NetworkManager not ready"}
|
||
upSince = time.Time{} // reset stability
|
||
} else {
|
||
lastStatus = st
|
||
switch st.StatusCode {
|
||
case StatusEthernetConnectedInternetUp, StatusWifiConnectedInternetUp:
|
||
if upSince.IsZero() {
|
||
upSince = time.Now()
|
||
}
|
||
if time.Since(upSince) >= stableFor {
|
||
return st, nil // stable "up"
|
||
}
|
||
default:
|
||
// not up; keep waiting
|
||
upSince = time.Time{}
|
||
}
|
||
}
|
||
time.Sleep(poll)
|
||
}
|
||
|
||
// Timed out; return the last observed status and error (if any).
|
||
if lastStatus == nil {
|
||
lastStatus = &ConnectionStatus{StatusError, "Timed out waiting for connectivity"}
|
||
}
|
||
if lastErr != nil {
|
||
return lastStatus, fmt.Errorf("timeout waiting for internet: %w", lastErr)
|
||
}
|
||
return lastStatus, nil
|
||
}
|