From 3a631f18f689de15f5fc351e5e2ab881525bdf3f Mon Sep 17 00:00:00 2001 From: Shaun Campbell Date: Wed, 16 Sep 2026 15:04:37 -0400 Subject: [PATCH] feat: read managed database passwords from secrets --- README.md | 15 ++++-- cmd/pawsql/main.go | 2 +- go.mod | 2 +- go.sum | 4 +- internal/postgres/provisioner.go | 38 +++++++++++---- .../postgres/provisioner_integration_test.go | 22 +++++---- internal/postgres/provisioner_test.go | 47 ++++++++++++++++++- internal/postgres/resolver.go | 2 +- internal/postgres/resolver_test.go | 20 ++++---- internal/router/routes.go | 2 +- internal/router/routes_test.go | 2 +- internal/server/server_test.go | 2 +- 12 files changed, 120 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 0792870..fbc2cf4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ PawSQL is a TLS-terminating PostgreSQL router. It accepts PostgreSQL clients on - Go 1.24 or later to build and run PawSQL natively. - Docker Engine and a usable `docker` CLI to build the PawSQL image. PawSQL also needs them in its own execution environment when it manages PostgreSQL containers. - Optional: a TLS certificate and private key readable by PawSQL. Omit the `tls` block to serve plaintext PostgreSQL; with TLS, the certificate must cover every hostname clients use for SNI routing. -- Docker Engine access for each `postgres` route. Managed database images are limited to `postgres:16`, `postgres:17`, and `postgres:18`. +- Docker Engine access for each `postgres` route. Managed database images are limited to `postgres:16`, `postgres:17`, and `postgres:18`; each route also requires a mounted Docker secret file. ## Build, configure, and run @@ -38,6 +38,7 @@ docker run --rm --publish 5432:5432 \ --volume "$PWD/Barkfile:/etc/pawsql/Barkfile:ro" \ --volume "$PWD/tls:/etc/pawsql/tls:ro" \ --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume "$PWD/secrets:/run/secrets:ro" \ pawsql ``` @@ -66,7 +67,7 @@ pawsql { postgres { image postgres:17 volume pawsql-application-data - password_env APPLICATION_POSTGRES_PASSWORD + password_secret application_postgres_password idle_timeout 10m traffic_idle_timeout 1h } @@ -76,7 +77,15 @@ pawsql { `listen` is PawSQL's TCP address. `cert` and `key` identify the client-facing TLS certificate and key. `hostname` is optional; it is used only for SNI routing. `upstream` is the address of an existing PostgreSQL server. -For a managed `postgres` route, `image`, `volume`, and `password_env` are required. On first use, PawSQL reads the named environment variable to create the database container and configures the database and PostgreSQL user with the route's database name. The named Docker volume preserves its data. Set the password environment variable in PawSQL's environment, not in the Barkfile. +For a managed `postgres` route, `image`, `volume`, and `password_secret` are required. `password_secret` is a short lowercase reference; PawSQL reads it from `/run/secrets/barkstack_`. The example therefore reads `/run/secrets/barkstack_application_postgres_password`. On first use, PawSQL uses that value to configure the database and PostgreSQL user with the route's database name. The named Docker volume preserves its data. + +Create the Docker Swarm secret before running `barkstack init`; Barkstack verifies that every referenced secret exists and mounts it into the PawSQL service: + +```sh +docker secret create barkstack_application_postgres_password /secure/path/application-postgres-password +``` + +For a standalone PawSQL container, mount a directory containing the equivalently named file at `/run/secrets`, as shown above. Never put the password value in the Barkfile. See [`examples/Barkfile`](examples/Barkfile) and its accompanying [`examples/docker-compose.yml`](examples/docker-compose.yml) for a two-route external PostgreSQL example with locally generated development certificates: diff --git a/cmd/pawsql/main.go b/cmd/pawsql/main.go index 0ab3708..01a55d6 100644 --- a/cmd/pawsql/main.go +++ b/cmd/pawsql/main.go @@ -14,7 +14,7 @@ import ( "syscall" "time" - config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2" "github.com/barkstack/pawsql/internal/adminui" "github.com/barkstack/pawsql/internal/postgres" "github.com/barkstack/pawsql/internal/router" diff --git a/go.mod b/go.mod index 50c3ca5..d49266c 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/barkstack/pawsql go 1.24 -require cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.1.0 +require cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2 v2.0.0 diff --git a/go.sum b/go.sum index aaa2ede..8d59ea4 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.1.0 h1:ULdpvY1VC1d5M8VHx8SqacVLmk5SgvDYABDuzewebjc= -cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.1.0/go.mod h1:UnKTlB8ifO3cmsrkh2LDAM+Y2ipaCrBeiURwrSRPPe4= +cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2 v2.0.0 h1:oXVNd8wwR06OaBMDPCVr4zAWZw6L0VBCODkXaHeMI/I= +cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2 v2.0.0/go.mod h1:um5p/9XtFqZqhh087AlPB2nr4NxhrQxw5sxe32uMbzw= diff --git a/internal/postgres/provisioner.go b/internal/postgres/provisioner.go index b40756c..53ba3df 100644 --- a/internal/postgres/provisioner.go +++ b/internal/postgres/provisioner.go @@ -9,10 +9,11 @@ import ( "net" "os" "os/exec" + "path/filepath" "strings" "time" - config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2" ) const managedDatabaseLabel = "io.barkstack.pawsql.database" @@ -20,8 +21,9 @@ const managedDatabaseLabel = "io.barkstack.pawsql.database" // Provisioner ensures configured PostgreSQL containers exist and exposes their // loopback-published PostgreSQL address to PawSQL's static router. type Provisioner struct { - DockerPath string - Logger *slog.Logger + DockerPath string + SecretDirectory string + Logger *slog.Logger } // NewProvisioner creates a Docker CLI-backed provisioner. @@ -29,7 +31,7 @@ func NewProvisioner(logger *slog.Logger) *Provisioner { if logger == nil { logger = slog.Default() } - return &Provisioner{DockerPath: "docker", Logger: logger} + return &Provisioner{DockerPath: "docker", SecretDirectory: "/run/secrets", Logger: logger} } // Ensure provisions each PostgreSQL-backed database and returns configuration @@ -65,13 +67,13 @@ func (p *Provisioner) EnsureDatabase(ctx context.Context, database string, postg return "", err } if !exists { - password, ok := os.LookupEnv(postgres.PasswordEnv) - if !ok || password == "" { - return "", fmt.Errorf("environment variable %q is required to create the container", postgres.PasswordEnv) + password, err := p.readSecret(postgres.PasswordSecret) + if err != nil { + return "", err } p.Logger.Info("creating PostgreSQL container", "database", database, "container", name, "image", postgres.Image, "volume", postgres.Volume) mount := postgresDataMount(postgres.Image) - _, err := p.run(ctx, []string{"POSTGRES_PASSWORD=" + password}, + _, err = p.run(ctx, []string{"POSTGRES_PASSWORD=" + password}, "container", "create", "--name", name, "--label", managedDatabaseLabel+"="+database, @@ -110,6 +112,26 @@ func (p *Provisioner) EnsureDatabase(ctx context.Context, database string, postg return address, nil } +func (p *Provisioner) readSecret(reference string) (string, error) { + if !config.ValidSecretReference(reference) { + return "", fmt.Errorf("invalid password secret reference %q", reference) + } + directory := p.SecretDirectory + if directory == "" { + directory = "/run/secrets" + } + name := config.DockerSecretName(reference) + contents, err := os.ReadFile(filepath.Join(directory, name)) + if err != nil { + return "", fmt.Errorf("read Docker secret %q: %w", name, err) + } + password := strings.TrimRight(string(contents), "\r\n") + if password == "" { + return "", fmt.Errorf("Docker secret %q is empty", name) + } + return password, nil +} + // StopDatabase stops a managed PostgreSQL container without removing its data volume. func (p *Provisioner) StopDatabase(ctx context.Context, database string) error { name := containerName(database) diff --git a/internal/postgres/provisioner_integration_test.go b/internal/postgres/provisioner_integration_test.go index 60d9200..91f99dd 100644 --- a/internal/postgres/provisioner_integration_test.go +++ b/internal/postgres/provisioner_integration_test.go @@ -5,20 +5,26 @@ package postgres import ( "context" "net" + "os" + "path/filepath" "testing" "time" - config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2" ) func TestProvisionerCreatesPersistentPostgres18(t *testing.T) { const ( - database = "pawsql_integration_test" - volume = "pawsql-integration-test-data" - envName = "PAWSQL_INTEGRATION_POSTGRES_PASSWORD" + database = "pawsql_integration_test" + volume = "pawsql-integration-test-data" + secretRef = "integration_postgres_password" ) - t.Setenv(envName, "integration-test-password") + secretDirectory := t.TempDir() + if err := os.WriteFile(filepath.Join(secretDirectory, config.DockerSecretName(secretRef)), []byte("integration-test-password"), 0o600); err != nil { + t.Fatal(err) + } provisioner := NewProvisioner(nil) + provisioner.SecretDirectory = secretDirectory defer func() { _, _ = provisioner.run(context.Background(), nil, "container", "rm", "--force", containerName(database)) _, _ = provisioner.run(context.Background(), nil, "volume", "rm", "--force", volume) @@ -27,9 +33,9 @@ func TestProvisionerCreatesPersistentPostgres18(t *testing.T) { cfg := config.Config{Databases: []config.DatabaseConfig{{ Name: database, Postgres: &config.PostgresConfig{ - Image: "postgres:18", - Volume: volume, - PasswordEnv: envName, + Image: "postgres:18", + Volume: volume, + PasswordSecret: secretRef, }, }}} resolved, err := provisioner.Ensure(context.Background(), cfg) diff --git a/internal/postgres/provisioner_test.go b/internal/postgres/provisioner_test.go index 80f0413..0713915 100644 --- a/internal/postgres/provisioner_test.go +++ b/internal/postgres/provisioner_test.go @@ -1,6 +1,13 @@ package postgres -import "testing" +import ( + "os" + "path/filepath" + "strings" + "testing" + + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2" +) func TestPostgresDataMount(t *testing.T) { tests := []struct { @@ -17,3 +24,41 @@ func TestPostgresDataMount(t *testing.T) { } } } + +func TestReadSecretUsesPrefixedDockerName(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, config.DockerSecretName("application_password")) + if err := os.WriteFile(path, []byte("correct horse battery staple\n"), 0o600); err != nil { + t.Fatal(err) + } + provisioner := &Provisioner{SecretDirectory: directory} + password, err := provisioner.readSecret("application_password") + if err != nil { + t.Fatal(err) + } + if password != "correct horse battery staple" { + t.Fatalf("password = %q", password) + } +} + +func TestReadSecretRejectsUnsafeReference(t *testing.T) { + provisioner := &Provisioner{SecretDirectory: t.TempDir()} + _, err := provisioner.readSecret("../../etc/passwd") + if err == nil || !strings.Contains(err.Error(), "invalid password secret reference") { + t.Fatalf("readSecret() error = %v", err) + } +} + +func TestReadSecretRejectsMissingAndEmptySecret(t *testing.T) { + directory := t.TempDir() + provisioner := &Provisioner{SecretDirectory: directory} + if _, err := provisioner.readSecret("missing"); err == nil || !strings.Contains(err.Error(), "barkstack_missing") { + t.Fatalf("missing secret error = %v", err) + } + if err := os.WriteFile(filepath.Join(directory, config.DockerSecretName("empty")), []byte("\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := provisioner.readSecret("empty"); err == nil || !strings.Contains(err.Error(), "is empty") { + t.Fatalf("empty secret error = %v", err) + } +} diff --git a/internal/postgres/resolver.go b/internal/postgres/resolver.go index cc54d18..0272405 100644 --- a/internal/postgres/resolver.go +++ b/internal/postgres/resolver.go @@ -6,7 +6,7 @@ import ( "sync" "time" - config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2" "github.com/barkstack/pawsql/internal/router" ) diff --git a/internal/postgres/resolver_test.go b/internal/postgres/resolver_test.go index 7ae3be6..20cdd58 100644 --- a/internal/postgres/resolver_test.go +++ b/internal/postgres/resolver_test.go @@ -6,14 +6,14 @@ import ( "testing" "time" - config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2" "github.com/barkstack/pawsql/internal/router" ) func TestResolverDefersManagedDatabaseStartup(t *testing.T) { routes, err := router.NewStaticResolver([]config.DatabaseConfig{ {Name: "external", Upstream: "192.0.2.1:5432"}, - {Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD"}}, + {Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password"}}, }) if err != nil { t.Fatal(err) @@ -21,7 +21,7 @@ func TestResolverDefersManagedDatabaseStartup(t *testing.T) { ensurer := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}} resolver := NewResolver(routes, []config.DatabaseConfig{ {Name: "external", Upstream: "192.0.2.1:5432"}, - {Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD"}}, + {Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password"}}, }, ensurer) backend, err := resolver.ResolveDatabase(context.Background(), "external") @@ -42,12 +42,12 @@ func TestResolverDefersManagedDatabaseStartup(t *testing.T) { } func TestResolverSerializesManagedStartup(t *testing.T) { - routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD"}}}) + routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password"}}}) if err != nil { t.Fatal(err) } ensurer := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}, gate: make(chan struct{}), started: make(chan struct{})} - resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD"}}}, ensurer) + resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password"}}}, ensurer) var wg sync.WaitGroup for range 2 { @@ -69,12 +69,12 @@ func TestResolverSerializesManagedStartup(t *testing.T) { } func TestResolverStopsDatabaseAfterIdleTimeout(t *testing.T) { - routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD", IdleTimeout: 20 * time.Millisecond}}}) + routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password", IdleTimeout: 20 * time.Millisecond}}}) if err != nil { t.Fatal(err) } controller := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}, stopped: make(chan string, 1)} - resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD", IdleTimeout: 20 * time.Millisecond}}}, controller) + resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password", IdleTimeout: 20 * time.Millisecond}}}, controller) if _, err := resolver.ResolveDatabase(context.Background(), "managed"); err != nil { t.Fatal(err) @@ -91,12 +91,12 @@ func TestResolverStopsDatabaseAfterIdleTimeout(t *testing.T) { } func TestResolverCancelsIdleStopForNewConnection(t *testing.T) { - routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD", IdleTimeout: 40 * time.Millisecond}}}) + routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password", IdleTimeout: 40 * time.Millisecond}}}) if err != nil { t.Fatal(err) } controller := &fakeEnsurer{addresses: map[string]string{"managed": "127.0.0.1:55432"}, stopped: make(chan string, 1)} - resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordEnv: "MANAGED_PASSWORD", IdleTimeout: 40 * time.Millisecond}}}, controller) + resolver := NewResolver(routes, []config.DatabaseConfig{{Name: "managed", Postgres: &config.PostgresConfig{Image: "postgres:18", Volume: "managed-data", PasswordSecret: "managed_password", IdleTimeout: 40 * time.Millisecond}}}, controller) if _, err := resolver.ResolveDatabase(context.Background(), "managed"); err != nil { t.Fatal(err) @@ -122,7 +122,7 @@ func TestResolverMetersTrafficAndStopsTrafficIdleSession(t *testing.T) { postgres := &config.PostgresConfig{ Image: "postgres:18", Volume: "managed-data", - PasswordEnv: "MANAGED_PASSWORD", + PasswordSecret: "managed_password", TrafficIdleTimeout: 100 * time.Millisecond, } routes, err := router.NewStaticResolver([]config.DatabaseConfig{{Name: "managed", Postgres: postgres}}) diff --git a/internal/router/routes.go b/internal/router/routes.go index e6ea5ec..991b630 100644 --- a/internal/router/routes.go +++ b/internal/router/routes.go @@ -7,7 +7,7 @@ import ( "fmt" "strings" - config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2" ) var ( diff --git a/internal/router/routes_test.go b/internal/router/routes_test.go index 479c1aa..4eaa206 100644 --- a/internal/router/routes_test.go +++ b/internal/router/routes_test.go @@ -5,7 +5,7 @@ import ( "errors" "testing" - config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2" ) func TestStaticResolverRoutesCaseInsensitiveHostnames(t *testing.T) { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 3323a82..f6e0416 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -14,7 +14,7 @@ import ( "testing" "time" - config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2" "github.com/barkstack/pawsql/internal/pgwire" "github.com/barkstack/pawsql/internal/router" )