11 Commits

Author SHA1 Message Date
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
b12e8df35b fix CI
All checks were successful
Publish / release (push) Successful in 43s
2026-01-04 13:32:09 -05:00
28 changed files with 2417 additions and 659 deletions

View File

@@ -11,6 +11,11 @@ jobs:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') # Only run on tag push
steps:
- name: Set up Go Environment
uses: actions/setup-go@v4
with:
go-version: '1.25'
- name: Run Go List
continue-on-error: true
env:

1
.gitignore vendored Normal file
View File

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

34
CHANGELOG.md Normal file
View File

@@ -0,0 +1,34 @@
# Changelog
All notable changes to this project will be documented in this file.
## v0.3.0 - 2026-01-14
### Changed
- Renamed project from toughswitch to ubiquiti-clients
- Added EdgeOS support
## v0.2.1 - 2026-01-05
### Changed
- Minor LSP improvements to tests
- Refactor entire project edgeos -> toughswitch
## [v0.2.0] - 2026-01-04
### Added
- Thread-safe `Add` and `Del` methods to `Client` for dynamic host management.
- `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.

65
CLAUDE.md Normal file
View File

@@ -0,0 +1,65 @@
# 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 a single test
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.
### Package Structure
- `pkg/toughswitch/` - Client for ToughSwitch devices (e.g., TS-8-PRO)
- `pkg/edgeos/` - Client for EdgeOS devices (EdgeRouter, EdgeSwitch)
- `cmd/` - Optional CLI tool using cobra (work in progress)
### Client Design Pattern
Both packages 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
- Concurrent multi-device: `GetAll*` methods use `sync.WaitGroup.Go()` for parallel queries
### API Pattern
Each package exposes:
- `MustNew(ctx, []Config)` - Constructor that accepts multiple device configs
- `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`
### 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.

161
README.md
View File

@@ -1,10 +1,17 @@
# edgeos
# ubiquiti-clients
A Go client library for interacting with Ubiquiti EdgeOS devices (specifically tested with EdgeSwitch XP / ToughSwitch) via their internal REST API.
Go client libraries for interacting with Ubiquiti network devices via their REST APIs.
**⚠️ Disclaimer: This library is based on reverse-engineered API calls. It is not an official Ubiquiti product and is subject to change if the device firmware changes.**
**⚠️ Disclaimer: These libraries are based on reverse-engineered API calls. They are not official Ubiquiti products and are subject to change if the device firmware changes.**
## Features
## Packages
### toughswitch
A client library for interacting with Ubiquiti ToughSwitch devices (specifically tested with
ToughSwitch POE Pro (TS-8-PRO)) via their internal REST API.
#### Features
- **Authentication**: Handles login and session token management automatically.
- **Multi-Device Support**: Manage multiple devices with a single client instance.
@@ -16,15 +23,33 @@ A Go client library for interacting with Ubiquiti EdgeOS devices (specifically t
- **Statistics**: Real-time throughput, errors, and resource usage.
- **Discovery**: Neighbor discovery via UBNT protocol.
### edgeos
A client library for interacting with Ubiquiti EdgeOS devices (EdgeRouter, EdgeSwitch) via their REST API.
#### Features
- **Authentication**: Handles login and session management automatically.
- **Multi-Device Support**: Manage multiple devices with a single client instance.
- **Data Retrieval**:
- **System Configuration**: Hostname, domain name, and other system settings.
- **Interface Configuration**: Ethernet and switch interface settings, including PoE.
- **VLAN Configuration**: VLAN assignments, PVID, and tagged VLANs.
- **Device Information**: Model, ports, PoE capabilities, and features.
## Installation
```bash
go get gitea.libretechconsulting.com/rmcguire/edgeos-client/pkg/edgeos
# For ToughSwitch
go get gitea.libretechconsulting.com/rmcguire/toughswitch-client/pkg/toughswitch
# For EdgeOS
go get gitea.libretechconsulting.com/rmcguire/toughswitch-client/pkg/edgeos
```
## Usage
### Basic Example
### ToughSwitch Basic Example
```go
package main
@@ -35,14 +60,14 @@ import (
"log"
"time"
"gitea.libretechconsulting.com/rmcguire/edgeos-client/pkg/edgeos"
"gitea.libretechconsulting.com/rmcguire/toughswitch-client/pkg/toughswitch"
)
func main() {
ctx := context.Background()
// Configure your device(s)
configs := []edgeos.Config{
configs := []toughswitch.Config{
{
Host: "192.168.1.1",
Username: "ubnt",
@@ -53,7 +78,7 @@ func main() {
}
// Initialize the client
client := edgeos.MustNew(ctx, configs)
client := toughswitch.MustNew(ctx, configs)
// Fetch system information
deviceHost := "192.168.1.1"
@@ -71,16 +96,82 @@ func main() {
}
for _, iface := range ifaces {
fmt.Printf("Interface %s: %s (POE: %s)\n",
iface.Identification.ID,
iface.Status.Speed,
fmt.Printf("Interface %s: %s (POE: %s)\n",
iface.Identification.ID,
iface.Status.Speed,
iface.Port.POE,
)
}
}
```
### Retrieving Statistics
### EdgeOS Basic Example
```go
package main
import (
"context"
"fmt"
"log"
"time"
"gitea.libretechconsulting.com/rmcguire/toughswitch-client/pkg/edgeos"
)
func main() {
ctx := context.Background()
// Configure your device(s)
configs := []edgeos.Config{
{
Host: "192.168.1.1",
Username: "ubnt",
Password: "ubnt",
Insecure: true, // Set to true if using self-signed certs
Timeout: 10 * time.Second,
},
}
// Initialize the client
client := edgeos.MustNew(ctx, configs)
// Fetch device information
deviceHost := "192.168.1.1"
authInfo, err := client.GetAuthInfo(ctx, deviceHost)
if err != nil {
log.Fatalf("Failed to get auth info: %v", err)
}
fmt.Printf("Connected to: %s (%s) with %d ports\n",
authInfo.ModelName, authInfo.Model, authInfo.Ports)
// Fetch system configuration
system, err := client.GetSystem(ctx, deviceHost)
if err != nil {
log.Fatalf("Failed to get system config: %v", err)
}
fmt.Printf("Hostname: %s, Domain: %s\n", system.HostName, system.DomainName)
// Fetch interfaces
interfaces, err := client.GetInterfaces(ctx, deviceHost)
if err != nil {
log.Fatalf("Failed to get interfaces: %v", err)
}
for name, config := range interfaces.Ethernet {
fmt.Printf("Interface %s: %s (Speed: %s, Duplex: %s)\n",
name,
config.Description,
config.Speed,
config.Duplex,
)
}
}
```
### ToughSwitch: Retrieving Statistics
```go
stats, err := client.GetStatistics(ctx, "192.168.1.1")
@@ -91,10 +182,10 @@ if err != nil {
for _, stat := range stats {
// Device level stats
fmt.Printf("CPU Usage: %d%%\n", stat.Device.CPU[0].Usage)
// Per-interface stats
for _, iface := range stat.Interfaces {
fmt.Printf("[%s] Rx: %d bps, Tx: %d bps\n",
fmt.Printf("[%s] Rx: %d bps, Tx: %d bps\n",
iface.Name,
iface.Statistics.RxRate,
iface.Statistics.TxRate,
@@ -105,29 +196,48 @@ for _, stat := range stats {
### Working with Multiple Devices
The client is designed to handle multiple devices concurrently.
Both clients are designed to handle multiple devices concurrently.
```go
configs := []edgeos.Config{
// ToughSwitch example
configs := []toughswitch.Config{
{Host: "192.168.1.1", ...},
{Host: "192.168.1.2", ...},
}
client := edgeos.MustNew(ctx, configs)
client := toughswitch.MustNew(ctx, configs)
// Get info for all configured devices in parallel
allSystems, err := client.GetAllSystems(ctx)
if err != nil {
// Note: This returns partial results if available, check implementation
log.Printf("Error fetching some systems: %v", err)
}
for host, sys := range allSystems {
fmt.Printf("[%s] Hostname: %s\n", host, sys.Hostname)
}
// EdgeOS example
edgeConfigs := []edgeos.Config{
{Host: "192.168.2.1", ...},
{Host: "192.168.2.2", ...},
}
edgeClient := edgeos.MustNew(ctx, edgeConfigs)
// Get config for all configured devices in parallel
allConfigs, err := edgeClient.GetAllConfigs(ctx)
if err != nil {
log.Printf("Error fetching some configs: %v", err)
}
for host, cfg := range allConfigs {
fmt.Printf("[%s] Hostname: %s\n", host, cfg.System.HostName)
}
```
## Supported Endpoints
### ToughSwitch
| Method | Description |
|--------|-------------|
| `GetSystem` | General system configuration and status |
@@ -138,6 +248,19 @@ for host, sys := range allSystems {
| `GetNeighbors` | Discovered UBNT neighbors |
| `GetDevice` | Hardware and capabilities info |
All methods have corresponding `GetAll*` variants for multi-device operations.
### EdgeOS
| Method | Description |
|--------|-------------|
| `GetConfig` | Complete device configuration |
| `GetAuthInfo` | Device authentication and feature information |
| `GetInterfaces` | Interface configuration (ethernet and switch) |
| `GetSystem` | System configuration (hostname, domain) |
All methods have corresponding `GetAll*` variants for multi-device operations.
## License
MIT

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.

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")
}

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

@@ -0,0 +1,102 @@
package cmd
import (
"context"
"fmt"
"os"
"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/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")
var result any
var err error
switch clientConf.Type {
case config.TypeToughSwitch:
result, err = getToughSwitchDevice(ctx, clientConf)
case config.TypeEdgeOS:
result, err = getEdgeOSDevice(ctx, clientConf)
default:
return fmt.Errorf("unknown device type: %s", clientConf.Type)
}
if err != nil {
return fmt.Errorf("failed to get device info: %w", err)
}
return util.YAMLOutput(os.Stdout, result, pretty, colorize)
}
func getToughSwitchDevice(ctx context.Context, clientConf *config.ClientConfig) (any, error) {
cfg := toughswitch.Config{
Host: clientConf.Host,
Scheme: clientConf.Scheme,
Insecure: clientConf.Insecure,
Username: clientConf.User,
Password: clientConf.Pass,
Timeout: clientConf.Timeout,
}
client := toughswitch.MustNew(ctx, []toughswitch.Config{cfg})
return client.GetDevice(ctx, clientConf.Host)
}
func getEdgeOSDevice(ctx context.Context, clientConf *config.ClientConfig) (any, error) {
cfg := edgeos.Config{
Host: clientConf.Host,
Scheme: clientConf.Scheme,
Insecure: clientConf.Insecure,
Username: clientConf.User,
Password: clientConf.Pass,
Timeout: clientConf.Timeout,
}
client := edgeos.MustNew(ctx, []edgeos.Config{cfg})
return client.GetConfig(ctx, clientConf.Host)
}

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

@@ -0,0 +1,123 @@
package cmd
import (
"context"
"fmt"
"os"
"sync"
"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/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,
}
var data any
var err error
switch cc.Type {
case config.TypeToughSwitch:
data, err = fetchToughSwitch(ctx, &cc)
case config.TypeEdgeOS:
data, err = fetchEdgeOS(ctx, &cc)
default:
err = fmt.Errorf("unknown device type: %s", cc.Type)
}
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)
}
func fetchToughSwitch(ctx context.Context, clientConf *config.ClientConfig) (any, error) {
cfg := toughswitch.Config{
Host: clientConf.Host,
Scheme: clientConf.Scheme,
Insecure: clientConf.Insecure,
Username: clientConf.User,
Password: clientConf.Pass,
Timeout: clientConf.Timeout,
}
client := toughswitch.MustNew(ctx, []toughswitch.Config{cfg})
return client.GetDevice(ctx, clientConf.Host)
}
func fetchEdgeOS(ctx context.Context, clientConf *config.ClientConfig) (any, error) {
cfg := edgeos.Config{
Host: clientConf.Host,
Scheme: clientConf.Scheme,
Insecure: clientConf.Insecure,
Username: clientConf.User,
Password: clientConf.Pass,
Timeout: clientConf.Timeout,
}
client := edgeos.MustNew(ctx, []edgeos.Config{cfg})
return client.GetConfig(ctx, clientConf.Host)
}

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

@@ -0,0 +1,86 @@
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"
"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,
}
// 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 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
}

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,43 @@
package util
import (
"context"
"gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/config"
"github.com/rs/zerolog"
)
type CmdContextVal uint8
const (
CTX_CONFIG CmdContextVal = iota
CTX_LOGGER
)
func ContextWithConfig(baseCtx context.Context, config *config.ClientsConfig) context.Context {
return context.WithValue(baseCtx, CTX_CONFIG, config)
}
func ConfigFromContext(ctx context.Context) *config.ClientsConfig {
val := ctx.Value(CTX_CONFIG)
conf, ok := val.(*config.ClientsConfig)
if !ok {
return nil
}
return conf
}
func ContextWithLogger(baseCtx context.Context, logger *zerolog.Logger) context.Context {
return context.WithValue(baseCtx, CTX_LOGGER, logger)
}
func LoggerFromContext(ctx context.Context) *zerolog.Logger {
val := ctx.Value(CTX_LOGGER)
logger, ok := val.(*zerolog.Logger)
if !ok {
return nil
}
return logger
}

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,204 @@ 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
// 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.
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
)
for host := range c.devices {
wg.Add(1)
go func(h string) {
defer wg.Done()
res, err := c.GetInterfaces(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.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
}
// 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 {
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
var wg sync.WaitGroup
for host := range c.devices {
wg.Add(1)
go func(h string) {
defer wg.Done()
res, err := c.GetVLANs(ctx, h)
if err != nil {
return
}
mu.Lock()
results[h] = 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,76 @@ 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
}
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 +153,27 @@ func (d *deviceClient) login(ctx context.Context) error {
return fmt.Errorf("login failed [%d]: %s", resp.StatusCode, string(respPayload))
}
token := resp.Header.Get("x-auth-token")
if token == "" {
return fmt.Errorf("login failed: no token in response")
var authResp AuthResponse
if err := json.Unmarshal(respPayload, &authResp); err != nil {
return fmt.Errorf("failed to parse auth response: %w", err)
}
d.token = token
if !authResp.Authenticated {
return fmt.Errorf("authentication failed for user %s", d.config.Username)
}
d.authInfo = &authResp
d.cookies = resp.Cookies()
return nil
}
func (d *deviceClient) do(ctx context.Context, method, path string, body any, out any) error {
// First attempt
err := d.doRequest(ctx, method, path, body, out)
if err == nil {
return nil
}
// If unauthorized, try to login and retry
if strings.Contains(err.Error(), "status 401") || strings.Contains(err.Error(), "unauthorized") {
if loginErr := d.login(ctx); loginErr != nil {
return fmt.Errorf("re-login failed: %w", loginErr)
@@ -131,7 +185,7 @@ func (d *deviceClient) do(ctx context.Context, method, path string, body any, ou
}
func (d *deviceClient) doRequest(ctx context.Context, method, path string, body any, out any) error {
url := fmt.Sprintf("%s://%s%s", d.config.Scheme, d.config.Host, path)
reqUrl := fmt.Sprintf("%s://%s%s", d.config.Scheme, d.config.Host, path)
var reqBody io.Reader
if body != nil {
@@ -142,23 +196,28 @@ func (d *deviceClient) doRequest(ctx context.Context, method, path string, body
reqBody = bytes.NewBuffer(b)
}
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
req, err := http.NewRequestWithContext(ctx, method, reqUrl, reqBody)
if err != nil {
return err
}
d.mu.Lock()
token := d.token
cookies := d.cookies
d.mu.Unlock()
if token != "" {
req.Header.Set("x-auth-token", token)
if len(cookies) > 0 {
cookieURL, _ := url.Parse(reqUrl)
for _, cookie := range cookies {
if cookie.Domain == "" || strings.HasSuffix(cookieURL.Host, cookie.Domain) {
req.AddCookie(cookie)
}
}
}
// Some endpoints might require Content-Type even for GET if we were strict, but usually only for POST/PUT
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
resp, err := d.client.Do(req)
if err != nil {
@@ -171,7 +230,6 @@ func (d *deviceClient) doRequest(ctx context.Context, method, path string, body
}
if resp.StatusCode != http.StatusOK {
// Read body to see error message
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("request failed: status %d, body: %s", resp.StatusCode, string(b))
}

View File

@@ -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,100 @@
package edgeos
// LoginResponse represents the response from the login endpoint.
type LoginResponse struct {
StatusCode int `json:"statusCode"`
Error int `json:"error"`
Detail string `json:"detail"`
Message string `json:"message"`
// 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"`
}
// 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"`
// 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"`
}
// 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"`
// SwitchFeatures contains switch-specific features.
type SwitchFeatures struct {
Ports []string `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"`
// 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"`
}
// 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"`
// ConfigData contains the full device configuration.
type ConfigData struct {
Interfaces InterfacesConfig `json:"interfaces"`
System SystemConfig `json:"system"`
}
// 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"`
// InterfacesConfig represents the interfaces configuration.
type InterfacesConfig struct {
Ethernet map[string]EthernetConfig `json:"ethernet,omitempty"`
Switch map[string]SwitchConfig `json:"switch,omitempty"`
}
// 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"`
// 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"`
}
// Interface represents a network interface.
type Interface struct {
Identification InterfaceIdentification `json:"identification"`
Status InterfaceStatus `json:"status"`
Addresses []InterfaceAddress `json:"addresses"`
Port InterfacePort `json:"port"`
// PoEState represents PoE output state.
type PoEState struct {
Output string `json:"output,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"`
// 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"`
}
// 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"`
// SwitchPortConfig represents switch port configuration with VLAN awareness.
type SwitchPortConfig struct {
Interface map[string]InterfaceVLAN `json:"interface,omitempty"`
VLANAware string `json:"vlan-aware,omitempty"`
}
// DeviceCapabilities represents device capabilities.
type DeviceCapabilities struct {
Interfaces []DeviceCapabilityInterface `json:"interfaces"`
// InterfaceVLAN represents VLAN configuration for an interface.
type InterfaceVLAN struct {
VLAN VLANConfig `json:"vlan,omitempty"`
}
// Device represents the device info.
type Device struct {
ErrorCodes []any `json:"errorCodes"`
Identification DeviceIdentification `json:"identification"`
Capabilities DeviceCapabilities `json:"capabilities"`
// VLANConfig represents VLAN ID configuration.
type VLANConfig struct {
PVID string `json:"pvid,omitempty"`
VID []string `json:"vid,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"`
// 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"`
}
// SystemUser represents a system user.
type SystemUser struct {
Username string `json:"username"`
ReadOnly bool `json:"readOnly"`
SSHKeys []any `json:"sshKeys"`
// SystemConfig contains system configuration.
type SystemConfig struct {
HostName string `json:"host-name,omitempty"`
DomainName string `json:"domain-name,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"`
}

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

@@ -0,0 +1,350 @@
package toughswitch
import (
"context"
"errors"
"sync"
)
// GetInterfaces retrieves the interfaces for a specific device.
func (c *Client) GetInterfaces(ctx context.Context, host string) ([]Interface, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out []Interface
if err := d.do(ctx, "GET", "/api/v1.0/interfaces", nil, &out); err != nil {
return nil, err
}
return out, nil
}
// GetAllInterfaces retrieves interfaces for all devices.
func (c *Client) GetAllInterfaces(ctx context.Context) (map[string][]Interface, error) {
results := make(map[string][]Interface)
var (
mu sync.Mutex
wg sync.WaitGroup
errs error
)
c.mu.RLock()
hosts := make([]string, 0, len(c.devices))
for h := range c.devices {
hosts = append(hosts, h)
}
c.mu.RUnlock()
for _, host := range hosts {
wg.Go(func() {
res, err := c.GetInterfaces(ctx, host)
if err != nil {
mu.Lock()
errs = errors.Join(errs, err)
mu.Unlock()
return
}
mu.Lock()
results[host] = res
mu.Unlock()
})
}
wg.Wait()
return results, errs
}
// GetDevice retrieves the device info for a specific device.
func (c *Client) GetDevice(ctx context.Context, host string) (*Device, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out Device
if err := d.do(ctx, "GET", "/api/v1.0/device", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetAllDevices retrieves device info for all devices.
func (c *Client) GetAllDevices(ctx context.Context) (map[string]*Device, error) {
results := make(map[string]*Device)
var (
mu sync.Mutex
wg sync.WaitGroup
errs error
)
c.mu.RLock()
hosts := make([]string, 0, len(c.devices))
for h := range c.devices {
hosts = append(hosts, h)
}
c.mu.RUnlock()
for _, host := range hosts {
wg.Go(func() {
res, err := c.GetDevice(ctx, host)
if err != nil {
mu.Lock()
errs = errors.Join(errs, err)
mu.Unlock()
return
}
mu.Lock()
results[host] = res
mu.Unlock()
})
}
wg.Wait()
return results, errs
}
// GetSystem retrieves the system info for a specific device.
func (c *Client) GetSystem(ctx context.Context, host string) (*System, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out System
if err := d.do(ctx, "GET", "/api/v1.0/system", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetAllSystems retrieves system info for all devices.
func (c *Client) GetAllSystems(ctx context.Context) (map[string]*System, error) {
results := make(map[string]*System)
var (
mu sync.Mutex
wg sync.WaitGroup
errs error
)
c.mu.RLock()
hosts := make([]string, 0, len(c.devices))
for h := range c.devices {
hosts = append(hosts, h)
}
c.mu.RUnlock()
for _, host := range hosts {
wg.Go(func() {
res, err := c.GetSystem(ctx, host)
if err != nil {
mu.Lock()
errs = errors.Join(errs, err)
mu.Unlock()
return
}
mu.Lock()
results[host] = res
mu.Unlock()
})
}
wg.Wait()
return results, errs
}
// GetVLANs retrieves the VLANs for a specific device.
func (c *Client) GetVLANs(ctx context.Context, host string) (*VLANs, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out VLANs
if err := d.do(ctx, "GET", "/api/v1.0/vlans", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetAllVLANs retrieves VLANs for all devices.
func (c *Client) GetAllVLANs(ctx context.Context) (map[string]*VLANs, error) {
results := make(map[string]*VLANs)
var (
mu sync.Mutex
wg sync.WaitGroup
errs error
)
c.mu.RLock()
hosts := make([]string, 0, len(c.devices))
for h := range c.devices {
hosts = append(hosts, h)
}
c.mu.RUnlock()
for _, host := range hosts {
wg.Go(func() {
res, err := c.GetVLANs(ctx, host)
if err != nil {
mu.Lock()
errs = errors.Join(errs, err)
mu.Unlock()
return
}
mu.Lock()
results[host] = res
mu.Unlock()
})
}
wg.Wait()
return results, errs
}
// GetServices retrieves the services for a specific device.
func (c *Client) GetServices(ctx context.Context, host string) (*Services, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out Services
if err := d.do(ctx, "GET", "/api/v1.0/services", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetAllServices retrieves services for all devices.
func (c *Client) GetAllServices(ctx context.Context) (map[string]*Services, error) {
results := make(map[string]*Services)
var (
mu sync.Mutex
wg sync.WaitGroup
errs error
)
c.mu.RLock()
hosts := make([]string, 0, len(c.devices))
for h := range c.devices {
hosts = append(hosts, h)
}
c.mu.RUnlock()
for _, host := range hosts {
wg.Go(func() {
res, err := c.GetServices(ctx, host)
if err != nil {
mu.Lock()
errs = errors.Join(errs, err)
mu.Unlock()
return
}
mu.Lock()
results[host] = res
mu.Unlock()
})
}
wg.Wait()
return results, errs
}
// GetStatistics retrieves the statistics for a specific device.
func (c *Client) GetStatistics(ctx context.Context, host string) ([]Statistics, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out []Statistics
if err := d.do(ctx, "GET", "/api/v1.0/statistics", nil, &out); err != nil {
return nil, err
}
return out, nil
}
// GetAllStatistics retrieves statistics for all devices.
func (c *Client) GetAllStatistics(ctx context.Context) (map[string][]Statistics, error) {
results := make(map[string][]Statistics)
var (
mu sync.Mutex
wg sync.WaitGroup
errs error
)
c.mu.RLock()
hosts := make([]string, 0, len(c.devices))
for h := range c.devices {
hosts = append(hosts, h)
}
c.mu.RUnlock()
for _, host := range hosts {
wg.Go(func() {
res, err := c.GetStatistics(ctx, host)
if err != nil {
mu.Lock()
errs = errors.Join(errs, err)
mu.Unlock()
return
}
mu.Lock()
results[host] = res
mu.Unlock()
})
}
wg.Wait()
return results, errs
}
// GetNeighbors retrieves the neighbors for a specific device.
func (c *Client) GetNeighbors(ctx context.Context, host string) ([]Neighbor, error) {
d, err := c.getDeviceByHost(host)
if err != nil {
return nil, err
}
var out []Neighbor
if err := d.do(ctx, "GET", "/api/v1.0/tools/discovery/neighbors", nil, &out); err != nil {
return nil, err
}
return out, nil
}
// GetAllNeighbors retrieves neighbors for all devices.
func (c *Client) GetAllNeighbors(ctx context.Context) (map[string][]Neighbor, error) {
results := make(map[string][]Neighbor)
var (
mu sync.Mutex
wg sync.WaitGroup
errs error
)
c.mu.RLock()
hosts := make([]string, 0, len(c.devices))
for h := range c.devices {
hosts = append(hosts, h)
}
c.mu.RUnlock()
for _, host := range hosts {
wg.Go(func() {
res, err := c.GetNeighbors(ctx, host)
if err != nil {
mu.Lock()
errs = errors.Join(errs, err)
mu.Unlock()
return
}
mu.Lock()
results[host] = res
mu.Unlock()
})
}
wg.Wait()
return results, errs
}

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

@@ -0,0 +1,241 @@
/*
Package toughswitch provides a client for interacting with Ubiquiti toughswitch devices
via their REST API. It supports authentication, token management, and
retrieval of system, interface, VLAN, and discovery information from
one or more devices.
*/
package toughswitch
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
)
// Client handles communication with toughswitch devices.
type Client struct {
mu sync.RWMutex
devices map[string]*deviceClient
}
type deviceClient struct {
config Config
client *http.Client
token string
mu sync.Mutex
}
func newDeviceClient(cfg Config) *deviceClient {
// Ensure scheme is set
if cfg.Scheme == "" {
cfg.Scheme = "https"
}
var tr http.RoundTripper
if cfg.Transport != nil {
tr = cfg.Transport
} else {
defaultTr := http.DefaultTransport.(*http.Transport).Clone()
if defaultTr.TLSClientConfig == nil {
defaultTr.TLSClientConfig = &tls.Config{}
}
defaultTr.TLSClientConfig.InsecureSkipVerify = cfg.Insecure
tr = defaultTr
}
client := &http.Client{
Transport: tr,
Timeout: cfg.Timeout,
}
return &deviceClient{
config: cfg,
client: client,
}
}
// MustNew creates a new Client with the given configurations.
// It panics if a configuration is invalid (though currently we just accept all).
func MustNew(ctx context.Context, configs []Config) *Client {
devices := make(map[string]*deviceClient)
for _, cfg := range configs {
devices[cfg.Host] = newDeviceClient(cfg)
}
return &Client{
devices: devices,
}
}
// Add adds a new device to the client.
// It returns an error if a device with the same host already exists.
func (c *Client) Add(cfg *Config) error {
if cfg == nil {
return fmt.Errorf("config cannot be nil")
}
d := newDeviceClient(*cfg)
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.devices[cfg.Host]; ok {
return fmt.Errorf("device already exists: %s", cfg.Host)
}
c.devices[cfg.Host] = d
return nil
}
// Del removes a device from the client.
// It returns an error if the device does not exist.
func (c *Client) Del(host string) error {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.devices[host]; !ok {
return fmt.Errorf("device not found: %s", host)
}
delete(c.devices, host)
return nil
}
func (c *Client) getDeviceByHost(host string) (*deviceClient, error) {
c.mu.RLock()
defer c.mu.RUnlock()
d, ok := c.devices[host]
if !ok {
return nil, fmt.Errorf("device not found: %s", host)
}
return d, nil
}
func (d *deviceClient) login(ctx context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
reqUrl := fmt.Sprintf("%s://%s/api/v1.0/user/login", d.config.Scheme, d.config.Host)
payload := map[string]string{
"username": d.config.Username,
"password": d.config.Password,
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "POST", reqUrl, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.URL.User = url.UserPassword(d.config.Username, d.config.Password)
resp, err := d.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respPayload, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("login failed [%d]: %s", resp.StatusCode, string(respPayload))
}
token := resp.Header.Get("x-auth-token")
if token == "" {
return fmt.Errorf("login failed: no token in response")
}
d.token = token
return nil
}
func (d *deviceClient) do(ctx context.Context, method, path string, body any, out any) error {
// First attempt
err := d.doRequest(ctx, method, path, body, out)
if err == nil {
return nil
}
// If unauthorized, try to login and retry
if strings.Contains(err.Error(), "status 401") || strings.Contains(err.Error(), "unauthorized") {
if loginErr := d.login(ctx); loginErr != nil {
return fmt.Errorf("re-login failed: %w", loginErr)
}
return d.doRequest(ctx, method, path, body, out)
}
return err
}
func (d *deviceClient) doRequest(ctx context.Context, method, path string, body any, out any) error {
url := fmt.Sprintf("%s://%s%s", d.config.Scheme, d.config.Host, path)
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
reqBody = bytes.NewBuffer(b)
}
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
if err != nil {
return err
}
d.mu.Lock()
token := d.token
d.mu.Unlock()
if token != "" {
req.Header.Set("x-auth-token", token)
}
// Some endpoints might require Content-Type even for GET if we were strict, but usually only for POST/PUT
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := d.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("status 401")
}
if resp.StatusCode != http.StatusOK {
// Read body to see error message
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("request failed: status %d, body: %s", resp.StatusCode, string(b))
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return err
}
}
return nil
}

View File

@@ -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
}

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

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