feat(database): connect postgres via socket and read config from env _FILE secrets (#2061)

* feat(database): connect postgres via socket

* feat(config): read env var secrets from file

* docs: explain env var secrets

* refactor: generate postgres dsn
This commit is contained in:
ze0s 2025-05-05 21:15:24 +02:00 committed by GitHub
parent 24648e45f7
commit fe4f385a22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 345 additions and 76 deletions

View file

@ -24,6 +24,8 @@ import (
"github.com/spf13/viper"
)
var EnvVarPrefix = "AUTOBRR__"
var configTemplate = `# config.toml
# Hostname / IP
@ -281,6 +283,7 @@ func (c *AppConfig) defaults() {
CustomDefinitions: "",
CheckForUpdates: true,
DatabaseType: "sqlite",
DatabaseDSN: "",
PostgresHost: "",
PostgresPort: 0,
PostgresDatabase: "",
@ -288,6 +291,7 @@ func (c *AppConfig) defaults() {
PostgresPass: "",
PostgresSSLMode: "disable",
PostgresExtraParams: "",
PostgresSocket: "",
ProfilingEnabled: false,
ProfilingHost: "127.0.0.1",
ProfilingPort: 6060,
@ -300,165 +304,187 @@ func (c *AppConfig) defaults() {
}
func (c *AppConfig) loadFromEnv() {
prefix := "AUTOBRR__"
if v := os.Getenv(prefix + "HOST"); v != "" {
if v := GetEnvStr("HOST"); v != "" {
c.Config.Host = v
}
if v := os.Getenv(prefix + "PORT"); v != "" {
i, _ := strconv.ParseInt(v, 10, 32)
if i > 0 {
c.Config.Port = int(i)
}
if v := GetEnvInt("PORT"); v > 0 {
c.Config.Port = v
}
if v := os.Getenv(prefix + "BASE_URL"); v != "" {
if v := GetEnvStr("BASE_URL"); v != "" {
c.Config.BaseURL = v
}
if v := os.Getenv(prefix + "BASE_URL_MODE_LEGACY"); v != "" {
if v := GetEnvStr("BASE_URL_MODE_LEGACY"); v != "" {
c.Config.BaseURLModeLegacy = strings.EqualFold(strings.ToLower(v), "true")
}
if v := os.Getenv(prefix + "LOG_LEVEL"); v != "" {
if v := GetEnvStr("LOG_LEVEL"); v != "" {
c.Config.LogLevel = v
}
if v := os.Getenv(prefix + "LOG_PATH"); v != "" {
if v := GetEnvStr("LOG_PATH"); v != "" {
c.Config.LogPath = v
}
if v := os.Getenv(prefix + "LOG_MAX_SIZE"); v != "" {
i, _ := strconv.ParseInt(v, 10, 32)
if i > 0 {
c.Config.LogMaxSize = int(i)
}
if v := GetEnvInt("LOG_MAX_SIZE"); v > 0 {
c.Config.LogMaxSize = v
}
if v := os.Getenv(prefix + "LOG_MAX_BACKUPS"); v != "" {
i, _ := strconv.ParseInt(v, 10, 32)
if i > 0 {
c.Config.LogMaxBackups = int(i)
}
if v := GetEnvInt("LOG_MAX_BACKUPS"); v > 0 {
c.Config.LogMaxBackups = v
}
if v := os.Getenv(prefix + "SESSION_SECRET"); v != "" {
if v := GetEnvStr("SESSION_SECRET"); v != "" {
c.Config.SessionSecret = v
}
if v := os.Getenv(prefix + "CUSTOM_DEFINITIONS"); v != "" {
if v := GetEnvStr("CUSTOM_DEFINITIONS"); v != "" {
c.Config.CustomDefinitions = v
}
if v := os.Getenv(prefix + "CHECK_FOR_UPDATES"); v != "" {
if v := GetEnvStr("CHECK_FOR_UPDATES"); v != "" {
c.Config.CheckForUpdates = strings.EqualFold(strings.ToLower(v), "true")
}
if v := os.Getenv(prefix + "DATABASE_TYPE"); v != "" {
if v := GetEnvStr("DATABASE_DSN"); v != "" {
c.Config.DatabaseDSN = v
}
if v := GetEnvStr("DATABASE_TYPE"); v != "" {
if validDatabaseType(v) {
c.Config.DatabaseType = v
}
}
if v := os.Getenv(prefix + "DATABASE_MAX_BACKUPS"); v != "" {
i, _ := strconv.ParseInt(v, 10, 32)
if i > 0 {
c.Config.DatabaseMaxBackups = int(i)
}
if v := GetEnvInt("DATABASE_MAX_BACKUPS"); v > 0 {
c.Config.DatabaseMaxBackups = v
}
if v := os.Getenv(prefix + "POSTGRES_HOST"); v != "" {
if v := GetEnvStr("POSTGRES_HOST"); v != "" {
c.Config.PostgresHost = v
}
if v := os.Getenv(prefix + "POSTGRES_PORT"); v != "" {
i, _ := strconv.ParseInt(v, 10, 32)
if i > 0 {
c.Config.PostgresPort = int(i)
}
if v := GetEnvInt("POSTGRES_PORT"); v > 0 {
c.Config.PostgresPort = v
}
if v := os.Getenv(prefix + "POSTGRES_DATABASE"); v != "" {
if v := GetEnvStr("POSTGRES_DATABASE"); v != "" {
c.Config.PostgresDatabase = v
}
if v := os.Getenv(prefix + "POSTGRES_USER"); v != "" {
if v := GetEnvStr("POSTGRES_DB"); v != "" {
c.Config.PostgresDatabase = v
}
if v := GetEnvStr("POSTGRES_USER"); v != "" {
c.Config.PostgresUser = v
}
if v := os.Getenv(prefix + "POSTGRES_PASS"); v != "" {
if v := GetEnvStr("POSTGRES_PASS"); v != "" {
c.Config.PostgresPass = v
}
if v := os.Getenv(prefix + "POSTGRES_SSLMODE"); v != "" {
if v := GetEnvStr("POSTGRES_PASSWORD"); v != "" {
c.Config.PostgresPass = v
}
if v := GetEnvStr("POSTGRES_SSLMODE"); v != "" {
c.Config.PostgresSSLMode = v
}
if v := os.Getenv(prefix + "POSTGRES_EXTRA_PARAMS"); v != "" {
if v := GetEnvStr("POSTGRES_SOCKET"); v != "" {
c.Config.PostgresSocket = v
}
if v := GetEnvStr("POSTGRES_EXTRA_PARAMS"); v != "" {
c.Config.PostgresExtraParams = v
}
if v := os.Getenv(prefix + "PROFILING_ENABLED"); v != "" {
if v := GetEnvStr("PROFILING_ENABLED"); v != "" {
c.Config.ProfilingEnabled = strings.EqualFold(strings.ToLower(v), "true")
}
if v := os.Getenv(prefix + "PROFILING_HOST"); v != "" {
if v := GetEnvStr("PROFILING_HOST"); v != "" {
c.Config.ProfilingHost = v
}
if v := os.Getenv(prefix + "PROFILING_PORT"); v != "" {
i, _ := strconv.ParseInt(v, 10, 32)
if i > 0 {
c.Config.ProfilingPort = int(i)
}
if v := GetEnvInt("PROFILING_PORT"); v > 0 {
c.Config.ProfilingPort = v
}
// OIDC Configuration
if v := os.Getenv(prefix + "OIDC_ENABLED"); v != "" {
if v := GetEnvStr("OIDC_ENABLED"); v != "" {
c.Config.OIDCEnabled = strings.EqualFold(strings.ToLower(v), "true")
}
if v := os.Getenv(prefix + "OIDC_ISSUER"); v != "" {
if v := GetEnvStr("OIDC_ISSUER"); v != "" {
c.Config.OIDCIssuer = v
}
if v := os.Getenv(prefix + "OIDC_CLIENT_ID"); v != "" {
if v := GetEnvStr("OIDC_CLIENT_ID"); v != "" {
c.Config.OIDCClientID = v
}
if v := os.Getenv(prefix + "OIDC_CLIENT_SECRET"); v != "" {
if v := GetEnvStr("OIDC_CLIENT_SECRET"); v != "" {
c.Config.OIDCClientSecret = v
}
if v := os.Getenv(prefix + "OIDC_REDIRECT_URL"); v != "" {
if v := GetEnvStr("OIDC_REDIRECT_URL"); v != "" {
c.Config.OIDCRedirectURL = v
}
if v := os.Getenv(prefix + "OIDC_DISABLE_BUILT_IN_LOGIN"); v != "" {
if v := GetEnvStr("OIDC_DISABLE_BUILT_IN_LOGIN"); v != "" {
c.Config.OIDCDisableBuiltInLogin = strings.EqualFold(strings.ToLower(v), "true")
}
if v := os.Getenv(prefix + "METRICS_ENABLED"); v != "" {
if v := GetEnvStr("METRICS_ENABLED"); v != "" {
c.Config.MetricsEnabled = strings.EqualFold(strings.ToLower(v), "true")
}
if v := os.Getenv(prefix + "METRICS_HOST"); v != "" {
if v := GetEnvStr("METRICS_HOST"); v != "" {
c.Config.MetricsHost = v
}
if v := os.Getenv(prefix + "METRICS_PORT"); v != "" {
i, _ := strconv.ParseInt(v, 10, 32)
if i > 0 {
c.Config.MetricsPort = int(i)
}
if v := GetEnvInt("METRICS_PORT"); v > 0 {
c.Config.MetricsPort = v
}
if v := os.Getenv(prefix + "METRICS_BASIC_AUTH_USERS"); v != "" {
if v := GetEnvStr("METRICS_BASIC_AUTH_USERS"); v != "" {
c.Config.MetricsBasicAuthUsers = v
}
}
func GetEnvStr(key string) string {
// first check if we have a variable with a _FILE ending
// commonly used for docker secrets and similar
if filePath := os.Getenv(EnvVarPrefix + key + "_FILE"); filePath != "" {
content, err := os.ReadFile(filePath)
if err != nil {
log.Fatalf("Could not read file: %s err: %q", filePath, err)
return ""
}
return strings.TrimSpace(string(content))
}
if v := os.Getenv(EnvVarPrefix + key); v != "" {
return v
}
return ""
}
func GetEnvInt(key string) int {
value := GetEnvStr(key)
i, err := strconv.ParseInt(value, 10, 32)
if err != nil {
return 0
}
return int(i)
}
func validDatabaseType(v string) bool {
valid := []string{"sqlite", "postgres"}
for _, s := range valid {

View file

@ -6,8 +6,8 @@ package database
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"sync"
"github.com/autobrr/autobrr/internal/domain"
@ -35,13 +35,33 @@ type DB struct {
func NewDB(cfg *domain.Config, log logger.Logger) (*DB, error) {
db := &DB{
// set default placeholder for squirrel to support both sqlite and postgres
// set a default placeholder for squirrel to support both sqlite and postgres
squirrel: sq.StatementBuilder.PlaceholderFormat(sq.Dollar),
log: log.With().Str("module", "database").Str("type", cfg.DatabaseType).Logger(),
cfg: cfg,
}
db.ctx, db.cancel = context.WithCancel(context.Background())
// Check for directly configured DSN in config
if cfg.DatabaseDSN != "" {
if strings.HasPrefix(cfg.DatabaseDSN, "postgres://") || strings.HasPrefix(cfg.DatabaseDSN, "postgresql://") {
db.Driver = "postgres"
db.DSN = cfg.DatabaseDSN
return db, nil
} else if strings.HasPrefix(cfg.DatabaseDSN, "file:") || cfg.DatabaseDSN == ":memory:" || strings.HasSuffix(cfg.DatabaseDSN, ".db") {
db.Driver = "sqlite"
if strings.HasPrefix(cfg.DatabaseDSN, "file:") && strings.HasSuffix(cfg.DatabaseDSN, ".db") {
db.DSN = strings.TrimPrefix(cfg.DatabaseDSN, "file:")
} else {
db.DSN = cfg.DatabaseDSN
}
return db, nil
}
return nil, errors.New("unsupported database DSN: %s", cfg.DatabaseDSN)
}
// If no direct DSN is provided, build it from individual settings
switch cfg.DatabaseType {
case "sqlite":
db.Driver = "sqlite"
@ -51,14 +71,19 @@ func NewDB(cfg *domain.Config, log logger.Logger) (*DB, error) {
db.DSN = dataSourceName(cfg.ConfigPath, "autobrr.db")
}
case "postgres":
if cfg.PostgresHost == "" || cfg.PostgresPort == 0 || cfg.PostgresDatabase == "" {
return nil, errors.New("postgres: bad variables")
}
db.DSN = fmt.Sprintf("postgres://%v:%v@%v:%d/%v?sslmode=%v", cfg.PostgresUser, cfg.PostgresPass, cfg.PostgresHost, cfg.PostgresPort, cfg.PostgresDatabase, cfg.PostgresSSLMode)
if cfg.PostgresExtraParams != "" {
db.DSN = fmt.Sprintf("%s&%s", db.DSN, cfg.PostgresExtraParams)
}
db.Driver = "postgres"
// If no database-specific settings are provided, return an error
if cfg.PostgresDatabase == "" && cfg.DatabaseDSN == "" {
return nil, errors.New("postgres: database name is required")
}
pgDsn, err := PostgresDSN(cfg.PostgresHost, cfg.PostgresPort, cfg.PostgresUser, cfg.PostgresPass, cfg.PostgresDatabase, cfg.PostgresSocket, cfg.PostgresSSLMode, cfg.PostgresExtraParams)
if err != nil {
return nil, errors.Wrap(err, "postgres: failed to build DSN")
}
db.DSN = pgDsn
default:
return nil, errors.New("unsupported database: %v", cfg.DatabaseType)
}

View file

@ -5,6 +5,9 @@ package database
import (
"database/sql"
"fmt"
"net"
"net/url"
"github.com/autobrr/autobrr/pkg/errors"
@ -87,3 +90,56 @@ func (db *DB) migratePostgres() error {
return tx.Commit()
}
// PostgresDSN build postgres dsn connect string
func PostgresDSN(host string, port int, user, pass, database, socket, sslMode, extraParams string) (string, error) {
// If no database is provided, return an error
if database == "" {
return "", errors.New("postgres: database name is required")
}
pgDsn, err := url.Parse("postgres://")
if err != nil {
return "", errors.Wrap(err, "could not parse postgres DSN")
}
pgDsn.Path = database
if user != "" {
pgDsn.User = url.UserPassword(user, pass)
}
queryParams := pgDsn.Query()
// Build DSN based on the connection type (TCP vs. Unix socket)
if socket != "" {
// Unix socket connection via the host param
queryParams.Add("host", socket)
} else {
// TCP connection
if host == "" && port == 0 {
return "", errors.New("postgres: host and port are required for TCP connection")
}
if port > 0 {
pgDsn.Host = net.JoinHostPort(host, fmt.Sprintf("%d", port))
} else {
pgDsn.Host = database
}
}
// Add SSL mode if provided
if sslMode != "" {
queryParams.Add("sslmode", sslMode)
}
pgDsn.RawQuery = queryParams.Encode()
// Add any extra parameters
if extraParams != "" {
values, err := url.ParseQuery(extraParams)
if err != nil {
return "", errors.Wrap(err, "could not parse extra params")
}
pgDsn.RawQuery = fmt.Sprintf("%s&%s", pgDsn.RawQuery, values.Encode())
}
return pgDsn.String(), nil
}

View file

@ -0,0 +1,67 @@
package database
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPostgresDSN(t *testing.T) {
type args struct {
host string
port int
user string
pass string
database string
socket string
sslMode string
extraParams string
}
tests := []struct {
name string
args args
want string
}{
{
name: "default",
args: args{
host: "localhost",
port: 5432,
user: "postgres",
pass: "PASSWORD",
database: "postgres",
sslMode: "disable",
socket: "",
},
want: "postgres://postgres:PASSWORD@localhost:5432/postgres?sslmode=disable",
},
{
name: "default",
args: args{
host: "localhost",
port: 5432,
user: "postgres",
pass: "PASSWORD",
database: "postgres",
sslMode: "disable",
extraParams: "connect_timeout=10",
socket: "",
},
want: "postgres://postgres:PASSWORD@localhost:5432/postgres?sslmode=disable&connect_timeout=10",
},
{
name: "default",
args: args{
database: "postgres",
socket: "/path/to/socket",
},
want: "postgres://postgres?host=%2Fpath%2Fto%2Fsocket",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, _ := PostgresDSN(tt.args.host, tt.args.port, tt.args.user, tt.args.pass, tt.args.database, tt.args.socket, tt.args.sslMode, tt.args.extraParams)
assert.Equalf(t, tt.want, got, "PostgresDSN(%v, %v, %v, %v, %v, %v, %v, %v)", tt.args.host, tt.args.port, tt.args.user, tt.args.pass, tt.args.database, tt.args.socket, tt.args.sslMode, tt.args.extraParams)
})
}
}

View file

@ -18,6 +18,7 @@ type Config struct {
CustomDefinitions string `toml:"customDefinitions"`
CheckForUpdates bool `toml:"checkForUpdates"`
DatabaseType string `toml:"databaseType"`
DatabaseDSN string `toml:"databaseDSN"`
DatabaseMaxBackups int `toml:"databaseMaxBackups"`
PostgresHost string `toml:"postgresHost"`
PostgresPort int `toml:"postgresPort"`
@ -25,6 +26,7 @@ type Config struct {
PostgresUser string `toml:"postgresUser"`
PostgresPass string `toml:"postgresPass"`
PostgresSSLMode string `toml:"postgresSSLMode"`
PostgresSocket string `toml:"postgresSocket"`
PostgresExtraParams string `toml:"postgresExtraParams"`
ProfilingEnabled bool `toml:"profilingEnabled"`
ProfilingHost string `toml:"profilingHost"`