Files
go-unifi/unifi/client.go

357 lines
13 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 int
const (
// SoftValidation indicates that validation errors are logged as warnings but do not prevent the request from proceeding.
SoftValidation ValidationMode = iota
// HardValidation indicates that validation errors are treated as fatal and will cause the request to be rejected.
HardValidation
// DisableValidation indicates that no validation is performed on the request body.
DisableValidation
)
// HttpTransportCustomizer 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 HttpTransportCustomizer func(transport *http.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 Password if APIKey is not used.
Password: The password for user/password authentication. Must be provided with User if APIKey is not used.
RememberMe: If true, the session is remembered for future requests. Useful for long-running processes. Default: false. Only used for user/password authentication.
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.
HttpTransportCustomizer: An optional function to customize the HTTP transport (e.g., for custom TLS settings).
HttpRoundTripperProvider: A function that returns a http.RoundTripper for customizing the HTTP client. If both HttpTransportCustomizer and HttpRoundTripperProvider are provided, HttpRoundTripperProvider takes precedence.
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 Password"`
User string `validate:"excluded_with=APIKey,required_with=Password"`
Password string `validate:"excluded_with=APIKey,required_with=User"`
RememberMe bool `validate:"excluded_with=APIKey"`
Timeout time.Duration // How long to wait for replies, default: forever.
VerifySSL bool
Interceptors []ClientInterceptor
HttpTransportCustomizer HttpTransportCustomizer
HttpRoundTripperProvider func() http.RoundTripper
UserAgent string
ErrorHandler ResponseErrorHandler
UseLocking bool
ValidationMode ValidationMode
Logger Logger
}
// 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
IsRememberMe() bool
}
// 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 "" }
func (a APIKeyCredentials) IsRememberMe() bool { return false }
// UserPassCredentials holds user/password authentication.
type UserPassCredentials struct {
User string
Password string
Remember bool
}
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.Password }
func (u UserPassCredentials) IsRememberMe() bool { return u.Remember }
// client represents a UniFi client.
type client struct {
Logger
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
}
var _ Client = &client{} // Ensure that client implements the Client interface. (compile-time check)
func (c *client) BaseURL() string {
return c.baseURL.String()
}
// 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 (c *client) Version() string {
if c.sysInfo != nil {
return c.sysInfo.Version
}
c.lock.Lock()
defer c.lock.Unlock()
i, err := c.GetSystemInformation()
if err != nil {
return ""
}
c.sysInfo = i
return c.sysInfo.Version
}
func newClientFromConfig(config *ClientConfig, v *validator) (*client, error) {
var log Logger
if config.Logger != nil {
log = config.Logger
} else {
log = NewDefaultLogger(InfoLevel)
}
log.Info("Initializing new UniFi client")
var rt http.RoundTripper
var err error
config.URL = strings.TrimRight(config.URL, "/")
log.Debugf("Connecting to UniFi controller at %s", config.URL)
if config.HttpRoundTripperProvider != nil {
log.Debug("Using custom HTTP round tripper provider")
rt = config.HttpRoundTripperProvider()
}
if rt == nil {
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{InsecureSkipVerify: !config.VerifySSL},
}
if config.HttpTransportCustomizer != nil {
log.Debug("Customizing HTTP transport")
if transport, err = config.HttpTransportCustomizer(transport); err != nil {
return nil, fmt.Errorf("failed customizing HTTP transport: %w", err)
}
}
rt = transport
}
httpClient := &http.Client{
Timeout: config.Timeout,
Transport: rt,
}
if config.APIKey == "" {
jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
if err != nil {
return nil, fmt.Errorf("failed creating cookiejar: %w", err)
}
httpClient.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 != "" {
log.Debug("Using API key authentication")
credentials = APIKeyCredentials{APIKey: config.APIKey}
interceptors = append(interceptors, &APIKeyAuthInterceptor{apiKey: config.APIKey})
} else {
log.Debug("Using user/pass authentication")
credentials = UserPassCredentials{User: config.User, Password: config.Password, Remember: config.RememberMe}
interceptors = append(interceptors, &CSRFInterceptor{})
}
if len(config.UserAgent) == 0 {
config.UserAgent = defaultUserAgent
} else {
log.Debugf("Using custom User-Agent header: %s", config.UserAgent)
}
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 {
log.Debug("Using custom response error handler")
errorHandler = config.ErrorHandler
} else {
log.Debug("Using default response error handler")
errorHandler = &DefaultResponseErrorHandler{}
}
log.Tracef("Validation mode: %d", config.ValidationMode)
u := &client{
baseURL: baseURL,
timeout: config.Timeout,
credentials: credentials,
validationMode: config.ValidationMode,
useLocking: config.UseLocking,
http: httpClient,
interceptors: interceptors,
errorHandler: errorHandler,
lock: sync.Mutex{},
validator: v,
Logger: log,
}
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) { //nolint: ireturn
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
c.Debugf("Connected to UniFi controller\nversion: %s; name: %s; build: %s; hostname: %s", sysInfo.Version, sysInfo.Name, sysInfo.Build, sysInfo.Hostname)
}
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) { //nolint: ireturn
return newBareClient(config)
}
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() {
c.Trace("API key authentication; skipping login")
return nil
}
c.Trace("Logging in with user/pass credentials")
ctx, cancel := c.newRequestContext()
defer cancel()
err := c.Post(ctx, c.apiPaths.LoginPath, &struct {
Username string `json:"username"`
Password string `json:"password"`
Remember bool `json:"remember"`
}{
Username: c.credentials.GetUser(),
Password: c.credentials.GetPass(),
Remember: c.credentials.IsRememberMe(),
}, 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
}