feat(server/auth): HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE option (JSON encoded)

- For example: `{"auth":"basic","username":"me","password":"pass"}`
- For example`{"auth":"apiKey","apikey":"xyz"}`
- For example`{"auth":"none"}` (I don't recommend)
This commit is contained in:
Quentin McGaw
2025-11-13 18:24:34 +00:00
parent f11f142bee
commit 3fac02a82a
5 changed files with 146 additions and 31 deletions

View File

@@ -201,6 +201,7 @@ ENV VPN_SERVICE_PROVIDER=pia \
HTTP_CONTROL_SERVER_LOG=on \
HTTP_CONTROL_SERVER_ADDRESS=":8000" \
HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH=/gluetun/auth/config.toml \
HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE="{}" \
# Server data updater
UPDATER_PERIOD=0 \
UPDATER_MIN_RATIO=0.8 \

View File

@@ -466,13 +466,10 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
go shadowsocksLooper.Run(shadowsocksCtx, shadowsocksDone)
otherGroupHandler.Add(shadowsocksHandler)
controlServerAddress := *allSettings.ControlServer.Address
controlServerLogging := *allSettings.ControlServer.Log
httpServerHandler, httpServerCtx, httpServerDone := goshutdown.NewGoRoutineHandler(
"http server", goroutine.OptionTimeout(defaultShutdownTimeout))
httpServer, err := server.New(httpServerCtx, controlServerAddress, controlServerLogging,
httpServer, err := server.New(httpServerCtx, allSettings.ControlServer,
logger.New(log.SetComponent("http server")),
allSettings.ControlServer.AuthFilePath,
buildInfo, vpnLooper, portForwardLooper, dnsLooper, updaterLooper, publicIPLooper,
storage, ipv6Supported)
if err != nil {

View File

@@ -1,11 +1,14 @@
package settings
import (
"bytes"
"encoding/json"
"fmt"
"net"
"os"
"strconv"
"github.com/qdm12/gluetun/internal/server/middlewares/auth"
"github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/reader"
"github.com/qdm12/gotree"
@@ -24,6 +27,9 @@ type ControlServer struct {
// It cannot be empty in the internal state and defaults to
// /gluetun/auth/config.toml.
AuthFilePath string
// AuthDefaultRole is a JSON encoded object defining the default role
// that applies to all routes without a previously user-defined role assigned to.
AuthDefaultRole string
}
func (c ControlServer) validate() (err error) {
@@ -44,14 +50,30 @@ func (c ControlServer) validate() (err error) {
ErrControlServerPrivilegedPort, port, uid)
}
jsonDecoder := json.NewDecoder(bytes.NewBufferString(c.AuthDefaultRole))
jsonDecoder.DisallowUnknownFields()
var role auth.Role
err = jsonDecoder.Decode(&role)
if err != nil {
return fmt.Errorf("default authentication role is not valid JSON: %w", err)
}
if role.Auth != "" {
err = role.Validate()
if err != nil {
return fmt.Errorf("default authentication role is not valid: %w", err)
}
}
return nil
}
func (c *ControlServer) copy() (copied ControlServer) {
return ControlServer{
Address: gosettings.CopyPointer(c.Address),
Log: gosettings.CopyPointer(c.Log),
AuthFilePath: c.AuthFilePath,
Address: gosettings.CopyPointer(c.Address),
Log: gosettings.CopyPointer(c.Log),
AuthFilePath: c.AuthFilePath,
AuthDefaultRole: c.AuthDefaultRole,
}
}
@@ -62,12 +84,21 @@ func (c *ControlServer) overrideWith(other ControlServer) {
c.Address = gosettings.OverrideWithPointer(c.Address, other.Address)
c.Log = gosettings.OverrideWithPointer(c.Log, other.Log)
c.AuthFilePath = gosettings.OverrideWithComparable(c.AuthFilePath, other.AuthFilePath)
c.AuthDefaultRole = gosettings.OverrideWithComparable(c.AuthDefaultRole, other.AuthDefaultRole)
}
func (c *ControlServer) setDefaults() {
c.Address = gosettings.DefaultPointer(c.Address, ":8000")
c.Log = gosettings.DefaultPointer(c.Log, true)
c.AuthFilePath = gosettings.DefaultComparable(c.AuthFilePath, "/gluetun/auth/config.toml")
c.AuthDefaultRole = gosettings.DefaultComparable(c.AuthDefaultRole, "{}")
if c.AuthDefaultRole != "{}" {
var role auth.Role
_ = json.Unmarshal([]byte(c.AuthDefaultRole), &role)
role.Name = "default"
roleBytes, _ := json.Marshal(role) //nolint:errchkjson
c.AuthDefaultRole = string(roleBytes)
}
}
func (c ControlServer) String() string {
@@ -79,6 +110,11 @@ func (c ControlServer) toLinesNode() (node *gotree.Node) {
node.Appendf("Listening address: %s", *c.Address)
node.Appendf("Logging: %s", gosettings.BoolToYesNo(c.Log))
node.Appendf("Authentication file path: %s", c.AuthFilePath)
if c.AuthDefaultRole != "{}" {
var role auth.Role
_ = json.Unmarshal([]byte(c.AuthDefaultRole), &role)
node.AppendNode(role.ToLinesNode())
}
return node
}
@@ -91,6 +127,7 @@ func (c *ControlServer) read(r *reader.Reader) (err error) {
c.Address = r.Get("HTTP_CONTROL_SERVER_ADDRESS")
c.AuthFilePath = r.String("HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH")
c.AuthDefaultRole = r.String("HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE")
return nil
}

View File

@@ -1,12 +1,16 @@
package auth
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/validate"
"github.com/qdm12/gotree"
)
type Settings struct {
@@ -15,6 +19,50 @@ type Settings struct {
Roles []Role
}
// SetDefaultRole sets a default role to apply to all routes without a
// previously user-defined role assigned to. Note the role argument
// routes are ignored. This should be called BEFORE calling [Settings.SetDefaults].
func (s *Settings) SetDefaultRole(jsonRole string) error {
var role Role
decoder := json.NewDecoder(bytes.NewBufferString(jsonRole))
decoder.DisallowUnknownFields()
err := decoder.Decode(&role)
if err != nil {
return fmt.Errorf("decoding default role: %w", err)
}
if role.Auth == "" {
return nil // no default role to set
}
err = role.Validate()
if err != nil {
return fmt.Errorf("validating default role: %w", err)
}
authenticatedRoutes := make(map[string]struct{}, len(validRoutes))
for _, role := range s.Roles {
for _, route := range role.Routes {
authenticatedRoutes[route] = struct{}{}
}
}
if len(authenticatedRoutes) == len(validRoutes) {
return nil
}
unauthenticatedRoutes := make([]string, 0, len(validRoutes))
for route := range validRoutes {
_, authenticated := authenticatedRoutes[route]
if !authenticated {
unauthenticatedRoutes = append(unauthenticatedRoutes, route)
}
}
slices.Sort(unauthenticatedRoutes)
role.Routes = unauthenticatedRoutes
s.Roles = append(s.Roles, role)
return nil
}
func (s *Settings) SetDefaults() {
s.Roles = gosettings.DefaultSlice(s.Roles, []Role{{ // TODO v3.41.0 leave empty
Name: "public",
@@ -42,7 +90,7 @@ func (s *Settings) SetDefaults() {
func (s Settings) Validate() (err error) {
for i, role := range s.Roles {
err = role.validate()
err = role.Validate()
if err != nil {
return fmt.Errorf("role %s (%d of %d): %w",
role.Name, i+1, len(s.Roles), err)
@@ -63,18 +111,18 @@ const (
type Role struct {
// Name is the role name and is only used for documentation
// and in the authentication middleware debug logs.
Name string
// Auth is the authentication method to use, which can be 'none' or 'apikey'.
Auth string
Name string `json:"name"`
// Auth is the authentication method to use, which can be 'none', 'basic' or 'apikey'.
Auth string `json:"auth"`
// APIKey is the API key to use when using the 'apikey' authentication.
APIKey string
APIKey string `json:"apikey"`
// Username for HTTP Basic authentication method.
Username string
Username string `json:"username"`
// Password for HTTP Basic authentication method.
Password string
Password string `json:"password"`
// Routes is a list of routes that the role can access in the format
// "HTTP_METHOD PATH", for example "GET /v1/vpn/status"
Routes []string
Routes []string `json:"-"`
}
var (
@@ -85,7 +133,7 @@ var (
ErrRouteNotSupported = errors.New("route not supported by the control server")
)
func (r Role) validate() (err error) {
func (r Role) Validate() (err error) {
err = validate.IsOneOf(r.Auth, AuthNone, AuthAPIKey, AuthBasic)
if err != nil {
return fmt.Errorf("%w: %s", ErrMethodNotSupported, r.Auth)
@@ -134,3 +182,20 @@ var validRoutes = map[string]struct{}{ //nolint:gochecknoglobals
http.MethodGet + " /v1/publicip/ip": {},
http.MethodGet + " /v1/portforward": {},
}
func (r Role) ToLinesNode() (node *gotree.Node) {
node = gotree.New("Role " + r.Name)
node.Appendf("Authentication method: %s", r.Auth)
switch r.Auth {
case AuthNone:
case AuthBasic:
node.Appendf("Username: %s", r.Username)
node.Appendf("Password: %s", gosettings.ObfuscateKey(r.Password))
case AuthAPIKey:
node.Appendf("API key: %s", gosettings.ObfuscateKey(r.APIKey))
default:
panic("missing code for authentication method: " + r.Auth)
}
node.Appendf("Number of routes covered: %d", len(r.Routes))
return node
}

View File

@@ -6,33 +6,25 @@ import (
"fmt"
"os"
"github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/httpserver"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/server/middlewares/auth"
)
func New(ctx context.Context, address string, logEnabled bool, logger Logger,
authConfigPath string, buildInfo models.BuildInformation, openvpnLooper VPNLooper,
func New(ctx context.Context, settings settings.ControlServer, logger Logger,
buildInfo models.BuildInformation, openvpnLooper VPNLooper,
pfGetter PortForwardedGetter, dnsLooper DNSLoop,
updaterLooper UpdaterLooper, publicIPLooper PublicIPLoop, storage Storage,
ipv6Supported bool) (
server *httpserver.Server, err error,
) {
authSettings, err := auth.Read(authConfigPath)
switch {
case errors.Is(err, os.ErrNotExist): // no auth file present
case err != nil:
return nil, fmt.Errorf("reading auth settings: %w", err)
default:
logger.Infof("read %d roles from authentication file", len(authSettings.Roles))
}
authSettings.SetDefaults()
err = authSettings.Validate()
authSettings, err := setupAuthMiddleware(settings.AuthFilePath, settings.AuthDefaultRole, logger)
if err != nil {
return nil, fmt.Errorf("validating auth settings: %w", err)
return nil, fmt.Errorf("building authentication middleware settings: %w", err)
}
handler, err := newHandler(ctx, logger, logEnabled, authSettings, buildInfo,
handler, err := newHandler(ctx, logger, *settings.Log, authSettings, buildInfo,
openvpnLooper, pfGetter, dnsLooper, updaterLooper, publicIPLooper,
storage, ipv6Supported)
if err != nil {
@@ -40,7 +32,7 @@ func New(ctx context.Context, address string, logEnabled bool, logger Logger,
}
httpServerSettings := httpserver.Settings{
Address: address,
Address: *settings.Address,
Handler: handler,
Logger: logger,
}
@@ -52,3 +44,26 @@ func New(ctx context.Context, address string, logEnabled bool, logger Logger,
return server, nil
}
func setupAuthMiddleware(authPath, jsonDefaultRole string, logger Logger) (
authSettings auth.Settings, err error,
) {
authSettings, err = auth.Read(authPath)
switch {
case errors.Is(err, os.ErrNotExist): // no auth file present
case err != nil:
return auth.Settings{}, fmt.Errorf("reading auth settings: %w", err)
default:
logger.Infof("read %d roles from authentication file", len(authSettings.Roles))
}
err = authSettings.SetDefaultRole(jsonDefaultRole)
if err != nil {
return auth.Settings{}, fmt.Errorf("setting default role: %w", err)
}
authSettings.SetDefaults()
err = authSettings.Validate()
if err != nil {
return auth.Settings{}, fmt.Errorf("validating auth settings: %w", err)
}
return authSettings, nil
}