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) SetDefaults() { s.Roles = gosettings.DefaultSlice(s.Roles, []Role{{ // TODO v3.41.0 leave empty Name: "public", Auth: "none", Routes: []string{ http.MethodGet + " /openvpn/actions/restart", http.MethodGet + " /unbound/actions/restart", http.MethodGet + " /openvpn/portforwarded", http.MethodGet + " /updater/restart", http.MethodGet + " /v1/version", http.MethodGet + " /v1/vpn/status", http.MethodPut + " /v1/vpn/status", http.MethodGet + " /v1/openvpn/status", http.MethodPut + " /v1/openvpn/status", http.MethodGet + " /v1/openvpn/portforwarded", 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 (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 }