Files

98 lines
3.7 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package backup
import (
"errors"
"net/url"
"os"
"sort"
"strconv"
"strings"
"unicode"
)
// ConnectionService captures a single-host PostgreSQL URL for pg_dump. The
// result contains secrets and must only be written to private staging, never
// logged or passed as a process argument. Unsupported settings fail closed.
func ConnectionService(dsn string) (string, error) {
return connectionService(dsn, os.Environ())
}
func connectionService(dsn string, environ []string) (string, error) {
invalid := errors.New("backup requires a single-host PostgreSQL URL with explicit user and database and supported libpq settings")
u, err := url.Parse(dsn)
if err != nil || (u.Scheme != "postgres" && u.Scheme != "postgresql") || u.User == nil || u.User.Username() == "" ||
u.Hostname() == "" || strings.Contains(u.Hostname(), ",") || strings.TrimLeft(u.Path, "/") == "" || u.Fragment != "" {
return "", invalid
}
settings := map[string]string{"host": u.Hostname(), "port": "5432", "user": u.User.Username(),
"dbname": strings.TrimLeft(u.Path, "/"), "sslmode": "prefer", "gssencmode": "disable"}
// Match the pgx startup environment for the supported native client settings.
// Target fields come exclusively from the URL. Service indirection and newer
// protocol options need separate compatibility work, not a guessed translation.
envKeys := map[string]string{"PGPASSWORD": "password", "PGPASSFILE": "passfile", "PGAPPNAME": "application_name",
"PGCONNECT_TIMEOUT": "connect_timeout", "PGSSLMODE": "sslmode", "PGSSLKEY": "sslkey", "PGSSLCERT": "sslcert",
"PGSSLROOTCERT": "sslrootcert", "PGSSLPASSWORD": "sslpassword", "PGSSLSNI": "sslsni",
"PGTARGETSESSIONATTRS": "target_session_attrs", "PGOPTIONS": "options"}
for _, entry := range environ {
key, value, _ := strings.Cut(entry, "=")
if value == "" {
continue
}
switch key {
case "PGSERVICE", "PGSERVICEFILE", "PGSSLNEGOTIATION", "PGMINPROTOCOLVERSION", "PGMAXPROTOCOLVERSION", "PGTZ":
return "", invalid
}
if name := envKeys[key]; name != "" {
settings[name] = value
}
if key == "PGPORT" && u.Port() == "" {
settings["port"] = value
}
}
if port := u.Port(); port != "" {
settings["port"] = port
}
if port, err := strconv.Atoi(settings["port"]); err != nil || port < 1 || port > 65535 {
return "", invalid
}
if password, ok := u.User.Password(); ok {
settings["password"] = password
}
query, err := url.ParseQuery(u.RawQuery)
if err != nil {
return "", invalid
}
for key, values := range query {
if len(values) != 1 || values[0] == "" {
return "", invalid
}
switch key {
case "pool_max_conns", "pool_min_conns", "pool_min_idle_conns", "pool_max_conn_lifetime", "pool_max_conn_idle_time", "pool_health_check_period", "pool_max_conn_lifetime_jitter":
continue // pgx-only pool controls; main has already validated them.
case "sslmode", "sslcert", "sslkey", "sslrootcert", "sslpassword", "sslsni", "passfile", "connect_timeout", "application_name", "target_session_attrs", "options":
settings[key] = values[0]
default:
return "", invalid
}
}
var result strings.Builder
result.WriteString("[beacon_backup]\n")
keys := make([]string, 0, len(settings))
for key, value := range settings {
// libpq service files have line-based, unquoted values. Reject anything
// their parser would trim or interpret as another entry.
if strings.TrimSpace(value) != value || strings.IndexFunc(value, unicode.IsControl) >= 0 || len(value) > 1000 {
return "", invalid
}
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
result.WriteString(key + "=" + settings[key] + "\n")
}
return result.String(), nil
}