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
- move log middleware to internal/server/middlewares/log

Co-authored-by: Joe Jose <45399349+joejose97@users.noreply.github.com>
  • Loading branch information
qdm12 and joejose97 committed Sep 29, 2024
1 parent cbfdb25 commit 67ae5f5
Show file tree
Hide file tree
Showing 27 changed files with 943 additions and 24 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,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 @@ -161,12 +161,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 @@ -177,7 +179,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 @@ -474,6 +476,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, unboundLooper, updaterLooper, publicIPLooper,
storage, ipv6Supported)
if err != nil {
Expand Down Expand Up @@ -595,6 +598,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.8
github.com/klauspost/pgzip v1.2.6
github.com/pelletier/go-toml/v2 v2.2.2
github.com/qdm12/dns v1.11.0
github.com/qdm12/golibs v0.0.0-20210822203818-5c568b0777b6
github.com/qdm12/gosettings v0.4.2
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo=
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/phayes/permbits v0.0.0-20190612203442-39d7c581d2ee/go.mod h1:3uODdxMgOaPYeWU7RzZLxVtJHZ/x1f/iHkBZuKJDzuY=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down Expand Up @@ -113,10 +115,16 @@ github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAm
github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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
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 @@ -78,7 +78,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
├── OS Alpine settings:
| ├── Process UID: 1000
| └── Process GID: 1000
Expand Down
29 changes: 21 additions & 8 deletions internal/server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +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 @@ -17,7 +21,7 @@ func newHandler(ctx context.Context, logger infoWarner, logging bool,
publicIPLooper PublicIPLoop,
storage Storage,
ipv6Supported bool,
) http.Handler {
) (httpHandler http.Handler, err error) {
handler := &handler{}

vpn := newVPNHandler(ctx, vpnLooper, storage, ipv6Supported, logger)
Expand All @@ -29,16 +33,25 @@ func newHandler(ctx context.Context, logger infoWarner, logging bool,
handler.v0 = newHandlerV0(ctx, logger, vpnLooper, unboundLooper, updaterLooper)
handler.v1 = newHandlerV1(logger, buildInfo, vpn, openvpn, dns, updater, publicip)

handlerWithLog := withLogMiddleware(handler, logger, logging)
handler.setLogEnabled = handlerWithLog.setEnabled
authMiddleware, err := auth.New(authSettings, logger)
if err != nil {
return nil, fmt.Errorf("creating auth middleware: %w", err)
}

return handlerWithLog
middlewares := []func(http.Handler) http.Handler{
authMiddleware,
log.New(logger, logging),
}
httpHandler = handler
for _, middleware := range middlewares {
httpHandler = middleware(httpHandler)
}
return httpHandler, nil
}

type handler struct {
v0 http.Handler
v1 http.Handler
setLogEnabled func(enabled bool)
v0 http.Handler
v1 http.Handler
}

func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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
}
35 changes: 35 additions & 0 deletions internal/server/middlewares/auth/configfile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package auth

import (
"errors"
"fmt"
"os"

"github.com/pelletier/go-toml/v2"
)

// 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)
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())
}
Loading

0 comments on commit 67ae5f5

Please sign in to comment.