Files
radiantwave-legacy/internal/network/network.go
T
NeilClack 363e6f81f6 Fix CI: extract computeState() for testability; make network init non-fatal
network.init() no longer panics when D-Bus is unavailable (logs warning
instead). This allows go test to run in the nix build sandbox.

checkState() now calls computeState() which returns the target state
without triggering page navigation (which requires OpenGL). Tests call
computeState() directly, avoiding the SIGSEGV from gl.GenVertexArrays.

7 regression tests for state machine, all passing.
2026-07-11 14:42:49 -05:00

578 lines
14 KiB
Go

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.Warning("network: NetworkManager unavailable, network features disabled: %v", 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
}
logger.Debug("network: device state %d, waiting...", state)
switch state {
case gonm.NmDeviceStateActivated:
current, err := CurrentNetwork()
if err == nil && current == ssid {
logger.Info("network: fully connected to %q (DHCP complete)", ssid)
return nil
}
case gonm.NmDeviceStateFailed:
return fmt.Errorf("network: connection to %q failed (wrong password?)", ssid)
case gonm.NmDeviceStateDisconnected:
return fmt.Errorf("network: connection to %q was rejected by the network", ssid)
}
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