16 Commits

Author SHA1 Message Date
227656e28e update changelog and readme
All checks were successful
Publish / release (push) Successful in 32s
2026-01-19 11:53:30 -05:00
63ef233357 add consolidated methods, update cli 2026-01-19 11:50:51 -05:00
b4f49be2c6 add toughswitch consolidated type, and use in cmd 2026-01-19 11:45:16 -05:00
4964317b6b update changelog
All checks were successful
Publish / release (push) Successful in 38s
2026-01-18 17:08:27 -05:00
baf321ece0 update claude and readme 2026-01-18 17:06:55 -05:00
5e8e7cd41d refactor CLI to use shared clients from context
- Add Login() method to toughswitch and edgeos clients
- Use zerolog's built-in context methods for logger storage
- Add context helpers for toughswitch/edgeos clients
- Create prepareClients prerun to initialize clients from config
- Consolidate device fetching into shared client.go helper

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 17:03:35 -05:00
868ab64bc9 fix config default flag 2026-01-17 17:10:58 -05:00
edc86feccd frame out cli tool 2026-01-17 17:06:32 -05:00
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
38eb2cc352 Create CHANGELOG.md
All checks were successful
Publish / release (push) Successful in 19s
2026-01-04 13:57:16 -05:00
1754eb6e84 Add thread-safe Add/Del methods and refactor client locking
- Add Add and Del methods to Client for dynamic host management.
- Add RWMutex to Client to protect the devices map.
- Add Transport to Config to allow mocking HTTP transport in tests.
- Add getDeviceByHost helper to centralize device lookup locking.
- Refactor GetAll* methods to snapshot host keys before iteration to avoid concurrent map read/write panic.
- Add tests for thread safety and Add/Del functionality.
2026-01-04 13:56:19 -05:00
906d005edf improves concurrent GetAll operations 2026-01-04 13:49:28 -05:00
28 changed files with 2854 additions and 652 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
config.y?ml

68
CHANGELOG.md Normal file
View File

@@ -0,0 +1,68 @@
# Changelog
All notable changes to this project will be documented in this file.
## v0.5.0 - 2026-01-19
### Added
- `ToughSwitch` consolidated type combining Device, System, Interfaces, VLANs, Services, Statistics, and Neighbors
- `GetToughSwitch(ctx, host)` method to fetch all data for a single ToughSwitch device in parallel
- `GetAllToughSwitches(ctx)` method to fetch consolidated data for all ToughSwitch devices
- `EdgeOS` consolidated type combining AuthInfo and Config
- `GetEdgeOS(ctx, host)` method to fetch all data for a single EdgeOS device in parallel
- `GetAllEdgeOS(ctx)` method to fetch consolidated data for all EdgeOS devices
### Changed
- CLI `get device` and `get devices` commands now return full consolidated data for both device types
- EdgeOS CLI output now includes device model, ports, PoE capability, and features from AuthInfo
### Fixed
- Typo in ToughSwitch struct field name (`Statistucs``Statistics`)
## v0.4.0 - 2026-01-18
### Added
- CLI tool (`cmd/`) for quick device queries using cobra
- `get device <name>` - fetch info from a single device
- `get devices` - fetch info from all configured devices in parallel
- YAML/JSON config file support with environment variable overlay
- Pretty print and colorized YAML output options
- `Login(ctx, host)` method to both toughswitch and edgeos clients for explicit pre-authentication
- Context helpers for storing/retrieving clients (`ToughSwitchClientFromContext`, `EdgeOSClientFromContext`)
### Changed
- CLI uses zerolog's built-in context methods for logger storage
- CLI prerun creates shared clients from config and stores in context
- Updated documentation (README.md, CLAUDE.md) with CLI usage and new Login method
### Fixed
- Config default flag handling in CLI
## 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.
- `RWMutex` to `Client` struct to protect `devices` map.
- `Transport` field to `Config` to allow mocking HTTP transport in tests.
- `getDeviceByHost` helper to centralize device lookup locking.
- Tests for thread safety and Add/Del functionality.
### Changed
- Refactored `GetAll*` methods to snapshot host keys before iteration to avoid concurrent map read/write panic.
- Improved concurrent `GetAll` operations.
## [v0.1.1] - 2026-01-04
### Fixed
- CI pipeline configuration.
## [v0.1.0] - 2026-01-04
### Added
- Initial CI pipeline setup.
- Initial release of Ubiquiti toughswitch Go Client.

74
CLAUDE.md Normal file
View File

@@ -0,0 +1,74 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build and Test Commands
```bash
# Build library packages
go build ./pkg/...
# Build CLI tool
go build ./cmd/...
# Run all tests
go test ./...
# Run tests for a specific package
go test ./pkg/toughswitch/... -run TestClient_AddDel
# Tidy module dependencies
go mod tidy
```
## Architecture
This repository provides Go client libraries for interacting with Ubiquiti network devices via reverse-engineered REST APIs, plus a CLI tool.
### Package Structure
- `pkg/toughswitch/` - Client for ToughSwitch devices (e.g., TS-8-PRO)
- `pkg/edgeos/` - Client for EdgeOS devices (EdgeRouter, EdgeSwitch)
- `cmd/` - CLI tool using cobra
### Library Client Design Pattern
Both `pkg/toughswitch` and `pkg/edgeos` follow the same multi-device client pattern:
1. **Client** - Top-level struct holding a map of `deviceClient` instances keyed by host
2. **deviceClient** - Per-device HTTP client with authentication state (token for ToughSwitch, cookies for EdgeOS)
3. **Config** - Device connection settings (host, credentials, TLS options, timeout)
Key characteristics:
- Thread-safe: Uses `sync.RWMutex` for device map access and `sync.Mutex` for per-device operations
- Auto-login: Automatically authenticates on 401 responses and retries the request
- Explicit login: `Login(ctx, host)` can be called to pre-authenticate
- Concurrent multi-device: `GetAll*` methods use `sync.WaitGroup.Go()` for parallel queries
API pattern for each package:
- `MustNew(ctx, []Config)` - Constructor that accepts multiple device configs
- `Login(ctx, host)` - Explicit authentication (also happens automatically on 401)
- `Add(cfg)` / `Del(host)` - Dynamic device management
- `Get<Resource>(ctx, host)` - Single device query
- `GetAll<Resources>(ctx)` - Parallel query across all devices, returns `map[string]*Resource`
### CLI Architecture (`cmd/`)
The CLI uses cobra with a prerun chain pattern:
- `cmd/cmd/root.go` - Root command with `PersistentPreRunE` hook
- `cmd/cmd/prerun.go` - Prerun functions executed in order: `validateConfigFile``prepareConfig``prepareLogger``setEnvironment``prepareClients`
- `cmd/cmd/client.go` - Shared `fetchDevice()` helper that retrieves clients from context
- `cmd/internal/util/context.go` - Context helpers for config, logger, and clients
- `cmd/internal/config/config.go` - Config loading from YAML/JSON files with env overlay
Context flow: Prerun creates clients based on config and stores them in context. Commands retrieve clients via `util.ToughSwitchClientFromContext(ctx)` / `util.EdgeOSClientFromContext(ctx)`.
### Authentication
- **ToughSwitch**: Token-based via `x-auth-token` header from `/api/v1.0/user/login`
- **EdgeOS**: Cookie-based from `/api/login2` endpoint
### Testing
Tests use `mockTransport` implementing `http.RoundTripper` to mock HTTP responses without network calls.

