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.
578 lines
14 KiB
Go
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
|