561 lines
13 KiB
Go
561 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 {
|
|
fmt.Printf("network: failed to connect to NetworkManager: %v\n", 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
|
|
}
|
|
|
|
// 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
|