268
README.md
View File

@@ -1,13 +1,21 @@
# 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, plus a CLI tool.
**⚠️ 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
- **Authentication**: Handles login and session token management automatically.
### 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 (or explicitly via `Login`).
- **Multi-Device Support**: Manage multiple devices with a single client instance.
- **Consolidated Data**: `GetToughSwitch` fetches all device data in a single call (Device, System, Interfaces, VLANs, Services, Statistics, Neighbors).
- **Data Retrieval**:
- **System Information**: Hostname, uptime, firmware version, etc.
- **Interfaces**: Status, POE settings, link speed, statistics.
@@ -16,15 +24,87 @@ 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.
## Installation
### edgeos
A client library for interacting with Ubiquiti EdgeOS devices (EdgeRouter, EdgeSwitch) via their REST API.
#### Features
- **Authentication**: Handles login and session management automatically (or explicitly via `Login`).
- **Multi-Device Support**: Manage multiple devices with a single client instance.
- **Consolidated Data**: `GetEdgeOS` fetches all device data in a single call (AuthInfo and Config).
- **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.
## CLI Tool
A command-line tool is included for quick device queries.
### Installation
```bash
go get gitea.libretechconsulting.com/rmcguire/edgeos-client/pkg/edgeos
go install gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd@latest
```
## Usage
### Configuration
### Basic Example
Create a config file (YAML or JSON):
```yaml
logLevel: info
logFormat: console
clients:
- name: switch1
type: toughswitch
host: 192.168.1.1
user: ubnt
pass: password
insecure: true
timeout: 10s
- name: router1
type: edgeos
host: 192.168.1.2
user: ubnt
pass: password
insecure: true
timeout: 10s
```
Environment variables can also be used (and take priority over config file):
- `LOG_LEVEL`, `LOG_FORMAT` for top-level settings
- `CLIENT_0_NAME`, `CLIENT_0_HOST`, `CLIENT_0_TYPE`, etc. for client array
### Usage
```bash
# Get info from a single device
cmd --config config.yaml get device switch1
# Get info from all configured devices
cmd --config config.yaml get devices
# With flags
cmd --config config.yaml get device switch1 --pretty --color
```
## Library Installation
```bash
# For ToughSwitch
go get gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/pkg/toughswitch
# For EdgeOS
go get gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/pkg/edgeos
```
## Library Usage
### ToughSwitch Basic Example
```go
package main
@@ -35,14 +115,14 @@ import (
"log"
"time"
"gitea.libretechconsulting.com/rmcguire/edgeos-client/pkg/edgeos"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/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 +133,12 @@ func main() {
}
// Initialize the client
client := edgeos.MustNew(ctx, configs)
client := toughswitch.MustNew(ctx, configs)
// Optionally pre-authenticate (otherwise happens automatically on first request)
if err := client.Login(ctx, "192.168.1.1"); err != nil {
log.Fatalf("Failed to login: %v", err)
}
// Fetch system information
deviceHost := "192.168.1.1"
@@ -80,7 +165,78 @@ func main() {
}
```
### Retrieving Statistics
### EdgeOS Basic Example
```go
package main
import (
"context"
"fmt"
"log"
"time"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/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)
// Optionally pre-authenticate (otherwise happens automatically on first request)
if err := client.Login(ctx, "192.168.1.1"); err != nil {
log.Fatalf("Failed to login: %v", err)
}
// 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")
@@ -103,40 +259,104 @@ for _, stat := range stats {
}
```
### Working with Multiple Devices
### Fetching All Device Data
The client is designed to handle multiple devices concurrently.
Use the consolidated methods to fetch all device information in a single call:
```go
configs := []edgeos.Config{
// ToughSwitch: Get everything at once
ts, err := client.GetToughSwitch(ctx, "192.168.1.1")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Model: %s\n", ts.Device.Identification.Model)
fmt.Printf("Hostname: %s\n", ts.System.Hostname)
fmt.Printf("Interfaces: %d\n", len(ts.Interfaces))
fmt.Printf("VLANs: %d\n", len(ts.VLANs.Vlans))
fmt.Printf("Neighbors: %d\n", len(ts.Neighbors))
// EdgeOS: Get everything at once
eos, err := edgeClient.GetEdgeOS(ctx, "192.168.2.1")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Model: %s (%s)\n", eos.AuthInfo.ModelName, eos.AuthInfo.Model)
fmt.Printf("Ports: %d, PoE: %v\n", eos.AuthInfo.Ports, eos.AuthInfo.PoE)
fmt.Printf("Hostname: %s\n", eos.Config.System.HostName)
```
### Working with Multiple Devices
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", ...},
}
client := edgeos.MustNew(ctx, configs)
client := toughswitch.MustNew(ctx, configs)
// Get info for all configured devices in parallel
allSystems, err := client.GetAllSystems(ctx)
// Get all data for all devices in parallel
allSwitches, err := client.GetAllToughSwitches(ctx)
if err != nil {
// Note: This returns partial results if available, check implementation
log.Printf("Error fetching some systems: %v", err)
log.Printf("Error fetching some devices: %v", err)
}
for host, sys := range allSystems {
fmt.Printf("[%s] Hostname: %s\n", host, sys.Hostname)
for host, ts := range allSwitches {
fmt.Printf("[%s] %s - %d interfaces\n", host, ts.System.Hostname, len(ts.Interfaces))
}
// EdgeOS example
edgeConfigs := []edgeos.Config{
{Host: "192.168.2.1", ...},
{Host: "192.168.2.2", ...},
}
edgeClient := edgeos.MustNew(ctx, edgeConfigs)
// Get all data for all devices in parallel
allEdge, err := edgeClient.GetAllEdgeOS(ctx)
if err != nil {
log.Printf("Error fetching some devices: %v", err)
}
for host, eos := range allEdge {
fmt.Printf("[%s] %s (%d ports)\n", host, eos.AuthInfo.ModelName, eos.AuthInfo.Ports)
}
```
## Supported Endpoints
### ToughSwitch
| Method | Description |
|--------|-------------|
| `Login` | Explicit authentication (also happens automatically on 401) |
| `GetToughSwitch` | All device data combined (Device, System, Interfaces, VLANs, Services, Statistics, Neighbors) |
| `GetDevice` | Hardware and capabilities info |
| `GetSystem` | General system configuration and status |
| `GetInterfaces` | Interface configuration and status |
| `GetVLANs` | VLAN and Trunk configuration |
| `GetServices` | State of running services (SSH, NTP, etc.) |
| `GetStatistics` | Performance metrics |
| `GetNeighbors` | Discovered UBNT neighbors |
| `GetDevice` | Hardware and capabilities info |
All `Get*` methods have corresponding `GetAll*` variants for multi-device operations.
### EdgeOS
| Method | Description |
|--------|-------------|
| `Login` | Explicit authentication (also happens automatically on 401) |
| `GetEdgeOS` | All device data combined (AuthInfo and Config) |
| `GetAuthInfo` | Device authentication and feature information |
| `GetConfig` | Complete device configuration |
| `GetInterfaces` | Interface configuration (ethernet and switch) |
| `GetSystem` | System configuration (hostname, domain) |
All `Get*` methods have corresponding `GetAll*` variants for multi-device operations.
## License

21
cmd/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright © 2026 Ryan McGuire <ryan@mcguire.app>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

48
cmd/cmd/client.go Normal file
View File

@@ -0,0 +1,48 @@
package cmd
import (
"context"
"fmt"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/config"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/util"
)
// fetchDevice retrieves device data using the pre-configured clients from context.
// It handles both ToughSwitch and EdgeOS device types.
func fetchDevice(ctx context.Context, clientConf *config.ClientConfig) (any, error) {
switch clientConf.Type {
case config.TypeToughSwitch:
return fetchToughSwitchDevice(ctx, clientConf)
case config.TypeEdgeOS:
return fetchEdgeOSDevice(ctx, clientConf)
default:
return nil, fmt.Errorf("unknown device type: %s", clientConf.Type)
}
}
func fetchToughSwitchDevice(ctx context.Context, clientConf *config.ClientConfig) (any, error) {
client := util.ToughSwitchClientFromContext(ctx)
if client == nil {
return nil, fmt.Errorf("toughswitch client not initialized")
}
if err := client.Login(ctx, clientConf.Host); err != nil {
return nil, fmt.Errorf("login failed: %w", err)
}
return client.GetToughSwitch(ctx, clientConf.Host)
}
func fetchEdgeOSDevice(ctx context.Context, clientConf *config.ClientConfig) (any, error) {
client := util.EdgeOSClientFromContext(ctx)
if client == nil {
return nil, fmt.Errorf("edgeos client not initialized")
}
if err := client.Login(ctx, clientConf.Host); err != nil {
return nil, fmt.Errorf("login failed: %w", err)
}
return client.GetEdgeOS(ctx, clientConf.Host)
}

21
cmd/cmd/get.go Normal file
View File

@@ -0,0 +1,21 @@
package cmd
import (
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/util"
"github.com/spf13/cobra"
)
var getCmd = &cobra.Command{
Use: "get",
Short: "Get device information",
Long: `Get device information from configured Ubiquiti devices.`,
}
func init() {
rootCmd.AddCommand(getCmd)
getCmd.PersistentFlags().BoolP(util.FLAG_PRETTY, util.FLAG_PRETTY_P, false,
"pretty print output with indentation")
getCmd.PersistentFlags().BoolP(util.FLAG_COLOR, util.FLAG_COLOR_P, false,
"colorize YAML output")
}

59
cmd/cmd/get_device.go Normal file
View File

@@ -0,0 +1,59 @@
package cmd
import (
"fmt"
"os"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/util"
"github.com/spf13/cobra"
)
var deviceCmd = &cobra.Command{
Use: "device <name>",
Short: "Get information from a single device",
Long: `Get information from a single configured device by name.`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: completeDeviceName,
RunE: runGetDevice,
}
func init() {
getCmd.AddCommand(deviceCmd)
}
func completeDeviceName(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
conf := util.ConfigFromContext(cmd.Context())
if conf == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return conf.GetClientNames(), cobra.ShellCompDirectiveNoFileComp
}
func runGetDevice(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
conf := util.ConfigFromContext(ctx)
logger := util.LoggerFromContext(ctx)
deviceName := args[0]
clientConf := conf.GetClientByName(deviceName)
if clientConf == nil {
return fmt.Errorf("device %q not found in configuration", deviceName)
}
pretty, _ := cmd.Flags().GetBool(util.FLAG_PRETTY)
colorize, _ := cmd.Flags().GetBool(util.FLAG_COLOR)
logger.Debug().Str("device", deviceName).Str("type", string(clientConf.Type)).Msg("fetching device info")
result, err := fetchDevice(ctx, clientConf)
if err != nil {
return fmt.Errorf("failed to get device info: %w", err)
}
return util.YAMLOutput(os.Stdout, result, pretty, colorize)
}

81
cmd/cmd/get_devices.go Normal file
View File

@@ -0,0 +1,81 @@
package cmd
import (
"fmt"
"os"
"sync"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/config"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/util"
"github.com/spf13/cobra"
)
var devicesCmd = &cobra.Command{
Use: "devices",
Short: "Get information from all configured devices",
Long: `Get information from all configured devices in parallel.`,
RunE: runGetDevices,
}
func init() {
getCmd.AddCommand(devicesCmd)
}
// DeviceResult holds the result of fetching a single device.
type DeviceResult struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Host string `yaml:"host"`
Data any `yaml:"data,omitempty"`
Error string `yaml:"error,omitempty"`
Failed bool `yaml:"failed,omitempty"`
}
func runGetDevices(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
conf := util.ConfigFromContext(ctx)
logger := util.LoggerFromContext(ctx)
pretty, _ := cmd.Flags().GetBool(util.FLAG_PRETTY)
colorize, _ := cmd.Flags().GetBool(util.FLAG_COLOR)
if len(conf.Clients) == 0 {
return fmt.Errorf("no devices configured")
}
logger.Debug().Int("count", len(conf.Clients)).Msg("fetching all devices")
results := make([]DeviceResult, len(conf.Clients))
var wg sync.WaitGroup
var mu sync.Mutex
for i, clientConf := range conf.Clients {
wg.Add(1)
go func(idx int, cc config.ClientConfig) {
defer wg.Done()
result := DeviceResult{
Name: cc.Name,
Type: string(cc.Type),
Host: cc.Host,
}
data, err := fetchDevice(ctx, &cc)
if err != nil {
result.Error = err.Error()
result.Failed = true
logger.Error().Err(err).Str("device", cc.Name).Msg("failed to fetch device")
} else {
result.Data = data
}
mu.Lock()
results[idx] = result
mu.Unlock()
}(i, clientConf)
}
wg.Wait()
return util.YAMLOutput(os.Stdout, results, pretty, colorize)
}

157
cmd/cmd/prerun.go Normal file
View File

@@ -0,0 +1,157 @@
package cmd
import (
"fmt"
"io"
"os"
"strings"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/config"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/util"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/pkg/edgeos"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/pkg/toughswitch"
"github.com/rs/zerolog"
"github.com/spf13/cobra"
)
// preRunFunc is a function that runs before command execution.
type preRunFunc func(cmd *cobra.Command, args []string) error
// preRunFuncs is the list of functions to run before command execution.
var preRunFuncs = []preRunFunc{
validateConfigFile,
prepareConfig,
prepareLogger,
setEnvironment,
prepareClients,
}
// preRun executes all registered pre-run functions in order.
func preRun(cmd *cobra.Command, args []string) error {
for _, fn := range preRunFuncs {
if err := fn(cmd, args); err != nil {
return err
}
}
return nil
}
func setEnvironment(cmd *cobra.Command, _ []string) error {
conf := util.ConfigFromContext(cmd.Context())
if conf == nil {
zerolog.Ctx(cmd.Context()).Fatal().Msg("unconfigured, nothing to do")
}
for _, device := range conf.Clients {
if device.Type == config.TypeToughSwitch {
zerolog.Ctx(cmd.Context()).Debug().
Msg("setting GODEBUG=x509negativeserial=1 for toughswitch devices")
os.Setenv("GODEBUG", "x509negativeserial=1")
return nil
}
}
return nil
}
func prepareConfig(cmd *cobra.Command, _ []string) error {
configFile, _ := cmd.Root().PersistentFlags().GetString(util.FLAG_CONFIG_FILE)
conf, err := config.LoadConfig(&configFile)
if err != nil {
return err
}
ctx := util.ContextWithConfig(cmd.Context(), conf)
cmd.SetContext(ctx)
return nil
}
func prepareLogger(cmd *cobra.Command, _ []string) error {
conf := util.ConfigFromContext(cmd.Context())
level, err := zerolog.ParseLevel(conf.LogLevel)
if err != nil {
return err
}
var writer io.Writer
switch conf.LogFormat {
case "console":
writer = zerolog.NewConsoleWriter()
case "json":
writer = os.Stderr
}
logger := zerolog.New(writer).Level(level)
ctx := util.ContextWithLogger(cmd.Context(), &logger)
cmd.SetContext(ctx)
logger.Trace().Any("config", conf).Msg("config and logging prepared")
return nil
}
// validateConfigFile checks that the config file exists if provided.
func validateConfigFile(cmd *cobra.Command, args []string) error {
configFile, _ := cmd.Root().PersistentFlags().GetString(util.FLAG_CONFIG_FILE)
if configFile == "" {
return nil
}
if !strings.HasSuffix(configFile, ".yaml") && !strings.HasSuffix(configFile, ".json") {
return fmt.Errorf("config file must end in .yaml or .json: %s", configFile)
}
if _, err := os.Stat(configFile); os.IsNotExist(err) {
return fmt.Errorf("config file does not exist: %s", configFile)
}
return nil
}
// prepareClients creates toughswitch and edgeos clients based on the configuration.
// Clients are only created if there are devices of that type in the config.
func prepareClients(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
conf := util.ConfigFromContext(ctx)
logger := zerolog.Ctx(ctx)
var tsConfigs []toughswitch.Config
var eosConfigs []edgeos.Config
for _, clientConf := range conf.Clients {
switch clientConf.Type {
case config.TypeToughSwitch:
tsConfigs = append(tsConfigs, toughswitch.Config{
Host: clientConf.Host,
Scheme: clientConf.Scheme,
Insecure: clientConf.Insecure,
Username: clientConf.User,
Password: clientConf.Pass,
Timeout: clientConf.Timeout,
})
case config.TypeEdgeOS:
eosConfigs = append(eosConfigs, edgeos.Config{
Host: clientConf.Host,
Scheme: clientConf.Scheme,
Insecure: clientConf.Insecure,
Username: clientConf.User,
Password: clientConf.Pass,
Timeout: clientConf.Timeout,
})
}
}
if len(tsConfigs) > 0 {
logger.Debug().Int("count", len(tsConfigs)).Msg("creating toughswitch client")
tsClient := toughswitch.MustNew(ctx, tsConfigs)
ctx = util.ContextWithToughSwitchClient(ctx, tsClient)
}
if len(eosConfigs) > 0 {
logger.Debug().Int("count", len(eosConfigs)).Msg("creating edgeos client")
eosClient := edgeos.MustNew(ctx, eosConfigs)
ctx = util.ContextWithEdgeOSClient(ctx, eosClient)
}
cmd.SetContext(ctx)
return nil
}

68
cmd/cmd/root.go Normal file
View File

@@ -0,0 +1,68 @@
/*
Package cmd -- ubiquiti-clients test command
Copyright © 2026 Ryan McGuire <ryan@mcguire.app>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
package cmd
import (
"context"
"os"
"os/signal"
"syscall"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/util"
"github.com/spf13/cobra"
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "cmd",
Short: "Simple CLI tool for using ubiquiti-clients packages",
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
ctx, cncl := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cncl()
err := rootCmd.ExecuteContext(ctx)
if err != nil {
os.Exit(1)
}
}
func init() {
rootCmd.PersistentFlags().StringP(util.FLAG_CONFIG_FILE, util.FLAG_CONFIG_FILE_P, "",
"config file for ubiquiti-clients (yaml/json), env takes priority")
rootCmd.RegisterFlagCompletionFunc(util.FLAG_CONFIG_FILE, completeConfigFile)
cobra.EnableTraverseRunHooks = true
rootCmd.PersistentPreRunE = preRun
}
// completeConfigFile provides shell completion for config file flag,
// returning only files ending in .yaml or .json.
func completeConfigFile(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"yaml", "json"}, cobra.ShellCompDirectiveFilterFileExt
}

View File

@@ -0,0 +1,80 @@
package config
import (
"fmt"
"os"
"time"
"github.com/caarlos0/env/v11"
yaml "github.com/oasdiff/yaml3"
)
type ClientsConfig struct {
LogLevel string `json:"logLevel" yaml:"logLevel" env:"LOG_LEVEL" default:"warn"`
LogFormat string `json:"logFormat" yaml:"logFormat" env:"LOG_FORMAT" default:"console"`
Clients []ClientConfig `json:"clients" yaml:"clients" envPrefix:"CLIENT_"`
}
type ClientType string
const (
TypeEdgeOS ClientType = "edgeos"
TypeToughSwitch ClientType = "toughswitch"
)
type ClientConfig struct {
Type ClientType `json:"type" yaml:"type" env:"TYPE"`
Name string `json:"name" yaml:"name" env:"NAME"`
Host string `json:"host" yaml:"host" env:"HOST"`
Scheme string `json:"scheme" yaml:"scheme" env:"SCHEME" default:"https"`
User string `json:"user" yaml:"user" env:"USER"`
Pass string `json:"pass" yaml:"pass" env:"PASS"`
Insecure bool `json:"insecure" yaml:"insecure" env:"INSECURE" default:"false"`
Timeout time.Duration `json:"timeout" yaml:"timeout" env:"TIMEOUT" default:"10s"`
}
// LoadConfig will load a file if given, layering env on-top of the config
// if present. Environment variables take the form:
// - LOG_LEVEL, LOG_FORMAT for top-level settings
// - CLIENT_0_NAME, CLIENT_0_HOST, CLIENT_0_TYPE, etc. for client array
func LoadConfig(configPath *string) (*ClientsConfig, error) {
conf, err := env.ParseAs[ClientsConfig]()
if err != nil {
return nil, fmt.Errorf("could not parse env config: %w", err)
}
if configPath != nil && *configPath != "" {
file, err := os.Open(*configPath)
if err != nil {
return nil, fmt.Errorf("could not open config file: %w", err)
}
defer file.Close()
decoder := yaml.NewDecoder(file)
if err := decoder.Decode(&conf); err != nil {
return nil, fmt.Errorf("could not decode config file: %w", err)
}
}
return &conf, nil
}
// GetClientByName returns a client config by its name, or nil if not found.
func (c *ClientsConfig) GetClientByName(name string) *ClientConfig {
for i := range c.Clients {
if c.Clients[i].Name == name {
return &c.Clients[i]
}
}
return nil
}
// GetClientNames returns a list of all configured client names.
func (c *ClientsConfig) GetClientNames() []string {
names := make([]string, len(c.Clients))
for i, client := range c.Clients {
names[i] = client.Name
}
return names
}

View File

@@ -0,0 +1,12 @@
package util
const (
FLAG_CONFIG_FILE = "config"
FLAG_CONFIG_FILE_P = "f"
FLAG_PRETTY = "pretty"
FLAG_PRETTY_P = "p"
FLAG_COLOR = "color"
FLAG_COLOR_P = "c"
)

View File

@@ -0,0 +1,72 @@
package util
import (
"context"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/config"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/pkg/edgeos"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/pkg/toughswitch"
"github.com/rs/zerolog"
)
type ctxKey uint8
const (
ctxConfig ctxKey = iota
ctxToughSwitchClient
ctxEdgeOSClient
)
func ContextWithConfig(baseCtx context.Context, config *config.ClientsConfig) context.Context {
return context.WithValue(baseCtx, ctxConfig, config)
}
func ConfigFromContext(ctx context.Context) *config.ClientsConfig {
val := ctx.Value(ctxConfig)
conf, ok := val.(*config.ClientsConfig)
if !ok {
return nil
}
return conf
}
// ContextWithLogger stores the logger in context using zerolog's built-in method.
func ContextWithLogger(baseCtx context.Context, logger *zerolog.Logger) context.Context {
return logger.WithContext(baseCtx)
}
// LoggerFromContext retrieves the logger from context using zerolog's built-in method.
func LoggerFromContext(ctx context.Context) *zerolog.Logger {
return zerolog.Ctx(ctx)
}
// ContextWithToughSwitchClient stores a toughswitch client in the context.
func ContextWithToughSwitchClient(baseCtx context.Context, client *toughswitch.Client) context.Context {
return context.WithValue(baseCtx, ctxToughSwitchClient, client)
}
// ToughSwitchClientFromContext retrieves the toughswitch client from context.
func ToughSwitchClientFromContext(ctx context.Context) *toughswitch.Client {
val := ctx.Value(ctxToughSwitchClient)
client, ok := val.(*toughswitch.Client)
if !ok {
return nil
}
return client
}
// ContextWithEdgeOSClient stores an edgeos client in the context.
func ContextWithEdgeOSClient(baseCtx context.Context, client *edgeos.Client) context.Context {
return context.WithValue(baseCtx, ctxEdgeOSClient, client)
}
// EdgeOSClientFromContext retrieves the edgeos client from context.
func EdgeOSClientFromContext(ctx context.Context) *edgeos.Client {
val := ctx.Value(ctxEdgeOSClient)
client, ok := val.(*edgeos.Client)
if !ok {
return nil
}
return client
}

101
cmd/internal/util/output.go Normal file
View File

@@ -0,0 +1,101 @@
package util
import (
"io"
"regexp"
"github.com/fatih/color"
yaml "github.com/oasdiff/yaml3"
)
// YAMLOutput writes data as YAML to the given writer.
// If pretty is true, output is indented with 2 spaces.
// If colorize is true, YAML syntax is colorized.
func YAMLOutput(w io.Writer, data any, pretty, colorize bool) error {
encoder := yaml.NewEncoder(w)
defer encoder.Close()
if pretty {
encoder.SetIndent(2)
}
if !colorize {
return encoder.Encode(data)
}
// For colored output, encode to bytes first, then colorize
var buf []byte
bufWriter := &byteWriter{buf: &buf}
bufEncoder := yaml.NewEncoder(bufWriter)
if pretty {
bufEncoder.SetIndent(2)
}
if err := bufEncoder.Encode(data); err != nil {
return err
}
bufEncoder.Close()
colorized := colorizeYAML(string(buf))
_, err := w.Write([]byte(colorized))
return err
}
type byteWriter struct {
buf *[]byte
}
func (b *byteWriter) Write(p []byte) (n int, err error) {
*b.buf = append(*b.buf, p...)
return len(p), nil
}
// colorizeYAML applies ANSI colors to YAML output.
func colorizeYAML(input string) string {
// Color definitions - wrap to match ReplaceAllStringFunc signature
keyColor := func(s string) string { return color.New(color.FgCyan).Sprint(s) }
stringColor := func(s string) string { return color.New(color.FgGreen).Sprint(s) }
numberColor := func(s string) string { return color.New(color.FgYellow).Sprint(s) }
boolColor := func(s string) string { return color.New(color.FgMagenta).Sprint(s) }
nullColor := func(s string) string { return color.New(color.FgRed).Sprint(s) }
// Patterns for YAML elements
keyPattern := regexp.MustCompile(`(?m)^(\s*)([a-zA-Z_][a-zA-Z0-9_-]*)(:)`)
stringPattern := regexp.MustCompile(`:\s*"([^"]*)"`)
quotedStringPattern := regexp.MustCompile(`:\s*'([^']*)'`)
numberPattern := regexp.MustCompile(`:\s*(-?\d+\.?\d*)\s*$`)
boolPattern := regexp.MustCompile(`:\s*(true|false)\s*$`)
nullPattern := regexp.MustCompile(`:\s*(null|~)\s*$`)
result := input
// Apply colors in order (specific patterns first)
result = nullPattern.ReplaceAllStringFunc(result, func(s string) string {
return regexp.MustCompile(`(null|~)`).ReplaceAllStringFunc(s, nullColor)
})
result = boolPattern.ReplaceAllStringFunc(result, func(s string) string {
return regexp.MustCompile(`(true|false)`).ReplaceAllStringFunc(s, boolColor)
})
result = numberPattern.ReplaceAllStringFunc(result, func(s string) string {
return regexp.MustCompile(`(-?\d+\.?\d*)\s*$`).ReplaceAllStringFunc(s, numberColor)
})
result = stringPattern.ReplaceAllStringFunc(result, func(s string) string {
return regexp.MustCompile(`"([^"]*)"`).ReplaceAllStringFunc(s, stringColor)
})
result = quotedStringPattern.ReplaceAllStringFunc(result, func(s string) string {
return regexp.MustCompile(`'([^']*)'`).ReplaceAllStringFunc(s, stringColor)
})
result = keyPattern.ReplaceAllStringFunc(result, func(s string) string {
matches := keyPattern.FindStringSubmatch(s)
if len(matches) >= 4 {
return matches[1] + keyColor(matches[2]) + matches[3]
}
return s
})
return result
}

