From b21475b4872b8b232dc94270238e368b73d9cf0b Mon Sep 17 00:00:00 2001 From: Ryan D McGuire Date: Wed, 14 Jan 2026 11:11:34 -0500 Subject: [PATCH] move to ubiquiti-clients, add edgeos --- README.md | 140 +++++++++++++++++++++++-- go.mod | 2 +- pkg/edgeos/api.go | 205 ++++++++++++++++++++++++++++++++++++ pkg/edgeos/client.go | 244 +++++++++++++++++++++++++++++++++++++++++++ pkg/edgeos/config.go | 17 +++ pkg/edgeos/types.go | 100 ++++++++++++++++++ 6 files changed, 698 insertions(+), 10 deletions(-) create mode 100644 pkg/edgeos/api.go create mode 100644 pkg/edgeos/client.go create mode 100644 pkg/edgeos/config.go create mode 100644 pkg/edgeos/types.go diff --git a/README.md b/README.md index a10ce1e..8d830eb 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,17 @@ -# toughswitch +# ubiquiti-clients -A Go client library for interacting with Ubiquiti toughswitch devices (specifically tested with +Go client libraries for interacting with Ubiquiti network devices via their REST APIs. + +**⚠️ Disclaimer: These libraries are based on reverse-engineered API calls. They are not official Ubiquiti products and are subject to change if the device firmware changes.** + +## Packages + +### toughswitch + +A client library for interacting with Ubiquiti ToughSwitch devices (specifically tested with ToughSwitch POE Pro (TS-8-PRO)) via their internal REST API. -**⚠️ Disclaimer: This library is based on reverse-engineered API calls. It is not an official Ubiquiti product and is subject to change if the device firmware changes.** - -## Features +#### Features - **Authentication**: Handles login and session token management automatically. - **Multi-Device Support**: Manage multiple devices with a single client instance. @@ -17,15 +23,33 @@ ToughSwitch POE Pro (TS-8-PRO)) via their internal REST API. - **Statistics**: Real-time throughput, errors, and resource usage. - **Discovery**: Neighbor discovery via UBNT protocol. +### edgeos + +A client library for interacting with Ubiquiti EdgeOS devices (EdgeRouter, EdgeSwitch) via their REST API. + +#### Features + +- **Authentication**: Handles login and session management automatically. +- **Multi-Device Support**: Manage multiple devices with a single client instance. +- **Data Retrieval**: + - **System Configuration**: Hostname, domain name, and other system settings. + - **Interface Configuration**: Ethernet and switch interface settings, including PoE. + - **VLAN Configuration**: VLAN assignments, PVID, and tagged VLANs. + - **Device Information**: Model, ports, PoE capabilities, and features. + ## Installation ```bash +# For ToughSwitch go get gitea.libretechconsulting.com/rmcguire/toughswitch-client/pkg/toughswitch + +# For EdgeOS +go get gitea.libretechconsulting.com/rmcguire/toughswitch-client/pkg/edgeos ``` ## Usage -### Basic Example +### ToughSwitch Basic Example ```go package main @@ -81,7 +105,73 @@ func main() { } ``` -### Retrieving Statistics +### EdgeOS Basic Example + +```go +package main + +import ( + "context" + "fmt" + "log" + "time" + + "gitea.libretechconsulting.com/rmcguire/toughswitch-client/pkg/edgeos" +) + +func main() { + ctx := context.Background() + + // Configure your device(s) + configs := []edgeos.Config{ + { + Host: "192.168.1.1", + Username: "ubnt", + Password: "ubnt", + Insecure: true, // Set to true if using self-signed certs + Timeout: 10 * time.Second, + }, + } + + // Initialize the client + client := edgeos.MustNew(ctx, configs) + + // Fetch device information + deviceHost := "192.168.1.1" + authInfo, err := client.GetAuthInfo(ctx, deviceHost) + if err != nil { + log.Fatalf("Failed to get auth info: %v", err) + } + + fmt.Printf("Connected to: %s (%s) with %d ports\n", + authInfo.ModelName, authInfo.Model, authInfo.Ports) + + // Fetch system configuration + system, err := client.GetSystem(ctx, deviceHost) + if err != nil { + log.Fatalf("Failed to get system config: %v", err) + } + + fmt.Printf("Hostname: %s, Domain: %s\n", system.HostName, system.DomainName) + + // Fetch interfaces + interfaces, err := client.GetInterfaces(ctx, deviceHost) + if err != nil { + log.Fatalf("Failed to get interfaces: %v", err) + } + + for name, config := range interfaces.Ethernet { + fmt.Printf("Interface %s: %s (Speed: %s, Duplex: %s)\n", + name, + config.Description, + config.Speed, + config.Duplex, + ) + } +} +``` + +### ToughSwitch: Retrieving Statistics ```go stats, err := client.GetStatistics(ctx, "192.168.1.1") @@ -106,9 +196,10 @@ for _, stat := range stats { ### Working with Multiple Devices -The client is designed to handle multiple devices concurrently. +Both clients are designed to handle multiple devices concurrently. ```go +// ToughSwitch example configs := []toughswitch.Config{ {Host: "192.168.1.1", ...}, {Host: "192.168.1.2", ...}, @@ -118,17 +209,35 @@ client := toughswitch.MustNew(ctx, configs) // Get info for all configured devices in parallel allSystems, err := client.GetAllSystems(ctx) if err != nil { - // Note: This returns partial results if available, check implementation log.Printf("Error fetching some systems: %v", err) } for host, sys := range allSystems { fmt.Printf("[%s] Hostname: %s\n", host, sys.Hostname) } + +// EdgeOS example +edgeConfigs := []edgeos.Config{ + {Host: "192.168.2.1", ...}, + {Host: "192.168.2.2", ...}, +} +edgeClient := edgeos.MustNew(ctx, edgeConfigs) + +// Get config for all configured devices in parallel +allConfigs, err := edgeClient.GetAllConfigs(ctx) +if err != nil { + log.Printf("Error fetching some configs: %v", err) +} + +for host, cfg := range allConfigs { + fmt.Printf("[%s] Hostname: %s\n", host, cfg.System.HostName) +} ``` ## Supported Endpoints +### ToughSwitch + | Method | Description | |--------|-------------| | `GetSystem` | General system configuration and status | @@ -139,6 +248,19 @@ for host, sys := range allSystems { | `GetNeighbors` | Discovered UBNT neighbors | | `GetDevice` | Hardware and capabilities info | +All methods have corresponding `GetAll*` variants for multi-device operations. + +### EdgeOS + +| Method | Description | +|--------|-------------| +| `GetConfig` | Complete device configuration | +| `GetAuthInfo` | Device authentication and feature information | +| `GetInterfaces` | Interface configuration (ethernet and switch) | +| `GetSystem` | System configuration (hostname, domain) | + +All methods have corresponding `GetAll*` variants for multi-device operations. + ## License MIT diff --git a/go.mod b/go.mod index 5ed77bd..3a5a6c5 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module gitea.libretechconsulting.com/rmcguire/toughswitch-client +module gitea.libretechconsulting.com/rmcguire/ubiquiti-clients go 1.25.5 diff --git a/pkg/edgeos/api.go b/pkg/edgeos/api.go new file mode 100644 index 0000000..c1c4914 --- /dev/null +++ b/pkg/edgeos/api.go @@ -0,0 +1,205 @@ +package edgeos + +import ( + "context" + "errors" + "sync" +) + +// GetConfig retrieves the complete device configuration from /api/edge/get.json for a specific device. +func (c *Client) GetConfig(ctx context.Context, host string) (*ConfigData, error) { + d, err := c.getDeviceByHost(host) + if err != nil { + return nil, err + } + + var out ConfigResponse + if err := d.do(ctx, "GET", "/api/edge/get.json", nil, &out); err != nil { + return nil, err + } + + if !out.Success { + return nil, errors.New("config request unsuccessful") + } + + return &out.GET, nil +} + +// GetAllConfigs retrieves device configuration for all devices. +func (c *Client) GetAllConfigs(ctx context.Context) (map[string]*ConfigData, error) { + results := make(map[string]*ConfigData) + var ( + mu sync.Mutex + wg sync.WaitGroup + errs error + ) + + c.mu.RLock() + hosts := make([]string, 0, len(c.devices)) + for h := range c.devices { + hosts = append(hosts, h) + } + c.mu.RUnlock() + + for _, host := range hosts { + wg.Go(func() { + res, err := c.GetConfig(ctx, host) + if err != nil { + mu.Lock() + errs = errors.Join(errs, err) + mu.Unlock() + return + } + mu.Lock() + results[host] = res + mu.Unlock() + }) + } + wg.Wait() + return results, errs +} + +// GetAuthInfo retrieves the authentication info for a specific device. +func (c *Client) GetAuthInfo(ctx context.Context, host string) (*AuthResponse, error) { + d, err := c.getDeviceByHost(host) + if err != nil { + return nil, err + } + + d.mu.Lock() + authInfo := d.authInfo + d.mu.Unlock() + + if authInfo == nil { + if err := d.login(ctx); err != nil { + return nil, err + } + d.mu.Lock() + authInfo = d.authInfo + d.mu.Unlock() + } + + return authInfo, nil +} + +// GetAllAuthInfo retrieves authentication info for all devices. +func (c *Client) GetAllAuthInfo(ctx context.Context) (map[string]*AuthResponse, error) { + results := make(map[string]*AuthResponse) + var ( + mu sync.Mutex + wg sync.WaitGroup + errs error + ) + + c.mu.RLock() + hosts := make([]string, 0, len(c.devices)) + for h := range c.devices { + hosts = append(hosts, h) + } + c.mu.RUnlock() + + for _, host := range hosts { + wg.Go(func() { + res, err := c.GetAuthInfo(ctx, host) + if err != nil { + mu.Lock() + errs = errors.Join(errs, err) + mu.Unlock() + return + } + mu.Lock() + results[host] = res + mu.Unlock() + }) + } + wg.Wait() + return results, errs +} + +// GetInterfaces retrieves the interfaces for a specific device from the config data. +func (c *Client) GetInterfaces(ctx context.Context, host string) (*InterfacesConfig, error) { + config, err := c.GetConfig(ctx, host) + if err != nil { + return nil, err + } + + return &config.Interfaces, nil +} + +// GetAllInterfaces retrieves interfaces for all devices. +func (c *Client) GetAllInterfaces(ctx context.Context) (map[string]*InterfacesConfig, error) { + results := make(map[string]*InterfacesConfig) + var ( + mu sync.Mutex + wg sync.WaitGroup + errs error + ) + + c.mu.RLock() + hosts := make([]string, 0, len(c.devices)) + for h := range c.devices { + hosts = append(hosts, h) + } + c.mu.RUnlock() + + for _, host := range hosts { + wg.Go(func() { + res, err := c.GetInterfaces(ctx, host) + if err != nil { + mu.Lock() + errs = errors.Join(errs, err) + mu.Unlock() + return + } + mu.Lock() + results[host] = res + mu.Unlock() + }) + } + wg.Wait() + return results, errs +} + +// GetSystem retrieves the system info for a specific device from the config data. +func (c *Client) GetSystem(ctx context.Context, host string) (*SystemConfig, error) { + config, err := c.GetConfig(ctx, host) + if err != nil { + return nil, err + } + + return &config.System, nil +} + +// GetAllSystems retrieves system info for all devices. +func (c *Client) GetAllSystems(ctx context.Context) (map[string]*SystemConfig, error) { + results := make(map[string]*SystemConfig) + var ( + mu sync.Mutex + wg sync.WaitGroup + errs error + ) + + c.mu.RLock() + hosts := make([]string, 0, len(c.devices)) + for h := range c.devices { + hosts = append(hosts, h) + } + c.mu.RUnlock() + + for _, host := range hosts { + wg.Go(func() { + res, err := c.GetSystem(ctx, host) + if err != nil { + mu.Lock() + errs = errors.Join(errs, err) + mu.Unlock() + return + } + mu.Lock() + results[host] = res + mu.Unlock() + }) + } + wg.Wait() + return results, errs +} diff --git a/pkg/edgeos/client.go b/pkg/edgeos/client.go new file mode 100644 index 0000000..04e1daf --- /dev/null +++ b/pkg/edgeos/client.go @@ -0,0 +1,244 @@ +/* +Package edgeos provides a client for interacting with Ubiquiti EdgeOS devices +via their REST API. It supports authentication, session management, and +retrieval of system and interface configuration from one or more devices. +*/ +package edgeos + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" +) + +// Client handles communication with EdgeOS devices. +type Client struct { + mu sync.RWMutex + devices map[string]*deviceClient +} + +type deviceClient struct { + config Config + client *http.Client + cookies []*http.Cookie + authInfo *AuthResponse + mu sync.Mutex +} + +func newDeviceClient(cfg Config) *deviceClient { + if cfg.Scheme == "" { + cfg.Scheme = "https" + } + + var tr http.RoundTripper + if cfg.Transport != nil { + tr = cfg.Transport + } else { + defaultTr := http.DefaultTransport.(*http.Transport).Clone() + if defaultTr.TLSClientConfig == nil { + defaultTr.TLSClientConfig = &tls.Config{} + } + defaultTr.TLSClientConfig.InsecureSkipVerify = cfg.Insecure + tr = defaultTr + } + + client := &http.Client{ + Transport: tr, + Timeout: cfg.Timeout, + } + + return &deviceClient{ + config: cfg, + client: client, + } +} + +// MustNew creates a new Client with the given configurations. +// It panics if a configuration is invalid (though currently we just accept all). +func MustNew(ctx context.Context, configs []Config) *Client { + devices := make(map[string]*deviceClient) + + for _, cfg := range configs { + devices[cfg.Host] = newDeviceClient(cfg) + } + + return &Client{ + devices: devices, + } +} + +// Add adds a new device to the client. +// It returns an error if a device with the same host already exists. +func (c *Client) Add(cfg *Config) error { + if cfg == nil { + return fmt.Errorf("config cannot be nil") + } + + d := newDeviceClient(*cfg) + + c.mu.Lock() + defer c.mu.Unlock() + + if _, ok := c.devices[cfg.Host]; ok { + return fmt.Errorf("device already exists: %s", cfg.Host) + } + + c.devices[cfg.Host] = d + return nil +} + +// Del removes a device from the client. +// It returns an error if the device does not exist. +func (c *Client) Del(host string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if _, ok := c.devices[host]; !ok { + return fmt.Errorf("device not found: %s", host) + } + + delete(c.devices, host) + return nil +} + +func (c *Client) getDeviceByHost(host string) (*deviceClient, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + d, ok := c.devices[host] + if !ok { + return nil, fmt.Errorf("device not found: %s", host) + } + return d, nil +} + +func (d *deviceClient) login(ctx context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() + + reqUrl := fmt.Sprintf("%s://%s/api/login2", d.config.Scheme, d.config.Host) + + data := url.Values{} + data.Set("username", d.config.Username) + data.Set("password", d.config.Password) + + req, err := http.NewRequestWithContext(ctx, "POST", reqUrl, strings.NewReader(data.Encode())) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + req.Header.Set("Origin", fmt.Sprintf("%s://%s", d.config.Scheme, d.config.Host)) + + resp, err := d.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + respPayload, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("login failed [%d]: %s", resp.StatusCode, string(respPayload)) + } + + var authResp AuthResponse + if err := json.Unmarshal(respPayload, &authResp); err != nil { + return fmt.Errorf("failed to parse auth response: %w", err) + } + + if !authResp.Authenticated { + return fmt.Errorf("authentication failed for user %s", d.config.Username) + } + + d.authInfo = &authResp + d.cookies = resp.Cookies() + + return nil +} + +func (d *deviceClient) do(ctx context.Context, method, path string, body any, out any) error { + err := d.doRequest(ctx, method, path, body, out) + if err == nil { + return nil + } + + if strings.Contains(err.Error(), "status 401") || strings.Contains(err.Error(), "unauthorized") { + if loginErr := d.login(ctx); loginErr != nil { + return fmt.Errorf("re-login failed: %w", loginErr) + } + return d.doRequest(ctx, method, path, body, out) + } + + return err +} + +func (d *deviceClient) doRequest(ctx context.Context, method, path string, body any, out any) error { + reqUrl := fmt.Sprintf("%s://%s%s", d.config.Scheme, d.config.Host, path) + + var reqBody io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + reqBody = bytes.NewBuffer(b) + } + + req, err := http.NewRequestWithContext(ctx, method, reqUrl, reqBody) + if err != nil { + return err + } + + d.mu.Lock() + cookies := d.cookies + d.mu.Unlock() + + if len(cookies) > 0 { + cookieURL, _ := url.Parse(reqUrl) + for _, cookie := range cookies { + if cookie.Domain == "" || strings.HasSuffix(cookieURL.Host, cookie.Domain) { + req.AddCookie(cookie) + } + } + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + + resp, err := d.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("status 401") + } + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("request failed: status %d, body: %s", resp.StatusCode, string(b)) + } + + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/edgeos/config.go b/pkg/edgeos/config.go new file mode 100644 index 0000000..5ef2acd --- /dev/null +++ b/pkg/edgeos/config.go @@ -0,0 +1,17 @@ +package edgeos + +import ( + "net/http" + "time" +) + +type Config struct { + Host string + Scheme string + Insecure bool + Username string + Password string + Timeout time.Duration + // Transport allows customizing the http transport (useful for testing or client middleware) + Transport http.RoundTripper +} diff --git a/pkg/edgeos/types.go b/pkg/edgeos/types.go new file mode 100644 index 0000000..087269e --- /dev/null +++ b/pkg/edgeos/types.go @@ -0,0 +1,100 @@ +package edgeos + +// AuthResponse represents the authentication response from login2 endpoint. +type AuthResponse struct { + Username string `json:"username"` + PoE bool `json:"poe"` + StatsURL string `json:"statsUrl"` + Features Features `json:"features"` + Level string `json:"level"` + Authenticated bool `json:"authenticated"` + Model string `json:"model"` + IsLicenseAccepted bool `json:"isLicenseAccepted"` + ModelName string `json:"model_name"` + Ports int `json:"ports"` +} + +// Features contains device feature information. +type Features struct { + Model string `json:"model"` + PoECap map[string]string `json:"poe_cap"` + Switch SwitchFeatures `json:"switch"` + SwitchIsVLANCapable bool `json:"switchIsVLANCapable"` + PoE bool `json:"poe"` + Ports int `json:"ports"` +} + +// SwitchFeatures contains switch-specific features. +type SwitchFeatures struct { + Ports []string `json:"ports"` +} + +// ConfigResponse represents the response from /api/edge/get.json. +type ConfigResponse struct { + SessionID string `json:"SESSION_ID"` + GET ConfigData `json:"GET"` + Success bool `json:"success"` +} + +// ConfigData contains the full device configuration. +type ConfigData struct { + Interfaces InterfacesConfig `json:"interfaces"` + System SystemConfig `json:"system"` +} + +// InterfacesConfig represents the interfaces configuration. +type InterfacesConfig struct { + Ethernet map[string]EthernetConfig `json:"ethernet,omitempty"` + Switch map[string]SwitchConfig `json:"switch,omitempty"` +} + +// EthernetConfig represents an ethernet interface configuration. +type EthernetConfig struct { + Description string `json:"description,omitempty"` + Duplex string `json:"duplex,omitempty"` + Speed string `json:"speed,omitempty"` + PoE *PoEState `json:"poe,omitempty"` +} + +// PoEState represents PoE output state. +type PoEState struct { + Output string `json:"output,omitempty"` +} + +// SwitchConfig represents a switch interface configuration. +type SwitchConfig struct { + Address []string `json:"address,omitempty"` + MTU string `json:"mtu,omitempty"` + SwitchPort *SwitchPortConfig `json:"switch-port,omitempty"` + VIF map[string]VIFConfig `json:"vif,omitempty"` +} + +// SwitchPortConfig represents switch port configuration with VLAN awareness. +type SwitchPortConfig struct { + Interface map[string]InterfaceVLAN `json:"interface,omitempty"` + VLANAware string `json:"vlan-aware,omitempty"` +} + +// InterfaceVLAN represents VLAN configuration for an interface. +type InterfaceVLAN struct { + VLAN VLANConfig `json:"vlan,omitempty"` +} + +// VLANConfig represents VLAN ID configuration. +type VLANConfig struct { + PVID string `json:"pvid,omitempty"` + VID []string `json:"vid,omitempty"` +} + +// VIFConfig represents a virtual interface (VLAN) configuration. +type VIFConfig struct { + Address []string `json:"address,omitempty"` + Description string `json:"description,omitempty"` + MTU string `json:"mtu,omitempty"` +} + +// SystemConfig contains system configuration. +type SystemConfig struct { + HostName string `json:"host-name,omitempty"` + DomainName string `json:"domain-name,omitempty"` +}