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