package network import ( "context" "fmt" "net" "net/http" "time" gonm "github.com/Wifx/gonetworkmanager/v2" "github.com/google/uuid" "rhemawavetech.com/rhemawave/internal/logger" ) // ─── Public types ───────────────────────────────────────────────────────────── type SecurityType string const ( SecurityOpen SecurityType = "Open" SecurityWEP SecurityType = "WEP" SecurityWPA SecurityType = "WPA" SecurityWPA2 SecurityType = "WPA2" SecurityWPA3 SecurityType = "WPA3" ) type Network struct { SSID string SignalStrength uint8 Security SecurityType Saved bool Connected bool } // ─── Package init ───────────────────────────────────────────────────────────── var nm gonm.NetworkManager func init() { var err error nm, err = gonm.NewNetworkManager() if err != nil { logger.Fatal("network: failed to connect to NetworkManager: %v\n", err) panic(err) } } // ─── ScanForNetworks ────────────────────────────────────────────────────────── func ScanForNetworks(ctx context.Context) (<-chan []Network, error) { dev, err := wifiDevice() if err != nil { return nil, err } ch := make(chan []Network, 1) go func() { defer close(ch) ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() scan := func() { if err := dev.RequestScan(); err != nil { logger.Warning("network: scan request failed: %v", err) return } time.Sleep(2 * time.Second) nets, err := buildNetworkList(dev) if err != nil { logger.Warning("network: building network list failed: %v", err) return } select { case ch <- nets: case <-ctx.Done(): } } scan() for { select { case <-ticker.C: scan() case <-ctx.Done(): return } } }() return ch, nil } // ─── ConnectToNetwork ───────────────────────────────────────────────────────── func ConnectToNetwork(ssid, password string) error { dev, err := wifiDevice() if err != nil { return err } ifaceName, err := dev.GetPropertyInterface() if err != nil { return fmt.Errorf("network: cannot get interface name: %w", err) } ap, err := findAccessPoint(dev, ssid) if err != nil { return fmt.Errorf("network: access point %q not found: %w", ssid, err) } // Delete any existing profiles for this SSID before creating a new one. // Stale/duplicate profiles cause NM to fail silently. if err := deleteExistingConnections(ssid); err != nil { logger.Warning("network: could not clean up old profiles for %q: %v", ssid, err) } security := detectSecurity(ap) settings, err := buildConnectionSettings(ssid, password, security, ifaceName) if err != nil { return err } logger.Info("network: connecting to %q (%s)", ssid, security) _, err = nm.AddAndActivateConnection(settings, dev) if err != nil { return fmt.Errorf("network: failed to connect to %q: %w", ssid, err) } return waitForConnection(dev, ssid, 20*time.Second) } // deleteExistingConnections removes all stored NM profiles for the given SSID. func deleteExistingConnections(ssid string) error { s, err := gonm.NewSettings() if err != nil { return err } connections, err := s.ListConnections() if err != nil { return err } for _, conn := range connections { cfg, err := conn.GetSettings() if err != nil { continue } wireless, ok := cfg["802-11-wireless"] if !ok { continue } rawSSID, ok := wireless["ssid"] if !ok { continue } if string(rawSSID.([]byte)) != ssid { continue } logger.Debug("network: removing existing profile for %q", ssid) if err := conn.Delete(); err != nil { logger.Warning("network: failed to delete profile for %q: %v", ssid, err) } } return nil } // ─── ForgetNetwork ──────────────────────────────────────────────────────────── func ForgetNetwork(ssid string) error { s, err := gonm.NewSettings() if err != nil { return fmt.Errorf("network: cannot access NM settings: %w", err) } connections, err := s.ListConnections() if err != nil { return fmt.Errorf("network: cannot list connections: %w", err) } found := false for _, conn := range connections { settings, err := conn.GetSettings() if err != nil { continue } wireless, ok := settings["802-11-wireless"] if !ok { continue } rawSSID, ok := wireless["ssid"] if !ok { continue } if string(rawSSID.([]byte)) != ssid { continue } found = true logger.Info("network: forgetting %q", ssid) if err := conn.Delete(); err != nil { return fmt.Errorf("network: failed to delete connection %q: %w", ssid, err) } } if !found { return fmt.Errorf("network: no saved connection found for %q", ssid) } return nil } // ─── CurrentNetwork ─────────────────────────────────────────────────────────── func CurrentNetwork() (string, error) { dev, err := wifiDevice() if err != nil { return "", err } ap, err := dev.GetPropertyActiveAccessPoint() if err != nil || ap == nil { return "", nil } ssid, err := ap.GetPropertySSID() if err != nil { return "", err } return ssid, nil } // ─── ListSavedNetworks ──────────────────────────────────────────────────────── func ListSavedNetworks() ([]string, error) { s, err := gonm.NewSettings() if err != nil { return nil, fmt.Errorf("network: cannot access NM settings: %w", err) } connections, err := s.ListConnections() if err != nil { return nil, fmt.Errorf("network: cannot list connections: %w", err) } var ssids []string for _, conn := range connections { settings, err := conn.GetSettings() if err != nil { continue } wireless, ok := settings["802-11-wireless"] if !ok { continue } rawSSID, ok := wireless["ssid"] if !ok { continue } ssids = append(ssids, string(rawSSID.([]byte))) } return ssids, nil } // ─── IsConnected ────────────────────────────────────────────────────────────── func IsConnected() (bool, error) { if nm == nil { return false, fmt.Errorf("network: NetworkManager not available") } state, err := nm.GetPropertyState() if err != nil { return false, fmt.Errorf("network: cannot get NM state: %w", err) } return state >= gonm.NmStateConnectedLocal, nil } // ─── HasInternet ────────────────────────────────────────────────────────────── func HasInternet() bool { targets := []string{ "1.1.1.1:53", "1.0.0.1:53", "8.8.8.8:53", "9.9.9.9:53", "8.8.4.4:53", "google.com:443", "cloudflare.com:443", } results := make(chan bool, len(targets)) for _, target := range targets { go func(addr string) { conn, err := net.DialTimeout("tcp", addr, 2*time.Second) if err == nil { conn.Close() results <- true } else { results <- false } }(target) } for range targets { if <-results { return true } } return false } // ─── WatchState ─────────────────────────────────────────────────────────────── func WatchState(ctx context.Context) (<-chan bool, error) { if nm == nil { return nil, fmt.Errorf("network: NetworkManager not available") } ch := make(chan bool, 1) go func() { defer close(ch) ticker := time.NewTicker(3 * time.Second) defer ticker.Stop() var last *bool for { select { case <-ticker.C: connected, err := IsConnected() if err != nil { logger.Warning("network: WatchState poll failed: %v", err) continue } if last == nil || *last != connected { last = &connected select { case ch <- connected: case <-ctx.Done(): return } } case <-ctx.Done(): return } } }() return ch, nil } // ─── Internal helpers ───────────────────────────────────────────────────────── func wifiDevice() (gonm.DeviceWireless, error) { if nm == nil { return nil, fmt.Errorf("network: NetworkManager not available") } devices, err := nm.GetPropertyAllDevices() if err != nil { return nil, fmt.Errorf("network: cannot list devices: %w", err) } for _, dev := range devices { devType, err := dev.GetPropertyDeviceType() if err != nil { continue } if devType == gonm.NmDeviceTypeWifi { return gonm.NewDeviceWireless(dev.GetPath()) } } return nil, fmt.Errorf("network: no WiFi device found") } func findAccessPoint(dev gonm.DeviceWireless, ssid string) (gonm.AccessPoint, error) { aps, err := dev.GetPropertyAccessPoints() if err != nil { return nil, err } for _, ap := range aps { apSSID, err := ap.GetPropertySSID() if err != nil { continue } if apSSID == ssid { return ap, nil } } return nil, fmt.Errorf("not found") } func detectSecurity(ap gonm.AccessPoint) SecurityType { flags, _ := ap.GetPropertyFlags() wpaFlags, _ := ap.GetPropertyWPAFlags() rsnFlags, _ := ap.GetPropertyRSNFlags() // WPA3 uses SAE (0x400) if rsnFlags&0x400 != 0 { return SecurityWPA3 } // WPA2 uses PSK (0x100) if rsnFlags&0x100 != 0 { return SecurityWPA2 } // WPA uses WPA flags (not RSN) if wpaFlags != 0 { return SecurityWPA } // WEP uses privacy flag if flags&0x1 != 0 { return SecurityWEP } return SecurityOpen } func buildConnectionSettings(ssid, password string, security SecurityType, interfaceName string) (map[string]map[string]interface{}, error) { settings := map[string]map[string]interface{}{ "connection": { "id": ssid, "type": "802-11-wireless", "uuid": uuid.NewString(), "interface-name": interfaceName, "autoconnect": true, "permissions": []string{}, }, "802-11-wireless": { "ssid": []byte(ssid), "mode": "infrastructure", }, "ipv4": {"method": "auto"}, "ipv6": {"method": "auto"}, } switch security { case SecurityOpen: // nothing case SecurityWEP: if password == "" { return nil, fmt.Errorf("network: WEP network requires a password") } settings["802-11-wireless-security"] = map[string]interface{}{ "key-mgmt": "none", "wep-key0": password, "wep-key-type": uint32(1), } case SecurityWPA, SecurityWPA2: if password == "" { return nil, fmt.Errorf("network: WPA/WPA2 network requires a password") } settings["802-11-wireless-security"] = map[string]interface{}{ "key-mgmt": "wpa-psk", "auth-alg": "open", "psk": password, "psk-flags": uint32(0), } case SecurityWPA3: if password == "" { return nil, fmt.Errorf("network: WPA3 network requires a password") } settings["802-11-wireless-security"] = map[string]interface{}{ "key-mgmt": "sae", "psk": password, } } return settings, nil } func buildNetworkList(dev gonm.DeviceWireless) ([]Network, error) { aps, err := dev.GetPropertyAccessPoints() if err != nil { return nil, err } activeAP, _ := dev.GetPropertyActiveAccessPoint() var activeSSID string if activeAP != nil { activeSSID, _ = activeAP.GetPropertySSID() } saved, _ := ListSavedNetworks() savedSet := make(map[string]bool, len(saved)) for _, s := range saved { savedSet[s] = true } var networks []Network seen := make(map[string]bool) for _, ap := range aps { ssid, err := ap.GetPropertySSID() if err != nil || ssid == "" { continue } if seen[ssid] { continue } seen[ssid] = true strength, _ := ap.GetPropertyStrength() networks = append(networks, Network{ SSID: ssid, SignalStrength: strength, Security: detectSecurity(ap), Saved: savedSet[ssid], Connected: ssid == activeSSID, }) } return networks, nil } // waitForConnection waits until the wifi device reaches the Activated state, // meaning DHCP has completed and the connection is fully up. func waitForConnection(dev gonm.DeviceWireless, ssid string, timeout time.Duration) error { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { state, err := dev.GetPropertyState() if err != nil { time.Sleep(500 * time.Millisecond) continue } // NmDeviceStateActivated = 100, fully connected with IP if state == gonm.NmDeviceStateActivated { // Confirm it's actually the SSID we wanted current, err := CurrentNetwork() if err == nil && current == ssid { logger.Info("network: fully connected to %q", ssid) return nil } } // NmDeviceStateFailed = 120, NmDeviceStateDisconnected = 30 if state == gonm.NmDeviceStateFailed { return fmt.Errorf("network: connection to %q failed (wrong password?)", ssid) } logger.Debug("network: device state %d, waiting...", state) time.Sleep(500 * time.Millisecond) } return fmt.Errorf("network: timed out waiting for connection to %q", ssid) } var internetCheckURL = "https://clients3.google.com/generate_204" func httpCheck() bool { client := http.Client{Timeout: 3 * time.Second} resp, err := client.Get(internetCheckURL) if err != nil { return false } resp.Body.Close() return resp.StatusCode == 204 } var _ = httpCheck