initial commit
This commit is contained in:
@@ -0,0 +1,577 @@
|
||||
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
|
||||
Reference in New Issue
Block a user