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:
@@ -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 \
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user