5 Commits

Author SHA1 Message Date
7661bff6f1 update CHANGELOG
All checks were successful
Publish / release (push) Successful in 19s
2026-01-14 11:15:45 -05:00
b21475b487 move to ubiquiti-clients, add edgeos 2026-01-14 11:11:34 -05:00
195a9f7a9f rename to toughswitch
All checks were successful
Publish / release (push) Successful in 29s
2026-01-05 16:25:23 -05:00
ecbf4d447c rename to toughswitch
All checks were successful
Publish / release (push) Successful in 19s
2026-01-05 15:48:43 -05:00
438d422b53 rename to toughswitch 2026-01-05 15:47:43 -05:00
13 changed files with 1362 additions and 668 deletions

View File

@@ -2,6 +2,16 @@
All notable changes to this project will be documented in this file.
## v0.3.0 - 2026-01-14
### Changed
- Renamed project from toughswitch to ubiquiti-clients
- Added EdgeOS support
## v0.2.1 - 2026-01-05
### Changed
- Minor LSP improvements to tests
- Refactor entire project edgeos -> toughswitch
## [v0.2.0] - 2026-01-04
### Added
- Thread-safe `Add` and `Del` methods to `Client` for dynamic host management.
@@ -21,4 +31,4 @@ All notable changes to this project will be documented in this file.
## [v0.1.0] - 2026-01-04
### Added
- Initial CI pipeline setup.
- Initial release of Ubiquiti EdgeOS Go Client.
- Initial release of Ubiquiti toughswitch Go Client.

151
README.md
View File

