Files
gluetun/internal/server/middlewares/auth/settings.go
2025-11-14 21:30:42 +00:00

177 lines
5.3 KiB
Go

package auth
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/validate"
"github.com/qdm12/gotree"
)
type Settings struct {
// Roles is a list of roles with their associated authentication
// and routes.
Roles []Role
}
// SetDefaultRole sets a default role to apply to all routes without a
// previously user-defined role assigned to. Note the role argument
// routes are ignored. This should be called BEFORE calling [Settings.SetDefaults].
func (s *Settings) SetDefaultRole(jsonRole string) error {
var role Role
decoder := json.NewDecoder(bytes.NewBufferString(jsonRole))
decoder.DisallowUnknownFields()
err := decoder.Decode(&role)
if err != nil {
return fmt.Errorf("decoding default role: %w", err)
}
if role.Auth == "" {
return nil // no default role to set
}
err = role.Validate()
if err != nil {
return fmt.Errorf("validating default role: %w", err)
}
authenticatedRoutes := make(map[string]struct{}, len(validRoutes))
for _, role := range s.Roles {
for _, route := range role.Routes {
authenticatedRoutes[route] = struct{}{}
}
}
if len(authenticatedRoutes) == len(validRoutes) {
return nil
}
unauthenticatedRoutes := make([]string, 0, len(validRoutes))
for route := range validRoutes {
_, authenticated := authenticatedRoutes[route]
if !authenticated {
unauthenticatedRoutes = append(unauthenticatedRoutes, route)
}
}
slices.Sort(unauthenticatedRoutes)
role.Routes = unauthenticatedRoutes
s.Roles = append(s.Roles, role)
return nil
}
func (s Settings) Validate() (err error) {
for i, role := range s.Roles {
err = role.Validate()
if err != nil {
return fmt.Errorf("role %s (%d of %d): %w",
role.Name, i+1, len(s.Roles), err)
}
}
return nil
}
const (
AuthNone = "none"
AuthAPIKey = "apikey"
AuthBasic = "basic"
)
// Role contains the role name, authentication method name and
// routes that the role can access.
type Role struct {
// Name is the role name and is only used for documentation
// and in the authentication middleware debug logs.
Name string `json:"name"`
// Auth is the authentication method to use, which can be 'none', 'basic' or 'apikey'.
Auth string `json:"auth"`
// APIKey is the API key to use when using the 'apikey' authentication.
APIKey string `json:"apikey"`
// Username for HTTP Basic authentication method.
Username string `json:"username"`
// Password for HTTP Basic authentication method.
Password string `json:"password"`
// Routes is a list of routes that the role can access in the format
// "HTTP_METHOD PATH", for example "GET /v1/vpn/status"
Routes []string `json:"-"`
}
var (
ErrMethodNotSupported = errors.New("authentication method not supported")
ErrAPIKeyEmpty = errors.New("api key is empty")
ErrBasicUsernameEmpty = errors.New("username is empty")
ErrBasicPasswordEmpty = errors.New("password is empty")
ErrRouteNotSupported = errors.New("route not supported by the control server")
)
func (r Role) Validate() (err error) {
err = validate.IsOneOf(r.Auth, AuthNone, AuthAPIKey, AuthBasic)
if err != nil {
return fmt.Errorf("%w: %s", ErrMethodNotSupported, r.Auth)
}
switch {
case r.Auth == AuthAPIKey && r.APIKey == "":
return fmt.Errorf("for role %s: %w", r.Name, ErrAPIKeyEmpty)
case r.Auth == AuthBasic && r.Username == "":
return fmt.Errorf("for role %s: %w", r.Name, ErrBasicUsernameEmpty)
case r.Auth == AuthBasic && r.Password == "":
return fmt.Errorf("for role %s: %w", r.Name, ErrBasicPasswordEmpty)
}
for i, route := range r.Routes {
_, ok := validRoutes[route]
if !ok {
return fmt.Errorf("route %d of %d: %w: %s",
i+1, len(r.Routes), ErrRouteNotSupported, route)
}
}
return nil
}
// WARNING: do not mutate programmatically.
var validRoutes = map[string]struct{}{ //nolint:gochecknoglobals
http.MethodGet + " /openvpn/actions/restart": {},
http.MethodGet + " /openvpn/portforwarded": {},
http.MethodGet + " /openvpn/settings": {},
http.MethodGet + " /unbound/actions/restart": {},
http.MethodGet + " /updater/restart": {},
http.MethodGet + " /v1/version": {},
http.MethodGet + " /v1/vpn/status": {},
http.MethodPut + " /v1/vpn/status": {},
http.MethodGet + " /v1/vpn/settings": {},
http.MethodPut + " /v1/vpn/settings": {},
http.MethodGet + " /v1/openvpn/status": {},
http.MethodPut + " /v1/openvpn/status": {},
http.MethodGet + " /v1/openvpn/portforwarded": {},
http.MethodGet + " /v1/openvpn/settings": {},
http.MethodGet + " /v1/dns/status": {},
http.MethodPut + " /v1/dns/status": {},
http.MethodGet + " /v1/updater/status": {},
http.MethodPut + " /v1/updater/status": {},
http.MethodGet + " /v1/publicip/ip": {},
http.MethodGet + " /v1/portforward": {},
}
func (r Role) ToLinesNode() (node *gotree.Node) {
node = gotree.New("Role " + r.Name)
node.Appendf("Authentication method: %s", r.Auth)
switch r.Auth {
case AuthNone:
case AuthBasic:
node.Appendf("Username: %s", r.Username)
node.Appendf("Password: %s", gosettings.ObfuscateKey(r.Password))
case AuthAPIKey:
node.Appendf("API key: %s", gosettings.ObfuscateKey(r.APIKey))
default:
panic("missing code for authentication method: " + r.Auth)
}
node.Appendf("Number of routes covered: %d", len(r.Routes))
return node
}