28
cmd/main.go Normal file
View File

@@ -0,0 +1,28 @@
/*
Copyright © 2026 Ryan McGuire <ryan@mcguire.app>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
package main
import "gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/cmd"
func main() {
cmd.Execute()
}

18
go.mod
View File

@@ -1,3 +1,19 @@
module gitea.libretechconsulting.com/rmcguire/edgeos-client
module gitea.libretechconsulting.com/rmcguire/ubiquiti-clients
go 1.25.5
require (
github.com/caarlos0/env/v11 v11.3.1
github.com/fatih/color v1.18.0
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90
github.com/rs/zerolog v1.34.0
github.com/spf13/cobra v1.10.2
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.25.0 // indirect
)

34
go.sum Normal file
View File

@@ -0,0 +1,34 @@
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -2,284 +2,280 @@ package edgeos
import (
"context"
"fmt"
"errors"
"sync"
)
// GetInterfaces retrieves the interfaces for a specific device.
func (c *Client) GetInterfaces(ctx context.Context, host string) ([]Interface, error) {
d, ok := c.devices[host]
if !ok {
return nil, fmt.Errorf("device not found: %s", host)
}
var out []Interface
if err := d.do(ctx, "GET", "/api/v1.0/interfaces", nil, &out); err != nil {
// 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
}
return out, nil
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][]Interface, error) {
results := make(map[string][]Interface)
var mu sync.Mutex
var wg sync.WaitGroup
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
)
// Use a buffered channel or just loop?
// Since we return error if any fails? Or partial results?
// Usually partial results + error or composite error.
// I will return partial results and the last error for now, or just stop on error?
// "methods to get ... for either all device"
// I will implement parallel fetch.
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 c.devices {
wg.Add(1)
go func(h string) {
defer wg.Done()
res, err := c.GetInterfaces(ctx, h)
for _, host := range hosts {
wg.Go(func() {
res, err := c.GetInterfaces(ctx, host)
if err != nil {
// For now, log error or ignore?
// We should probably return an error map or just return what we have?
// I will just skip failed ones for this implementation or log?
// I'll return what succeeds.
// The prompt doesn't specify error handling strategy for "all".
mu.Lock()
errs = errors.Join(errs, err)
mu.Unlock()
return
}
mu.Lock()
results[h] = res
results[host] = res
mu.Unlock()
}(host)
})
}
wg.Wait()
return results, nil
return results, errs
}
// GetDevice retrieves the device info for a specific device.
func (c *Client) GetDevice(ctx context.Context, host string) (*Device, error) {
d, ok := c.devices[host]
if !ok {
return nil, fmt.Errorf("device not found: %s", host)
}
var out Device
if err := d.do(ctx, "GET", "/api/v1.0/device", nil, &out); err != nil {
// 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 &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
var wg sync.WaitGroup
for host := range c.devices {
wg.Add(1)
go func(h string) {
defer wg.Done()
res, err := c.GetDevice(ctx, h)
if err != nil {
return
}
mu.Lock()
results[h] = res
mu.Unlock()
}(host)
}
wg.Wait()
return results, nil
}
// GetSystem retrieves the system info for a specific device.
func (c *Client) GetSystem(ctx context.Context, host string) (*System, error) {
d, ok := c.devices[host]
if !ok {
return nil, fmt.Errorf("device not found: %s", host)
}
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)
var mu sync.Mutex
var wg sync.WaitGroup
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
)
for host := range c.devices {
wg.Add(1)
go func(h string) {
defer wg.Done()
res, err := c.GetSystem(ctx, h)
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[h] = res
results[host] = res
mu.Unlock()
}(host)
})
}
wg.Wait()
return results, nil
return results, errs
}
// GetVLANs retrieves the VLANs for a specific device.
func (c *Client) GetVLANs(ctx context.Context, host string) (*VLANs, error) {
d, ok := c.devices[host]
if !ok {
return nil, fmt.Errorf("device not found: %s", host)
}
var out VLANs
if err := d.do(ctx, "GET", "/api/v1.0/vlans", nil, &out); err != nil {
// GetEdgeOS retrieves all information for a specific device,
// combining AuthInfo and Config.
func (c *Client) GetEdgeOS(ctx context.Context, host string) (*EdgeOS, error) {
_, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
return &out, nil
eos := &EdgeOS{}
var (
mu sync.Mutex
wg sync.WaitGroup
errs error
)
// Fetch auth info and config in parallel
wg.Go(func() {
res, err := c.GetAuthInfo(ctx, host)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = errors.Join(errs, err)
return
}
eos.AuthInfo = res
})
wg.Go(func() {
res, err := c.GetConfig(ctx, host)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = errors.Join(errs, err)
return
}
eos.Config = res
})
wg.Wait()
return eos, errs
}
// 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
var wg sync.WaitGroup
// GetAllEdgeOS retrieves all information for all devices.
func (c *Client) GetAllEdgeOS(ctx context.Context) (map[string]*EdgeOS, error) {
results := make(map[string]*EdgeOS)
var (
mu sync.Mutex
wg sync.WaitGroup
errs error
)
for host := range c.devices {
wg.Add(1)
go func(h string) {
defer wg.Done()
res, err := c.GetVLANs(ctx, h)
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.GetEdgeOS(ctx, host)
if err != nil {
mu.Lock()
errs = errors.Join(errs, err)
mu.Unlock()
return
}
mu.Lock()
results[h] = res
results[host] = res
mu.Unlock()
}(host)
})
}
wg.Wait()
return results, nil
}
// GetServices retrieves the services for a specific device.
func (c *Client) GetServices(ctx context.Context, host string) (*Services, error) {
d, ok := c.devices[host]
if !ok {
return nil, fmt.Errorf("device not found: %s", host)
}
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
var wg sync.WaitGroup
for host := range c.devices {
wg.Add(1)
go func(h string) {
defer wg.Done()
res, err := c.GetServices(ctx, h)
if err != nil {
return
}
mu.Lock()
results[h] = res
mu.Unlock()
}(host)
}
wg.Wait()
return results, nil
}
// GetStatistics retrieves the statistics for a specific device.
func (c *Client) GetStatistics(ctx context.Context, host string) ([]Statistics, error) {
d, ok := c.devices[host]
if !ok {
return nil, fmt.Errorf("device not found: %s", host)
}
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
var wg sync.WaitGroup
for host := range c.devices {
wg.Add(1)
go func(h string) {
defer wg.Done()
res, err := c.GetStatistics(ctx, h)
if err != nil {
return
}
mu.Lock()
results[h] = res
mu.Unlock()
}(host)
}
wg.Wait()
return results, nil
}
// GetNeighbors retrieves the neighbors for a specific device.
func (c *Client) GetNeighbors(ctx context.Context, host string) ([]Neighbor, error) {
d, ok := c.devices[host]
if !ok {
return nil, fmt.Errorf("device not found: %s", host)
}
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
var wg sync.WaitGroup
for host := range c.devices {
wg.Add(1)
go func(h string) {
defer wg.Done()
res, err := c.GetNeighbors(ctx, h)
if err != nil {
return
}
mu.Lock()
results[h] = res
mu.Unlock()
}(host)
}
wg.Wait()
return results, nil
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
@@ -21,14 +20,44 @@ import (
// Client handles communication with EdgeOS devices.
type Client struct {
mu sync.RWMutex
devices map[string]*deviceClient
}
type deviceClient struct {
config Config
client *http.Client
token string
mu sync.Mutex
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.
@@ -37,27 +66,7 @@ func MustNew(ctx context.Context, configs []Config) *Client {
devices := make(map[string]*deviceClient)
for _, cfg := range configs {
// Use Host as the key.
// Ensure scheme is set
if cfg.Scheme == "" {
cfg.Scheme = "https"
}
tr := http.DefaultTransport.(*http.Transport).Clone()
if tr.TLSClientConfig == nil {
tr.TLSClientConfig = &tls.Config{}
}
tr.TLSClientConfig.InsecureSkipVerify = cfg.Insecure
client := &http.Client{
Transport: tr,
Timeout: cfg.Timeout,
}
devices[cfg.Host] = &deviceClient{
config: cfg,
client: client,
}
devices[cfg.Host] = newDeviceClient(cfg)
}
return &Client{
@@ -65,35 +74,87 @@ func MustNew(ctx context.Context, configs []Config) *Client {
}
}
// 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
}
// Login authenticates with the device at the given host.
// This is called automatically on 401 responses, but can be called explicitly
// to pre-authenticate before making requests.
func (c *Client) Login(ctx context.Context, host string) error {
d, err := c.getDeviceByHost(host)
if err != nil {
return err
}
return d.login(ctx)
}
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
@@ -103,23 +164,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)
@@ -131,7 +196,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 {
@@ -142,23 +207,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 {
@@ -171,7 +241,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

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

View File

@@ -1,364 +1,106 @@
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"`
// EdgeOS combines all device information into one response.
type EdgeOS struct {
AuthInfo *AuthResponse `json:"authInfo,omitempty"`
Config *ConfigData `json:"config,omitempty"`
}
// 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"`
// 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"`
}
// 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"`
// 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"`
}
// 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"`
// SwitchFeatures contains switch-specific features.
type SwitchFeatures struct {
Ports []string `json:"ports"`
}
// 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"`
// 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"`
}
// 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"`
// ConfigData contains the full device configuration.
type ConfigData struct {
Interfaces InterfacesConfig `json:"interfaces"`
System SystemConfig `json:"system"`
}
// 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"`
// InterfacesConfig represents the interfaces configuration.
type InterfacesConfig struct {
Ethernet map[string]EthernetConfig `json:"ethernet,omitempty"`
Switch map[string]SwitchConfig `json:"switch,omitempty"`
}
// Interface represents a network interface.
type Interface struct {
Identification InterfaceIdentification `json:"identification"`
Status InterfaceStatus `json:"status"`
Addresses []InterfaceAddress `json:"addresses"`
Port InterfacePort `json:"port"`
// 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"`
}
// 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"`
// PoEState represents PoE output state.
type PoEState struct {
Output string `json:"output,omitempty"`
}
// 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"`
// 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"`
}
// DeviceCapabilities represents device capabilities.
type DeviceCapabilities struct {
Interfaces []DeviceCapabilityInterface `json:"interfaces"`
// SwitchPortConfig represents switch port configuration with VLAN awareness.
type SwitchPortConfig struct {
Interface map[string]InterfaceVLAN `json:"interface,omitempty"`
VLANAware string `json:"vlan-aware,omitempty"`
}
// Device represents the device info.
type Device struct {
ErrorCodes []any `json:"errorCodes"`
Identification DeviceIdentification `json:"identification"`
Capabilities DeviceCapabilities `json:"capabilities"`
// InterfaceVLAN represents VLAN configuration for an interface.
type InterfaceVLAN struct {
VLAN VLANConfig `json:"vlan,omitempty"`
}
// 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"`
// VLANConfig represents VLAN ID configuration.
type VLANConfig struct {
PVID string `json:"pvid,omitempty"`
VID []string `json:"vid,omitempty"`
}
// SystemUser represents a system user.
type SystemUser struct {
Username string `json:"username"`
ReadOnly bool `json:"readOnly"`
SSHKeys []any `json:"sshKeys"`
// 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"`
}
// 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"`
// SystemConfig contains system configuration.
type SystemConfig struct {
HostName string `json:"host-name,omitempty"`
DomainName string `json:"domain-name,omitempty"`
}

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

@@ -0,0 +1,481 @@
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
}
// GetToughSwitch retrieves all information for a specific device,
// combining Device, System, Interfaces, VLANs, Services, Statistics, and Neighbors.
func (c *Client) GetToughSwitch(ctx context.Context, host string) (*ToughSwitch, error) {
_, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
ts := &ToughSwitch{}
var (
mu sync.Mutex
wg sync.WaitGroup
errs error
)
// Fetch all resources in parallel
wg.Go(func() {
res, err := c.GetDevice(ctx, host)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = errors.Join(errs, err)
return
}
ts.Device = res
})
wg.Go(func() {
res, err := c.GetSystem(ctx, host)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = errors.Join(errs, err)
return
}
ts.System = res
})
wg.Go(func() {
res, err := c.GetInterfaces(ctx, host)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = errors.Join(errs, err)
return
}
ts.Interfaces = res
})
wg.Go(func() {
res, err := c.GetVLANs(ctx, host)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = errors.Join(errs, err)
return
}
ts.VLANs = res
})
wg.Go(func() {
res, err := c.GetServices(ctx, host)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = errors.Join(errs, err)
return
}
ts.Services = res
})
wg.Go(func() {
res, err := c.GetStatistics(ctx, host)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = errors.Join(errs, err)
return
}
ts.Statistics = res
})
wg.Go(func() {
res, err := c.GetNeighbors(ctx, host)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = errors.Join(errs, err)
return
}
ts.Neighbors = res
})
wg.Wait()
return ts, errs
}
// GetAllToughSwitches retrieves all information for all devices.
func (c *Client) GetAllToughSwitches(ctx context.Context) (map[string]*ToughSwitch, error) {
results := make(map[string]*ToughSwitch)
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.GetToughSwitch(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
}

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

@@ -0,0 +1,252 @@
/*
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
}
// Login authenticates with the device at the given host.
// This is called automatically on 401 responses, but can be called explicitly
// to pre-authenticate before making requests.
func (c *Client) Login(ctx context.Context, host string) error {
d, err := c.getDeviceByHost(host)
if err != nil {
return err
}
return d.login(ctx)
}
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

@@ -0,0 +1,105 @@
package toughswitch
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"sync"
"testing"
)
type mockTransport struct {
RoundTripFunc func(req *http.Request) (*http.Response, error)
}
func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if m.RoundTripFunc != nil {
return m.RoundTripFunc(req)
}
// Default mock response
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString("{}")),
Header: make(http.Header),
}, nil
}
func TestClient_ThreadSafety(t *testing.T) {
ctx := context.Background()
client := MustNew(ctx, []Config{})
var wg sync.WaitGroup
start := make(chan struct{})
// Writer: Adds and deletes hosts
wg.Go(func() {
<-start
for i := range 100 {
host := fmt.Sprintf("host-%d", i)
cfg := &Config{
Host: host,
Transport: &mockTransport{},
}
if err := client.Add(cfg); err != nil {
// verify we don't error on valid add
t.Logf("Add error: %v", err)
}
// We invoke Del immediately.
if err := client.Del(host); err != nil {
t.Logf("Del error: %v", err)
}
}
})
// Reader: Iterates hosts
wg.Go(func() {
<-start
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()
}
func TestClient_AddDel(t *testing.T) {
ctx := context.Background()
client := MustNew(ctx, []Config{})
cfg := &Config{
Host: "test-host",
Transport: &mockTransport{},
}
if err := client.Add(cfg); err != nil {
t.Fatalf("Add failed: %v", err)
}
if err := client.Add(cfg); err == nil {
t.Fatal("Expected error adding duplicate host, got nil")
}
// Verify we can retrieve it
// Mock transport returns 200 OK with empty body, so GetInterfaces should return empty slice (or error decoding if empty body is not valid JSON array? actually "{}" is valid object, but GetInterfaces expects array for /interfaces?)
// Let's check api.go: GetInterfaces calls /interfaces.
// We can customize the mock if we want to test success return.
// For this test, we just care that it doesn't return "device not found".
_, err := client.GetInterfaces(ctx, "test-host")
if err != nil && err.Error() == "device not found: test-host" {
t.Fatal("Device should exist")
}
if err := client.Del("test-host"); err != nil {
t.Fatalf("Del failed: %v", err)
}
if err := client.Del("test-host"); err == nil {
t.Fatal("Expected error deleting non-existent host, got nil")
}
}

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
}

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

@@ -0,0 +1,375 @@
package toughswitch
// ToughSwitch combines all types into one response.
type ToughSwitch struct {
Device *Device `json:"device,omitempty"`
System *System `json:"system,omitempty"`
Interfaces []Interface `json:"interfaces,omitempty"`
Neighbors []Neighbor `json:"neighbors,omitempty"`
Statistics []Statistics `json:"statistics,omitempty"`
Services *Services `json:"services,omitempty"`
VLANs *VLANs `json:"vlans,omitempty"`
}
// 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"`
}