@@ -1,10 +1,17 @@
# edgeos
# ubiquiti-clients
A Go client library for interacting with Ubiquiti EdgeOS devices (specifically tested with EdgeSwitch XP / ToughSwitch) via their internal REST API.
Go client libraries for interacting with Ubiquiti network devices via their REST APIs.
**⚠️ 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.**
**⚠️ 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.**
## Features
## 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.
#### Features
- **Authentication**: Handles login and session token management automatically.
- **Multi-Device Support**: Manage multiple devices with a single client instance.
@@ -16,15 +23,33 @@ A Go client library for interacting with Ubiquiti EdgeOS devices (specifically t
- **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
go get gitea.libretechconsulting.com/rmcguire/edgeos-client/pkg/edgeos
# 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
@@ -35,14 +60,14 @@ import (
"log"
"time"
"gitea.libretechconsulting.com/rmcguire/edgeos-client/pkg/edgeos"
"gitea.libretechconsulting.com/rmcguire/toughswitch-client/pkg/toughswitch"
)
func main() {
ctx := context.Background()
// Configure your device(s)
configs := []edgeos.Config{
configs := []toughswitch.Config{
{
Host: "192.168.1.1",
Username: "ubnt",
@@ -53,7 +78,7 @@ func main() {
}
// Initialize the client
client := edgeos.MustNew(ctx, configs)
client := toughswitch.MustNew(ctx, configs)
// Fetch system information
deviceHost := "192.168.1.1"
@@ -80,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")
@@ -105,29 +196,48 @@ 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
configs := []edgeos.Config{
// ToughSwitch example
configs := []toughswitch.Config{
{Host: "192.168.1.1", ...},
{Host: "192.168.1.2", ...},
}
client := edgeos.MustNew(ctx, configs)
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 |
@@ -138,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

2
go.mod
View File

@@ -1,3 +1,3 @@
module gitea.libretechconsulting.com/rmcguire/edgeos-client
module gitea.libretechconsulting.com/rmcguire/ubiquiti-clients
go 1.25.5

View File

@@ -6,24 +6,129 @@ import (
"sync"
)
// GetInterfaces retrieves the interfaces for a specific device.
func (c *Client) GetInterfaces(ctx context.Context, host string) ([]Interface, error) {
// 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 []Interface
if err := d.do(ctx, "GET", "/api/v1.0/interfaces", nil, &out); err != nil {
var out ConfigResponse
if err := d.do(ctx, "GET", "/api/edge/get.json", nil, &out); err != nil {
return nil, err
}
return out, nil
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][]Interface, error) {
results := make(map[string][]Interface)
func (c *Client) GetAllInterfaces(ctx context.Context) (map[string]*InterfacesConfig, error) {
results := make(map[string]*InterfacesConfig)
var (
mu sync.Mutex
wg sync.WaitGroup
@@ -55,73 +160,19 @@ func (c *Client) GetAllInterfaces(ctx context.Context) (map[string][]Interface,
return results, errs
}
// GetDevice retrieves the device info for a specific device.
func (c *Client) GetDevice(ctx context.Context, host string) (*Device, error) {
d, err := c.getDeviceByHost(host)
// 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
}
var out Device
if err := d.do(ctx, "GET", "/api/v1.0/device", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetAllDevices retrieves device info for all devices.
func (c *Client) GetAllDevices(ctx context.Context) (map[string]*Device, error) {
results := make(map[string]*Device)
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.GetDevice(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.
func (c *Client) GetSystem(ctx context.Context, host string) (*System, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out System
if err := d.do(ctx, "GET", "/api/v1.0/system", nil, &out); err != nil {
return nil, err
}
return &out, nil
return &config.System, nil
}
// GetAllSystems retrieves system info for all devices.
func (c *Client) GetAllSystems(ctx context.Context) (map[string]*System, error) {
results := make(map[string]*System)
func (c *Client) GetAllSystems(ctx context.Context) (map[string]*SystemConfig, error) {
results := make(map[string]*SystemConfig)
var (
mu sync.Mutex
wg sync.WaitGroup
@@ -152,199 +203,3 @@ func (c *Client) GetAllSystems(ctx context.Context) (map[string]*System, error)
wg.Wait()
return results, errs
}
// GetVLANs retrieves the VLANs for a specific device.
func (c *Client) GetVLANs(ctx context.Context, host string) (*VLANs, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out VLANs
if err := d.do(ctx, "GET", "/api/v1.0/vlans", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetAllVLANs retrieves VLANs for all devices.
func (c *Client) GetAllVLANs(ctx context.Context) (map[string]*VLANs, error) {
results := make(map[string]*VLANs)
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.GetVLANs(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
}
// GetServices retrieves the services for a specific device.
func (c *Client) GetServices(ctx context.Context, host string) (*Services, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out Services
if err := d.do(ctx, "GET", "/api/v1.0/services", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetAllServices retrieves services for all devices.
func (c *Client) GetAllServices(ctx context.Context) (map[string]*Services, error) {
results := make(map[string]*Services)
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.GetServices(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
}
// GetStatistics retrieves the statistics for a specific device.
func (c *Client) GetStatistics(ctx context.Context, host string) ([]Statistics, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out []Statistics
if err := d.do(ctx, "GET", "/api/v1.0/statistics", nil, &out); err != nil {
return nil, err
}
return out, nil
}
// GetAllStatistics retrieves statistics for all devices.
func (c *Client) GetAllStatistics(ctx context.Context) (map[string][]Statistics, error) {
results := make(map[string][]Statistics)
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.GetStatistics(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
}
// GetNeighbors retrieves the neighbors for a specific device.
func (c *Client) GetNeighbors(ctx context.Context, host string) ([]Neighbor, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out []Neighbor
if err := d.do(ctx, "GET", "/api/v1.0/tools/discovery/neighbors", nil, &out); err != nil {
return nil, err
}
return out, nil
}
// GetAllNeighbors retrieves neighbors for all devices.
func (c *Client) GetAllNeighbors(ctx context.Context) (map[string][]Neighbor, error) {
results := make(map[string][]Neighbor)
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.GetNeighbors(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
}

View File

@@ -1,8 +1,7 @@
/*
Package edgeos provides a client for interacting with Ubiquiti EdgeOS devices
via their REST API. It supports authentication, token management, and
retrieval of system, interface, VLAN, and discovery information from
one or more devices.
via their REST API. It supports authentication, session management, and
retrieval of system and interface configuration from one or more devices.
*/
package edgeos
@@ -28,12 +27,12 @@ type Client struct {
type deviceClient struct {
config Config
client *http.Client
token string
cookies []*http.Cookie
authInfo *AuthResponse
mu sync.Mutex
}
func newDeviceClient(cfg Config) *deviceClient {
// Ensure scheme is set
if cfg.Scheme == "" {
cfg.Scheme = "https"
}
@@ -124,31 +123,27 @@ func (d *deviceClient) login(ctx context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
reqUrl := fmt.Sprintf("%s://%s/api/v1.0/user/login", d.config.Scheme, d.config.Host)
payload := map[string]string{
"username": d.config.Username,
"password": d.config.Password,
}
body, err := json.Marshal(payload)
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, err := http.NewRequestWithContext(ctx, "POST", reqUrl, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.URL.User = url.UserPassword(d.config.Username, d.config.Password)
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
@@ -158,23 +153,27 @@ func (d *deviceClient) login(ctx context.Context) error {
return fmt.Errorf("login failed [%d]: %s", resp.StatusCode, string(respPayload))
}
token := resp.Header.Get("x-auth-token")
if token == "" {
return fmt.Errorf("login failed: no token in response")
var authResp AuthResponse
if err := json.Unmarshal(respPayload, &authResp); err != nil {
return fmt.Errorf("failed to parse auth response: %w", err)
}
d.token = token
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 {
// First attempt
err := d.doRequest(ctx, method, path, body, out)
if err == nil {
return nil
}
// If unauthorized, try to login and retry
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)
@@ -186,7 +185,7 @@ func (d *deviceClient) do(ctx context.Context, method, path string, body any, ou
}
func (d *deviceClient) doRequest(ctx context.Context, method, path string, body any, out any) error {
url := fmt.Sprintf("%s://%s%s", d.config.Scheme, d.config.Host, path)
reqUrl := fmt.Sprintf("%s://%s%s", d.config.Scheme, d.config.Host, path)
var reqBody io.Reader
if body != nil {
@@ -197,23 +196,28 @@ func (d *deviceClient) doRequest(ctx context.Context, method, path string, body
reqBody = bytes.NewBuffer(b)
}
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
req, err := http.NewRequestWithContext(ctx, method, reqUrl, reqBody)
if err != nil {
return err
}
d.mu.Lock()
token := d.token
cookies := d.cookies
d.mu.Unlock()
if token != "" {
req.Header.Set("x-auth-token", token)
if len(cookies) > 0 {
cookieURL, _ := url.Parse(reqUrl)
for _, cookie := range cookies {
if cookie.Domain == "" || strings.HasSuffix(cookieURL.Host, cookie.Domain) {
req.AddCookie(cookie)
}
}
}
// Some endpoints might require Content-Type even for GET if we were strict, but usually only for POST/PUT
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 {
@@ -226,7 +230,6 @@ func (d *deviceClient) doRequest(ctx context.Context, method, path string, body
}
if resp.StatusCode != http.StatusOK {
// Read body to see error message
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("request failed: status %d, body: %s", resp.StatusCode, string(b))
}

View File

@@ -5,7 +5,6 @@ import (
"time"
)
// Config represents the configuration for an EdgeOS device.
type Config struct {
Host string
Scheme string
@@ -13,6 +12,6 @@ type Config struct {
Username string
Password string
Timeout time.Duration
// Transport allows customizing the http transport (useful for testing)
// Transport allows customizing the http transport (useful for testing or client middleware)
Transport http.RoundTripper
}

View File

@@ -1,364 +1,100 @@
package edgeos
// LoginResponse represents the response from the login endpoint.
type LoginResponse struct {
StatusCode int `json:"statusCode"`
Error int `json:"error"`
Detail string `json:"detail"`
Message string `json:"message"`
}
// InterfaceIdentification represents identification info for an interface.
type InterfaceIdentification struct {
ID string `json:"id"`
Name string `json:"name"`
Mac string `json:"mac"`
Type string `json:"type"`
}
// InterfaceStatus represents status info for an interface.
type InterfaceStatus struct {
Enabled bool `json:"enabled"`
Plugged bool `json:"plugged"`
CurrentSpeed string `json:"currentSpeed"`
Speed string `json:"speed"`
MTU int `json:"mtu"`
}
// InterfaceAddress represents an address on an interface.
type InterfaceAddress struct {
Type string `json:"type"`
Version string `json:"version"`
CIDR string `json:"cidr"`
EUI64 bool `json:"eui64"`
}
// InterfacePort represents port specific settings.
type InterfacePort struct {
STP PortSTP `json:"stp"`
POE string `json:"poe"`
FlowControl bool `json:"flowControl"`
Routed bool `json:"routed"`
PingWatchdog PingWatchdog `json:"pingWatchdog"`
}
// PortSTP represents STP settings for a port.
type PortSTP struct {
Enabled bool `json:"enabled"`
EdgePort string `json:"edgePort"`
PathCost int `json:"pathCost"`
PortPriority int `json:"portPriority"`
State string `json:"state"`
}
// PingWatchdog represents ping watchdog settings.
type PingWatchdog struct {
Enabled bool `json:"enabled"`
Address string `json:"address"`
FailureCount int `json:"failureCount"`
Interval int `json:"interval"`
OffDelay int `json:"offDelay"`
StartDelay int `json:"startDelay"`
}
// Interface represents a network interface.
type Interface struct {
Identification InterfaceIdentification `json:"identification"`
Status InterfaceStatus `json:"status"`
Addresses []InterfaceAddress `json:"addresses"`
Port InterfacePort `json:"port"`
}
// DeviceIdentification represents device identification.
type DeviceIdentification struct {
Mac string `json:"mac"`
Model string `json:"model"`
Family string `json:"family"`
SubsystemID string `json:"subsystemID"`
FirmwareVersion string `json:"firmwareVersion"`
Firmware string `json:"firmware"`
Product string `json:"product"`
ServerVersion string `json:"serverVersion"`
BridgeVersion string `json:"bridgeVersion"`
}
// DeviceCapabilityInterface represents interface capabilities.
type DeviceCapabilityInterface struct {
ID string `json:"id"`
Type string `json:"type"`
SupportBlock bool `json:"supportBlock"`
SupportDelete bool `json:"supportDelete"`
SupportReset bool `json:"supportReset"`
Configurable bool `json:"configurable"`
SupportDHCPSnooping bool `json:"supportDHCPSnooping"`
SupportIsolate bool `json:"supportIsolate"`
SupportAutoEdge bool `json:"supportAutoEdge"`
MaxMTU int `json:"maxMTU"`
SupportPOE bool `json:"supportPOE"`
SupportCableTest bool `json:"supportCableTest"`
POEValues []string `json:"poeValues"`
Media string `json:"media"`
SpeedValues []string `json:"speedValues"`
}
// DeviceCapabilities represents device capabilities.
type DeviceCapabilities struct {
Interfaces []DeviceCapabilityInterface `json:"interfaces"`
}
// Device represents the device info.
type Device struct {
ErrorCodes []any `json:"errorCodes"`
Identification DeviceIdentification `json:"identification"`
Capabilities DeviceCapabilities `json:"capabilities"`
}
// SystemSTP represents system STP settings.
type SystemSTP struct {
Enabled bool `json:"enabled"`
Version string `json:"version"`
MaxAge int `json:"maxAge"`
HelloTime int `json:"helloTime"`
ForwardDelay int `json:"forwardDelay"`
Priority int `json:"priority"`
}
// SystemUser represents a system user.
type SystemUser struct {
// AuthResponse represents the authentication response from login2 endpoint.
type AuthResponse struct {
Username string `json:"username"`
ReadOnly bool `json:"readOnly"`
SSHKeys []any `json:"sshKeys"`
}
// SystemManagement represents management settings.
type SystemManagement struct {
VlanID int `json:"vlanID"`
ManagementPortOnly bool `json:"managementPortOnly"`
Addresses []InterfaceAddress `json:"addresses"`
}
// SystemAddress represents a system-level address configuration (DNS, Gateway).
type SystemAddress struct {
Type string `json:"type"`
Version string `json:"version"`
Address string `json:"address"`
}
// System represents system information.
type System struct {
Hostname string `json:"hostname"`
Timezone string `json:"timezone"`
DomainName string `json:"domainName"`
FactoryDefault bool `json:"factoryDefault"`
STP SystemSTP `json:"stp"`
AnalyticsEnabled bool `json:"analyticsEnabled"`
DNSServers []SystemAddress `json:"dnsServers"`
DefaultGateway []SystemAddress `json:"defaultGateway"`
Users []SystemUser `json:"users"`
Management SystemManagement `json:"management"`
}
// Trunk represents a VLAN trunk.
type Trunk struct {
Interface InterfaceIdentification `json:"interface"`
}
// VlanParticipation represents interface participation in a VLAN.
type VlanParticipation struct {
Interface InterfaceIdentification `json:"interface"`
Mode string `json:"mode"`
}
// Vlan represents a VLAN.
type Vlan struct {
Name string `json:"name"`
Type string `json:"type"`
ID int `json:"id"`
Participation []VlanParticipation `json:"participation"`
}
// VLANs represents the VLAN configuration.
type VLANs struct {
Trunks []Trunk `json:"trunks"`
Vlans []Vlan `json:"vlans"`
}
// ServiceDiscoveryResponder ...
type ServiceDiscoveryResponder struct {
Enabled bool `json:"enabled"`
}
// ServiceSSHServer ...
type ServiceSSHServer struct {
Enabled bool `json:"enabled"`
SSHPort int `json:"sshPort"`
PasswordAuthentication bool `json:"passwordAuthentication"`
}
// ServiceTelnetServer ...
type ServiceTelnetServer struct {
Enabled bool `json:"enabled"`
Port int `json:"port"`
}
// ServiceWebServer ...
type ServiceWebServer struct {
Enabled bool `json:"enabled"`
HTTPPort int `json:"httpPort"`
HTTPSPort int `json:"httpsPort"`
}
// ServiceSystemLog ...
type ServiceSystemLog struct {
Enabled bool `json:"enabled"`
Port int `json:"port"`
Server string `json:"server"`
PoE bool `json:"poe"`
StatsURL string `json:"statsUrl"`
Features Features `json:"features"`
Level string `json:"level"`
}
// ServiceNTPClient ...
type ServiceNTPClient struct {
Enabled bool `json:"enabled"`
NTPServers []string `json:"ntpServers"`
}
// ServiceUNMS ...
type ServiceUNMS struct {
Enabled bool `json:"enabled"`
Key string `json:"key"`
Status string `json:"status"`
}
// ServiceLLDP ...
type ServiceLLDP struct {
Enabled bool `json:"enabled"`
}
// ServiceSNMPAgent ...
type ServiceSNMPAgent struct {
Enabled bool `json:"enabled"`
Community string `json:"community"`
Contact string `json:"contact"`
Location string `json:"location"`
}
// DDNSClient represents a dynamic DNS client configuration.
type DDNSClient struct {
Hostname string `json:"hostname"`
Service string `json:"service"`
Username string `json:"username"`
Password string `json:"password"`
}
// ServiceDDNS represents dynamic DNS service configuration.
type ServiceDDNS struct {
Enabled bool `json:"enabled"`
Clients []DDNSClient `json:"clients"`
}
// Services represents services configuration.
type Services struct {
DiscoveryResponder ServiceDiscoveryResponder `json:"discoveryResponder"`
SSHServer ServiceSSHServer `json:"sshServer"`
TelnetServer ServiceTelnetServer `json:"telnetServer"`
WebServer ServiceWebServer `json:"webServer"`
SystemLog ServiceSystemLog `json:"systemLog"`
NTPClient ServiceNTPClient `json:"ntpClient"`
UNMS ServiceUNMS `json:"unms"`
LLDP ServiceLLDP `json:"lldp"`
SNMPAgent ServiceSNMPAgent `json:"snmpAgent"`
DDNS ServiceDDNS `json:"ddns"`
}
// InterfaceStatistics represents statistics for an interface.
type InterfaceStatistics struct {
Dropped int64 `json:"dropped"`
TxDropped int64 `json:"txDropped"`
RxDropped int64 `json:"rxDropped"`
Errors int64 `json:"errors"`
TxErrors int64 `json:"txErrors"`
RxErrors int64 `json:"rxErrors"`
Rate int64 `json:"rate"`
TxRate int64 `json:"txRate"`
RxRate int64 `json:"rxRate"`
Bytes int64 `json:"bytes"`
TxBytes int64 `json:"txBytes"`
RxBytes int64 `json:"rxBytes"`
Packets int64 `json:"packets"`
TxPackets int64 `json:"txPackets"`
RxPackets int64 `json:"rxPackets"`
PPS int64 `json:"pps"`
TxPPS int64 `json:"txPPS"`
RxPPS int64 `json:"rxPPS"`
TxBroadcast int64 `json:"txBroadcast"`
RxBroadcast int64 `json:"rxBroadcast"`
TxMulticast int64 `json:"txMulticast"`
RxMulticast int64 `json:"rxMulticast"`
}
// InterfaceWithStats represents an interface within the statistics response.
type InterfaceWithStats struct {
ID string `json:"id"`
Name string `json:"name"`
Statistics InterfaceStatistics `json:"statistics"`
}
// CPUStat represents CPU usage statistics.
type CPUStat struct {
Identifier string `json:"identifier"`
Usage int `json:"usage"`
}
// RAMStat represents RAM usage statistics.
type RAMStat struct {
Usage int64 `json:"usage"`
Free int64 `json:"free"`
Total int64 `json:"total"`
}
// StorageStat represents storage usage statistics.
type StorageStat struct {
Name string `json:"name"`
Type string `json:"type"`
SysName string `json:"sysName"`
Used int64 `json:"used"`
Size int64 `json:"size"`
}
// DeviceStats represents device level stats in the statistics response.
type DeviceStats struct {
CPU []CPUStat `json:"cpu"`
RAM RAMStat `json:"ram"`
Temperatures []any `json:"temperatures"`
Storage []StorageStat `json:"storage"`
Uptime int64 `json:"uptime"`
}
// Statistics represents a statistics entry.
type Statistics struct {
Timestamp int64 `json:"timestamp"`
Device DeviceStats `json:"device"`
Interfaces []InterfaceWithStats `json:"interfaces"`
}
// NeighborAddress represents an address of a neighbor.
type NeighborAddress struct {
Mac string `json:"mac"`
IP string `json:"ip"`
}
// Neighbor represents a discovered neighbor.
type Neighbor struct {
Mac string `json:"mac"`
Age int `json:"age"`
Protocol string `json:"protocol"`
FW string `json:"fw"`
Authenticated bool `json:"authenticated"`
Model string `json:"model"`
Product string `json:"product"`
Hostname string `json:"hostname"`
Uptime int64 `json:"uptime"`
Configured bool `json:"configured"`
IP string `json:"ip"`
ZoneID string `json:"zoneID"`
Addresses []NeighborAddress `json:"addresses"`
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"`
}

350
pkg/toughswitch/api.go Normal file
View File

@@ -0,0 +1,350 @@
package toughswitch
import (
"context"
"errors"
"sync"
)
// GetInterfaces retrieves the interfaces for a specific device.
func (c *Client) GetInterfaces(ctx context.Context, host string) ([]Interface, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out []Interface
if err := d.do(ctx, "GET", "/api/v1.0/interfaces", nil, &out); err != nil {
return nil, err
}
return out, nil
}
// GetAllInterfaces retrieves interfaces for all devices.
func (c *Client) GetAllInterfaces(ctx context.Context) (map[string][]Interface, error) {
results := make(map[string][]Interface)
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
}
// GetDevice retrieves the device info for a specific device.
func (c *Client) GetDevice(ctx context.Context, host string) (*Device, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out Device
if err := d.do(ctx, "GET", "/api/v1.0/device", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetAllDevices retrieves device info for all devices.
func (c *Client) GetAllDevices(ctx context.Context) (map[string]*Device, error) {
results := make(map[string]*Device)
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.GetDevice(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.
func (c *Client) GetSystem(ctx context.Context, host string) (*System, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out System
if err := d.do(ctx, "GET", "/api/v1.0/system", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetAllSystems retrieves system info for all devices.
func (c *Client) GetAllSystems(ctx context.Context) (map[string]*System, error) {
results := make(map[string]*System)
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
}
// GetVLANs retrieves the VLANs for a specific device.
func (c *Client) GetVLANs(ctx context.Context, host string) (*VLANs, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out VLANs
if err := d.do(ctx, "GET", "/api/v1.0/vlans", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetAllVLANs retrieves VLANs for all devices.
func (c *Client) GetAllVLANs(ctx context.Context) (map[string]*VLANs, error) {
results := make(map[string]*VLANs)
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.GetVLANs(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
}
// GetServices retrieves the services for a specific device.
func (c *Client) GetServices(ctx context.Context, host string) (*Services, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out Services
if err := d.do(ctx, "GET", "/api/v1.0/services", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetAllServices retrieves services for all devices.
func (c *Client) GetAllServices(ctx context.Context) (map[string]*Services, error) {
results := make(map[string]*Services)
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.GetServices(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
}
// GetStatistics retrieves the statistics for a specific device.
func (c *Client) GetStatistics(ctx context.Context, host string) ([]Statistics, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out []Statistics
if err := d.do(ctx, "GET", "/api/v1.0/statistics", nil, &out); err != nil {
return nil, err
}
return out, nil
}
// GetAllStatistics retrieves statistics for all devices.
func (c *Client) GetAllStatistics(ctx context.Context) (map[string][]Statistics, error) {
results := make(map[string][]Statistics)
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.GetStatistics(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
}
// GetNeighbors retrieves the neighbors for a specific device.
func (c *Client) GetNeighbors(ctx context.Context, host string) ([]Neighbor, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out []Neighbor
if err := d.do(ctx, "GET", "/api/v1.0/tools/discovery/neighbors", nil, &out); err != nil {
return nil, err
}
return out, nil
}
// GetAllNeighbors retrieves neighbors for all devices.
func (c *Client) GetAllNeighbors(ctx context.Context) (map[string][]Neighbor, error) {
results := make(map[string][]Neighbor)
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.GetNeighbors(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
}

241
pkg/toughswitch/client.go Normal file
View File

@@ -0,0 +1,241 @@
/*
Package toughswitch provides a client for interacting with Ubiquiti toughswitch devices
via their REST API. It supports authentication, token management, and
retrieval of system, interface, VLAN, and discovery information from
one or more devices.
*/
package toughswitch
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
)
// Client handles communication with toughswitch devices.
type Client struct {
mu sync.RWMutex
devices map[string]*deviceClient
}
type deviceClient struct {
config Config
client *http.Client
token string
mu sync.Mutex
}
func newDeviceClient(cfg Config) *deviceClient {
// Ensure scheme is set
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/v1.0/user/login", d.config.Scheme, d.config.Host)
payload := map[string]string{
"username": d.config.Username,
"password": d.config.Password,
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "POST", reqUrl, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.URL.User = url.UserPassword(d.config.Username, d.config.Password)
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))
}
token := resp.Header.Get("x-auth-token")
if token == "" {
return fmt.Errorf("login failed: no token in response")
}
d.token = token
return nil
}
func (d *deviceClient) do(ctx context.Context, method, path string, body any, out any) error {
// First attempt
err := d.doRequest(ctx, method, path, body, out)
if err == nil {
return nil
}
// If unauthorized, try to login and retry
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 {
url := 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, url, reqBody)
if err != nil {
return err
}
d.mu.Lock()
token := d.token
d.mu.Unlock()
if token != "" {
req.Header.Set("x-auth-token", token)
}
// Some endpoints might require Content-Type even for GET if we were strict, but usually only for POST/PUT
if body != nil {
req.Header.Set("Content-Type", "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 {
// Read body to see error message
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
}

View File

@@ -1,4 +1,4 @@
package edgeos
package toughswitch
import (
"bytes"
@@ -34,11 +34,9 @@ func TestClient_ThreadSafety(t *testing.T) {
start := make(chan struct{})
// Writer: Adds and deletes hosts
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
<-start
for i := 0; i < 100; i++ {
for i := range 100 {
host := fmt.Sprintf("host-%d", i)
cfg := &Config{
Host: host,
@@ -54,20 +52,18 @@ func TestClient_ThreadSafety(t *testing.T) {
t.Logf("Del error: %v", err)
}
}
}()
})
// Reader: Iterates hosts
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
<-start
for i := 0; i < 10; i++ {
for range 10 {
// GetAllInterfaces iterates keys.
// With mock transport, this will succeed (returning empty structs)
// checking for race conditions.
_, _ = client.GetAllInterfaces(ctx)
}
}()
})
close(start)
wg.Wait()

17
pkg/toughswitch/config.go Normal file
View File

@@ -0,0 +1,17 @@
package toughswitch
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
}

364
pkg/toughswitch/types.go Normal file
View File

@@ -0,0 +1,364 @@
package toughswitch
// LoginResponse represents the response from the login endpoint.
type LoginResponse struct {
StatusCode int `json:"statusCode"`
Error int `json:"error"`
Detail string `json:"detail"`
Message string `json:"message"`
}
// InterfaceIdentification represents identification info for an interface.
type InterfaceIdentification struct {
ID string `json:"id"`
Name string `json:"name"`
Mac string `json:"mac"`
Type string `json:"type"`
}
// InterfaceStatus represents status info for an interface.
type InterfaceStatus struct {
Enabled bool `json:"enabled"`
Plugged bool `json:"plugged"`
CurrentSpeed string `json:"currentSpeed"`
Speed string `json:"speed"`
MTU int `json:"mtu"`
}
// InterfaceAddress represents an address on an interface.
type InterfaceAddress struct {
Type string `json:"type"`
Version string `json:"version"`
CIDR string `json:"cidr"`
EUI64 bool `json:"eui64"`
}
// InterfacePort represents port specific settings.
type InterfacePort struct {
STP PortSTP `json:"stp"`
POE string `json:"poe"`
FlowControl bool `json:"flowControl"`
Routed bool `json:"routed"`
PingWatchdog PingWatchdog `json:"pingWatchdog"`
}
// PortSTP represents STP settings for a port.
type PortSTP struct {
Enabled bool `json:"enabled"`
EdgePort string `json:"edgePort"`
PathCost int `json:"pathCost"`
PortPriority int `json:"portPriority"`
State string `json:"state"`
}
// PingWatchdog represents ping watchdog settings.
type PingWatchdog struct {
Enabled bool `json:"enabled"`
Address string `json:"address"`
FailureCount int `json:"failureCount"`
Interval int `json:"interval"`
OffDelay int `json:"offDelay"`
StartDelay int `json:"startDelay"`
}
// Interface represents a network interface.
type Interface struct {
Identification InterfaceIdentification `json:"identification"`
Status InterfaceStatus `json:"status"`
Addresses []InterfaceAddress `json:"addresses"`
Port InterfacePort `json:"port"`
}
// DeviceIdentification represents device identification.
type DeviceIdentification struct {
Mac string `json:"mac"`
Model string `json:"model"`
Family string `json:"family"`
SubsystemID string `json:"subsystemID"`
FirmwareVersion string `json:"firmwareVersion"`
Firmware string `json:"firmware"`
Product string `json:"product"`
ServerVersion string `json:"serverVersion"`
BridgeVersion string `json:"bridgeVersion"`
}
// DeviceCapabilityInterface represents interface capabilities.
type DeviceCapabilityInterface struct {
ID string `json:"id"`
Type string `json:"type"`
SupportBlock bool `json:"supportBlock"`
SupportDelete bool `json:"supportDelete"`
SupportReset bool `json:"supportReset"`
Configurable bool `json:"configurable"`
SupportDHCPSnooping bool `json:"supportDHCPSnooping"`
SupportIsolate bool `json:"supportIsolate"`
SupportAutoEdge bool `json:"supportAutoEdge"`
MaxMTU int `json:"maxMTU"`
SupportPOE bool `json:"supportPOE"`
SupportCableTest bool `json:"supportCableTest"`
POEValues []string `json:"poeValues"`
Media string `json:"media"`
SpeedValues []string `json:"speedValues"`
}
// DeviceCapabilities represents device capabilities.
type DeviceCapabilities struct {
Interfaces []DeviceCapabilityInterface `json:"interfaces"`
}
// Device represents the device info.
type Device struct {
ErrorCodes []any `json:"errorCodes"`
Identification DeviceIdentification `json:"identification"`
Capabilities DeviceCapabilities `json:"capabilities"`
}
// SystemSTP represents system STP settings.
type SystemSTP struct {
Enabled bool `json:"enabled"`
Version string `json:"version"`
MaxAge int `json:"maxAge"`
HelloTime int `json:"helloTime"`
ForwardDelay int `json:"forwardDelay"`
Priority int `json:"priority"`
}
// SystemUser represents a system user.
type SystemUser struct {
Username string `json:"username"`
ReadOnly bool `json:"readOnly"`
SSHKeys []any `json:"sshKeys"`
}
// SystemManagement represents management settings.
type SystemManagement struct {
VlanID int `json:"vlanID"`
ManagementPortOnly bool `json:"managementPortOnly"`
Addresses []InterfaceAddress `json:"addresses"`
}
// SystemAddress represents a system-level address configuration (DNS, Gateway).
type SystemAddress struct {
Type string `json:"type"`
Version string `json:"version"`
Address string `json:"address"`
}
// System represents system information.
type System struct {
Hostname string `json:"hostname"`
Timezone string `json:"timezone"`
DomainName string `json:"domainName"`
FactoryDefault bool `json:"factoryDefault"`
STP SystemSTP `json:"stp"`
AnalyticsEnabled bool `json:"analyticsEnabled"`
DNSServers []SystemAddress `json:"dnsServers"`
DefaultGateway []SystemAddress `json:"defaultGateway"`
Users []SystemUser `json:"users"`
Management SystemManagement `json:"management"`
}
// Trunk represents a VLAN trunk.
type Trunk struct {
Interface InterfaceIdentification `json:"interface"`
}
// VlanParticipation represents interface participation in a VLAN.
type VlanParticipation struct {
Interface InterfaceIdentification `json:"interface"`
Mode string `json:"mode"`
}
// Vlan represents a VLAN.
type Vlan struct {
Name string `json:"name"`
Type string `json:"type"`
ID int `json:"id"`
Participation []VlanParticipation `json:"participation"`
}
// VLANs represents the VLAN configuration.
type VLANs struct {
Trunks []Trunk `json:"trunks"`
Vlans []Vlan `json:"vlans"`
}
// ServiceDiscoveryResponder ...
type ServiceDiscoveryResponder struct {
Enabled bool `json:"enabled"`
}
// ServiceSSHServer ...
type ServiceSSHServer struct {
Enabled bool `json:"enabled"`
SSHPort int `json:"sshPort"`
PasswordAuthentication bool `json:"passwordAuthentication"`
}
// ServiceTelnetServer ...
type ServiceTelnetServer struct {
Enabled bool `json:"enabled"`
Port int `json:"port"`
}
// ServiceWebServer ...
type ServiceWebServer struct {
Enabled bool `json:"enabled"`
HTTPPort int `json:"httpPort"`
HTTPSPort int `json:"httpsPort"`
}
// ServiceSystemLog ...
type ServiceSystemLog struct {
Enabled bool `json:"enabled"`
Port int `json:"port"`
Server string `json:"server"`
Level string `json:"level"`
}
// ServiceNTPClient ...
type ServiceNTPClient struct {
Enabled bool `json:"enabled"`
NTPServers []string `json:"ntpServers"`
}
// ServiceUNMS ...
type ServiceUNMS struct {
Enabled bool `json:"enabled"`
Key string `json:"key"`
Status string `json:"status"`
}
// ServiceLLDP ...
type ServiceLLDP struct {
Enabled bool `json:"enabled"`
}
// ServiceSNMPAgent ...
type ServiceSNMPAgent struct {
Enabled bool `json:"enabled"`
Community string `json:"community"`
Contact string `json:"contact"`
Location string `json:"location"`
}
// DDNSClient represents a dynamic DNS client configuration.
type DDNSClient struct {
Hostname string `json:"hostname"`
Service string `json:"service"`
Username string `json:"username"`
Password string `json:"password"`
}
// ServiceDDNS represents dynamic DNS service configuration.
type ServiceDDNS struct {
Enabled bool `json:"enabled"`
Clients []DDNSClient `json:"clients"`
}
// Services represents services configuration.
type Services struct {
DiscoveryResponder ServiceDiscoveryResponder `json:"discoveryResponder"`
SSHServer ServiceSSHServer `json:"sshServer"`
TelnetServer ServiceTelnetServer `json:"telnetServer"`
WebServer ServiceWebServer `json:"webServer"`
SystemLog ServiceSystemLog `json:"systemLog"`
NTPClient ServiceNTPClient `json:"ntpClient"`
UNMS ServiceUNMS `json:"unms"`
LLDP ServiceLLDP `json:"lldp"`
SNMPAgent ServiceSNMPAgent `json:"snmpAgent"`
DDNS ServiceDDNS `json:"ddns"`
}
// InterfaceStatistics represents statistics for an interface.
type InterfaceStatistics struct {
Dropped int64 `json:"dropped"`
TxDropped int64 `json:"txDropped"`
RxDropped int64 `json:"rxDropped"`
Errors int64 `json:"errors"`
TxErrors int64 `json:"txErrors"`
RxErrors int64 `json:"rxErrors"`
Rate int64 `json:"rate"`
TxRate int64 `json:"txRate"`
RxRate int64 `json:"rxRate"`
Bytes int64 `json:"bytes"`
TxBytes int64 `json:"txBytes"`
RxBytes int64 `json:"rxBytes"`
Packets int64 `json:"packets"`
TxPackets int64 `json:"txPackets"`
RxPackets int64 `json:"rxPackets"`
PPS int64 `json:"pps"`
TxPPS int64 `json:"txPPS"`
RxPPS int64 `json:"rxPPS"`
TxBroadcast int64 `json:"txBroadcast"`
RxBroadcast int64 `json:"rxBroadcast"`
TxMulticast int64 `json:"txMulticast"`
RxMulticast int64 `json:"rxMulticast"`
}
// InterfaceWithStats represents an interface within the statistics response.
type InterfaceWithStats struct {
ID string `json:"id"`
Name string `json:"name"`
Statistics InterfaceStatistics `json:"statistics"`
}
// CPUStat represents CPU usage statistics.
type CPUStat struct {
Identifier string `json:"identifier"`
Usage int `json:"usage"`
}
// RAMStat represents RAM usage statistics.
type RAMStat struct {
Usage int64 `json:"usage"`
Free int64 `json:"free"`
Total int64 `json:"total"`
}
// StorageStat represents storage usage statistics.
type StorageStat struct {
Name string `json:"name"`
Type string `json:"type"`
SysName string `json:"sysName"`
Used int64 `json:"used"`
Size int64 `json:"size"`
}
// DeviceStats represents device level stats in the statistics response.
type DeviceStats struct {
CPU []CPUStat `json:"cpu"`
RAM RAMStat `json:"ram"`
Temperatures []any `json:"temperatures"`
Storage []StorageStat `json:"storage"`
Uptime int64 `json:"uptime"`
}
// Statistics represents a statistics entry.
type Statistics struct {
Timestamp int64 `json:"timestamp"`
Device DeviceStats `json:"device"`
Interfaces []InterfaceWithStats `json:"interfaces"`
}
// NeighborAddress represents an address of a neighbor.
type NeighborAddress struct {
Mac string `json:"mac"`
IP string `json:"ip"`
}
// Neighbor represents a discovered neighbor.
type Neighbor struct {
Mac string `json:"mac"`
Age int `json:"age"`
Protocol string `json:"protocol"`
FW string `json:"fw"`
Model string `json:"model"`
Product string `json:"product"`
Hostname string `json:"hostname"`
Uptime int64 `json:"uptime"`
Configured bool `json:"configured"`
IP string `json:"ip"`
ZoneID string `json:"zoneID"`
Addresses []NeighborAddress `json:"addresses"`
}