Files
radiantwave-legacy/internal/network/network.go
T

562 lines
13 KiB
Go

package network
import (
"context"
"fmt"
"net"
"net/http"
"time"
gonm "github.com/Wifx/gonetworkmanager/v2"
"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.Error("network: failed to connect to NetworkManager: %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
}
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)
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()
if rsnFlags != 0 {
if rsnFlags&0x100 != 0 {
return SecurityWPA3
}
return SecurityWPA2
}
if wpaFlags != 0 {
return SecurityWPA
}
if flags&0x1 != 0 {
return SecurityWEP
}
return SecurityOpen
}
func buildConnectionSettings(ssid, password string, security SecurityType) (map[string]map[string]interface{}, error) {
settings := map[string]map[string]interface{}{
"connection": {
"id": ssid,
"type": "802-11-wireless",
},
"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",
"psk": password,
}
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