frame out cli tool
This commit is contained in:
65
CLAUDE.md
Normal file
65
CLAUDE.md
Normal 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.
|
||||||
21
cmd/LICENSE
Normal file
21
cmd/LICENSE
Normal 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
21
cmd/cmd/get.go
Normal 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
102
cmd/cmd/get_device.go
Normal 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
123
cmd/cmd/get_devices.go
Normal 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
86
cmd/cmd/prerun.go
Normal 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
68
cmd/cmd/root.go
Normal 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
|
||||||
|
}
|
||||||
80
cmd/internal/config/config.go
Normal file
80
cmd/internal/config/config.go
Normal 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" envDefault:"warn"`
|
||||||
|
LogFormat string `json:"logFormat" yaml:"logFormat" env:"LOG_FORMAT" envDefault:"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" envDefault:"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" envDefault:"false"`
|
||||||
|
Timeout time.Duration `json:"timeout" yaml:"timeout" env:"TIMEOUT" envDefault:"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
|
||||||
|
}
|
||||||
12
cmd/internal/util/constants.go
Normal file
12
cmd/internal/util/constants.go
Normal 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"
|
||||||
|
)
|
||||||
43
cmd/internal/util/context.go
Normal file
43
cmd/internal/util/context.go
Normal 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
101
cmd/internal/util/output.go
Normal 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
28
cmd/main.go
Normal 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()
|
||||||
|
}
|
||||||
16
go.mod
16
go.mod
@@ -1,3 +1,19 @@
|
|||||||
module gitea.libretechconsulting.com/rmcguire/ubiquiti-clients
|
module gitea.libretechconsulting.com/rmcguire/ubiquiti-clients
|
||||||
|
|
||||||
go 1.25.5
|
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
34
go.sum
Normal 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=
|
||||||
Reference in New Issue
Block a user