Files
gluetun/internal/server/middlewares/auth/settings.go
T
Quentin McGaw 4a78989d9d chore: do not use sentinel errors when unneeded
- main reason being it's a burden to always define sentinel errors at global scope, wrap them with `%w` instead of using a string directly
- only use sentinel errors when it has to be checked using `errors.Is`
- replace all usage of these sentinel errors in `fmt.Errorf` with direct strings that were in the sentinel error
- exclude the sentinel error definition requirement from .golangci.yml
- update unit tests to use ContainersError instead of ErrorIs so it stays as a "not a change detector test" without requiring a sentinel error
2026-05-02 03:29:46 +00:00

183 lines
5.3 KiB
Go

package auth
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"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)
}
maxRoutes := countValidRoutes()
authenticatedRoutes := make(map[string]struct{}, maxRoutes)
for _, role := range s.Roles {
for _, route := range role.Routes {
authenticatedRoutes[route] = struct{}{}
}
}
if len(authenticatedRoutes) == maxRoutes {
return nil
}
var unauthenticatedRoutes []string
for urlPath, methods := range validRoutes {
for _, method := range methods {
route := method + " " + urlPath
_, 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:"-"`
}
func (r Role) Validate() (err error) {
err = validate.IsOneOf(r.Auth, AuthNone, AuthAPIKey, AuthBasic)
if err != nil {
return fmt.Errorf("authentication method not supported: %s", r.Auth)
}
switch {
case r.Auth == AuthAPIKey && r.APIKey == "":
return fmt.Errorf("for role %s: api key is empty", r.Name)
case r.Auth == AuthBasic && r.Username == "":
return fmt.Errorf("for role %s: username is empty", r.Name)
case r.Auth == AuthBasic && r.Password == "":
return fmt.Errorf("for role %s: password is empty", r.Name)
}
for i, route := range r.Routes {
const maxRouteFields = 2
parts := strings.SplitN(route, " ", maxRouteFields)
method, path := parts[0], parts[1]
methods, ok := validRoutes[path]
if !ok {
return fmt.Errorf("route %d of %d: route path not supported by the control server: %s",
i+1, len(r.Routes), path)
} else if !slices.Contains(methods, method) {
return fmt.Errorf("route %d of %d: route method not supported for the path: %s for path %s",
i+1, len(r.Routes), method, path)
}
}
return nil
}
// validRoutes maps URL paths to allowed HTTP methods.
// WARNING: do not mutate programmatically.
var validRoutes = map[string][]string{ //nolint:gochecknoglobals
"/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.MethodGet, http.MethodPut},
"/v1/vpn/settings": {http.MethodGet, http.MethodPut},
"/v1/openvpn/status": {http.MethodGet, http.MethodPut},
"/v1/openvpn/portforwarded": {http.MethodGet},
"/v1/openvpn/settings": {http.MethodGet},
"/v1/dns/status": {http.MethodGet, http.MethodPut},
"/v1/updater/status": {http.MethodGet, http.MethodPut},
"/v1/publicip/ip": {http.MethodGet},
"/v1/portforward": {http.MethodGet, http.MethodPut},
}
func countValidRoutes() (count int) {
for _, methods := range validRoutes {
count += len(methods)
}
return count
}
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
}