mirror of
https://github.com/anotherhadi/settuings.git
synced 2026-10-05 16:18:24 +02:00
@@ -0,0 +1,122 @@
|
||||
package network
|
||||
|
||||
import "strings"
|
||||
|
||||
// Device is one row of `nmcli device status`.
|
||||
type Device struct {
|
||||
Name string
|
||||
Type string // e.g. "wifi", "ethernet", "wireguard", "loopback"
|
||||
State string // e.g. "connected", "disconnected", "unavailable"
|
||||
Connection string // active connection profile name, empty if none
|
||||
}
|
||||
|
||||
func (d Device) Connected() bool {
|
||||
return strings.HasPrefix(d.State, "connected")
|
||||
}
|
||||
|
||||
// ListDevices returns every network device known to NetworkManager.
|
||||
func ListDevices() ([]Device, error) {
|
||||
rows, err := runTerse("DEVICE,TYPE,STATE,CONNECTION", "device", "status")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
devices := make([]Device, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
devices = append(devices, Device{
|
||||
Name: field(row, 0),
|
||||
Type: field(row, 1),
|
||||
State: field(row, 2),
|
||||
Connection: field(row, 3),
|
||||
})
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// DeviceConnect asks NetworkManager to bring a device up using whichever
|
||||
// saved connection profile fits it best. Unlike VPNUp (which reactivates a
|
||||
// specific, already-known connection by name), this works even right after
|
||||
// a disconnect, when `device status` no longer reports which connection
|
||||
// profile was last active on that device.
|
||||
func DeviceConnect(device string) error {
|
||||
_, err := run("device", "connect", device)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeviceIP4 returns the device's primary private IPv4 address without its
|
||||
// CIDR suffix (e.g. "192.168.1.54"), or "" if the device has none.
|
||||
func DeviceIP4(device string) (string, error) {
|
||||
out, err := getField("IP4.ADDRESS", "device", "show", device)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out == "" {
|
||||
return "", nil
|
||||
}
|
||||
// nmcli -g can return multiple addresses newline-separated; keep the first.
|
||||
if i := strings.IndexByte(out, '\n'); i >= 0 {
|
||||
out = out[:i]
|
||||
}
|
||||
if i := strings.IndexByte(out, '/'); i >= 0 {
|
||||
out = out[:i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeviceDetail holds everything settuings shows on the "network info" screen
|
||||
// for a connected device: addressing, gateway, DNS and hardware details.
|
||||
type DeviceDetail struct {
|
||||
HWAddr string
|
||||
MTU string
|
||||
IPv4 string // address + CIDR, e.g. "192.168.1.54/24"
|
||||
Gateway4 string
|
||||
DNS4 []string
|
||||
IPv6 []string
|
||||
Gateway6 string
|
||||
DNS6 []string
|
||||
}
|
||||
|
||||
// DeviceDetails fetches full addressing/hardware info for device via
|
||||
// `nmcli device show`. Unlike the tabular nmcli commands elsewhere in this
|
||||
// package, `device show` prints one "KEY:VALUE" line per property (with
|
||||
// "KEY[n]" for multi-valued ones) and does not escape colons inside values,
|
||||
// so it's parsed differently from runTerse.
|
||||
func DeviceDetails(device string) (DeviceDetail, error) {
|
||||
out, err := run("-t", "-f",
|
||||
"GENERAL.HWADDR,GENERAL.MTU,IP4.ADDRESS,IP4.GATEWAY,IP4.DNS,IP6.ADDRESS,IP6.GATEWAY,IP6.DNS",
|
||||
"device", "show", device)
|
||||
if err != nil {
|
||||
return DeviceDetail{}, err
|
||||
}
|
||||
|
||||
var d DeviceDetail
|
||||
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if i := strings.IndexByte(key, '['); i >= 0 {
|
||||
key = key[:i] // strip the "[n]" multi-value index
|
||||
}
|
||||
switch key {
|
||||
case "GENERAL.HWADDR":
|
||||
d.HWAddr = value
|
||||
case "GENERAL.MTU":
|
||||
d.MTU = value
|
||||
case "IP4.ADDRESS":
|
||||
if d.IPv4 == "" {
|
||||
d.IPv4 = value
|
||||
}
|
||||
case "IP4.GATEWAY":
|
||||
d.Gateway4 = value
|
||||
case "IP4.DNS":
|
||||
d.DNS4 = append(d.DNS4, value)
|
||||
case "IP6.ADDRESS":
|
||||
d.IPv6 = append(d.IPv6, value)
|
||||
case "IP6.GATEWAY":
|
||||
d.Gateway6 = value
|
||||
case "IP6.DNS":
|
||||
d.DNS6 = append(d.DNS6, value)
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package network
|
||||
|
||||
// EthernetDevice is a wired network interface and its active profile, if any.
|
||||
type EthernetDevice struct {
|
||||
Device string
|
||||
Connection string
|
||||
State string
|
||||
IP4 string
|
||||
}
|
||||
|
||||
func (e EthernetDevice) Connected() bool {
|
||||
return e.State == "connected"
|
||||
}
|
||||
|
||||
// ListEthernet returns every ethernet device, with its private IPv4 address
|
||||
// filled in when connected.
|
||||
func ListEthernet() ([]EthernetDevice, error) {
|
||||
devices, err := ListDevices()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
eth := make([]EthernetDevice, 0)
|
||||
for _, d := range devices {
|
||||
if d.Type != "ethernet" {
|
||||
continue
|
||||
}
|
||||
e := EthernetDevice{
|
||||
Device: d.Name,
|
||||
Connection: d.Connection,
|
||||
State: d.State,
|
||||
}
|
||||
if e.Connected() {
|
||||
e.IP4, _ = DeviceIP4(d.Name)
|
||||
}
|
||||
eth = append(eth, e)
|
||||
}
|
||||
return eth, nil
|
||||
}
|
||||
|
||||
// EthernetDown deactivates an ethernet device.
|
||||
func EthernetDown(device string) error {
|
||||
_, err := run("device", "disconnect", device)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package network
|
||||
|
||||
import "strings"
|
||||
|
||||
// KnownNetwork is a saved Wi-Fi connection profile ("known network" in the
|
||||
// iOS sense), independent of whether its AP is currently in range.
|
||||
type KnownNetwork struct {
|
||||
Name string
|
||||
Security string
|
||||
AutoConnect bool
|
||||
Active bool
|
||||
}
|
||||
|
||||
// ListKnownWifi returns every saved Wi-Fi connection profile.
|
||||
func ListKnownWifi() ([]KnownNetwork, error) {
|
||||
rows, err := runTerse("NAME,TYPE,AUTOCONNECT,ACTIVE", "connection", "show")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
known := make([]KnownNetwork, 0)
|
||||
for _, row := range rows {
|
||||
if field(row, 1) != "802-11-wireless" {
|
||||
continue
|
||||
}
|
||||
name := field(row, 0)
|
||||
security, _ := getField("802-11-wireless-security.key-mgmt", "connection", "show", "id", name)
|
||||
known = append(known, KnownNetwork{
|
||||
Name: name,
|
||||
Security: security,
|
||||
AutoConnect: field(row, 2) == "yes",
|
||||
Active: field(row, 3) == "yes",
|
||||
})
|
||||
}
|
||||
return known, nil
|
||||
}
|
||||
|
||||
// Secret returns the saved PSK/passphrase for a Wi-Fi connection profile.
|
||||
// It is empty for open networks or profiles using a non-PSK auth method.
|
||||
func Secret(name string) (string, error) {
|
||||
out, err := run("--show-secrets", "-g", "802-11-wireless-security.psk", "connection", "show", "id", name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
|
||||
// SetAutoConnect toggles whether a saved connection is joined automatically.
|
||||
func SetAutoConnect(name string, enabled bool) error {
|
||||
value := "no"
|
||||
if enabled {
|
||||
value = "yes"
|
||||
}
|
||||
_, err := run("connection", "modify", "id", name, "connection.autoconnect", value)
|
||||
return err
|
||||
}
|
||||
|
||||
// Forget deletes a saved connection profile entirely.
|
||||
func Forget(name string) error {
|
||||
_, err := run("connection", "delete", "id", name)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Package network wraps nmcli to read and control network state
|
||||
// (Wi-Fi, Ethernet, VPN). Every exported function shells out to nmcli;
|
||||
// there is no direct D-Bus/NetworkManager integration.
|
||||
package network
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("nmcli not found in PATH")
|
||||
ErrNotRunning = errors.New("NetworkManager is not running")
|
||||
)
|
||||
|
||||
const defaultTimeout = 15 * time.Second
|
||||
|
||||
// CheckAvailable reports whether nmcli is installed and NetworkManager is
|
||||
// reachable. Call it before using the rest of the package.
|
||||
func CheckAvailable() error {
|
||||
if _, err := exec.LookPath("nmcli"); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
out, err := run("-t", "-f", "RUNNING", "general")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrNotRunning, err)
|
||||
}
|
||||
if strings.TrimSpace(out) != "running" {
|
||||
return ErrNotRunning
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func run(args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "nmcli", args...) // #nosec G204 -- args are fixed nmcli subcommands or user-supplied SSID/password, never shell-interpreted
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("nmcli %s: %s", strings.Join(args, " "), msg)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// getField runs `nmcli -g <field> ...` and returns the single trimmed value.
|
||||
func getField(field string, args ...string) (string, error) {
|
||||
out, err := run(append([]string{"-g", field}, args...)...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
|
||||
// runTerse runs nmcli with -t -f <fields> and parses each output line into
|
||||
// its colon-separated columns, unescaping nmcli's `\:` and `\\`.
|
||||
func runTerse(fields string, args ...string) ([][]string, error) {
|
||||
out, err := run(append([]string{"-t", "-f", fields}, args...)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = strings.TrimRight(out, "\n")
|
||||
if out == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
lines := strings.Split(out, "\n")
|
||||
rows := make([][]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
rows = append(rows, parseTerseLine(line))
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func parseTerseLine(line string) []string {
|
||||
var fields []string
|
||||
var cur strings.Builder
|
||||
escaped := false
|
||||
for _, r := range line {
|
||||
if escaped {
|
||||
cur.WriteRune(r)
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '\\':
|
||||
escaped = true
|
||||
case ':':
|
||||
fields = append(fields, cur.String())
|
||||
cur.Reset()
|
||||
default:
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
}
|
||||
fields = append(fields, cur.String())
|
||||
return fields
|
||||
}
|
||||
|
||||
// field is a small helper to safely index a parsed terse row.
|
||||
func field(row []string, i int) string {
|
||||
if i < 0 || i >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return row[i]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package network
|
||||
|
||||
// VPNConnection is a saved VPN or WireGuard connection profile.
|
||||
type VPNConnection struct {
|
||||
Name string
|
||||
Type string // "vpn" or "wireguard"
|
||||
Active bool
|
||||
Device string
|
||||
}
|
||||
|
||||
// ListVPN returns every saved connection whose type is "vpn" or "wireguard".
|
||||
func ListVPN() ([]VPNConnection, error) {
|
||||
rows, err := runTerse("NAME,TYPE,ACTIVE,DEVICE", "connection", "show")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vpns := make([]VPNConnection, 0)
|
||||
for _, row := range rows {
|
||||
typ := field(row, 1)
|
||||
if typ != "vpn" && typ != "wireguard" {
|
||||
continue
|
||||
}
|
||||
vpns = append(vpns, VPNConnection{
|
||||
Name: field(row, 0),
|
||||
Type: typ,
|
||||
Active: field(row, 2) == "yes",
|
||||
Device: field(row, 3),
|
||||
})
|
||||
}
|
||||
return vpns, nil
|
||||
}
|
||||
|
||||
// VPNUp activates a VPN/WireGuard connection by name.
|
||||
func VPNUp(name string) error {
|
||||
_, err := run("connection", "up", "id", name)
|
||||
return err
|
||||
}
|
||||
|
||||
// VPNDown deactivates a VPN/WireGuard connection by name.
|
||||
func VPNDown(name string) error {
|
||||
_, err := run("connection", "down", "id", name)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var ErrNoWifiDevice = errors.New("no Wi-Fi device found")
|
||||
|
||||
// WifiDevice returns the first Wi-Fi radio device, if any.
|
||||
func WifiDevice() (Device, error) {
|
||||
devices, err := ListDevices()
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
for _, d := range devices {
|
||||
if d.Type == "wifi" {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
return Device{}, ErrNoWifiDevice
|
||||
}
|
||||
|
||||
// WifiNetwork is one access point seen by `nmcli device wifi list`.
|
||||
type WifiNetwork struct {
|
||||
Active bool // this is the AP we're currently associated with
|
||||
SSID string
|
||||
BSSID string
|
||||
Signal int // 0-100
|
||||
Security string
|
||||
}
|
||||
|
||||
func (w WifiNetwork) Secured() bool {
|
||||
return w.Security != "" && w.Security != "--"
|
||||
}
|
||||
|
||||
// ListWifi returns visible access points, deduplicated by SSID (keeping the
|
||||
// strongest signal seen for each network), sorted strongest-first.
|
||||
func ListWifi() ([]WifiNetwork, error) {
|
||||
rows, err := runTerse("IN-USE,SSID,BSSID,SIGNAL,SECURITY", "device", "wifi", "list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bySSID := make(map[string]WifiNetwork)
|
||||
order := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ssid := field(row, 1)
|
||||
if ssid == "" {
|
||||
continue // hidden network with no broadcast SSID
|
||||
}
|
||||
signal, _ := strconv.Atoi(field(row, 3))
|
||||
net := WifiNetwork{
|
||||
Active: field(row, 0) == "*",
|
||||
SSID: ssid,
|
||||
BSSID: field(row, 2),
|
||||
Signal: signal,
|
||||
Security: field(row, 4),
|
||||
}
|
||||
existing, ok := bySSID[ssid]
|
||||
if !ok || net.Active || net.Signal > existing.Signal {
|
||||
bySSID[ssid] = net
|
||||
}
|
||||
if !ok {
|
||||
order = append(order, ssid)
|
||||
}
|
||||
}
|
||||
|
||||
networks := make([]WifiNetwork, 0, len(order))
|
||||
for _, ssid := range order {
|
||||
networks = append(networks, bySSID[ssid])
|
||||
}
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
// Rescan asks NetworkManager to trigger a fresh Wi-Fi scan. It returns once
|
||||
// the scan is requested, not once it completes; call ListWifi shortly after.
|
||||
func Rescan() error {
|
||||
_, err := run("device", "wifi", "rescan")
|
||||
return err
|
||||
}
|
||||
|
||||
// ConnectWifi joins ssid, creating a new saved profile if none exists yet.
|
||||
// Pass an empty password for open networks or when a saved profile already
|
||||
// has one.
|
||||
func ConnectWifi(ssid, password string) error {
|
||||
args := []string{"device", "wifi", "connect", ssid}
|
||||
if password != "" {
|
||||
args = append(args, "password", password)
|
||||
}
|
||||
_, err := run(args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// DisconnectWifi disconnects the given Wi-Fi device without forgetting it.
|
||||
func DisconnectWifi(device string) error {
|
||||
_, err := run("device", "disconnect", device)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetWifiRadio turns the Wi-Fi radio on or off entirely.
|
||||
func SetWifiRadio(on bool) error {
|
||||
state := "off"
|
||||
if on {
|
||||
state = "on"
|
||||
}
|
||||
_, err := run("radio", "wifi", state)
|
||||
return err
|
||||
}
|
||||
|
||||
// RadioEnabled reports whether the Wi-Fi radio is currently switched on.
|
||||
func RadioEnabled() (bool, error) {
|
||||
out, err := getField("WIFI", "general")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return out == "enabled", nil
|
||||
}
|
||||
Reference in New Issue
Block a user