mirror of
https://github.com/qdm12/gluetun.git
synced 2026-05-07 04:20:12 +02:00
3b9c9b24bd
- Fix #3275
193 lines
5.8 KiB
Go
193 lines
5.8 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"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:"-"`
|
|
}
|
|
|
|
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")
|
|
ErrRoutePathNotSupported = errors.New("route path not supported by the control server")
|
|
ErrRouteMethodNotSupported = errors.New("route method not supported for the path")
|
|
)
|
|
|
|
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 {
|
|
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: %w: %s",
|
|
i+1, len(r.Routes), ErrRoutePathNotSupported, path)
|
|
} else if !slices.Contains(methods, method) {
|
|
return fmt.Errorf("route %d of %d: %w: %s for path %s",
|
|
i+1, len(r.Routes), ErrRouteMethodNotSupported, 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
|
|
}
|