mirror of
https://github.com/netbirdio/netbird.git
synced 2026-03-31 06:34:19 -04:00
[self-hosted] support embedded IDP postgres db (#5443)
* Add postgres config for embedded idp Entire-Checkpoint: 9ace190c1067 * Rename idpStore to authStore Entire-Checkpoint: 73a896c79614 * Fix review notes Entire-Checkpoint: 6556783c0df3 * Don't accept pq port = 0 Entire-Checkpoint: 80d45e37782f * Optimize configs Entire-Checkpoint: 80d45e37782f * Fix lint issues Entire-Checkpoint: 3eec968003d1 * Fail fast on combined postgres config Entire-Checkpoint: b17839d3d8c6 * Simplify management config method Entire-Checkpoint: 0f083effa20e
This commit is contained in:
@@ -71,6 +71,7 @@ type ServerConfig struct {
|
|||||||
Auth AuthConfig `yaml:"auth"`
|
Auth AuthConfig `yaml:"auth"`
|
||||||
Store StoreConfig `yaml:"store"`
|
Store StoreConfig `yaml:"store"`
|
||||||
ActivityStore StoreConfig `yaml:"activityStore"`
|
ActivityStore StoreConfig `yaml:"activityStore"`
|
||||||
|
AuthStore StoreConfig `yaml:"authStore"`
|
||||||
ReverseProxy ReverseProxyConfig `yaml:"reverseProxy"`
|
ReverseProxy ReverseProxyConfig `yaml:"reverseProxy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,6 +534,68 @@ func stripSignalProtocol(uri string) string {
|
|||||||
return uri
|
return uri
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildRelayConfig(relays RelaysConfig) (*nbconfig.Relay, error) {
|
||||||
|
var ttl time.Duration
|
||||||
|
if relays.CredentialsTTL != "" {
|
||||||
|
var err error
|
||||||
|
ttl, err = time.ParseDuration(relays.CredentialsTTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid relay credentials TTL %q: %w", relays.CredentialsTTL, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &nbconfig.Relay{
|
||||||
|
Addresses: relays.Addresses,
|
||||||
|
CredentialsTTL: util.Duration{Duration: ttl},
|
||||||
|
Secret: relays.Secret,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEmbeddedIdPConfig builds the embedded IdP configuration.
|
||||||
|
// authStore overrides auth.storage when set.
|
||||||
|
func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.EmbeddedIdPConfig, error) {
|
||||||
|
authStorageType := mgmt.Auth.Storage.Type
|
||||||
|
authStorageDSN := c.Server.AuthStore.DSN
|
||||||
|
if c.Server.AuthStore.Engine != "" {
|
||||||
|
authStorageType = c.Server.AuthStore.Engine
|
||||||
|
}
|
||||||
|
if authStorageType == "" {
|
||||||
|
authStorageType = "sqlite3"
|
||||||
|
}
|
||||||
|
authStorageFile := ""
|
||||||
|
if authStorageType == "postgres" {
|
||||||
|
if authStorageDSN == "" {
|
||||||
|
return nil, fmt.Errorf("authStore.dsn is required when authStore.engine is postgres")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
authStorageFile = path.Join(mgmt.DataDir, "idp.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &idp.EmbeddedIdPConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Issuer: mgmt.Auth.Issuer,
|
||||||
|
LocalAuthDisabled: mgmt.Auth.LocalAuthDisabled,
|
||||||
|
SignKeyRefreshEnabled: mgmt.Auth.SignKeyRefreshEnabled,
|
||||||
|
Storage: idp.EmbeddedStorageConfig{
|
||||||
|
Type: authStorageType,
|
||||||
|
Config: idp.EmbeddedStorageTypeConfig{
|
||||||
|
File: authStorageFile,
|
||||||
|
DSN: authStorageDSN,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DashboardRedirectURIs: mgmt.Auth.DashboardRedirectURIs,
|
||||||
|
CLIRedirectURIs: mgmt.Auth.CLIRedirectURIs,
|
||||||
|
}
|
||||||
|
|
||||||
|
if mgmt.Auth.Owner != nil && mgmt.Auth.Owner.Email != "" {
|
||||||
|
cfg.Owner = &idp.OwnerConfig{
|
||||||
|
Email: mgmt.Auth.Owner.Email,
|
||||||
|
Hash: mgmt.Auth.Owner.Password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ToManagementConfig converts CombinedConfig to management server config
|
// ToManagementConfig converts CombinedConfig to management server config
|
||||||
func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) {
|
func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) {
|
||||||
mgmt := c.Management
|
mgmt := c.Management
|
||||||
@@ -551,19 +614,11 @@ func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) {
|
|||||||
// Build relay config
|
// Build relay config
|
||||||
var relayConfig *nbconfig.Relay
|
var relayConfig *nbconfig.Relay
|
||||||
if len(mgmt.Relays.Addresses) > 0 || mgmt.Relays.Secret != "" {
|
if len(mgmt.Relays.Addresses) > 0 || mgmt.Relays.Secret != "" {
|
||||||
var ttl time.Duration
|
relay, err := buildRelayConfig(mgmt.Relays)
|
||||||
if mgmt.Relays.CredentialsTTL != "" {
|
|
||||||
var err error
|
|
||||||
ttl, err = time.ParseDuration(mgmt.Relays.CredentialsTTL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid relay credentials TTL %q: %w", mgmt.Relays.CredentialsTTL, err)
|
return nil, err
|
||||||
}
|
|
||||||
}
|
|
||||||
relayConfig = &nbconfig.Relay{
|
|
||||||
Addresses: mgmt.Relays.Addresses,
|
|
||||||
CredentialsTTL: util.Duration{Duration: ttl},
|
|
||||||
Secret: mgmt.Relays.Secret,
|
|
||||||
}
|
}
|
||||||
|
relayConfig = relay
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build signal config
|
// Build signal config
|
||||||
@@ -599,31 +654,9 @@ func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) {
|
|||||||
httpConfig := &nbconfig.HttpServerConfig{}
|
httpConfig := &nbconfig.HttpServerConfig{}
|
||||||
|
|
||||||
// Build embedded IDP config (always enabled in combined server)
|
// Build embedded IDP config (always enabled in combined server)
|
||||||
storageFile := mgmt.Auth.Storage.File
|
embeddedIdP, err := c.buildEmbeddedIdPConfig(mgmt)
|
||||||
if storageFile == "" {
|
if err != nil {
|
||||||
storageFile = path.Join(mgmt.DataDir, "idp.db")
|
return nil, err
|
||||||
}
|
|
||||||
|
|
||||||
embeddedIdP := &idp.EmbeddedIdPConfig{
|
|
||||||
Enabled: true,
|
|
||||||
Issuer: mgmt.Auth.Issuer,
|
|
||||||
LocalAuthDisabled: mgmt.Auth.LocalAuthDisabled,
|
|
||||||
SignKeyRefreshEnabled: mgmt.Auth.SignKeyRefreshEnabled,
|
|
||||||
Storage: idp.EmbeddedStorageConfig{
|
|
||||||
Type: mgmt.Auth.Storage.Type,
|
|
||||||
Config: idp.EmbeddedStorageTypeConfig{
|
|
||||||
File: storageFile,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
DashboardRedirectURIs: mgmt.Auth.DashboardRedirectURIs,
|
|
||||||
CLIRedirectURIs: mgmt.Auth.CLIRedirectURIs,
|
|
||||||
}
|
|
||||||
|
|
||||||
if mgmt.Auth.Owner != nil && mgmt.Auth.Owner.Email != "" {
|
|
||||||
embeddedIdP.Owner = &idp.OwnerConfig{
|
|
||||||
Email: mgmt.Auth.Owner.Email,
|
|
||||||
Hash: mgmt.Auth.Owner.Password, // Will be hashed if plain text
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set HTTP config fields for embedded IDP
|
// Set HTTP config fields for embedded IDP
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ server:
|
|||||||
# engine: "sqlite" # sqlite or postgres
|
# engine: "sqlite" # sqlite or postgres
|
||||||
# dsn: "" # Connection string for postgres
|
# dsn: "" # Connection string for postgres
|
||||||
|
|
||||||
|
# Auth (embedded IdP) store configuration (optional, defaults to sqlite3 in dataDir/idp.db)
|
||||||
|
# authStore:
|
||||||
|
# engine: "sqlite3" # sqlite3 or postgres
|
||||||
|
# dsn: "" # Connection string for postgres (e.g., "host=localhost port=5432 user=postgres password=postgres dbname=netbird_idp sslmode=disable")
|
||||||
|
|
||||||
# Reverse proxy settings (optional)
|
# Reverse proxy settings (optional)
|
||||||
# reverseProxy:
|
# reverseProxy:
|
||||||
# trustedHTTPProxies: []
|
# trustedHTTPProxies: []
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -195,11 +198,175 @@ func (s *Storage) OpenStorage(logger *slog.Logger) (storage.Storage, error) {
|
|||||||
return nil, fmt.Errorf("sqlite3 storage requires 'file' config")
|
return nil, fmt.Errorf("sqlite3 storage requires 'file' config")
|
||||||
}
|
}
|
||||||
return (&sql.SQLite3{File: file}).Open(logger)
|
return (&sql.SQLite3{File: file}).Open(logger)
|
||||||
|
case "postgres":
|
||||||
|
dsn, _ := s.Config["dsn"].(string)
|
||||||
|
if dsn == "" {
|
||||||
|
return nil, fmt.Errorf("postgres storage requires 'dsn' config")
|
||||||
|
}
|
||||||
|
pg, err := parsePostgresDSN(dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid postgres DSN: %w", err)
|
||||||
|
}
|
||||||
|
return pg.Open(logger)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported storage type: %s", s.Type)
|
return nil, fmt.Errorf("unsupported storage type: %s", s.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parsePostgresDSN parses a DSN into a sql.Postgres config.
|
||||||
|
// It accepts both URI format (postgres://user:pass@host:port/dbname?sslmode=disable)
|
||||||
|
// and libpq key=value format (host=localhost port=5432 dbname=mydb), including quoted values.
|
||||||
|
func parsePostgresDSN(dsn string) (*sql.Postgres, error) {
|
||||||
|
var params map[string]string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||||
|
params, err = parsePostgresURI(dsn)
|
||||||
|
} else {
|
||||||
|
params, err = parsePostgresKeyValue(dsn)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
host := params["host"]
|
||||||
|
if host == "" {
|
||||||
|
host = "localhost"
|
||||||
|
}
|
||||||
|
|
||||||
|
var port uint16 = 5432
|
||||||
|
if p, ok := params["port"]; ok && p != "" {
|
||||||
|
v, err := strconv.ParseUint(p, 10, 16)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid port %q: %w", p, err)
|
||||||
|
}
|
||||||
|
if v == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid port %q: must be non-zero", p)
|
||||||
|
}
|
||||||
|
port = uint16(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbname := params["dbname"]
|
||||||
|
if dbname == "" {
|
||||||
|
return nil, fmt.Errorf("dbname is required in DSN")
|
||||||
|
}
|
||||||
|
|
||||||
|
pg := &sql.Postgres{
|
||||||
|
NetworkDB: sql.NetworkDB{
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
Database: dbname,
|
||||||
|
User: params["user"],
|
||||||
|
Password: params["password"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if sslMode := params["sslmode"]; sslMode != "" {
|
||||||
|
switch sslMode {
|
||||||
|
case "disable", "allow", "prefer", "require", "verify-ca", "verify-full":
|
||||||
|
pg.SSL.Mode = sslMode
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported sslmode %q: valid values are disable, allow, prefer, require, verify-ca, verify-full", sslMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePostgresURI parses a postgres:// or postgresql:// URI into parameter key-value pairs.
|
||||||
|
func parsePostgresURI(dsn string) (map[string]string, error) {
|
||||||
|
u, err := url.Parse(dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid postgres URI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := make(map[string]string)
|
||||||
|
|
||||||
|
if u.User != nil {
|
||||||
|
params["user"] = u.User.Username()
|
||||||
|
if p, ok := u.User.Password(); ok {
|
||||||
|
params["password"] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.Hostname() != "" {
|
||||||
|
params["host"] = u.Hostname()
|
||||||
|
}
|
||||||
|
if u.Port() != "" {
|
||||||
|
params["port"] = u.Port()
|
||||||
|
}
|
||||||
|
|
||||||
|
dbname := strings.TrimPrefix(u.Path, "/")
|
||||||
|
if dbname != "" {
|
||||||
|
params["dbname"] = dbname
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range u.Query() {
|
||||||
|
if len(v) > 0 {
|
||||||
|
params[k] = v[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePostgresKeyValue parses a libpq key=value DSN string, handling single-quoted values
|
||||||
|
// (e.g., password='my pass' host=localhost).
|
||||||
|
func parsePostgresKeyValue(dsn string) (map[string]string, error) {
|
||||||
|
params := make(map[string]string)
|
||||||
|
s := strings.TrimSpace(dsn)
|
||||||
|
|
||||||
|
for s != "" {
|
||||||
|
eqIdx := strings.IndexByte(s, '=')
|
||||||
|
if eqIdx < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(s[:eqIdx])
|
||||||
|
|
||||||
|
value, rest, err := parseDSNValue(s[eqIdx+1:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w for key %q", err, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
params[key] = value
|
||||||
|
s = strings.TrimSpace(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDSNValue parses the next value from a libpq key=value string positioned after the '='.
|
||||||
|
// It returns the parsed value and the remaining unparsed string.
|
||||||
|
func parseDSNValue(s string) (value, rest string, err error) {
|
||||||
|
if len(s) > 0 && s[0] == '\'' {
|
||||||
|
return parseQuotedDSNValue(s[1:])
|
||||||
|
}
|
||||||
|
// Unquoted value: read until whitespace.
|
||||||
|
idx := strings.IndexAny(s, " \t\n")
|
||||||
|
if idx < 0 {
|
||||||
|
return s, "", nil
|
||||||
|
}
|
||||||
|
return s[:idx], s[idx:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseQuotedDSNValue parses a single-quoted value starting after the opening quote.
|
||||||
|
// Libpq uses ” to represent a literal single quote inside quoted values.
|
||||||
|
func parseQuotedDSNValue(s string) (value, rest string, err error) {
|
||||||
|
var buf strings.Builder
|
||||||
|
for len(s) > 0 {
|
||||||
|
if s[0] == '\'' {
|
||||||
|
if len(s) > 1 && s[1] == '\'' {
|
||||||
|
buf.WriteByte('\'')
|
||||||
|
s = s[2:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return buf.String(), s[1:], nil
|
||||||
|
}
|
||||||
|
buf.WriteByte(s[0])
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
|
return "", "", fmt.Errorf("unterminated quoted value")
|
||||||
|
}
|
||||||
|
|
||||||
// Validate validates the configuration
|
// Validate validates the configuration
|
||||||
func (c *YAMLConfig) Validate() error {
|
func (c *YAMLConfig) Validate() error {
|
||||||
if c.Issuer == "" {
|
if c.Issuer == "" {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ type EmbeddedIdPConfig struct {
|
|||||||
|
|
||||||
// EmbeddedStorageConfig holds storage configuration for the embedded IdP.
|
// EmbeddedStorageConfig holds storage configuration for the embedded IdP.
|
||||||
type EmbeddedStorageConfig struct {
|
type EmbeddedStorageConfig struct {
|
||||||
// Type is the storage type (currently only "sqlite3" is supported)
|
// Type is the storage type: "sqlite3" (default) or "postgres"
|
||||||
Type string
|
Type string
|
||||||
// Config contains type-specific configuration
|
// Config contains type-specific configuration
|
||||||
Config EmbeddedStorageTypeConfig
|
Config EmbeddedStorageTypeConfig
|
||||||
@@ -62,6 +62,8 @@ type EmbeddedStorageConfig struct {
|
|||||||
type EmbeddedStorageTypeConfig struct {
|
type EmbeddedStorageTypeConfig struct {
|
||||||
// File is the path to the SQLite database file (for sqlite3 type)
|
// File is the path to the SQLite database file (for sqlite3 type)
|
||||||
File string
|
File string
|
||||||
|
// DSN is the connection string for postgres
|
||||||
|
DSN string
|
||||||
}
|
}
|
||||||
|
|
||||||
// OwnerConfig represents the initial owner/admin user for the embedded IdP.
|
// OwnerConfig represents the initial owner/admin user for the embedded IdP.
|
||||||
@@ -74,6 +76,22 @@ type OwnerConfig struct {
|
|||||||
Username string
|
Username string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildIdpStorageConfig builds the Dex storage config map based on the storage type.
|
||||||
|
func buildIdpStorageConfig(storageType string, cfg EmbeddedStorageTypeConfig) (map[string]interface{}, error) {
|
||||||
|
switch storageType {
|
||||||
|
case "sqlite3":
|
||||||
|
return map[string]interface{}{
|
||||||
|
"file": cfg.File,
|
||||||
|
}, nil
|
||||||
|
case "postgres":
|
||||||
|
return map[string]interface{}{
|
||||||
|
"dsn": cfg.DSN,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported IdP storage type: %s", storageType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ToYAMLConfig converts EmbeddedIdPConfig to dex.YAMLConfig.
|
// ToYAMLConfig converts EmbeddedIdPConfig to dex.YAMLConfig.
|
||||||
func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
|
func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
|
||||||
if c.Issuer == "" {
|
if c.Issuer == "" {
|
||||||
@@ -85,6 +103,14 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
|
|||||||
if c.Storage.Type == "sqlite3" && c.Storage.Config.File == "" {
|
if c.Storage.Type == "sqlite3" && c.Storage.Config.File == "" {
|
||||||
return nil, fmt.Errorf("storage file is required for sqlite3")
|
return nil, fmt.Errorf("storage file is required for sqlite3")
|
||||||
}
|
}
|
||||||
|
if c.Storage.Type == "postgres" && c.Storage.Config.DSN == "" {
|
||||||
|
return nil, fmt.Errorf("storage DSN is required for postgres")
|
||||||
|
}
|
||||||
|
|
||||||
|
storageConfig, err := buildIdpStorageConfig(c.Storage.Type, c.Storage.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid IdP storage config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Build CLI redirect URIs including the device callback (both relative and absolute)
|
// Build CLI redirect URIs including the device callback (both relative and absolute)
|
||||||
cliRedirectURIs := c.CLIRedirectURIs
|
cliRedirectURIs := c.CLIRedirectURIs
|
||||||
@@ -101,9 +127,7 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
|
|||||||
Issuer: c.Issuer,
|
Issuer: c.Issuer,
|
||||||
Storage: dex.Storage{
|
Storage: dex.Storage{
|
||||||
Type: c.Storage.Type,
|
Type: c.Storage.Type,
|
||||||
Config: map[string]interface{}{
|
Config: storageConfig,
|
||||||
"file": c.Storage.Config.File,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Web: dex.Web{
|
Web: dex.Web{
|
||||||
AllowedOrigins: []string{"*"},
|
AllowedOrigins: []string{"*"},
|
||||||
|
|||||||
Reference in New Issue
Block a user