diff --git a/Dockerfile b/Dockerfile index 94d8f7bf..c7db09b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -201,6 +201,7 @@ ENV VPN_SERVICE_PROVIDER=pia \ HTTP_CONTROL_SERVER_LOG=on \ HTTP_CONTROL_SERVER_ADDRESS=":8000" \ HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH=/gluetun/auth/config.toml \ + HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE="{}" \ # Server data updater UPDATER_PERIOD=0 \ UPDATER_MIN_RATIO=0.8 \ diff --git a/cmd/gluetun/main.go b/cmd/gluetun/main.go index 79061182..b14b0200 100644 --- a/cmd/gluetun/main.go +++ b/cmd/gluetun/main.go @@ -466,13 +466,10 @@ func _main(ctx context.Context, buildInfo models.BuildInformation, go shadowsocksLooper.Run(shadowsocksCtx, shadowsocksDone) otherGroupHandler.Add(shadowsocksHandler) - controlServerAddress := *allSettings.ControlServer.Address - controlServerLogging := *allSettings.ControlServer.Log httpServerHandler, httpServerCtx, httpServerDone := goshutdown.NewGoRoutineHandler( "http server", goroutine.OptionTimeout(defaultShutdownTimeout)) - httpServer, err := server.New(httpServerCtx, controlServerAddress, controlServerLogging, + httpServer, err := server.New(httpServerCtx, allSettings.ControlServer, logger.New(log.SetComponent("http server")), - allSettings.ControlServer.AuthFilePath, buildInfo, vpnLooper, portForwardLooper, dnsLooper, updaterLooper, publicIPLooper, storage, ipv6Supported) if err != nil { diff --git a/internal/configuration/settings/server.go b/internal/configuration/settings/server.go index 155d5cdb..25577035 100644 --- a/internal/configuration/settings/server.go +++ b/internal/configuration/settings/server.go @@ -1,11 +1,14 @@ package settings import ( + "bytes" + "encoding/json" "fmt" "net" "os" "strconv" + "github.com/qdm12/gluetun/internal/server/middlewares/auth" "github.com/qdm12/gosettings" "github.com/qdm12/gosettings/reader" "github.com/qdm12/gotree" @@ -24,6 +27,9 @@ type ControlServer struct { // It cannot be empty in the internal state and defaults to // /gluetun/auth/config.toml. AuthFilePath string + // AuthDefaultRole is a JSON encoded object defining the default role + // that applies to all routes without a previously user-defined role assigned to. + AuthDefaultRole string } func (c ControlServer) validate() (err error) { @@ -44,14 +50,30 @@ func (c ControlServer) validate() (err error) { ErrControlServerPrivilegedPort, port, uid) } + jsonDecoder := json.NewDecoder(bytes.NewBufferString(c.AuthDefaultRole)) + jsonDecoder.DisallowUnknownFields() + var role auth.Role + err = jsonDecoder.Decode(&role) + if err != nil { + return fmt.Errorf("default authentication role is not valid JSON: %w", err) + } + + if role.Auth != "" { + err = role.Validate() + if err != nil { + return fmt.Errorf("default authentication role is not valid: %w", err) + } + } + return nil } func (c *ControlServer) copy() (copied ControlServer) { return ControlServer{ - Address: gosettings.CopyPointer(c.Address), - Log: gosettings.CopyPointer(c.Log), - AuthFilePath: c.AuthFilePath, + Address: gosettings.CopyPointer(c.Address), + Log: gosettings.CopyPointer(c.Log), + AuthFilePath: c.AuthFilePath, + AuthDefaultRole: c.AuthDefaultRole, } } @@ -62,12 +84,21 @@ func (c *ControlServer) overrideWith(other ControlServer) { c.Address = gosettings.OverrideWithPointer(c.Address, other.Address) c.Log = gosettings.OverrideWithPointer(c.Log, other.Log) c.AuthFilePath = gosettings.OverrideWithComparable(c.AuthFilePath, other.AuthFilePath) + c.AuthDefaultRole = gosettings.OverrideWithComparable(c.AuthDefaultRole, other.AuthDefaultRole) } func (c *ControlServer) setDefaults() { c.Address = gosettings.DefaultPointer(c.Address, ":8000") c.Log = gosettings.DefaultPointer(c.Log, true) c.AuthFilePath = gosettings.DefaultComparable(c.AuthFilePath, "/gluetun/auth/config.toml") + c.AuthDefaultRole = gosettings.DefaultComparable(c.AuthDefaultRole, "{}") + if c.AuthDefaultRole != "{}" { + var role auth.Role + _ = json.Unmarshal([]byte(c.AuthDefaultRole), &role) + role.Name = "default" + roleBytes, _ := json.Marshal(role) //nolint:errchkjson + c.AuthDefaultRole = string(roleBytes) + } } func (c ControlServer) String() string { @@ -79,6 +110,11 @@ func (c ControlServer) toLinesNode() (node *gotree.Node) { node.Appendf("Listening address: %s", *c.Address) node.Appendf("Logging: %s", gosettings.BoolToYesNo(c.Log)) node.Appendf("Authentication file path: %s", c.AuthFilePath) + if c.AuthDefaultRole != "{}" { + var role auth.Role + _ = json.Unmarshal([]byte(c.AuthDefaultRole), &role) + node.AppendNode(role.ToLinesNode()) + } return node } @@ -91,6 +127,7 @@ func (c *ControlServer) read(r *reader.Reader) (err error) { c.Address = r.Get("HTTP_CONTROL_SERVER_ADDRESS") c.AuthFilePath = r.String("HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH") + c.AuthDefaultRole = r.String("HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE") return nil } diff --git a/internal/server/middlewares/auth/settings.go b/internal/server/middlewares/auth/settings.go index b9630b2e..5e08385b 100644 --- a/internal/server/middlewares/auth/settings.go +++ b/internal/server/middlewares/auth/settings.go @@ -1,12 +1,16 @@ 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 { @@ -15,6 +19,50 @@ type Settings struct { 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", @@ -42,7 +90,7 @@ func (s *Settings) SetDefaults() { func (s Settings) Validate() (err error) { for i, role := range s.Roles { - err = role.validate() + err = role.Validate() if err != nil { return fmt.Errorf("role %s (%d of %d): %w", role.Name, i+1, len(s.Roles), err) @@ -63,18 +111,18 @@ const ( type Role struct { // Name is the role name and is only used for documentation // and in the authentication middleware debug logs. - Name string - // Auth is the authentication method to use, which can be 'none' or 'apikey'. - Auth string + 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 + APIKey string `json:"apikey"` // Username for HTTP Basic authentication method. - Username string + Username string `json:"username"` // Password for HTTP Basic authentication method. - Password string + 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 + Routes []string `json:"-"` } var ( @@ -85,7 +133,7 @@ var ( ErrRouteNotSupported = errors.New("route not supported by the control server") ) -func (r Role) validate() (err error) { +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) @@ -134,3 +182,20 @@ var validRoutes = map[string]struct{}{ //nolint:gochecknoglobals 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 +} diff --git a/internal/server/server.go b/internal/server/server.go index 73b87c35..bf18bba9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -6,33 +6,25 @@ import ( "fmt" "os" + "github.com/qdm12/gluetun/internal/configuration/settings" "github.com/qdm12/gluetun/internal/httpserver" "github.com/qdm12/gluetun/internal/models" "github.com/qdm12/gluetun/internal/server/middlewares/auth" ) -func New(ctx context.Context, address string, logEnabled bool, logger Logger, - authConfigPath string, buildInfo models.BuildInformation, openvpnLooper VPNLooper, +func New(ctx context.Context, settings settings.ControlServer, logger Logger, + buildInfo models.BuildInformation, openvpnLooper VPNLooper, pfGetter PortForwardedGetter, dnsLooper DNSLoop, updaterLooper UpdaterLooper, publicIPLooper PublicIPLoop, storage Storage, ipv6Supported bool) ( server *httpserver.Server, err error, ) { - authSettings, err := auth.Read(authConfigPath) - switch { - case errors.Is(err, os.ErrNotExist): // no auth file present - case err != nil: - return nil, fmt.Errorf("reading auth settings: %w", err) - default: - logger.Infof("read %d roles from authentication file", len(authSettings.Roles)) - } - authSettings.SetDefaults() - err = authSettings.Validate() + authSettings, err := setupAuthMiddleware(settings.AuthFilePath, settings.AuthDefaultRole, logger) if err != nil { - return nil, fmt.Errorf("validating auth settings: %w", err) + return nil, fmt.Errorf("building authentication middleware settings: %w", err) } - handler, err := newHandler(ctx, logger, logEnabled, authSettings, buildInfo, + handler, err := newHandler(ctx, logger, *settings.Log, authSettings, buildInfo, openvpnLooper, pfGetter, dnsLooper, updaterLooper, publicIPLooper, storage, ipv6Supported) if err != nil { @@ -40,7 +32,7 @@ func New(ctx context.Context, address string, logEnabled bool, logger Logger, } httpServerSettings := httpserver.Settings{ - Address: address, + Address: *settings.Address, Handler: handler, Logger: logger, } @@ -52,3 +44,26 @@ func New(ctx context.Context, address string, logEnabled bool, logger Logger, return server, nil } + +func setupAuthMiddleware(authPath, jsonDefaultRole string, logger Logger) ( + authSettings auth.Settings, err error, +) { + authSettings, err = auth.Read(authPath) + switch { + case errors.Is(err, os.ErrNotExist): // no auth file present + case err != nil: + return auth.Settings{}, fmt.Errorf("reading auth settings: %w", err) + default: + logger.Infof("read %d roles from authentication file", len(authSettings.Roles)) + } + err = authSettings.SetDefaultRole(jsonDefaultRole) + if err != nil { + return auth.Settings{}, fmt.Errorf("setting default role: %w", err) + } + authSettings.SetDefaults() + err = authSettings.Validate() + if err != nil { + return auth.Settings{}, fmt.Errorf("validating auth settings: %w", err) + } + return authSettings, nil +}