From 65846a33c006df0a6664f7cf4885609867b8b3ab Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sat, 3 May 2025 22:21:46 +0200 Subject: [PATCH] initial commit --- .gitignore | 1 + Makefile | 19 +++ README.md | 68 +++++++++ api/rcond.yaml | 96 +++++++++++++ cmd/rcond/main.go | 35 +++++ go.mod | 12 ++ go.sum | 4 + pkg/http/handlers.go | 78 ++++++++++ pkg/http/server.go | 53 +++++++ pkg/network/nm.go | 330 +++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 696 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 api/rcond.yaml create mode 100644 cmd/rcond/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pkg/http/handlers.go create mode 100644 pkg/http/server.go create mode 100644 pkg/network/nm.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5e82d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6d39571 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +ARCH ?= arm64 +ADDR ?= 0.0.0.0:8080 + +generate: + swagger generate server -f api/rcond.yaml -t api/ + go mod tidy + +build: + mkdir -p bin + env GOOS=linux GOARCH=${ARCH} go build -o bin/rcond-${ARCH} ./cmd/rcond/main.go + +run: + bin/rcond-${ARCH} ${ADDR} + +dev: + go run cmd/rcond/main.go ${ADDR} + +upload: + scp rcond-${ARCH} pi@rpi-40ac:/home/pi/rcond diff --git a/README.md b/README.md new file mode 100644 index 0000000..1802d2f --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# rcond + +A simple daemon to manage network connections through NetworkManager's D-Bus interface. + +It provides a REST API to: +- Create and activate WiFi connections +- Deactivate WiFi connections +- Remove stored connection profiles + +The daemon is designed to run on Linux systems with NetworkManager. + +## Build and Run + +```bash +make build +make run +``` + +## API + +The full API specification can be found in [api/rcond.yaml](api/rcond.yaml). + +### Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/health` | Health check endpoint that returns status | +| POST | `/network/up` | Create and activate a WiFi access point | +| POST | `/network/down` | Deactivate a WiFi interface | +| POST | `/network/remove` | Remove the stored connection profile | + +### Response Codes + +- 200: Success +- 400: Bad request (invalid JSON payload) +- 405: Method not allowed +- 500: Internal server error + +### Request/Response Format +All endpoints use JSON for request and response payloads. + +### 1) Bring a network up + +```bash +curl -v -X POST http://localhost:8080/network/up \ + -H "Content-Type: application/json" \ + -d '{ + "interface": "wlan0", + "ssid": "MyNetworkSSID", + "password": "SuperSecretPassword" + }' +``` + +### 2) Bring a network down + +```bash +curl -v -X POST http://localhost:8080/network/down \ + -H "Content-Type: application/json" \ + -d '{ + "interface": "wlan0" + }' +``` + +### 3) Remove the stored connection + +```bash +curl -v -X POST http://localhost:8080/network/remove +``` diff --git a/api/rcond.yaml b/api/rcond.yaml new file mode 100644 index 0000000..bbc565f --- /dev/null +++ b/api/rcond.yaml @@ -0,0 +1,96 @@ +openapi: 3.0.0 +info: + title: rcond API + description: API for managing network connections through NetworkManager + version: 1.0.0 + +servers: + - url: http://localhost:8080 + description: Local development server + +paths: + /health: + get: + summary: Health check endpoint + description: Returns the health status of the service + responses: + '200': + description: Service is healthy + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: "healthy" + + /network/up: + post: + summary: Create and activate WiFi access point + description: Creates and activates a WiFi access point on the specified interface + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - interface + - ssid + - password + properties: + interface: + type: string + description: Network interface name + example: "wlan0" + ssid: + type: string + description: WiFi network SSID + example: "MyNetworkSSID" + password: + type: string + description: WiFi network password + example: "SuperSecretPassword" + responses: + '200': + description: Network interface brought up successfully + '400': + description: Invalid request payload + '500': + description: Internal server error + + /network/down: + post: + summary: Deactivate network interface + description: Deactivates the specified network interface + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - interface + properties: + interface: + type: string + description: Network interface name + example: "wlan0" + responses: + '200': + description: Network interface brought down successfully + '400': + description: Invalid request payload + '500': + description: Internal server error + + /network/remove: + post: + summary: Remove stored connection profile + description: Removes the stored NetworkManager connection profile + responses: + '200': + description: Connection profile removed successfully + '500': + description: Internal server error diff --git a/cmd/rcond/main.go b/cmd/rcond/main.go new file mode 100644 index 0000000..9352574 --- /dev/null +++ b/cmd/rcond/main.go @@ -0,0 +1,35 @@ +// Usage: rcond
+ +package main + +import ( + "fmt" + "log" + "os" + + http "github.com/0x1d/rcond/pkg/http" +) + +const ( + NETWORK_CONNECTION_UUID = "7d706027-727c-4d4c-a816-f0e1b99db8ab" +) + +func usage() { + fmt.Printf("Usage: %s
\n", os.Args[0]) + os.Exit(0) +} + +func main() { + if len(os.Args) < 2 { + usage() + } + + addr := os.Args[1] + srv := http.NewServer(addr) + srv.RegisterRoutes() + + log.Printf("Starting server on %s", addr) + if err := srv.Start(); err != nil { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..41585a9 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/0x1d/rcond + +go 1.23.4 + +replace github.com/0x1d/rcond/cmd => ./cmd + +replace github.com/0x1d/rcond/pkg => ./pkg + +require ( + github.com/godbus/dbus/v5 v5.1.0 + github.com/gorilla/mux v1.8.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..337a340 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= diff --git a/pkg/http/handlers.go b/pkg/http/handlers.go new file mode 100644 index 0000000..60c5417 --- /dev/null +++ b/pkg/http/handlers.go @@ -0,0 +1,78 @@ +package http + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/0x1d/rcond/pkg/network" +) + +const ( + NETWORK_CONNECTION_UUID = "7d706027-727c-4d4c-a816-f0e1b99db8ab" +) + +func HandleNetworkUp(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Interface string `json:"interface"` + SSID string `json:"ssid"` + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + log.Printf("Bringing up network interface %s with SSID %s", req.Interface, req.SSID) + if err := network.Up(req.Interface, req.SSID, req.Password, NETWORK_CONNECTION_UUID); err != nil { + log.Printf("Failed to bring up network interface %s: %v", req.Interface, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Printf("Successfully brought up network interface %s", req.Interface) + + w.WriteHeader(http.StatusOK) +} + +func HandleNetworkDown(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Interface string `json:"interface"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := network.Down(req.Interface); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func HandleNetworkRemove(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := network.Remove(NETWORK_CONNECTION_UUID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/pkg/http/server.go b/pkg/http/server.go new file mode 100644 index 0000000..5a9f969 --- /dev/null +++ b/pkg/http/server.go @@ -0,0 +1,53 @@ +package http + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/gorilla/mux" +) + +type Server struct { + router *mux.Router + srv *http.Server +} + +func NewServer(addr string) *Server { + router := mux.NewRouter() + + srv := &http.Server{ + Addr: addr, + Handler: router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + } + + return &Server{ + router: router, + srv: srv, + } +} + +func (s *Server) Start() error { + return s.srv.ListenAndServe() +} + +func (s *Server) Shutdown(ctx context.Context) error { + return s.srv.Shutdown(ctx) +} + +func (s *Server) RegisterRoutes() { + s.router.HandleFunc("/health", s.healthHandler).Methods(http.MethodGet) + s.router.HandleFunc("/network/up", HandleNetworkUp).Methods(http.MethodPost) + s.router.HandleFunc("/network/down", HandleNetworkDown).Methods(http.MethodPost) + s.router.HandleFunc("/network/remove", HandleNetworkRemove).Methods(http.MethodPost) +} + +func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "healthy", + }) +} diff --git a/pkg/network/nm.go b/pkg/network/nm.go new file mode 100644 index 0000000..8f8d8d0 --- /dev/null +++ b/pkg/network/nm.go @@ -0,0 +1,330 @@ +package network + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/godbus/dbus/v5" +) + +const ( + ourUUID = "7d706027-727c-4d4c-a816-f0e1b99db8ab" +) + +var ( + defaultSSID = "PIAP" + defaultPassword = "raspberry" +) + +// withDbus executes the given function with a D-Bus system connection +// and handles any connection errors +func withDbus(fn func(*dbus.Conn) error) error { + conn, err := dbus.SystemBus() + if err != nil { + log.Printf("Failed to connect to system bus: %v", err) + return err + } + if err := fn(conn); err != nil { + log.Print(err) + return err + } + return nil +} + +// ActivateConnection activates a NetworkManager connection profile. +// It takes a D-Bus connection, connection profile path, and device path as arguments. +// The function waits up to 10 seconds for the connection to become active. +// Returns an error if activation fails or times out. +func ActivateConnection(conn *dbus.Conn, connPath, devPath dbus.ObjectPath) error { + nmObj := conn.Object( + "org.freedesktop.NetworkManager", + "/org/freedesktop/NetworkManager", + ) + + // Activate the connection + var activePath dbus.ObjectPath + err := nmObj. + Call("org.freedesktop.NetworkManager.ActivateConnection", 0, + connPath, devPath, dbus.ObjectPath("/")). + Store(&activePath) + if err != nil { + return fmt.Errorf("ActivateConnection failed: %v", err) + } + + // Wait until the connection is activated + props := conn.Object( + "org.freedesktop.NetworkManager", + activePath, + ) + + start := time.Now() + for time.Since(start) < 10*time.Second { + var stateVar dbus.Variant + err = props. + Call("org.freedesktop.DBus.Properties.Get", 0, + "org.freedesktop.NetworkManager.Connection.Active", + "State"). + Store(&stateVar) + if err != nil { + return fmt.Errorf("Properties.Get(State) failed: %v", err) + } + if state, ok := stateVar.Value().(uint32); ok && state == 2 { + log.Printf("Connection activated on connection path %v", connPath) + return nil + } + time.Sleep(1 * time.Second) + } + return fmt.Errorf("failed to activate connection") +} + +// DisconnectDevice disconnects a NetworkManager device, stopping any active connections. +// Takes a D-Bus connection and device path as arguments. +// Returns an error if the disconnect operation fails. +func DisconnectDevice(conn *dbus.Conn, devPath dbus.ObjectPath) error { + devObj := conn.Object( + "org.freedesktop.NetworkManager", + devPath, + ) + err := devObj. + Call("org.freedesktop.NetworkManager.Device.Disconnect", 0). + Err + if err != nil { + return fmt.Errorf("Device.Disconnect failed: %v", err) + } + fmt.Println("Access point stopped") + return nil +} + +// DeleteConnection removes a NetworkManager connection profile. +// Takes a D-Bus connection and connection profile path as arguments. +// Returns an error if the delete operation fails. +func DeleteConnection(conn *dbus.Conn, connPath dbus.ObjectPath) error { + connObj := conn.Object( + "org.freedesktop.NetworkManager", + connPath, + ) + err := connObj. + Call("org.freedesktop.NetworkManager.Settings.Connection.Delete", 0). + Err + if err != nil { + return fmt.Errorf("Connection.Delete failed: %v", err) + } + log.Printf("Connection removed: %v", connPath) + return nil +} + +// GetWifiConfig retrieves the WiFi SSID and password from environment variables or command line arguments. +// Takes an operation string ("up", "down", etc) to determine whether to check command line args. +// Returns the SSID and password strings. +// Priority order: command line args > environment variables > default values +func GetWifiConfig(op string) (string, string) { + // Get SSID and password from args, env vars or use defaults + ssid := defaultSSID + password := defaultPassword + + // Check environment variables first + if v, ok := os.LookupEnv("WIFI_SSID"); ok { + ssid = v + } + if v, ok := os.LookupEnv("WIFI_PASSWORD"); ok { + password = v + } + + // Command line args override environment variables + if op == "up" && len(os.Args) >= 5 { + ssid = os.Args[3] + password = os.Args[4] + } + + return ssid, password +} + +// GetConnectionPath looks up a NetworkManager connection profile by UUID. +// Takes a D-Bus connection and connection UUID string as arguments. +// Returns the D-Bus object path of the connection if found, or empty string if not found. +// Returns an error if the lookup operation fails. +func GetConnectionPath(conn *dbus.Conn, connUUID string) (dbus.ObjectPath, error) { + // Get the Settings interface + settingsObj := conn.Object( + "org.freedesktop.NetworkManager", + "/org/freedesktop/NetworkManager/Settings", + ) + + // List existing connections + var paths []dbus.ObjectPath + err := settingsObj. + Call("org.freedesktop.NetworkManager.Settings.ListConnections", 0). + Store(&paths) + if err != nil { + return "", fmt.Errorf("ListConnections failed: %v", err) + } + + // Look up our connection by UUID + var connPath dbus.ObjectPath + for _, p := range paths { + obj := conn.Object( + "org.freedesktop.NetworkManager", + p, + ) + var cfg map[string]map[string]dbus.Variant + err = obj. + Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0). + Store(&cfg) + if err != nil { + continue + } + if v, ok := cfg["connection"]["uuid"].Value().(string); ok && v == connUUID { + connPath = p + break + } + } + + return connPath, nil +} + +// AddConnection creates a new NetworkManager connection profile for a WiFi access point. +// Takes a D-Bus connection, SSID string, and password string as arguments. +// Returns the D-Bus object path of the new connection profile. +// Returns an error if the connection creation fails. +func AddConnection(conn *dbus.Conn, ssid string, password string) (dbus.ObjectPath, error) { + settingsObj := conn.Object( + "org.freedesktop.NetworkManager", + "/org/freedesktop/NetworkManager/Settings", + ) + + settingsMap := map[string]map[string]dbus.Variant{ + "connection": { + "type": dbus.MakeVariant("802-11-wireless"), + "uuid": dbus.MakeVariant(ourUUID), + "id": dbus.MakeVariant(ssid), + "autoconnect": dbus.MakeVariant(true), + }, + "802-11-wireless": { + "ssid": dbus.MakeVariant([]byte(ssid)), + "mode": dbus.MakeVariant("ap"), + "band": dbus.MakeVariant("bg"), + "channel": dbus.MakeVariant(uint32(1)), + }, + "802-11-wireless-security": { + "key-mgmt": dbus.MakeVariant("wpa-psk"), + "psk": dbus.MakeVariant(password), + }, + "ipv4": { + "method": dbus.MakeVariant("shared"), + }, + "ipv6": { + "method": dbus.MakeVariant("ignore"), + }, + } + + var connPath dbus.ObjectPath + err := settingsObj. + Call("org.freedesktop.NetworkManager.Settings.AddConnection", 0, settingsMap). + Store(&connPath) + if err != nil { + return "", fmt.Errorf("AddConnection failed: %v", err) + } + + return connPath, nil +} + +// GetDeviceByIpIface looks up a NetworkManager device by its interface name. +// Takes a D-Bus connection and interface name string as arguments. +// Returns the D-Bus object path of the device. +// Returns an error if the device lookup fails. +func GetDeviceByIpIface(conn *dbus.Conn, iface string) (dbus.ObjectPath, error) { + // Get the NetworkManager interface + nmObj := conn.Object( + "org.freedesktop.NetworkManager", + "/org/freedesktop/NetworkManager", + ) + + // Find the device by interface name + var devPath dbus.ObjectPath + err := nmObj. + Call("org.freedesktop.NetworkManager.GetDeviceByIpIface", 0, iface). + Store(&devPath) + if err != nil { + return "", fmt.Errorf("GetDeviceByIpIface(%s) failed: %v", iface, err) + } + + return devPath, nil +} + +// Up creates and activates a WiFi access point connection. +// It takes the interface name, SSID, password and UUID as arguments. +// If a connection with the given UUID exists, it will be reused. +// Otherwise, a new connection will be created. +// The connection will be activated on the specified interface. +// Returns an error if any operation fails. +func Up(iface string, ssid string, password string, uuid string) error { + return withDbus(func(conn *dbus.Conn) error { + connPath, err := GetConnectionPath(conn, uuid) + if err != nil { + return err + } + + if connPath == "" { + connPath, err = AddConnection(conn, ssid, password) + if err != nil { + return err + } + } + + log.Printf("Getting device path for interface %s", iface) + devPath, err := GetDeviceByIpIface(conn, iface) + if err != nil { + log.Printf("Failed to get device path for interface %s: %v", iface, err) + return err + } + log.Printf("Got device path %s for interface %s", devPath, iface) + + if err := ActivateConnection(conn, connPath, devPath); err != nil { + return err + } + + return nil + }) +} + +// Down deactivates a network connection on the specified interface. +// It takes the interface name as an argument. +// Returns an error if the device cannot be found or disconnected. +func Down(iface string) error { + return withDbus(func(conn *dbus.Conn) error { + devPath, err := GetDeviceByIpIface(conn, iface) + if err != nil { + return err + } + + if err := DisconnectDevice(conn, devPath); err != nil { + return err + } + + return nil + }) +} + +// Remove deletes a NetworkManager connection profile with the given UUID. +// If no connection with the UUID exists, it returns nil. +// Returns an error if the connection exists but cannot be deleted. +func Remove(uuid string) error { + return withDbus(func(conn *dbus.Conn) error { + connPath, err := GetConnectionPath(conn, uuid) + if err != nil { + return err + } + + if connPath == "" { + return nil + } + + if err := DeleteConnection(conn, connPath); err != nil { + return err + } + + return nil + }) +}