rename to toughswitch
All checks were successful
Publish / release (push) Successful in 29s

This commit is contained in:
2026-01-05 16:25:23 -05:00
parent ecbf4d447c
commit 195a9f7a9f
6 changed files with 0 additions and 0 deletions

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
}

View File

@@ -0,0 +1,82 @@
# Auth
- POST to /api/v1.0/users/login, application/json, `{username: "user", password: "password"}`
- Response contains x-auth-token header
- Successful response payload: `{"statusCode":200,"error":0,"detail":"User account
valid.","message":"Success"}`
- Failure payload: `{"statusCode":401,"error":1,"detail":"User account invalid...","message":"Failure"}`
- Use token until 401 responses, then refresh
# Endpoints
## Interfaces
**GET /api/v1.0/interfaces**
### Response
```json
[{"identification":{"id":"0\/1","name":"Switch-Main","mac":"00:11:22:33:44:55","type":"port"},"status":{"enabled":true,"plugged":true,"currentSpeed":"100-full","speed":"auto","mtu":1518},"addresses":[{"type":"static","version":"v4","cidr":"192.168.1.10\/24","eui64":false}],"port":{"stp":{"enabled":true,"edgePort":"disable","pathCost":0,"portPriority":128,"state":"disabled"},"poe":"off","flowControl":true,"routed":false,"pingWatchdog":{"enabled":false,"address":"","failureCount":3,"interval":15,"offDelay":5,"startDelay":300}}},{"identification":{"id":"0\/2","name":"Access Point","mac":"00:11:22:33:44:55","type":"port"},"status":{"enabled":true,"plugged":true,"currentSpeed":"1000-full","speed":"auto","mtu":1518},"addresses":[{"type":"static","version":"v4","cidr":"192.168.1.10\/24","eui64":false}],"port":{"stp":{"enabled":true,"edgePort":"disable","pathCost":0,"portPriority":128,"state":"disabled"},"poe":"24v","flowControl":true,"routed":false,"pingWatchdog":{"enabled":false,"address":"192.168.1.20","failureCount":3,"interval":30,"offDelay":5,"startDelay":300}}},{"identification":{"id":"0\/3","name":"Camera-Front","mac":"00:11:22:33:44:55","type":"port"},"status":{"enabled":true,"plugged":true,"currentSpeed":"100-full","speed":"auto","mtu":1518},"addresses":[],"port":{"stp":{"enabled":true,"edgePort":"disable","pathCost":0,"portPriority":128,"state":"disabled"},"poe":"48v","flowControl":true,"routed":false,"pingWatchdog":{"enabled":false,"address":"","failureCount":3,"interval":15,"offDelay":5,"startDelay":300}}},{"identification":{"id":"0\/4","name":"Camera-Back","mac":"00:11:22:33:44:55","type":"port"},"status":{"enabled":true,"plugged":true,"currentSpeed":"100-full","speed":"auto","mtu":1518},"addresses":[],"port":{"stp":{"enabled":true,"edgePort":"disable","pathCost":0,"portPriority":128,"state":"disabled"},"poe":"24v","flowControl":true,"routed":false,"pingWatchdog":{"enabled":false,"address":"","failureCount":3,"interval":15,"offDelay":5,"startDelay":300}}},{"identification"...
```
## Device
**GET /api/v1.0/device**
### Response
```json
{"errorCodes":[],"identification":{"mac":"00:11:22:33:44:55","model":"TSW-PoE PRO","family":"EdgeSwitch-XP","subsystemID":"e702","firmwareVersion":"2.2.1","firmware":"SW.ar7240.v2.2.1.165.240717.1112","product":"EdgeSwitch 8XP","serverVersion":"1.4.0-222-gb5269d7","bridgeVersion":"0.33.0-dev-9-gdebf0c5"},"capabilities":{"interfaces":[{"id":"0\/1","type":"port","supportBlock":true,"supportDelete":false,"supportReset":true,"configurable":true,"supportDHCPSnooping":false,"supportIsolate":false,"supportAutoEdge":false,"maxMTU":9720,"supportPOE":true,"supportCableTest":false,"poeValues":["off","24v","48v"],"media":"GE","speedValues":["auto","10-half","10-full","100-half","100-full"]},{"id":"0\/2","type":"port","supportBlock":true,"supportDelete":false,"supportReset":true,"configurable":true,"supportDHCPSnooping":false,"supportIsolate":false,"supportAutoEdge":false,"maxMTU":9720,"supportPOE":true,"supportCableTest":false,"poeValues":["off","24v","48v"],"media":"GE","speedValues":["auto","10-half","10-full","100-half","100-full"]},{"id":"0\/3","type":"port","supportBlock":true,"supportDelete":false,"supportReset":true,"configurable":true,"supportDHCPSnooping":false,"supportIsolate":false,"supportAutoEdge":false,"maxMTU":9720,"supportPOE":true,"supportCableTest":false,"poeValues":["off","24v","48v"],"media":"GE","speedValues":["auto","10-half","10-full","100-half","100-full"]},{"id":"0\/4","type":"port","supportBlock":true,"supportDelete":false,"supportReset":true,"configurable":true,"supportDHCPSnooping":false,"supportIsolate":false,"supportAutoEdge":false,"maxMTU":9720,"supportPOE":true,"supportCableTest":false,"poeValues":["off","24v","48v"],"media":"GE","speedValues":["auto","10-half","10-full","100-half","100-full"]},{"id":"0\/5","type":"port","supportBlock":true,"supportDelete":false,"supportReset":true,"configurable":true,"supportDHCPSnooping":false,"supportIsolate":false,"supportAutoEdge":false,"maxMTU":9720,"supportPOE":true,"supportCableTest":false,"poeValues":["o...
```
## System
**GET /api/v1.0/system**
### Response
```json
{"hostname":"Switch-01","timezone":"UTC","domainName":"","factoryDefault":false,"stp":{"enabled":false,"version":"RSTP","maxAge":6,"helloTime":2,"forwardDelay":4,"priority":32768},"analyticsEnabled":false,"dnsServers":[{"type":"static","version":"v4","address":"192.168.1.1"},{"type":"static","version":"v4","address":"8.8.8.8"}],"defaultGateway":[{"type":"static","version":"v4","address":"192.168.1.1"}],"users":[{"username":"admin","readOnly":false,"sshKeys":[]}],"management":{"vlanID":1,"managementPortOnly":false,"addresses":[{"type":"static","version":"v4","cidr":"192.168.1.10\/24","eui64":false}]}}
```
## VLANs
**GET /api/v1.0/vlans**
### Response
```json
{"trunks":[{"interface":{"id":"0\/8","name":"Uplink","mac":"00:11:22:33:44:55","type":"port"}}],"vlans":[{"name":"Management","type":"single","id":1,"participation":[{"interface":{"id":"0\/1","name":"Switch-Main","mac":"00:11:22:33:44:55","type":"port"},"mode":"untagged"},{"interface":{"id":"0\/2","name":"Access Point","mac":"00:11:22:33:44:55","type":"port"},"mode":"untagged"},{"interface":{"id":"0\/7","name":"Switch-Secondary","mac":"00:11:22:33:44:55","type":"port"},"mode":"untagged"},{"interface":{"id":"0\/8","name":"Uplink","mac":"00:11:22:33:44:55","type":"port"},"mode":"untagged"}]},{"name":"Security","type":"single","id":250,"participation":[{"interface":{"id":"0\/2","name":"Access Point","mac":"00:11:22:33:44:55","type":"port"},"mode":"tagged"},{"interface":{"id":"0\/3","name":"Camera-Front","mac":"00:11:22:33:44:55","type":"port"},"mode":"untagged"},{"interface":{"id":"0\/4","name":"Camera-Back","mac":"00:11:22:33:44:55","type":"port"},"mode":"untagged"},{"interface":{"id":"0\/5","name":"WISP AP","mac":"00:11:22:33:44:55","type":"port"},"mode":"untagged"},{"interface":{"id":"0\/6","name":"Shop Cam","mac":"00:11:22:33:44:55","type":"port"},"mode":"untagged"},{"interface":{"id":"0\/7","name":"Switch-Secondary","mac":"00:11:22:33:44:55","type":"port"},"mode":"tagged"},{"interface":{"id":"0\/8","name":"Uplink","mac":"00:11:22:33:44:55","type":"port"},"mode":"tagged"}]},{"name":"WISP","type":"single","id":500,"participation":[{"interface":{"id":"0\/7","name":"Switch-Secondary","mac":"00:11:22:33:44:55","type":"port"},"mode":"tagged"},{"interface":{"id":"0\/8","name":"Uplink","mac":"00:11:22:33:44:55","type":"port"},"mode":"tagged"}]},{"name":"Guest","type":"single","id":750,"participation":[{"interface":{"id":"0\/2","name":"Access Point","mac":"00:11:22:33:44:55","type":"port"},"mode":"tagged"},{"interface":{"id":"0\/7","name":"Switch-Secondary","mac":"00:11:22:33:44:55","type":"port"},"mode":"tagged"},{"interface":{"id":"0\/8","name":"Uplink",...
```
## Services
**GET /api/v1.0/services**
### Response
```json
{"discoveryResponder":{"enabled":true},"sshServer":{"enabled":true,"sshPort":22,"passwordAuthentication":true},"telnetServer":{"enabled":false,"port":23},"webServer":{"enabled":true,"httpPort":80,"httpsPort":443},"systemLog":{"enabled":true,"port":514,"server":"192.168.1.50","level":"info"},"ntpClient":{"enabled":true,"ntpServers":["192.168.1.1","pool.ntp.org"]},"unms":{"enabled":true,"key":"wss:\/\/unms.example.com:443+xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx+allowUntrustedCertificate","status":"connecting"},"lldp":{"enabled":true},"snmpAgent":{"enabled":true,"community":"public","contact":"Admin","location":"Server Room"},"ddns":{"enabled":false,"clients":[{"hostname":"","service":"dyndns_org","username":"","password":""}]}}
```
## Statistics
**GET /api/v1.0/statistics**
### Response
```json
[{"timestamp":1767542414936,"device":{"cpu":[{"identifier":"MIPS 24Kc V7.4","usage":29}],"ram":{"usage":16,"free":53293056,"total":63479808},"temperatures":[],"storage":[{"name":"\/ (rootfs)","type":"other","sysName":"rootfs","used":4718592,"size":4718592},{"name":"\/ (squashfs)","type":"other","sysName":"\/dev\/root","used":4718592,"size":4718592},{"name":"\/var (tmpfs)","type":"other","sysName":"tmpfs","used":868352,"size":9437184},{"name":"\/dev (tmpfs)","type":"other","sysName":"dev","used":0,"size":31739904}],"uptime":1789723},"interfaces":[{"id":"0\/1","name":"Switch-Main","statistics":{"dropped":0,"txDropped":0,"rxDropped":0,"errors":0,"txErrors":0,"rxErrors":0,"rate":2198,"txRate":2198,"rxRate":0,"bytes":0,"txBytes":1422812962,"rxBytes":28554596,"packets":0,"txPackets":12985860,"rxPackets":340667,"pps":3,"txPPS":3,"rxPPS":0,"txJumbo":0,"rxJumbo":0,"rxFlowCtrl":0,"txBroadcast":8282188,"rxBroadcast":97324,"txMulticast":4339420,"rxMulticast":18}},{"id":"0\/2","name":"Access Point","statistics":{"dropped":0,"txDropped":0,"rxDropped":0,"errors":0,"txErrors":0,"rxErrors":0,"rate":248172,"txRate":34387,"rxRate":213785,"bytes":0,"txBytes":14413696328,"rxBytes":2347117438,"packets":0,"txPackets":28331501,"rxPackets":10615209,"pps":57,"txPPS":25,"rxPPS":32,"txJumbo":0,"rxJumbo":0,"rxFlowCtrl":0,"txBroadcast":9467127,"rxBroadcast":710595,"txMulticast":4590381,"rxMulticast":115771}},{"id":"0\/3","name":"Camera-Front","statistics":{"dropped":0,"txDropped":0,"rxDropped":0,"errors":0,"txErrors":0,"rxErrors":0,"rate":1063473,"txRate":37551,"rxRate":1025922,"bytes":0,"txBytes":8764077315,"rxBytes":414045802743,"packets":0,"txPackets":124114257,"rxPackets":319678738,"pps":176,"txPPS":66,"rxPPS":110,"txJumbo":0,"rxJumbo":0,"rxFlowCtrl":0,"txBroadcast":1742749,"rxBroadcast":36036,"txMulticast":246225,"rxMulticast":59498}},{"id":"0\/4","name":"Camera-Back","statistics":{"dropped":0,"txDropped":0,"rxDropped":0,"errors":0,"txErrors":0,"rxErrors":0,"rate":3877962,"txRate":94828,"rxRate":3783134,...
```
{"statusCode":404,"error":6,"detail":"Entity '\/neighbors' is not supported","message":"Request is not supported"}
## Neighbors
**GET /api/v1.0/tools/discovery/neighbors**
### Response
```json
[{"mac":"00:11:22:33:44:55","age":10,"protocol":"UBNT","fw":"SW.ar7240.v2.2.1.165.240717.1112","model":"TSW-PoE PRO","product":"EdgeSwitch 8XP","hostname":"Switch-01","uptime":1789897,"configured":true,"ip":"fe80::822a:a8ff:fedf:97ca","zoneID":"eth0.4086","addresses":[{"mac":"00:11:22:33:44:55","ip":"192.168.1.10"}]}]
```

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