Skip to content

Commit

Permalink
feat(server): role based authentication system (#2434)
Browse files Browse the repository at this point in the history
- Parse toml configuration file, see https://github.com/qdm12/gluetun-wiki/blob/main/setup/advanced/control-server.md#authentication
- Retro-compatible with existing AND documented routes, until after v3.41 release
- Log a warning if an unprotected-by-default route is accessed unprotected
- Authentication methods: none, apikey, basic
- `genkey` command to generate API keys

Co-authored-by: Joe Jose <[email protected]>
  • Loading branch information
qdm12 and joejose97 authored Sep 18, 2024
1 parent 0765168 commit a2e76e1
Show file tree
Hide file tree
Showing 25 changed files with 920 additions and 10 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
8 changes: 6 additions & 2 deletions cmd/gluetun/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,14 @@ 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])
}
}

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
}
Expand All @@ -176,7 +178,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",
Expand Down Expand Up @@ -465,6 +467,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.AuthFilePath,
buildInfo, vpnLooper, portForwardLooper, dnsLooper, updaterLooper, publicIPLooper,
storage, ipv6Supported)
if err != nil {
Expand Down Expand Up @@ -586,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 {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
66 changes: 66 additions & 0 deletions internal/cli/genkey.go
Original file line number Diff line number Diff line change
@@ -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])
}
17 changes: 15 additions & 2 deletions internal/configuration/settings/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ 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
}

func (c ControlServer) validate() (err error) {
Expand All @@ -44,8 +49,9 @@ func (c ControlServer) validate() (err error) {

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,
}
}

Expand All @@ -55,11 +61,13 @@ 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)
}

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")
}

func (c ControlServer) String() string {
Expand All @@ -70,6 +78,7 @@ 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)
return node
}

Expand All @@ -78,6 +87,10 @@ 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")

return nil
}
3 changes: 2 additions & 1 deletion internal/configuration/settings/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ 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
├── Storage settings:
| └── Filepath: /gluetun/servers.json
├── OS Alpine settings:
Expand Down
15 changes: 12 additions & 3 deletions internal/server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions internal/server/logger.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package server

type Logger interface {
Debugf(format string, args ...any)
infoer
warner
Warnf(format string, args ...any)
errorer
}

Expand Down
36 changes: 36 additions & 0 deletions internal/server/middlewares/auth/apikey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package auth

import (
"crypto/sha256"
"crypto/subtle"
"net/http"
)

type apiKeyMethod struct {
apiKeyDigest [32]byte
}

func newAPIKeyMethod(apiKey string) *apiKeyMethod {
return &apiKeyMethod{
apiKeyDigest: sha256.Sum256([]byte(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.apiKeyDigest == otherTokenMethod.apiKeyDigest
}

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")
}
xAPIKeyDigest := sha256.Sum256([]byte(xAPIKey))
return subtle.ConstantTimeCompare(xAPIKeyDigest[:], a.apiKeyDigest[:]) == 1
}
37 changes: 37 additions & 0 deletions internal/server/middlewares/auth/basic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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(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))
return subtle.ConstantTimeCompare(a.authDigest[:], requestAuthDigest[:]) == 1
}
Loading

0 comments on commit a2e76e1

Please sign in to comment.