From cac49e91fd7e913bfa682d64f75c7ba3859272ed Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Fri, 23 Aug 2024 17:08:13 +0000 Subject: [PATCH 01/10] faet(server): add authentication middleware --- cmd/gluetun/main.go | 1 + go.mod | 1 + go.sum | 11 + internal/configuration/settings/server.go | 31 +++ .../configuration/settings/settings_test.go | 6 +- internal/server/handler.go | 15 +- internal/server/logger.go | 2 + .../server/middlewares/auth/configfile.go | 31 +++ .../middlewares/auth/configfile_test.go | 84 +++++++ internal/server/middlewares/auth/format.go | 26 +++ .../server/middlewares/auth/interfaces.go | 6 + .../middlewares/auth/interfaces_local.go | 8 + internal/server/middlewares/auth/lookup.go | 55 +++++ .../server/middlewares/auth/lookup_test.go | 72 ++++++ .../server/middlewares/auth/middleware.go | 103 +++++++++ .../middlewares/auth/middleware_test.go | 136 +++++++++++ .../middlewares/auth/mocks_generate_test.go | 3 + .../server/middlewares/auth/mocks_test.go | 68 ++++++ internal/server/middlewares/auth/none.go | 20 ++ internal/server/middlewares/auth/settings.go | 213 ++++++++++++++++++ internal/server/server.go | 8 +- 21 files changed, 894 insertions(+), 6 deletions(-) create mode 100644 internal/server/middlewares/auth/configfile.go create mode 100644 internal/server/middlewares/auth/configfile_test.go create mode 100644 internal/server/middlewares/auth/format.go create mode 100644 internal/server/middlewares/auth/interfaces.go create mode 100644 internal/server/middlewares/auth/interfaces_local.go create mode 100644 internal/server/middlewares/auth/lookup.go create mode 100644 internal/server/middlewares/auth/lookup_test.go create mode 100644 internal/server/middlewares/auth/middleware.go create mode 100644 internal/server/middlewares/auth/middleware_test.go create mode 100644 internal/server/middlewares/auth/mocks_generate_test.go create mode 100644 internal/server/middlewares/auth/mocks_test.go create mode 100644 internal/server/middlewares/auth/none.go create mode 100644 internal/server/middlewares/auth/settings.go diff --git a/cmd/gluetun/main.go b/cmd/gluetun/main.go index 7f52c8874..d5c2d9eca 100644 --- a/cmd/gluetun/main.go +++ b/cmd/gluetun/main.go @@ -465,6 +465,7 @@ func _main(ctx context.Context, buildInfo models.BuildInformation, "http server", goroutine.OptionTimeout(defaultShutdownTimeout)) httpServer, err := server.New(httpServerCtx, controlServerAddress, controlServerLogging, logger.New(log.SetComponent("http server")), + allSettings.ControlServer.Auth, buildInfo, vpnLooper, portForwardLooper, dnsLooper, updaterLooper, publicIPLooper, storage, ipv6Supported) if err != nil { diff --git a/go.mod b/go.mod index 73b059a28..a5a16c5a4 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/golang/mock v1.6.0 github.com/klauspost/compress v1.17.9 github.com/klauspost/pgzip v1.2.6 + github.com/pelletier/go-toml/v2 v2.2.2 github.com/qdm12/dns/v2 v2.0.0-rc6 github.com/qdm12/gosettings v0.4.2 github.com/qdm12/goshutdown v0.3.0 diff --git a/go.sum b/go.sum index acbfc6aeb..3ff3d7141 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,7 @@ github.com/breml/rootcerts v0.2.17 h1:0/M2BE2Apw0qEJCXDOkaiu7d5Sx5ObNfe1BkImJ4u1 github.com/breml/rootcerts v0.2.17/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= @@ -47,6 +48,8 @@ github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= @@ -75,6 +78,13 @@ github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= @@ -148,6 +158,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ= diff --git a/internal/configuration/settings/server.go b/internal/configuration/settings/server.go index 82f2773a8..5452de542 100644 --- a/internal/configuration/settings/server.go +++ b/internal/configuration/settings/server.go @@ -6,6 +6,7 @@ import ( "os" "strconv" + "github.com/qdm12/gluetun/internal/server/middlewares/auth" "github.com/qdm12/gosettings" "github.com/qdm12/gosettings/reader" "github.com/qdm12/gotree" @@ -19,6 +20,15 @@ type ControlServer struct { // Log can be true or false to enable logging on requests. // It cannot be nil in the internal state. Log *bool + // AuthFilePath is the path to the file containing the authentication + // configuration for the middleware. + // It cannot be empty in the internal state and defaults to + // /gluetun/auth/config.toml. + AuthFilePath string + // Auth contains settings for the authentication middleware. + // These are parsed from a configuration file specified by + // AuthFilePath. + Auth auth.Settings } func (c ControlServer) validate() (err error) { @@ -39,6 +49,11 @@ func (c ControlServer) validate() (err error) { ErrControlServerPrivilegedPort, port, uid) } + err = c.Auth.Validate() + if err != nil { + return fmt.Errorf("validating authentication middleware: %w", err) + } + return nil } @@ -55,11 +70,15 @@ func (c *ControlServer) copy() (copied ControlServer) { 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.Auth.OverrideWith(other.Auth) } 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.Auth.SetDefaults() } func (c ControlServer) String() string { @@ -70,6 +89,8 @@ func (c ControlServer) toLinesNode() (node *gotree.Node) { node = gotree.New("Control server settings:") node.Appendf("Listening address: %s", *c.Address) node.Appendf("Logging: %s", gosettings.BoolToYesNo(c.Log)) + node.Appendf("Authentication file path: %s", c.AuthFilePath) + node.AppendNode(c.Auth.ToLinesNode()) return node } @@ -78,6 +99,16 @@ func (c *ControlServer) read(r *reader.Reader) (err error) { if err != nil { return err } + c.Address = r.Get("HTTP_CONTROL_SERVER_ADDRESS") + + c.AuthFilePath = r.String("HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH") + if c.AuthFilePath != "" { + c.Auth, err = auth.Read(c.AuthFilePath) + if err != nil { + return fmt.Errorf("reading authentication middleware settings: %w", err) + } + } + return nil } diff --git a/internal/configuration/settings/settings_test.go b/internal/configuration/settings/settings_test.go index 276c5688b..39a3cba2e 100644 --- a/internal/configuration/settings/settings_test.go +++ b/internal/configuration/settings/settings_test.go @@ -70,7 +70,11 @@ func Test_Settings_String(t *testing.T) { | └── Enabled: no ├── Control server settings: | ├── Listening address: :8000 -| └── Logging: yes +| ├── Logging: yes +| ├── Authentication file path: /gluetun/auth/config.toml +| └── Authentication middleware settings: +| ├── Authentications defined: public +| └── Roles defined: public ├── Storage settings: | └── Filepath: /gluetun/servers.json ├── OS Alpine settings: diff --git a/internal/server/handler.go b/internal/server/handler.go index 4788e4e57..92cdf7b90 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -2,14 +2,17 @@ package server import ( "context" + "fmt" "net/http" "strings" "github.com/qdm12/gluetun/internal/models" + "github.com/qdm12/gluetun/internal/server/middlewares/auth" "github.com/qdm12/gluetun/internal/server/middlewares/log" ) -func newHandler(ctx context.Context, logger infoWarner, logging bool, +func newHandler(ctx context.Context, logger Logger, logging bool, + authSettings auth.Settings, buildInfo models.BuildInformation, vpnLooper VPNLooper, pfGetter PortForwardedGetter, @@ -18,7 +21,7 @@ func newHandler(ctx context.Context, logger infoWarner, logging bool, publicIPLooper PublicIPLoop, storage Storage, ipv6Supported bool, -) (httpHandler http.Handler) { +) (httpHandler http.Handler, err error) { handler := &handler{} vpn := newVPNHandler(ctx, vpnLooper, storage, ipv6Supported, logger) @@ -30,14 +33,20 @@ func newHandler(ctx context.Context, logger infoWarner, logging bool, handler.v0 = newHandlerV0(ctx, logger, vpnLooper, dnsLooper, updaterLooper) handler.v1 = newHandlerV1(logger, buildInfo, vpn, openvpn, dns, updater, publicip) + authMiddleware, err := auth.New(authSettings, logger) + if err != nil { + return nil, fmt.Errorf("creating auth middleware: %w", err) + } + middlewares := []func(http.Handler) http.Handler{ + authMiddleware, log.New(logger, logging), } httpHandler = handler for _, middleware := range middlewares { httpHandler = middleware(httpHandler) } - return httpHandler + return httpHandler, nil } type handler struct { diff --git a/internal/server/logger.go b/internal/server/logger.go index 4b099c07a..4e847aa36 100644 --- a/internal/server/logger.go +++ b/internal/server/logger.go @@ -1,8 +1,10 @@ package server type Logger interface { + Debugf(format string, args ...any) infoer warner + Warnf(format string, args ...any) errorer } diff --git a/internal/server/middlewares/auth/configfile.go b/internal/server/middlewares/auth/configfile.go new file mode 100644 index 000000000..2a398536e --- /dev/null +++ b/internal/server/middlewares/auth/configfile.go @@ -0,0 +1,31 @@ +package auth + +import ( + "errors" + "fmt" + "os" + + "github.com/pelletier/go-toml/v2" +) + +// Read reads the toml file specified by the filepath given. +func Read(filepath string) (settings Settings, err error) { + file, err := os.Open(filepath) + if err != nil { + return settings, fmt.Errorf("opening file: %w", err) + } + decoder := toml.NewDecoder(file) + decoder.DisallowUnknownFields() + err = decoder.Decode(&settings) + if err == nil { + return settings, nil + } + + strictErr := new(toml.StrictMissingError) + ok := errors.As(err, &strictErr) + if !ok { + return settings, fmt.Errorf("toml decoding file: %w", err) + } + return settings, fmt.Errorf("toml decoding file: %w:\n%s", + strictErr, strictErr.String()) +} diff --git a/internal/server/middlewares/auth/configfile_test.go b/internal/server/middlewares/auth/configfile_test.go new file mode 100644 index 000000000..8665720af --- /dev/null +++ b/internal/server/middlewares/auth/configfile_test.go @@ -0,0 +1,84 @@ +package auth + +import ( + "io/fs" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Read reads the toml file specified by the filepath given. +func Test_Read(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + fileContent string + settings Settings + errMessage string + }{ + "empty_file": {}, + "malformed_toml": { + fileContent: "this is not a toml file", + errMessage: `toml decoding file: toml: expected character =`, + }, + "unknown_field": { + fileContent: `unknown = "what is this"`, + errMessage: `toml decoding file: strict mode: fields in the document are missing in the target struct: +1| unknown = "what is this" + | ~~~~~~~ missing field`, + }, + "filled_settings": { + fileContent: `[[auths]] +name = "abc" +method = "none" + +[[auths]] +name = "xyz" +# comments are ignored +method = "oauth2" + +[[roles]] +name = "public" +auths = ["abc"] +routes = ["GET /v1/vpn/status", "PUT /v1/vpn/status"]`, + settings: Settings{ + Auths: []Auth{{ + Name: "abc", + Method: MethodNone, + }, { + Name: "xyz", + Method: "oauth2", + }}, + Roles: []Role{{ + Name: "public", + Auths: []string{"abc"}, + Routes: []string{"GET /v1/vpn/status", "PUT /v1/vpn/status"}, + }}, + }, + }, + } + + for name, testCase := range testCases { + testCase := testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + filepath := tempDir + "/config.toml" + const permissions fs.FileMode = 0600 + err := os.WriteFile(filepath, []byte(testCase.fileContent), permissions) + require.NoError(t, err) + + settings, err := Read(filepath) + + assert.Equal(t, testCase.settings, settings) + if testCase.errMessage != "" { + assert.EqualError(t, err, testCase.errMessage) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/server/middlewares/auth/format.go b/internal/server/middlewares/auth/format.go new file mode 100644 index 000000000..045ca7f6b --- /dev/null +++ b/internal/server/middlewares/auth/format.go @@ -0,0 +1,26 @@ +package auth + +func andStrings(strings []string) (result string) { + return joinStrings(strings, "and") +} + +func orStrings(strings []string) (result string) { + return joinStrings(strings, "or") +} + +func joinStrings(strings []string, lastJoin string) (result string) { + if len(strings) == 0 { + return "" + } + + result = strings[0] + for i := 1; i < len(strings); i++ { + if i < len(strings)-1 { + result += ", " + strings[i] + } else { + result += " " + lastJoin + " " + strings[i] + } + } + + return result +} diff --git a/internal/server/middlewares/auth/interfaces.go b/internal/server/middlewares/auth/interfaces.go new file mode 100644 index 000000000..7a6901d23 --- /dev/null +++ b/internal/server/middlewares/auth/interfaces.go @@ -0,0 +1,6 @@ +package auth + +type DebugLogger interface { + Debugf(format string, args ...any) + Warnf(format string, args ...any) +} diff --git a/internal/server/middlewares/auth/interfaces_local.go b/internal/server/middlewares/auth/interfaces_local.go new file mode 100644 index 000000000..abab54804 --- /dev/null +++ b/internal/server/middlewares/auth/interfaces_local.go @@ -0,0 +1,8 @@ +package auth + +import "net/http" + +type authorizationChecker interface { + equal(other authorizationChecker) bool + isAuthorized(request *http.Request) bool +} diff --git a/internal/server/middlewares/auth/lookup.go b/internal/server/middlewares/auth/lookup.go new file mode 100644 index 000000000..818af3867 --- /dev/null +++ b/internal/server/middlewares/auth/lookup.go @@ -0,0 +1,55 @@ +package auth + +import ( + "fmt" + + "golang.org/x/exp/maps" +) + +type internalRole struct { + name string + checker authorizationChecker +} + +func settingsToLookupMap(settings Settings) (routeToRoles map[string][]internalRole, err error) { + authNameToChecker := make(map[string]authorizationChecker, len(settings.Auths)) + for _, auth := range settings.Auths { + switch auth.Method { + case MethodNone: + authNameToChecker[auth.Name] = newNoneMethod() + default: + return nil, fmt.Errorf("%w: %s", ErrMethodNotSupported, auth.Name) + } + } + + routeToRoles = make(map[string][]internalRole) + for _, role := range settings.Roles { + for _, authName := range role.Auths { + checker, ok := authNameToChecker[authName] + if !ok { + return nil, fmt.Errorf("%w: %s is not one of %s", ErrAuthNameNotDefined, + authName, orStrings(maps.Keys(authNameToChecker))) + } + + iRole := internalRole{ + name: role.Name, + checker: checker, + } + for _, route := range role.Routes { + checkerExists := false + for _, role := range routeToRoles[route] { + if role.checker.equal(iRole.checker) { + checkerExists = true + break + } + } + if checkerExists { + // even if the role name is different, if the checker is the same, skip it. + continue + } + routeToRoles[route] = append(routeToRoles[route], iRole) + } + } + } + return routeToRoles, nil +} diff --git a/internal/server/middlewares/auth/lookup_test.go b/internal/server/middlewares/auth/lookup_test.go new file mode 100644 index 000000000..bb6a92361 --- /dev/null +++ b/internal/server/middlewares/auth/lookup_test.go @@ -0,0 +1,72 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Read reads the toml file specified by the filepath given. +func Test_settingsToLookupMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + settings Settings + routeToRoles map[string][]internalRole + errWrapped error + errMessage string + }{ + "empty_settings": { + routeToRoles: map[string][]internalRole{}, + }, + "auth_method_not_supported": { + settings: Settings{ + Auths: []Auth{{Name: "bad", Method: "not_supported"}}, + }, + errWrapped: ErrMethodNotSupported, + errMessage: "authentication method not supported: bad", + }, + "auth_name_not_defined": { + settings: Settings{ + Auths: []Auth{{Name: "x", Method: MethodNone}, {Name: "y", Method: MethodNone}}, + Roles: []Role{{Name: "a", Auths: []string{"xyz"}}}, + }, + errWrapped: ErrAuthNameNotDefined, + errMessage: "authentication name not defined: xyz is not one of x or y", + }, + "success": { + settings: Settings{ + Auths: []Auth{ + {Name: "x", Method: MethodNone}, + {Name: "y", Method: MethodNone}, + }, + Roles: []Role{ + {Name: "a", Auths: []string{"x"}, Routes: []string{"GET /path"}}, + {Name: "b", Auths: []string{"x", "y"}, Routes: []string{"GET /path", "PUT /path"}}, + }, + }, + routeToRoles: map[string][]internalRole{ + "GET /path": { + {name: "a", checker: newNoneMethod()}, // deduplicated method + }, + "PUT /path": { + {name: "b", checker: newNoneMethod()}, + }}, + }, + } + + for name, testCase := range testCases { + testCase := testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + routeToRoles, err := settingsToLookupMap(testCase.settings) + + assert.Equal(t, testCase.routeToRoles, routeToRoles) + assert.ErrorIs(t, err, testCase.errWrapped) + if testCase.errWrapped != nil { + assert.EqualError(t, err, testCase.errMessage) + } + }) + } +} diff --git a/internal/server/middlewares/auth/middleware.go b/internal/server/middlewares/auth/middleware.go new file mode 100644 index 000000000..4dccde6e0 --- /dev/null +++ b/internal/server/middlewares/auth/middleware.go @@ -0,0 +1,103 @@ +package auth + +import ( + "fmt" + "net/http" +) + +func New(settings Settings, debugLogger DebugLogger) ( + middleware func(http.Handler) http.Handler, + err error) { + routeToRoles, err := settingsToLookupMap(settings) + if err != nil { + return nil, fmt.Errorf("converting settings to lookup maps: %w", err) + } + + //nolint:goconst + return func(handler http.Handler) http.Handler { + return &authHandler{ + childHandler: handler, + routeToRoles: routeToRoles, + unprotectedRoutes: map[string]struct{}{ + http.MethodGet + " /openvpn/actions/restart": {}, + http.MethodGet + " /unbound/actions/restart": {}, + http.MethodGet + " /updater/restart": {}, + http.MethodGet + " /v1/version": {}, + http.MethodGet + " /v1/vpn/status": {}, + http.MethodPut + " /v1/vpn/status": {}, + // GET /v1/vpn/settings is protected by default + // PUT /v1/vpn/settings is protected by default + http.MethodGet + " /v1/openvpn/status": {}, + http.MethodPut + " /v1/openvpn/status": {}, + http.MethodGet + " /v1/openvpn/portforwarded": {}, + // GET /v1/openvpn/settings is protected by default + 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": {}, + }, + logger: debugLogger, + } + }, nil +} + +type authHandler struct { + childHandler http.Handler + routeToRoles map[string][]internalRole + unprotectedRoutes map[string]struct{} // TODO v3.41.0 remove + logger DebugLogger +} + +func (h *authHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + route := request.Method + " " + request.URL.Path + roles := h.routeToRoles[route] + if len(roles) == 0 { + h.logger.Debugf("no authentication role defined for route %s", route) + http.Error(writer, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + for _, role := range roles { + if !role.checker.isAuthorized(request) { + continue + } + + h.warnIfUnprotectedByDefault(role, route) // TODO v3.41.0 remove + + h.logger.Debugf("access to route %s authorized for role %s", route, role.name) + h.childHandler.ServeHTTP(writer, request) + return + } + + allRoleNames := make([]string, len(roles)) + for i, role := range roles { + allRoleNames[i] = role.name + } + h.logger.Debugf("access to route %s unauthorized after checking for roles %s", + route, andStrings(allRoleNames)) + http.Error(writer, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) +} + +func (h *authHandler) warnIfUnprotectedByDefault(role internalRole, route string) { + // TODO v3.41.0 remove + if role.name != "public" { + // custom role name, allow none authentication to be specified + return + } + _, isNoneChecker := role.checker.(*noneMethod) + if !isNoneChecker { + // not the none authentication method + return + } + _, isUnprotectedByDefault := h.unprotectedRoutes[route] + if !isUnprotectedByDefault { + // route is not unprotected by default, so this is a user decision + return + } + h.logger.Warnf("route %s is unprotected by default, "+ + "please set up authentication following the documentation at "+ + "https://github.com/gluetun-wiki/setup/advanced/control-server.md#authentication "+ + "since this will become no longer publicly accessible after release v3.40.", + route) +} diff --git a/internal/server/middlewares/auth/middleware_test.go b/internal/server/middlewares/auth/middleware_test.go new file mode 100644 index 000000000..a50c89c99 --- /dev/null +++ b/internal/server/middlewares/auth/middleware_test.go @@ -0,0 +1,136 @@ +package auth + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_authHandler_ServeHTTP(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + settings Settings + makeLogger func(ctrl *gomock.Controller) *MockDebugLogger + requestMethod string + requestPath string + statusCode int + responseBody string + }{ + "route_has_no_role": { + settings: Settings{ + Auths: []Auth{ + {Name: "auth1", Method: MethodNone}, + {Name: "auth2", Method: MethodNone}, + }, + Roles: []Role{ + {Name: "role1", Auths: []string{"auth1"}, Routes: []string{"GET /a"}}, + }, + }, + makeLogger: func(ctrl *gomock.Controller) *MockDebugLogger { + logger := NewMockDebugLogger(ctrl) + logger.EXPECT().Debugf("no authentication role defined for route %s", "GET /b") + return logger + }, + requestMethod: http.MethodGet, + requestPath: "/b", + statusCode: http.StatusUnauthorized, + responseBody: "Unauthorized\n", + }, + "authorized_unprotected_by_default": { + settings: Settings{ + Auths: []Auth{ + {Name: "auth1", Method: MethodNone}, + {Name: "auth2", Method: MethodNone}, + }, + Roles: []Role{ + {Name: "public", Auths: []string{"auth1"}, Routes: []string{"GET /v1/vpn/status"}}, + }, + }, + makeLogger: func(ctrl *gomock.Controller) *MockDebugLogger { + logger := NewMockDebugLogger(ctrl) + logger.EXPECT().Warnf("route %s is unprotected by default, "+ + "please set up authentication following the documentation at "+ + "https://github.com/gluetun-wiki/setup/advanced/control-server.md#authentication "+ + "since this will become no longer publicly accessible after release v3.40.", + "GET /v1/vpn/status") + logger.EXPECT().Debugf("access to route %s authorized for role %s", + "GET /v1/vpn/status", "public") + return logger + }, + requestMethod: http.MethodGet, + requestPath: "/v1/vpn/status", + statusCode: http.StatusOK, + }, + "authorized_none": { + settings: Settings{ + Auths: []Auth{ + {Name: "auth1", Method: MethodNone}, + {Name: "auth2", Method: MethodNone}, + }, + Roles: []Role{ + {Name: "role1", Auths: []string{"auth1"}, Routes: []string{"GET /a"}}, + }, + }, + makeLogger: func(ctrl *gomock.Controller) *MockDebugLogger { + logger := NewMockDebugLogger(ctrl) + logger.EXPECT().Debugf("access to route %s authorized for role %s", + "GET /a", "role1") + return logger + }, + requestMethod: http.MethodGet, + requestPath: "/a", + statusCode: http.StatusOK, + }, + } + + for name, testCase := range testCases { + testCase := testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + var debugLogger DebugLogger + if testCase.makeLogger != nil { + debugLogger = testCase.makeLogger(ctrl) + } + middleware, err := New(testCase.settings, debugLogger) + require.NoError(t, err) + + childHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := middleware(childHandler) + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + client := server.Client() + + requestURL, err := url.JoinPath(server.URL, testCase.requestPath) + require.NoError(t, err) + request, err := http.NewRequestWithContext(context.Background(), + testCase.requestMethod, requestURL, nil) + require.NoError(t, err) + + response, err := client.Do(request) + require.NoError(t, err) + t.Cleanup(func() { + err = response.Body.Close() + assert.NoError(t, err) + }) + + assert.Equal(t, testCase.statusCode, response.StatusCode) + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + assert.Equal(t, testCase.responseBody, string(body)) + }) + } +} diff --git a/internal/server/middlewares/auth/mocks_generate_test.go b/internal/server/middlewares/auth/mocks_generate_test.go new file mode 100644 index 000000000..d9ce4b052 --- /dev/null +++ b/internal/server/middlewares/auth/mocks_generate_test.go @@ -0,0 +1,3 @@ +package auth + +//go:generate mockgen -destination=mocks_test.go -package=$GOPACKAGE . DebugLogger diff --git a/internal/server/middlewares/auth/mocks_test.go b/internal/server/middlewares/auth/mocks_test.go new file mode 100644 index 000000000..37538c5bf --- /dev/null +++ b/internal/server/middlewares/auth/mocks_test.go @@ -0,0 +1,68 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/qdm12/gluetun/internal/server/middlewares/auth (interfaces: DebugLogger) + +// Package auth is a generated GoMock package. +package auth + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockDebugLogger is a mock of DebugLogger interface. +type MockDebugLogger struct { + ctrl *gomock.Controller + recorder *MockDebugLoggerMockRecorder +} + +// MockDebugLoggerMockRecorder is the mock recorder for MockDebugLogger. +type MockDebugLoggerMockRecorder struct { + mock *MockDebugLogger +} + +// NewMockDebugLogger creates a new mock instance. +func NewMockDebugLogger(ctrl *gomock.Controller) *MockDebugLogger { + mock := &MockDebugLogger{ctrl: ctrl} + mock.recorder = &MockDebugLoggerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDebugLogger) EXPECT() *MockDebugLoggerMockRecorder { + return m.recorder +} + +// Debugf mocks base method. +func (m *MockDebugLogger) Debugf(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Debugf", varargs...) +} + +// Debugf indicates an expected call of Debugf. +func (mr *MockDebugLoggerMockRecorder) Debugf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debugf", reflect.TypeOf((*MockDebugLogger)(nil).Debugf), varargs...) +} + +// Warnf mocks base method. +func (m *MockDebugLogger) Warnf(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Warnf", varargs...) +} + +// Warnf indicates an expected call of Warnf. +func (mr *MockDebugLoggerMockRecorder) Warnf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warnf", reflect.TypeOf((*MockDebugLogger)(nil).Warnf), varargs...) +} diff --git a/internal/server/middlewares/auth/none.go b/internal/server/middlewares/auth/none.go new file mode 100644 index 000000000..66aa9529e --- /dev/null +++ b/internal/server/middlewares/auth/none.go @@ -0,0 +1,20 @@ +package auth + +import "net/http" + +type noneMethod struct{} + +func newNoneMethod() *noneMethod { + return &noneMethod{} +} + +// equal returns true if another auth checker is equal. +// This is used to deduplicate checkers for a particular route. +func (n *noneMethod) equal(other authorizationChecker) bool { + _, ok := other.(*noneMethod) + return ok +} + +func (n *noneMethod) isAuthorized(_ *http.Request) bool { + return true +} diff --git a/internal/server/middlewares/auth/settings.go b/internal/server/middlewares/auth/settings.go new file mode 100644 index 000000000..37d08e0e4 --- /dev/null +++ b/internal/server/middlewares/auth/settings.go @@ -0,0 +1,213 @@ +package auth + +import ( + "errors" + "fmt" + "net/http" + + "github.com/qdm12/gosettings" + "github.com/qdm12/gosettings/validate" + "github.com/qdm12/gotree" +) + +type Settings struct { + // Auths is a list of authentication methods which can be used + // by each role. + Auths []Auth + // Roles is a list of roles with their associated authentication + // and routes. + Roles []Role +} + +func (s *Settings) SetDefaults() { + s.Auths = gosettings.DefaultSlice(s.Auths, []Auth{{ + Name: "public", + Method: MethodNone, + }}) // TODO v3.41.0 leave empty + s.Roles = gosettings.DefaultSlice(s.Roles, []Role{{ // TODO v3.41.0 leave empty + Name: "public", + Auths: []string{"public"}, + Routes: []string{ + http.MethodGet + " /openvpn/actions/restart", + 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/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", + }, + }}) +} + +var ( + ErrAuthNameNotDefined = errors.New("authentication name not defined") + ErrAuthNameNotUnique = errors.New("authentication name is not unique") +) + +func (s Settings) Validate() (err error) { + authNameToAuthIndex := make(map[string]int, len(s.Auths)) + for i, auth := range s.Auths { + existingIndex, exists := authNameToAuthIndex[auth.Name] + if exists { + return fmt.Errorf("%w: %q for auths %d of %d and %d of %d", + ErrAuthNameNotUnique, auth.Name, + i+1, len(s.Auths), existingIndex+1, len(s.Auths)) + } + authNameToAuthIndex[auth.Name] = i + + err = auth.validate() + if err != nil { + return fmt.Errorf("auth %d of %d: %w", i+1, len(s.Auths), err) + } + } + + for i, role := range s.Roles { + for _, auth := range role.Auths { + _, isDefined := authNameToAuthIndex[auth] + if !isDefined { + return fmt.Errorf("%w: %q for role %s (%d of %d)", + ErrAuthNameNotDefined, auth, role.Name, i+1, len(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 +} + +func (s Settings) Copy() (copied Settings) { + copied.Auths = make([]Auth, len(s.Auths)) + copy(copied.Auths, s.Auths) + copied.Roles = make([]Role, len(s.Roles)) + for i := range s.Roles { + copied.Roles[i] = s.Roles[i].copy() + } + return copied +} + +func (s *Settings) OverrideWith(other Settings) { + s.Auths = gosettings.OverrideWithSlice(s.Auths, other.Auths) + s.Roles = gosettings.OverrideWithSlice(s.Roles, other.Roles) +} + +func (s Settings) ToLinesNode() (node *gotree.Node) { + node = gotree.New("Authentication middleware settings:") + + authNames := make([]string, len(s.Auths)) + for i, auth := range s.Auths { + authNames[i] = auth.Name + } + node.Appendf("Authentications defined: %s", andStrings(authNames)) + + roleNames := make([]string, len(s.Roles)) + for i, role := range s.Roles { + roleNames[i] = role.Name + } + node.Appendf("Roles defined: %s", andStrings(roleNames)) + + return node +} + +const ( + MethodNone = "none" +) + +// Auth contains the authentication method name and fields +// specific to each authentication method. +type Auth struct { + // Name is the unique authentication name. + Name string + // Method is the authentication method to use. + Method string +} + +func (a Auth) validate() (err error) { + err = validateAuthMethod(a.Method) + if err != nil { + return fmt.Errorf("method for name %s: %w", a.Name, err) + } + return nil +} + +var ( + ErrMethodNotSupported = errors.New("authentication method not supported") +) + +func validateAuthMethod(method string) (err error) { + err = validate.IsOneOf(method, MethodNone) + if err != nil { + return fmt.Errorf("%w: %s", ErrMethodNotSupported, method) + } + return nil +} + +// 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 + // Auths is a list of authentication names that the role can use, + // where each must match a defined authentication. + Auths []string + // 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 +} + +var ( + ErrRouteNotSupported = errors.New("route not supported by the control server") +) + +func (r Role) validate() (err error) { + 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 + " /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": {}, +} + +func (r Role) copy() (copied Role) { + copied.Name = r.Name + copied.Auths = make([]string, len(r.Auths)) + copy(copied.Auths, r.Auths) + copied.Routes = make([]string, len(r.Routes)) + copy(copied.Routes, r.Routes) + return copied +} diff --git a/internal/server/server.go b/internal/server/server.go index c940fec61..f484ba9e5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -6,17 +6,21 @@ import ( "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, - buildInfo models.BuildInformation, openvpnLooper VPNLooper, + authSettings auth.Settings, buildInfo models.BuildInformation, openvpnLooper VPNLooper, pfGetter PortForwardedGetter, dnsLooper DNSLoop, updaterLooper UpdaterLooper, publicIPLooper PublicIPLoop, storage Storage, ipv6Supported bool) ( server *httpserver.Server, err error) { - handler := newHandler(ctx, logger, logEnabled, buildInfo, + handler, err := newHandler(ctx, logger, logEnabled, authSettings, buildInfo, openvpnLooper, pfGetter, dnsLooper, updaterLooper, publicIPLooper, storage, ipv6Supported) + if err != nil { + return nil, fmt.Errorf("creating handler: %w", err) + } httpServerSettings := httpserver.Settings{ Address: address, From 88d2c989876bb928207352497adef8f7dbd8273f Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Wed, 11 Sep 2024 07:09:32 +0000 Subject: [PATCH 02/10] define a single auth per role directly within role --- .../configuration/settings/settings_test.go | 1 - .../middlewares/auth/configfile_test.go | 22 +---- internal/server/middlewares/auth/format.go | 4 - internal/server/middlewares/auth/lookup.go | 56 +++++------ .../server/middlewares/auth/lookup_test.go | 18 +--- .../middlewares/auth/middleware_test.go | 18 +--- internal/server/middlewares/auth/settings.go | 95 +++---------------- 7 files changed, 44 insertions(+), 170 deletions(-) diff --git a/internal/configuration/settings/settings_test.go b/internal/configuration/settings/settings_test.go index 39a3cba2e..e5c768aa4 100644 --- a/internal/configuration/settings/settings_test.go +++ b/internal/configuration/settings/settings_test.go @@ -73,7 +73,6 @@ func Test_Settings_String(t *testing.T) { | ├── Logging: yes | ├── Authentication file path: /gluetun/auth/config.toml | └── Authentication middleware settings: -| ├── Authentications defined: public | └── Roles defined: public ├── Storage settings: | └── Filepath: /gluetun/servers.json diff --git a/internal/server/middlewares/auth/configfile_test.go b/internal/server/middlewares/auth/configfile_test.go index 8665720af..0123cbb18 100644 --- a/internal/server/middlewares/auth/configfile_test.go +++ b/internal/server/middlewares/auth/configfile_test.go @@ -30,30 +30,14 @@ func Test_Read(t *testing.T) { | ~~~~~~~ missing field`, }, "filled_settings": { - fileContent: `[[auths]] -name = "abc" -method = "none" - -[[auths]] -name = "xyz" -# comments are ignored -method = "oauth2" - -[[roles]] + fileContent: `[[roles]] name = "public" -auths = ["abc"] +auth = "none" routes = ["GET /v1/vpn/status", "PUT /v1/vpn/status"]`, settings: Settings{ - Auths: []Auth{{ - Name: "abc", - Method: MethodNone, - }, { - Name: "xyz", - Method: "oauth2", - }}, Roles: []Role{{ Name: "public", - Auths: []string{"abc"}, + Auth: AuthNone, Routes: []string{"GET /v1/vpn/status", "PUT /v1/vpn/status"}, }}, }, diff --git a/internal/server/middlewares/auth/format.go b/internal/server/middlewares/auth/format.go index 045ca7f6b..26d858e4f 100644 --- a/internal/server/middlewares/auth/format.go +++ b/internal/server/middlewares/auth/format.go @@ -4,10 +4,6 @@ func andStrings(strings []string) (result string) { return joinStrings(strings, "and") } -func orStrings(strings []string) (result string) { - return joinStrings(strings, "or") -} - func joinStrings(strings []string, lastJoin string) (result string) { if len(strings) == 0 { return "" diff --git a/internal/server/middlewares/auth/lookup.go b/internal/server/middlewares/auth/lookup.go index 818af3867..e725fd369 100644 --- a/internal/server/middlewares/auth/lookup.go +++ b/internal/server/middlewares/auth/lookup.go @@ -2,8 +2,6 @@ package auth import ( "fmt" - - "golang.org/x/exp/maps" ) type internalRole struct { @@ -12,43 +10,33 @@ type internalRole struct { } func settingsToLookupMap(settings Settings) (routeToRoles map[string][]internalRole, err error) { - authNameToChecker := make(map[string]authorizationChecker, len(settings.Auths)) - for _, auth := range settings.Auths { - switch auth.Method { - case MethodNone: - authNameToChecker[auth.Name] = newNoneMethod() - default: - return nil, fmt.Errorf("%w: %s", ErrMethodNotSupported, auth.Name) - } - } - routeToRoles = make(map[string][]internalRole) for _, role := range settings.Roles { - for _, authName := range role.Auths { - checker, ok := authNameToChecker[authName] - if !ok { - return nil, fmt.Errorf("%w: %s is not one of %s", ErrAuthNameNotDefined, - authName, orStrings(maps.Keys(authNameToChecker))) - } + var checker authorizationChecker + switch role.Auth { + case AuthNone: + checker = newNoneMethod() + default: + return nil, fmt.Errorf("%w: %s", ErrMethodNotSupported, role.Auth) + } - iRole := internalRole{ - name: role.Name, - checker: checker, - } - for _, route := range role.Routes { - checkerExists := false - for _, role := range routeToRoles[route] { - if role.checker.equal(iRole.checker) { - checkerExists = true - break - } - } - if checkerExists { - // even if the role name is different, if the checker is the same, skip it. - continue + iRole := internalRole{ + name: role.Name, + checker: checker, + } + for _, route := range role.Routes { + checkerExists := false + for _, role := range routeToRoles[route] { + if role.checker.equal(iRole.checker) { + checkerExists = true + break } - routeToRoles[route] = append(routeToRoles[route], iRole) } + if checkerExists { + // even if the role name is different, if the checker is the same, skip it. + continue + } + routeToRoles[route] = append(routeToRoles[route], iRole) } } return routeToRoles, nil diff --git a/internal/server/middlewares/auth/lookup_test.go b/internal/server/middlewares/auth/lookup_test.go index bb6a92361..225f7f8ce 100644 --- a/internal/server/middlewares/auth/lookup_test.go +++ b/internal/server/middlewares/auth/lookup_test.go @@ -21,28 +21,16 @@ func Test_settingsToLookupMap(t *testing.T) { }, "auth_method_not_supported": { settings: Settings{ - Auths: []Auth{{Name: "bad", Method: "not_supported"}}, + Roles: []Role{{Name: "a", Auth: "bad"}}, }, errWrapped: ErrMethodNotSupported, errMessage: "authentication method not supported: bad", }, - "auth_name_not_defined": { - settings: Settings{ - Auths: []Auth{{Name: "x", Method: MethodNone}, {Name: "y", Method: MethodNone}}, - Roles: []Role{{Name: "a", Auths: []string{"xyz"}}}, - }, - errWrapped: ErrAuthNameNotDefined, - errMessage: "authentication name not defined: xyz is not one of x or y", - }, "success": { settings: Settings{ - Auths: []Auth{ - {Name: "x", Method: MethodNone}, - {Name: "y", Method: MethodNone}, - }, Roles: []Role{ - {Name: "a", Auths: []string{"x"}, Routes: []string{"GET /path"}}, - {Name: "b", Auths: []string{"x", "y"}, Routes: []string{"GET /path", "PUT /path"}}, + {Name: "a", Auth: AuthNone, Routes: []string{"GET /path"}}, + {Name: "b", Auth: AuthNone, Routes: []string{"GET /path", "PUT /path"}}, }, }, routeToRoles: map[string][]internalRole{ diff --git a/internal/server/middlewares/auth/middleware_test.go b/internal/server/middlewares/auth/middleware_test.go index a50c89c99..5b98f755f 100644 --- a/internal/server/middlewares/auth/middleware_test.go +++ b/internal/server/middlewares/auth/middleware_test.go @@ -26,12 +26,8 @@ func Test_authHandler_ServeHTTP(t *testing.T) { }{ "route_has_no_role": { settings: Settings{ - Auths: []Auth{ - {Name: "auth1", Method: MethodNone}, - {Name: "auth2", Method: MethodNone}, - }, Roles: []Role{ - {Name: "role1", Auths: []string{"auth1"}, Routes: []string{"GET /a"}}, + {Name: "role1", Auth: AuthNone, Routes: []string{"GET /a"}}, }, }, makeLogger: func(ctrl *gomock.Controller) *MockDebugLogger { @@ -46,12 +42,8 @@ func Test_authHandler_ServeHTTP(t *testing.T) { }, "authorized_unprotected_by_default": { settings: Settings{ - Auths: []Auth{ - {Name: "auth1", Method: MethodNone}, - {Name: "auth2", Method: MethodNone}, - }, Roles: []Role{ - {Name: "public", Auths: []string{"auth1"}, Routes: []string{"GET /v1/vpn/status"}}, + {Name: "public", Auth: AuthNone, Routes: []string{"GET /v1/vpn/status"}}, }, }, makeLogger: func(ctrl *gomock.Controller) *MockDebugLogger { @@ -71,12 +63,8 @@ func Test_authHandler_ServeHTTP(t *testing.T) { }, "authorized_none": { settings: Settings{ - Auths: []Auth{ - {Name: "auth1", Method: MethodNone}, - {Name: "auth2", Method: MethodNone}, - }, Roles: []Role{ - {Name: "role1", Auths: []string{"auth1"}, Routes: []string{"GET /a"}}, + {Name: "role1", Auth: AuthNone, Routes: []string{"GET /a"}}, }, }, makeLogger: func(ctrl *gomock.Controller) *MockDebugLogger { diff --git a/internal/server/middlewares/auth/settings.go b/internal/server/middlewares/auth/settings.go index 37d08e0e4..6f3541bec 100644 --- a/internal/server/middlewares/auth/settings.go +++ b/internal/server/middlewares/auth/settings.go @@ -11,22 +11,15 @@ import ( ) type Settings struct { - // Auths is a list of authentication methods which can be used - // by each role. - Auths []Auth // Roles is a list of roles with their associated authentication // and routes. Roles []Role } func (s *Settings) SetDefaults() { - s.Auths = gosettings.DefaultSlice(s.Auths, []Auth{{ - Name: "public", - Method: MethodNone, - }}) // TODO v3.41.0 leave empty s.Roles = gosettings.DefaultSlice(s.Roles, []Role{{ // TODO v3.41.0 leave empty - Name: "public", - Auths: []string{"public"}, + Name: "public", + Auth: "none", Routes: []string{ http.MethodGet + " /openvpn/actions/restart", http.MethodGet + " /unbound/actions/restart", @@ -46,36 +39,8 @@ func (s *Settings) SetDefaults() { }}) } -var ( - ErrAuthNameNotDefined = errors.New("authentication name not defined") - ErrAuthNameNotUnique = errors.New("authentication name is not unique") -) - func (s Settings) Validate() (err error) { - authNameToAuthIndex := make(map[string]int, len(s.Auths)) - for i, auth := range s.Auths { - existingIndex, exists := authNameToAuthIndex[auth.Name] - if exists { - return fmt.Errorf("%w: %q for auths %d of %d and %d of %d", - ErrAuthNameNotUnique, auth.Name, - i+1, len(s.Auths), existingIndex+1, len(s.Auths)) - } - authNameToAuthIndex[auth.Name] = i - - err = auth.validate() - if err != nil { - return fmt.Errorf("auth %d of %d: %w", i+1, len(s.Auths), err) - } - } - for i, role := range s.Roles { - for _, auth := range role.Auths { - _, isDefined := authNameToAuthIndex[auth] - if !isDefined { - return fmt.Errorf("%w: %q for role %s (%d of %d)", - ErrAuthNameNotDefined, auth, role.Name, i+1, len(s.Roles)) - } - } err = role.validate() if err != nil { return fmt.Errorf("role %s (%d of %d): %w", @@ -87,8 +52,6 @@ func (s Settings) Validate() (err error) { } func (s Settings) Copy() (copied Settings) { - copied.Auths = make([]Auth, len(s.Auths)) - copy(copied.Auths, s.Auths) copied.Roles = make([]Role, len(s.Roles)) for i := range s.Roles { copied.Roles[i] = s.Roles[i].copy() @@ -97,19 +60,12 @@ func (s Settings) Copy() (copied Settings) { } func (s *Settings) OverrideWith(other Settings) { - s.Auths = gosettings.OverrideWithSlice(s.Auths, other.Auths) s.Roles = gosettings.OverrideWithSlice(s.Roles, other.Roles) } func (s Settings) ToLinesNode() (node *gotree.Node) { node = gotree.New("Authentication middleware settings:") - authNames := make([]string, len(s.Auths)) - for i, auth := range s.Auths { - authNames[i] = auth.Name - } - node.Appendf("Authentications defined: %s", andStrings(authNames)) - roleNames := make([]string, len(s.Roles)) for i, role := range s.Roles { roleNames[i] = role.Name @@ -120,57 +76,33 @@ func (s Settings) ToLinesNode() (node *gotree.Node) { } const ( - MethodNone = "none" + AuthNone = "none" ) -// Auth contains the authentication method name and fields -// specific to each authentication method. -type Auth struct { - // Name is the unique authentication name. - Name string - // Method is the authentication method to use. - Method string -} - -func (a Auth) validate() (err error) { - err = validateAuthMethod(a.Method) - if err != nil { - return fmt.Errorf("method for name %s: %w", a.Name, err) - } - return nil -} - -var ( - ErrMethodNotSupported = errors.New("authentication method not supported") -) - -func validateAuthMethod(method string) (err error) { - err = validate.IsOneOf(method, MethodNone) - if err != nil { - return fmt.Errorf("%w: %s", ErrMethodNotSupported, method) - } - return nil -} - // 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 - // Auths is a list of authentication names that the role can use, - // where each must match a defined authentication. - Auths []string + // Auth is the authentication method to use, which can be 'none'. + Auth string // 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 } var ( - ErrRouteNotSupported = errors.New("route not supported by the control server") + ErrMethodNotSupported = errors.New("authentication method not supported") + ErrRouteNotSupported = errors.New("route not supported by the control server") ) func (r Role) validate() (err error) { + err = validate.IsOneOf(r.Auth, AuthNone) + if err != nil { + return fmt.Errorf("%w: %s", ErrMethodNotSupported, r.Auth) + } + for i, route := range r.Routes { _, ok := validRoutes[route] if !ok { @@ -205,8 +137,7 @@ var validRoutes = map[string]struct{}{ //nolint:gochecknoglobals func (r Role) copy() (copied Role) { copied.Name = r.Name - copied.Auths = make([]string, len(r.Auths)) - copy(copied.Auths, r.Auths) + copied.Auth = r.Auth copied.Routes = make([]string, len(r.Routes)) copy(copied.Routes, r.Routes) return copied From 0426c3142cde6fff0880d2523d6bd8fcd4aa052a Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Wed, 11 Sep 2024 07:27:47 +0000 Subject: [PATCH 03/10] Set up announcement message --- cmd/gluetun/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/gluetun/main.go b/cmd/gluetun/main.go index d5c2d9eca..725fab918 100644 --- a/cmd/gluetun/main.go +++ b/cmd/gluetun/main.go @@ -165,7 +165,7 @@ func _main(ctx context.Context, buildInfo models.BuildInformation, } } - announcementExp, err := time.Parse(time.RFC3339, "2023-07-01T00:00:00Z") + announcementExp, err := time.Parse(time.RFC3339, "2024-12-01T00:00:00Z") if err != nil { return err } @@ -176,7 +176,7 @@ func _main(ctx context.Context, buildInfo models.BuildInformation, Version: buildInfo.Version, Commit: buildInfo.Commit, Created: buildInfo.Created, - Announcement: "Wiki moved to https://github.com/qdm12/gluetun-wiki", + Announcement: "All control server routes will become private by default after the v3.41.0 release", AnnounceExp: announcementExp, // Sponsor information PaypalUser: "qmcgaw", From 47b7b55fd4ebc433e47eeacf4d3f014715fb5c0e Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Wed, 11 Sep 2024 09:34:35 +0200 Subject: [PATCH 04/10] feat(server): add apikey auth method (#2437) --- internal/server/middlewares/auth/apikey.go | 34 +++++++++++++++++++ .../middlewares/auth/configfile_test.go | 14 +++++++- internal/server/middlewares/auth/lookup.go | 2 ++ internal/server/middlewares/auth/settings.go | 14 ++++++-- 4 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 internal/server/middlewares/auth/apikey.go diff --git a/internal/server/middlewares/auth/apikey.go b/internal/server/middlewares/auth/apikey.go new file mode 100644 index 000000000..b9a3d8dcd --- /dev/null +++ b/internal/server/middlewares/auth/apikey.go @@ -0,0 +1,34 @@ +package auth + +import ( + "crypto/subtle" + "net/http" +) + +type apiKeyMethod struct { + apiKey string +} + +func newAPIKeyMethod(apiKey string) *apiKeyMethod { + return &apiKeyMethod{ + apiKey: apiKey, + } +} + +// equal returns true if another auth checker is equal. +// This is used to deduplicate checkers for a particular route. +func (a *apiKeyMethod) equal(other authorizationChecker) bool { + otherTokenMethod, ok := other.(*apiKeyMethod) + if !ok { + return false + } + return a.apiKey == otherTokenMethod.apiKey +} + +func (a *apiKeyMethod) isAuthorized(request *http.Request) bool { + xAPIKey := request.Header.Get("X-API-Key") + if xAPIKey == "" { + xAPIKey = request.URL.Query().Get("api_key") + } + return subtle.ConstantTimeCompare([]byte(xAPIKey), []byte(a.apiKey)) == 1 +} diff --git a/internal/server/middlewares/auth/configfile_test.go b/internal/server/middlewares/auth/configfile_test.go index 0123cbb18..4dcc30097 100644 --- a/internal/server/middlewares/auth/configfile_test.go +++ b/internal/server/middlewares/auth/configfile_test.go @@ -33,12 +33,24 @@ func Test_Read(t *testing.T) { fileContent: `[[roles]] name = "public" auth = "none" -routes = ["GET /v1/vpn/status", "PUT /v1/vpn/status"]`, +routes = ["GET /v1/vpn/status", "PUT /v1/vpn/status"] + +[[roles]] +name = "client" +auth = "apikey" +apikey = "xyz" +routes = ["GET /v1/vpn/status"] +`, settings: Settings{ Roles: []Role{{ Name: "public", Auth: AuthNone, Routes: []string{"GET /v1/vpn/status", "PUT /v1/vpn/status"}, + }, { + Name: "client", + Auth: AuthAPIKey, + APIKey: "xyz", + Routes: []string{"GET /v1/vpn/status"}, }}, }, }, diff --git a/internal/server/middlewares/auth/lookup.go b/internal/server/middlewares/auth/lookup.go index e725fd369..51323f931 100644 --- a/internal/server/middlewares/auth/lookup.go +++ b/internal/server/middlewares/auth/lookup.go @@ -16,6 +16,8 @@ func settingsToLookupMap(settings Settings) (routeToRoles map[string][]internalR switch role.Auth { case AuthNone: checker = newNoneMethod() + case AuthAPIKey: + checker = newAPIKeyMethod(role.APIKey) default: return nil, fmt.Errorf("%w: %s", ErrMethodNotSupported, role.Auth) } diff --git a/internal/server/middlewares/auth/settings.go b/internal/server/middlewares/auth/settings.go index 6f3541bec..93c30fde7 100644 --- a/internal/server/middlewares/auth/settings.go +++ b/internal/server/middlewares/auth/settings.go @@ -76,7 +76,8 @@ func (s Settings) ToLinesNode() (node *gotree.Node) { } const ( - AuthNone = "none" + AuthNone = "none" + AuthAPIKey = "apikey" ) // Role contains the role name, authentication method name and @@ -85,8 +86,10 @@ 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'. + // Auth is the authentication method to use, which can be 'none' or 'apikey'. Auth string + // APIKey is the API key to use when using the 'apikey' authentication. + APIKey string // 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 @@ -94,15 +97,20 @@ type Role struct { var ( ErrMethodNotSupported = errors.New("authentication method not supported") + ErrAPIKeyEmpty = errors.New("api key is empty") ErrRouteNotSupported = errors.New("route not supported by the control server") ) func (r Role) validate() (err error) { - err = validate.IsOneOf(r.Auth, AuthNone) + err = validate.IsOneOf(r.Auth, AuthNone, AuthAPIKey) if err != nil { return fmt.Errorf("%w: %s", ErrMethodNotSupported, r.Auth) } + if r.Auth == AuthAPIKey && r.APIKey == "" { + return fmt.Errorf("for role %s: %w", r.Name, ErrAPIKeyEmpty) + } + for i, route := range r.Routes { _, ok := validRoutes[route] if !ok { From dcc5e858ff4ce43f14b129a8a54af0265b05cd60 Mon Sep 17 00:00:00 2001 From: Joe Jose <45399349+joejose97@users.noreply.github.com> Date: Wed, 11 Sep 2024 13:09:31 +0530 Subject: [PATCH 05/10] feat(control server authentication): add basic http auth (#2423) --- internal/server/middlewares/auth/basic.go | 36 ++++++++++++++++++++ internal/server/middlewares/auth/lookup.go | 2 ++ internal/server/middlewares/auth/settings.go | 16 +++++++-- 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 internal/server/middlewares/auth/basic.go diff --git a/internal/server/middlewares/auth/basic.go b/internal/server/middlewares/auth/basic.go new file mode 100644 index 000000000..3f9da93ea --- /dev/null +++ b/internal/server/middlewares/auth/basic.go @@ -0,0 +1,36 @@ +package auth + +import ( + "crypto/sha256" + "crypto/subtle" + "net/http" +) + +type basicAuthMethod struct { + authDigest [32]byte +} + +func newBasicAuthMethod(username, password string) *basicAuthMethod { + return &basicAuthMethod{ + authDigest: sha256.Sum256([]byte(username + password)), + } +} + +// equal returns true if another auth checker is equal. +// This is used to deduplicate checkers for a particular route. +func (a *basicAuthMethod) equal(other authorizationChecker) bool { + otherBasicMethod, ok := other.(*basicAuthMethod) + if !ok { + return false + } + return a.authDigest == otherBasicMethod.authDigest +} + +func (a *basicAuthMethod) isAuthorized(request *http.Request) bool { + username, password, ok := request.BasicAuth() + if !ok { + return false + } + requestAuthDigest := sha256.Sum256([]byte(username + password)) + return subtle.ConstantTimeCompare(a.authDigest[:], requestAuthDigest[:]) == 1 +} diff --git a/internal/server/middlewares/auth/lookup.go b/internal/server/middlewares/auth/lookup.go index 51323f931..d02c433b3 100644 --- a/internal/server/middlewares/auth/lookup.go +++ b/internal/server/middlewares/auth/lookup.go @@ -18,6 +18,8 @@ func settingsToLookupMap(settings Settings) (routeToRoles map[string][]internalR checker = newNoneMethod() case AuthAPIKey: checker = newAPIKeyMethod(role.APIKey) + case AuthBasic: + checker = newBasicAuthMethod(role.Username, role.Password) default: return nil, fmt.Errorf("%w: %s", ErrMethodNotSupported, role.Auth) } diff --git a/internal/server/middlewares/auth/settings.go b/internal/server/middlewares/auth/settings.go index 93c30fde7..0a9d88837 100644 --- a/internal/server/middlewares/auth/settings.go +++ b/internal/server/middlewares/auth/settings.go @@ -78,6 +78,7 @@ func (s Settings) ToLinesNode() (node *gotree.Node) { const ( AuthNone = "none" AuthAPIKey = "apikey" + AuthBasic = "basic" ) // Role contains the role name, authentication method name and @@ -90,6 +91,10 @@ type Role struct { Auth string // APIKey is the API key to use when using the 'apikey' authentication. APIKey string + // Username for HTTP Basic authentication method. + Username string + // Password for HTTP Basic authentication method. + Password string // 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 @@ -98,17 +103,24 @@ type Role struct { 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) + err = validate.IsOneOf(r.Auth, AuthNone, AuthAPIKey, AuthBasic) if err != nil { return fmt.Errorf("%w: %s", ErrMethodNotSupported, r.Auth) } - if r.Auth == AuthAPIKey && r.APIKey == "" { + 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 { From 47e6e16864546a0a53a4720c057366f3b7da834b Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Wed, 11 Sep 2024 07:58:35 +0000 Subject: [PATCH 06/10] fix: read configuration after settings defaulting --- Dockerfile | 1 + cmd/gluetun/main.go | 2 +- internal/configuration/settings/server.go | 24 ++------------ .../configuration/settings/settings_test.go | 4 +-- .../server/middlewares/auth/configfile.go | 4 +++ internal/server/middlewares/auth/settings.go | 33 ------------------- internal/server/server.go | 12 ++++++- 7 files changed, 21 insertions(+), 59 deletions(-) diff --git a/Dockerfile b/Dockerfile index 85046a14a..7f4e1bfd7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -194,6 +194,7 @@ ENV VPN_SERVICE_PROVIDER=pia \ # Control server HTTP_CONTROL_SERVER_LOG=on \ HTTP_CONTROL_SERVER_ADDRESS=":8000" \ + HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH=/gluetun/auth/config.toml \ # Server data updater UPDATER_PERIOD=0 \ UPDATER_MIN_RATIO=0.8 \ diff --git a/cmd/gluetun/main.go b/cmd/gluetun/main.go index 725fab918..1f29821aa 100644 --- a/cmd/gluetun/main.go +++ b/cmd/gluetun/main.go @@ -465,7 +465,7 @@ func _main(ctx context.Context, buildInfo models.BuildInformation, "http server", goroutine.OptionTimeout(defaultShutdownTimeout)) httpServer, err := server.New(httpServerCtx, controlServerAddress, controlServerLogging, logger.New(log.SetComponent("http server")), - allSettings.ControlServer.Auth, + 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 5452de542..155d5cdb3 100644 --- a/internal/configuration/settings/server.go +++ b/internal/configuration/settings/server.go @@ -6,7 +6,6 @@ import ( "os" "strconv" - "github.com/qdm12/gluetun/internal/server/middlewares/auth" "github.com/qdm12/gosettings" "github.com/qdm12/gosettings/reader" "github.com/qdm12/gotree" @@ -25,10 +24,6 @@ type ControlServer struct { // It cannot be empty in the internal state and defaults to // /gluetun/auth/config.toml. AuthFilePath string - // Auth contains settings for the authentication middleware. - // These are parsed from a configuration file specified by - // AuthFilePath. - Auth auth.Settings } func (c ControlServer) validate() (err error) { @@ -49,18 +44,14 @@ func (c ControlServer) validate() (err error) { ErrControlServerPrivilegedPort, port, uid) } - err = c.Auth.Validate() - if err != nil { - return fmt.Errorf("validating authentication middleware: %w", err) - } - return nil } func (c *ControlServer) copy() (copied ControlServer) { return ControlServer{ - Address: gosettings.CopyPointer(c.Address), - Log: gosettings.CopyPointer(c.Log), + Address: gosettings.CopyPointer(c.Address), + Log: gosettings.CopyPointer(c.Log), + AuthFilePath: c.AuthFilePath, } } @@ -71,14 +62,12 @@ 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.Auth.OverrideWith(other.Auth) } 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.Auth.SetDefaults() } func (c ControlServer) String() string { @@ -90,7 +79,6 @@ 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) - node.AppendNode(c.Auth.ToLinesNode()) return node } @@ -103,12 +91,6 @@ 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") - if c.AuthFilePath != "" { - c.Auth, err = auth.Read(c.AuthFilePath) - if err != nil { - return fmt.Errorf("reading authentication middleware settings: %w", err) - } - } return nil } diff --git a/internal/configuration/settings/settings_test.go b/internal/configuration/settings/settings_test.go index e5c768aa4..5c3350fc5 100644 --- a/internal/configuration/settings/settings_test.go +++ b/internal/configuration/settings/settings_test.go @@ -71,9 +71,7 @@ func Test_Settings_String(t *testing.T) { ├── Control server settings: | ├── Listening address: :8000 | ├── Logging: yes -| ├── Authentication file path: /gluetun/auth/config.toml -| └── Authentication middleware settings: -| └── Roles defined: public +| └── Authentication file path: /gluetun/auth/config.toml ├── Storage settings: | └── Filepath: /gluetun/servers.json ├── OS Alpine settings: diff --git a/internal/server/middlewares/auth/configfile.go b/internal/server/middlewares/auth/configfile.go index 2a398536e..1722a9107 100644 --- a/internal/server/middlewares/auth/configfile.go +++ b/internal/server/middlewares/auth/configfile.go @@ -9,9 +9,13 @@ import ( ) // Read reads the toml file specified by the filepath given. +// If the file does not exist, it returns empty settings and no error. func Read(filepath string) (settings Settings, err error) { file, err := os.Open(filepath) if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Settings{}, nil + } return settings, fmt.Errorf("opening file: %w", err) } decoder := toml.NewDecoder(file) diff --git a/internal/server/middlewares/auth/settings.go b/internal/server/middlewares/auth/settings.go index 0a9d88837..70bc5e09e 100644 --- a/internal/server/middlewares/auth/settings.go +++ b/internal/server/middlewares/auth/settings.go @@ -7,7 +7,6 @@ import ( "github.com/qdm12/gosettings" "github.com/qdm12/gosettings/validate" - "github.com/qdm12/gotree" ) type Settings struct { @@ -51,30 +50,6 @@ func (s Settings) Validate() (err error) { return nil } -func (s Settings) Copy() (copied Settings) { - copied.Roles = make([]Role, len(s.Roles)) - for i := range s.Roles { - copied.Roles[i] = s.Roles[i].copy() - } - return copied -} - -func (s *Settings) OverrideWith(other Settings) { - s.Roles = gosettings.OverrideWithSlice(s.Roles, other.Roles) -} - -func (s Settings) ToLinesNode() (node *gotree.Node) { - node = gotree.New("Authentication middleware settings:") - - roleNames := make([]string, len(s.Roles)) - for i, role := range s.Roles { - roleNames[i] = role.Name - } - node.Appendf("Roles defined: %s", andStrings(roleNames)) - - return node -} - const ( AuthNone = "none" AuthAPIKey = "apikey" @@ -154,11 +129,3 @@ var validRoutes = map[string]struct{}{ //nolint:gochecknoglobals http.MethodPut + " /v1/updater/status": {}, http.MethodGet + " /v1/publicip/ip": {}, } - -func (r Role) copy() (copied Role) { - copied.Name = r.Name - copied.Auth = r.Auth - copied.Routes = make([]string, len(r.Routes)) - copy(copied.Routes, r.Routes) - return copied -} diff --git a/internal/server/server.go b/internal/server/server.go index f484ba9e5..7e715dbc8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,11 +10,21 @@ import ( ) func New(ctx context.Context, address string, logEnabled bool, logger Logger, - authSettings auth.Settings, buildInfo models.BuildInformation, openvpnLooper VPNLooper, + authConfigPath string, 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) + if err != nil { + return nil, fmt.Errorf("reading auth settings: %w", err) + } + authSettings.SetDefaults() + err = authSettings.Validate() + if err != nil { + return nil, fmt.Errorf("validating auth settings: %w", err) + } + handler, err := newHandler(ctx, logger, logEnabled, authSettings, buildInfo, openvpnLooper, pfGetter, dnsLooper, updaterLooper, publicIPLooper, storage, ipv6Supported) From 048af9d9adbd9ae841d6f21272d790c73f647a0d Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Wed, 11 Sep 2024 08:29:38 +0000 Subject: [PATCH 07/10] fix: basic auth response header WWW-Authenticate if all roles failed --- internal/server/middlewares/auth/apikey.go | 2 +- internal/server/middlewares/auth/basic.go | 3 ++- internal/server/middlewares/auth/interfaces_local.go | 2 +- internal/server/middlewares/auth/middleware.go | 10 +++++++++- internal/server/middlewares/auth/none.go | 2 +- 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/internal/server/middlewares/auth/apikey.go b/internal/server/middlewares/auth/apikey.go index b9a3d8dcd..bbab156e3 100644 --- a/internal/server/middlewares/auth/apikey.go +++ b/internal/server/middlewares/auth/apikey.go @@ -25,7 +25,7 @@ func (a *apiKeyMethod) equal(other authorizationChecker) bool { return a.apiKey == otherTokenMethod.apiKey } -func (a *apiKeyMethod) isAuthorized(request *http.Request) bool { +func (a *apiKeyMethod) isAuthorized(_ http.Header, request *http.Request) bool { xAPIKey := request.Header.Get("X-API-Key") if xAPIKey == "" { xAPIKey = request.URL.Query().Get("api_key") diff --git a/internal/server/middlewares/auth/basic.go b/internal/server/middlewares/auth/basic.go index 3f9da93ea..7017ed831 100644 --- a/internal/server/middlewares/auth/basic.go +++ b/internal/server/middlewares/auth/basic.go @@ -26,9 +26,10 @@ func (a *basicAuthMethod) equal(other authorizationChecker) bool { return a.authDigest == otherBasicMethod.authDigest } -func (a *basicAuthMethod) isAuthorized(request *http.Request) bool { +func (a *basicAuthMethod) isAuthorized(headers http.Header, request *http.Request) bool { username, password, ok := request.BasicAuth() if !ok { + headers.Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) return false } requestAuthDigest := sha256.Sum256([]byte(username + password)) diff --git a/internal/server/middlewares/auth/interfaces_local.go b/internal/server/middlewares/auth/interfaces_local.go index abab54804..31a0aeed4 100644 --- a/internal/server/middlewares/auth/interfaces_local.go +++ b/internal/server/middlewares/auth/interfaces_local.go @@ -4,5 +4,5 @@ import "net/http" type authorizationChecker interface { equal(other authorizationChecker) bool - isAuthorized(request *http.Request) bool + isAuthorized(headers http.Header, request *http.Request) bool } diff --git a/internal/server/middlewares/auth/middleware.go b/internal/server/middlewares/auth/middleware.go index 4dccde6e0..fd7f86793 100644 --- a/internal/server/middlewares/auth/middleware.go +++ b/internal/server/middlewares/auth/middleware.go @@ -58,8 +58,9 @@ func (h *authHandler) ServeHTTP(writer http.ResponseWriter, request *http.Reques return } + responseHeader := make(http.Header, 0) for _, role := range roles { - if !role.checker.isAuthorized(request) { + if !role.checker.isAuthorized(responseHeader, request) { continue } @@ -70,6 +71,13 @@ func (h *authHandler) ServeHTTP(writer http.ResponseWriter, request *http.Reques return } + // Flush out response headers if all roles failed to authenticate + for headerKey, headerValues := range responseHeader { + for _, headerValue := range headerValues { + writer.Header().Add(headerKey, headerValue) + } + } + allRoleNames := make([]string, len(roles)) for i, role := range roles { allRoleNames[i] = role.name diff --git a/internal/server/middlewares/auth/none.go b/internal/server/middlewares/auth/none.go index 66aa9529e..4d9f82018 100644 --- a/internal/server/middlewares/auth/none.go +++ b/internal/server/middlewares/auth/none.go @@ -15,6 +15,6 @@ func (n *noneMethod) equal(other authorizationChecker) bool { return ok } -func (n *noneMethod) isAuthorized(_ *http.Request) bool { +func (n *noneMethod) isAuthorized(_ http.Header, _ *http.Request) bool { return true } From 4f99208f9ab36bb83c663644febb3cf331ebf551 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Sun, 15 Sep 2024 19:17:12 +0000 Subject: [PATCH 08/10] fix: api key to use hash digest to prevent timing attacks --- internal/server/middlewares/auth/apikey.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/server/middlewares/auth/apikey.go b/internal/server/middlewares/auth/apikey.go index bbab156e3..f9fd53834 100644 --- a/internal/server/middlewares/auth/apikey.go +++ b/internal/server/middlewares/auth/apikey.go @@ -1,17 +1,18 @@ package auth import ( + "crypto/sha256" "crypto/subtle" "net/http" ) type apiKeyMethod struct { - apiKey string + apiKeyDigest [32]byte } func newAPIKeyMethod(apiKey string) *apiKeyMethod { return &apiKeyMethod{ - apiKey: apiKey, + apiKeyDigest: sha256.Sum256([]byte(apiKey)), } } @@ -22,7 +23,7 @@ func (a *apiKeyMethod) equal(other authorizationChecker) bool { if !ok { return false } - return a.apiKey == otherTokenMethod.apiKey + return a.apiKeyDigest == otherTokenMethod.apiKeyDigest } func (a *apiKeyMethod) isAuthorized(_ http.Header, request *http.Request) bool { @@ -30,5 +31,6 @@ func (a *apiKeyMethod) isAuthorized(_ http.Header, request *http.Request) bool { if xAPIKey == "" { xAPIKey = request.URL.Query().Get("api_key") } - return subtle.ConstantTimeCompare([]byte(xAPIKey), []byte(a.apiKey)) == 1 + xAPIKeyDigest := sha256.Sum256([]byte(xAPIKey)) + return subtle.ConstantTimeCompare(xAPIKeyDigest[:], a.apiKeyDigest[:]) == 1 } From 0b9784b30a3b31f50873fcb1a85f4ba8251ce96d Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Sun, 15 Sep 2024 19:25:51 +0000 Subject: [PATCH 09/10] fix github wiki link --- internal/server/middlewares/auth/middleware.go | 2 +- internal/server/middlewares/auth/middleware_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/server/middlewares/auth/middleware.go b/internal/server/middlewares/auth/middleware.go index fd7f86793..7a6c18bcd 100644 --- a/internal/server/middlewares/auth/middleware.go +++ b/internal/server/middlewares/auth/middleware.go @@ -105,7 +105,7 @@ func (h *authHandler) warnIfUnprotectedByDefault(role internalRole, route string } h.logger.Warnf("route %s is unprotected by default, "+ "please set up authentication following the documentation at "+ - "https://github.com/gluetun-wiki/setup/advanced/control-server.md#authentication "+ + "https://github.com/qdm12/gluetun-wiki/setup/advanced/control-server.md#authentication "+ "since this will become no longer publicly accessible after release v3.40.", route) } diff --git a/internal/server/middlewares/auth/middleware_test.go b/internal/server/middlewares/auth/middleware_test.go index 5b98f755f..5f9f75ccf 100644 --- a/internal/server/middlewares/auth/middleware_test.go +++ b/internal/server/middlewares/auth/middleware_test.go @@ -50,7 +50,7 @@ func Test_authHandler_ServeHTTP(t *testing.T) { logger := NewMockDebugLogger(ctrl) logger.EXPECT().Warnf("route %s is unprotected by default, "+ "please set up authentication following the documentation at "+ - "https://github.com/gluetun-wiki/setup/advanced/control-server.md#authentication "+ + "https://github.com/qdm12/gluetun-wiki/setup/advanced/control-server.md#authentication "+ "since this will become no longer publicly accessible after release v3.40.", "GET /v1/vpn/status") logger.EXPECT().Debugf("access to route %s authorized for role %s", From 759d7981d1d57be868aec9ca28c88f8346711c10 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Wed, 18 Sep 2024 11:21:14 +0000 Subject: [PATCH 10/10] feat(cli): add genkey command --- cmd/gluetun/main.go | 3 ++ internal/cli/genkey.go | 66 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 internal/cli/genkey.go diff --git a/cmd/gluetun/main.go b/cmd/gluetun/main.go index 1f29821aa..a6403ca37 100644 --- a/cmd/gluetun/main.go +++ b/cmd/gluetun/main.go @@ -160,6 +160,8 @@ func _main(ctx context.Context, buildInfo models.BuildInformation, return cli.Update(ctx, args[2:], logger) case "format-servers": return cli.FormatServers(args[2:]) + case "genkey": + return cli.GenKey(args[2:]) default: return fmt.Errorf("%w: %s", errCommandUnknown, args[1]) } @@ -587,6 +589,7 @@ type clier interface { OpenvpnConfig(logger cli.OpenvpnConfigLogger, reader *reader.Reader, ipv6Checker cli.IPv6Checker) error HealthCheck(ctx context.Context, reader *reader.Reader, warner cli.Warner) error Update(ctx context.Context, args []string, logger cli.UpdaterLogger) error + GenKey(args []string) error } type Tun interface { diff --git a/internal/cli/genkey.go b/internal/cli/genkey.go new file mode 100644 index 000000000..ac161cf00 --- /dev/null +++ b/internal/cli/genkey.go @@ -0,0 +1,66 @@ +package cli + +import ( + "crypto/rand" + "flag" + "fmt" +) + +func (c *CLI) GenKey(args []string) (err error) { + flagSet := flag.NewFlagSet("genkey", flag.ExitOnError) + err = flagSet.Parse(args) + if err != nil { + return fmt.Errorf("parsing flags: %w", err) + } + + const keyLength = 128 / 8 + keyBytes := make([]byte, keyLength) + + _, _ = rand.Read(keyBytes) + + key := base58Encode(keyBytes) + fmt.Println(key) + + return nil +} + +func base58Encode(data []byte) string { + const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + const radix = 58 + + zcount := 0 + for zcount < len(data) && data[zcount] == 0 { + zcount++ + } + + // integer simplification of ceil(log(256)/log(58)) + ceilLog256Div58 := (len(data)-zcount)*555/406 + 1 //nolint:gomnd + size := zcount + ceilLog256Div58 + + output := make([]byte, size) + + high := size - 1 + for _, b := range data { + i := size - 1 + for carry := uint32(b); i > high || carry != 0; i-- { + carry += 256 * uint32(output[i]) //nolint:gomnd + output[i] = byte(carry % radix) + carry /= radix + } + high = i + } + + // Determine the additional "zero-gap" in the output buffer + additionalZeroGapEnd := zcount + for additionalZeroGapEnd < size && output[additionalZeroGapEnd] == 0 { + additionalZeroGapEnd++ + } + + val := output[additionalZeroGapEnd-zcount:] + size = len(val) + for i := range val { + output[i] = alphabet[val[i]] + } + + return string(output[:size]) +}