299 lines
10 KiB
Go
299 lines
10 KiB
Go
package unifi
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/net/publicsuffix"
|
|
)
|
|
|
|
// validationMode represents the mode for request validation.
|
|
// It may be set to "soft", "hard", or "disable". The default is "soft".
|
|
type validationMode string
|
|
|
|
const (
|
|
// SoftValidation indicates that validation errors are logged as warnings but do not prevent the request from proceeding.
|
|
SoftValidation validationMode = "soft"
|
|
// HardValidation indicates that validation errors are treated as fatal and will cause the request to be rejected.
|
|
HardValidation validationMode = "hard"
|
|
// DisableValidation indicates that no validation is performed on the request body.
|
|
DisableValidation validationMode = "disable"
|
|
// DefaultValidation is the default validation mode used if none is specified.
|
|
// Currently set to SoftValidation, but may change to HardValidation in a future major version.
|
|
DefaultValidation validationMode = SoftValidation // TODO: change to hard in next major version
|
|
)
|
|
|
|
// HttpCustomizer is a function type for customizing the HTTP transport.
|
|
// It receives a pointer to an http.Transport and returns an error if customization fails.
|
|
type HttpCustomizer func(transport *http.Transport) error
|
|
|
|
// ResponseErrorHandler defines a method for handling HTTP response errors.
|
|
// HandleError processes the HTTP response and returns an error if the response indicates failure.
|
|
type ResponseErrorHandler interface {
|
|
// HandleError processes the HTTP response and returns an error if the response signals a failure.
|
|
HandleError(resp *http.Response) error
|
|
}
|
|
|
|
/*
|
|
ClientConfig holds configuration parameters for creating a UniFi Client.
|
|
|
|
Fields:
|
|
|
|
URL: The base URL of the UniFi controller. Must be a valid URL and should not include the `/api` suffix.
|
|
APIKey: An API key used for authentication. Provide this if user/password credentials are not used.
|
|
User: The username for user/password authentication. Must be provided with Pass if APIKey is not used.
|
|
Pass: The password for user/password authentication. Must be provided with User if APIKey is not used.
|
|
Timeout: The maximum duration to wait for responses; default is no timeout.
|
|
VerifySSL: When false, disables SSL certificate verification.
|
|
Interceptors: A slice of ClientInterceptor implementations that can modify requests and responses.
|
|
HttpCustomizer:An optional function to customize the HTTP transport (e.g., for custom TLS settings).
|
|
UserAgent: The User-Agent header string for outgoing HTTP requests.
|
|
ErrorHandler: A custom handler for processing HTTP response errors.
|
|
UseLocking: If true, enables internal locking for concurrent request processing.
|
|
ValidationMode:The mode for validating request bodies. Can be "soft", "hard", or "disable".
|
|
*/
|
|
type ClientConfig struct {
|
|
URL string `validate:"required,http_url"`
|
|
APIKey string `validate:"required_without_all=User Pass"`
|
|
User string `validate:"excluded_with=APIKey,required_with=Pass"`
|
|
Pass string `validate:"excluded_with=APIKey,required_with=User"`
|
|
Timeout time.Duration // How long to wait for replies, default: forever.
|
|
VerifySSL bool
|
|
Interceptors []ClientInterceptor
|
|
HttpCustomizer HttpCustomizer
|
|
UserAgent string
|
|
ErrorHandler ResponseErrorHandler
|
|
UseLocking bool
|
|
ValidationMode validationMode `validate:"omitempty,oneof=soft hard disable"`
|
|
}
|
|
|
|
// Credentials abstracts authentication credentials.
|
|
// It defines methods to determine the type of credentials and retrieve the associated values.
|
|
type Credentials interface {
|
|
// IsAPIKey returns true if the credentials represent an API key.
|
|
IsAPIKey() bool
|
|
// GetAPIKey returns the API key; returns an empty string if not applicable.
|
|
GetAPIKey() string
|
|
// GetUser returns the username for authentication; returns an empty string if not applicable.
|
|
GetUser() string
|
|
// GetPass returns the password for authentication; returns an empty string if not applicable.
|
|
GetPass() string
|
|
}
|
|
|
|
// APIKeyCredentials holds API key authentication details.
|
|
type APIKeyCredentials struct {
|
|
APIKey string
|
|
}
|
|
|
|
func (a APIKeyCredentials) IsAPIKey() bool { return true }
|
|
func (a APIKeyCredentials) GetAPIKey() string { return a.APIKey }
|
|
func (a APIKeyCredentials) GetUser() string { return "" }
|
|
func (a APIKeyCredentials) GetPass() string { return "" }
|
|
|
|
// UserPassCredentials holds user/password authentication.
|
|
type UserPassCredentials struct {
|
|
User string
|
|
Pass string
|
|
}
|
|
|
|
func (u UserPassCredentials) IsAPIKey() bool { return false }
|
|
func (u UserPassCredentials) GetAPIKey() string { return "" }
|
|
func (u UserPassCredentials) GetUser() string { return u.User }
|
|
func (u UserPassCredentials) GetPass() string { return u.Pass }
|
|
|
|
// Client represents a UniFi client.
|
|
type Client struct {
|
|
BaseURL *url.URL
|
|
SysInfo *SysInfo
|
|
apiPaths *APIPaths
|
|
timeout time.Duration
|
|
credentials Credentials
|
|
validationMode validationMode
|
|
useLocking bool
|
|
|
|
http *http.Client
|
|
interceptors []ClientInterceptor
|
|
errorHandler ResponseErrorHandler
|
|
lock sync.Mutex
|
|
validator *validator
|
|
}
|
|
|
|
// AddInterceptor adds a ClientInterceptor to the client's interceptor list if it is not already present.
|
|
// It appends the interceptor only if it is not already included in the list.
|
|
func (c *Client) AddInterceptor(interceptor *ClientInterceptor) {
|
|
if !slices.Contains(c.interceptors, *interceptor) {
|
|
c.interceptors = append(c.interceptors, *interceptor)
|
|
}
|
|
}
|
|
|
|
func parseBaseURL(base string) (*url.URL, error) {
|
|
baseURL, err := url.Parse(base)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Check if base URL's path is "/api" (deprecated usage now in api_paths.go)
|
|
if strings.TrimSuffix(baseURL.Path, "/") == "/api" {
|
|
return nil, fmt.Errorf("expected a base URL without the `/api`, got: %q", baseURL)
|
|
}
|
|
return baseURL, nil
|
|
}
|
|
|
|
func newClientFromConfig(config *ClientConfig, v *validator) (*Client, error) {
|
|
var err error
|
|
config.URL = strings.TrimRight(config.URL, "/")
|
|
transport := &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: !config.VerifySSL},
|
|
}
|
|
if config.HttpCustomizer != nil {
|
|
if err = config.HttpCustomizer(transport); err != nil {
|
|
return nil, fmt.Errorf("failed customizing HTTP transport: %w", err)
|
|
}
|
|
}
|
|
client := &http.Client{
|
|
Timeout: config.Timeout,
|
|
Transport: transport,
|
|
}
|
|
if config.APIKey == "" {
|
|
jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed creating cookiejar: %w", err)
|
|
}
|
|
client.Jar = jar
|
|
}
|
|
baseURL, err := parseBaseURL(config.URL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed parsing base URL: %w", err)
|
|
}
|
|
var interceptors []ClientInterceptor
|
|
var credentials Credentials
|
|
|
|
if config.APIKey != "" {
|
|
credentials = APIKeyCredentials{APIKey: config.APIKey}
|
|
interceptors = append(interceptors, &APIKeyAuthInterceptor{apiKey: config.APIKey})
|
|
} else {
|
|
credentials = UserPassCredentials{User: config.User, Pass: config.Pass}
|
|
interceptors = append(interceptors, &CSRFInterceptor{})
|
|
}
|
|
if len(config.UserAgent) == 0 {
|
|
config.UserAgent = defaultUserAgent
|
|
}
|
|
interceptors = append(interceptors, &DefaultHeadersInterceptor{headers: map[string]string{
|
|
UserAgentHeader: config.UserAgent,
|
|
AcceptHeader: "application/json",
|
|
ContentTypeHeader: "application/json; charset=utf-8",
|
|
}})
|
|
var errorHandler ResponseErrorHandler
|
|
if config.ErrorHandler != nil {
|
|
errorHandler = config.ErrorHandler
|
|
} else {
|
|
errorHandler = &DefaultResponseErrorHandler{}
|
|
}
|
|
if config.ValidationMode == "" {
|
|
config.ValidationMode = DefaultValidation
|
|
}
|
|
u := &Client{
|
|
BaseURL: baseURL,
|
|
timeout: config.Timeout,
|
|
credentials: credentials,
|
|
validationMode: config.ValidationMode,
|
|
useLocking: config.UseLocking,
|
|
http: client,
|
|
interceptors: interceptors,
|
|
errorHandler: errorHandler,
|
|
lock: sync.Mutex{},
|
|
validator: v,
|
|
}
|
|
for _, interceptor := range config.Interceptors {
|
|
u.AddInterceptor(&interceptor)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// NewClient creates and initializes a new UniFi client based on the provided ClientConfig.
|
|
// It validates the configuration, determines the API style, performs login if necessary,
|
|
// and retrieves system information from the UniFi controller.
|
|
// On success, it returns a pointer to a Client; otherwise, it returns an error.
|
|
func NewClient(config *ClientConfig) (*Client, error) {
|
|
c, err := NewBareClient(config)
|
|
if err != nil {
|
|
return c, err
|
|
}
|
|
if err = c.Login(); err != nil {
|
|
return c, fmt.Errorf("failed logging in: %w", err)
|
|
}
|
|
if sysInfo, err := c.GetSystemInformation(); err != nil {
|
|
return c, fmt.Errorf("failed getting server info: %w", err)
|
|
} else {
|
|
c.SysInfo = sysInfo
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// NewBareClient creates a new UniFi client without performing login or system information retrieval.
|
|
// When user/pass authentication is used, you must call Login before making requests.
|
|
// It validates the configuration, determines the API style, and returns a pointer to the client on success.
|
|
func NewBareClient(config *ClientConfig) (*Client, error) {
|
|
v, err := newValidator()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed creating validator: %w", err)
|
|
}
|
|
if err = v.Validate(config); err != nil {
|
|
return nil, fmt.Errorf("failed validating client configuration: %w", err)
|
|
}
|
|
c, err := newClientFromConfig(config, v)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed creating unifi client: %w", err)
|
|
}
|
|
if err = c.determineApiStyle(); err != nil {
|
|
return c, fmt.Errorf("failed determining API style: %w", err)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// Login authenticates the client using user/pass credentials.
|
|
// For API key authentication, Login does nothing.
|
|
// It returns an error if the authentication process fails.
|
|
func (c *Client) Login() error {
|
|
if c.credentials.IsAPIKey() {
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := c.newRequestContext()
|
|
defer cancel()
|
|
|
|
err := c.Post(ctx, c.apiPaths.LoginPath, &struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}{
|
|
Username: c.credentials.GetUser(),
|
|
Password: c.credentials.GetPass(),
|
|
}, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Logout terminates the client's session for user/pass authentication.
|
|
// For API key authentication, Logout does nothing.
|
|
// It returns an error if the logout process fails.
|
|
func (c *Client) Logout() error {
|
|
if c.credentials.IsAPIKey() {
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := c.newRequestContext()
|
|
defer cancel()
|
|
|
|
err := c.Post(ctx, c.apiPaths.LogoutPath, nil, nil)
|
|
return err
|
|
}
|