diff --git a/default.yaml b/default.yaml index 43176e87..5e3e4992 100644 --- a/default.yaml +++ b/default.yaml @@ -885,9 +885,16 @@ datastore: # if socket-path is set, it will be used instead of host:port # PostgreSQL uses the socket directory, not the socket file path #socket-path: "/var/run/postgresql" + # PostgreSQL SSL/TLS configuration: + ssl-mode: "disable" # options: disable, require, verify-ca, verify-full + #ssl-cert: "/path/to/client-cert.pem" + #ssl-key: "/path/to/client-key.pem" + #ssl-root-cert: "/path/to/ca-cert.pem" user: "ergo" password: "hunter2" history-database: "ergo_history" + # uri takes a postgresql:// (libpq) URI, overriding the above parameters if present: + # uri: "postgresql://ergo:hunter2@localhost/ergo_history" timeout: 3s max-conns: 4 # this may be necessary to prevent middleware from closing your connections: @@ -896,11 +903,6 @@ datastore: #application-name: "ergo" # timeout for establishing initial connections to PostgreSQL: #connect-timeout: 10s - # PostgreSQL SSL/TLS configuration: - ssl-mode: "disable" # options: disable, require, verify-ca, verify-full - #ssl-cert: "/path/to/client-cert.pem" - #ssl-key: "/path/to/client-key.pem" - #ssl-root-cert: "/path/to/ca-cert.pem" # languages config languages: diff --git a/irc/postgres/config.go b/irc/postgres/config.go index 0c872533..10e56100 100644 --- a/irc/postgres/config.go +++ b/irc/postgres/config.go @@ -4,6 +4,8 @@ package postgres import ( + "fmt" + "net/url" "time" ) @@ -33,8 +35,66 @@ type Config struct { SSLCert string `yaml:"ssl-cert"` // client certificate path SSLKey string `yaml:"ssl-key"` // client key path SSLRootCert string `yaml:"ssl-root-cert"` // CA certificate path + URI string `yaml:"uri"` // libpq postgresql:// URI overriding the above // XXX these are copied from elsewhere in the config: ExpireTime time.Duration TrackAccountMessages bool } + +func (config *Config) buildURI() (string, error) { + u := &url.URL{ + Scheme: "postgresql", + Path: "/" + config.HistoryDatabase, + } + + q := url.Values{} + + if config.SocketPath != "" { + // For Unix sockets, pgx uses host as a query parameter + q.Set("host", config.SocketPath) + if config.User != "" || config.Password != "" { + u.User = url.UserPassword(config.User, config.Password) + } + } else { + // TCP connection + port := config.Port + if port == 0 { + port = 5432 + } + host := config.Host + if host == "" { + host = "localhost" + } + u.Host = fmt.Sprintf("%s:%d", host, port) + if config.User != "" || config.Password != "" { + u.User = url.UserPassword(config.User, config.Password) + } + + sslMode := config.SSLMode + if sslMode == "" { + sslMode = "disable" + } + q.Set("sslmode", sslMode) + + if config.SSLCert != "" { + q.Set("sslcert", config.SSLCert) + } + if config.SSLKey != "" { + q.Set("sslkey", config.SSLKey) + } + if config.SSLRootCert != "" { + q.Set("sslrootcert", config.SSLRootCert) + } + } + + if config.ApplicationName != "" { + q.Set("application_name", config.ApplicationName) + } + if config.ConnectTimeout != 0 { + q.Set("connect_timeout", fmt.Sprintf("%d", int(config.ConnectTimeout.Seconds()))) + } + + u.RawQuery = q.Encode() + return u.String(), nil +} diff --git a/irc/postgres/config_test.go b/irc/postgres/config_test.go new file mode 100644 index 00000000..46e67492 --- /dev/null +++ b/irc/postgres/config_test.go @@ -0,0 +1,84 @@ +// Copyright (c) 2020 Shivaram Lingamneni +// released under the MIT license + +package postgres + +import ( + "testing" + "time" +) + +func testBuildURI(t *testing.T, config Config, expected string) { + t.Helper() + uri, err := config.buildURI() + if err != nil { + t.Fatal(err) + } + if uri != expected { + t.Errorf("got %q, want %q", uri, expected) + } +} + +func TestBuildURITCP(t *testing.T) { + testBuildURI(t, Config{ + Host: "db.example.com", + Port: 5432, + User: "ergo", + Password: "secret", + HistoryDatabase: "ergo_history", + }, "postgresql://ergo:secret@db.example.com:5432/ergo_history?sslmode=disable") +} + +func TestBuildURIDefaultPort(t *testing.T) { + testBuildURI(t, Config{ + Host: "localhost", + HistoryDatabase: "ergo_history", + }, "postgresql://localhost:5432/ergo_history?sslmode=disable") +} + +func TestBuildURIDefaultHost(t *testing.T) { + testBuildURI(t, Config{ + HistoryDatabase: "ergo_history", + }, "postgresql://localhost:5432/ergo_history?sslmode=disable") +} + +func TestBuildURISSLMode(t *testing.T) { + testBuildURI(t, Config{ + Host: "db.example.com", + Port: 5432, + HistoryDatabase: "ergo_history", + SSLMode: "verify-full", + SSLCert: "/etc/ssl/client.crt", + SSLKey: "/etc/ssl/client.key", + SSLRootCert: "/etc/ssl/ca.crt", + }, "postgresql://db.example.com:5432/ergo_history?sslcert=%2Fetc%2Fssl%2Fclient.crt&sslkey=%2Fetc%2Fssl%2Fclient.key&sslmode=verify-full&sslrootcert=%2Fetc%2Fssl%2Fca.crt") +} + +func TestBuildURIUnixSocket(t *testing.T) { + testBuildURI(t, Config{ + SocketPath: "/var/run/postgresql", + User: "ergo", + Password: "secret", + HistoryDatabase: "ergo_history", + }, "postgresql://ergo:secret@/ergo_history?host=%2Fvar%2Frun%2Fpostgresql") +} + +func TestBuildURISpecialCharsInPassword(t *testing.T) { + testBuildURI(t, Config{ + Host: "db.example.com", + Port: 5432, + User: "ergo", + Password: "p@ss:w/ord?#&=", + HistoryDatabase: "ergo_history", + }, "postgresql://ergo:p%40ss%3Aw%2Ford%3F%23&=@db.example.com:5432/ergo_history?sslmode=disable") +} + +func TestBuildURIOptionalParams(t *testing.T) { + testBuildURI(t, Config{ + Host: "db.example.com", + Port: 5433, + HistoryDatabase: "ergo_history", + ApplicationName: "ergo", + ConnectTimeout: 30 * time.Second, + }, "postgresql://db.example.com:5433/ergo_history?application_name=ergo&connect_timeout=30&sslmode=disable") +} diff --git a/irc/postgres/history.go b/irc/postgres/history.go index 9f01521b..f5e0f525 100644 --- a/irc/postgres/history.go +++ b/irc/postgres/history.go @@ -11,6 +11,7 @@ import ( "encoding/json" "fmt" "io" + "net/url" "runtime/debug" "slices" "strings" @@ -93,45 +94,20 @@ func (pg *PostgreSQL) getExpireTime() (expireTime time.Duration) { func (pg *PostgreSQL) open() (err error) { // Build PostgreSQL connection string - var connString string - if pg.config.SocketPath != "" { - // PostgreSQL uses host parameter for Unix socket directory - connString = fmt.Sprintf("host=%s user=%s password=%s dbname=%s", - pg.config.SocketPath, pg.config.User, pg.config.Password, pg.config.HistoryDatabase) + uri := pg.config.URI + if uri != "" { + uri, err = pg.mungeURI(uri) + if err != nil { + return err + } } else { - // TCP connection - port := pg.config.Port - if port == 0 { - port = 5432 // Default PostgreSQL port - } - sslMode := pg.config.SSLMode - if sslMode == "" { - sslMode = "disable" - } - connString = fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", - pg.config.Host, port, pg.config.User, pg.config.Password, pg.config.HistoryDatabase, sslMode) - - // Add SSL certificate paths if provided - if pg.config.SSLCert != "" { - connString += fmt.Sprintf(" sslcert=%s", pg.config.SSLCert) - } - if pg.config.SSLKey != "" { - connString += fmt.Sprintf(" sslkey=%s", pg.config.SSLKey) - } - if pg.config.SSLRootCert != "" { - connString += fmt.Sprintf(" sslrootcert=%s", pg.config.SSLRootCert) + uri, err = pg.config.buildURI() + if err != nil { + return err } } - // Add optional PostgreSQL-specific parameters - if pg.config.ApplicationName != "" { - connString += fmt.Sprintf(" application_name=%s", pg.config.ApplicationName) - } - if pg.config.ConnectTimeout != 0 { - connString += fmt.Sprintf(" connect_timeout=%d", int(pg.config.ConnectTimeout.Seconds())) - } - - pg.db, err = sql.Open("pgx", connString) + pg.db, err = sql.Open("pgx", uri) if err != nil { return err } @@ -160,6 +136,22 @@ func (pg *PostgreSQL) open() (err error) { return nil } +func (pg *PostgreSQL) mungeURI(uriStr string) (result string, err error) { + uri, err := url.Parse(uriStr) + if err != nil { + return "", fmt.Errorf("could not parse postgres URI: %w", err) + } + values := uri.Query() + if !values.Has("application_name") { + values.Set("application_name", pg.config.ApplicationName) + } + if !values.Has("connect_timeout") { + values.Set("connect_timeout", fmt.Sprintf("%d", int(pg.config.ConnectTimeout.Seconds()))) + } + uri.RawQuery = values.Encode() + return uri.String(), nil +} + func (pg *PostgreSQL) fixSchemas() (err error) { _, err = pg.db.Exec(`CREATE TABLE IF NOT EXISTS metadata ( key_name VARCHAR(32) PRIMARY KEY, diff --git a/traditional.yaml b/traditional.yaml index 920980b5..564043e1 100644 --- a/traditional.yaml +++ b/traditional.yaml @@ -856,9 +856,16 @@ datastore: # if socket-path is set, it will be used instead of host:port # PostgreSQL uses the socket directory, not the socket file path #socket-path: "/var/run/postgresql" + # PostgreSQL SSL/TLS configuration: + ssl-mode: "disable" # options: disable, require, verify-ca, verify-full + #ssl-cert: "/path/to/client-cert.pem" + #ssl-key: "/path/to/client-key.pem" + #ssl-root-cert: "/path/to/ca-cert.pem" user: "ergo" password: "hunter2" history-database: "ergo_history" + # uri takes a postgresql:// (libpq) URI, overriding the above parameters if present: + # uri: "postgresql://ergo:hunter2@localhost/ergo_history" timeout: 3s max-conns: 4 # this may be necessary to prevent middleware from closing your connections: @@ -867,11 +874,6 @@ datastore: #application-name: "ergo" # timeout for establishing initial connections to PostgreSQL: #connect-timeout: 10s - # PostgreSQL SSL/TLS configuration: - ssl-mode: "disable" # options: disable, require, verify-ca, verify-full - #ssl-cert: "/path/to/client-cert.pem" - #ssl-key: "/path/to/client-key.pem" - #ssl-root-cert: "/path/to/ca-cert.pem" # languages config languages: