From 6d2bf233e8ef44321982df1210532755f3991215 Mon Sep 17 00:00:00 2001 From: Viktor Torstensson Date: Thu, 11 Jun 2026 12:35:00 +0200 Subject: [PATCH] config: block startup on mixed SQL backends Reject `litd` startup when another SQL database exists than the selected SQL `databasebackend` choice. When starting with the `databasebackend` config flag set to `postgres`, we check whether an `sqlite` database file exists at the default path and fail startup if it does. When starting with `sqlite`, we instead check whether `postgres` database configuration has been provided and, if so, whether a database exists at the configured `postgres` connection parameters. This prevents accidental switches between SQL backends. This is especially important because allowing such a switch would retrigger the KVDB-to-SQL migration for the newly configured SQL backend. In that scenario, the data would already have been migrated previously, meaning the migration source would be stale and could result in outdated data being imported. Additionally, it would create two divergent copies of the data across separate SQL backends. Since we do not support migrations between SQL backends, recovering from such a situation would not be possible. --- config.go | 127 +++++++++++++++++++++++++++++++++++++++++++ config_test.go | 143 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 config_test.go diff --git a/config.go b/config.go index 0ea6fffb..6ff2b635 100644 --- a/config.go +++ b/config.go @@ -3,6 +3,7 @@ package terminal import ( "context" "crypto/tls" + "database/sql" "errors" "fmt" "io/ioutil" @@ -15,6 +16,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/jessevdk/go-flags" + "github.com/lib/pq" "github.com/lightninglabs/faraday" "github.com/lightninglabs/faraday/chain" "github.com/lightninglabs/faraday/frdrpcserver" @@ -727,6 +729,11 @@ func loadAndValidateConfig(interceptor signal.Interceptor) (*Config, error) { ) } + err = validateExclusiveSQLBackends(cfg, litDir) + if err != nil { + return nil, err + } + err = cfg.DevConfig.Validate() if err != nil { return nil, err @@ -1149,6 +1156,126 @@ func readAutoMigrateKVDB(config *Config) error { return nil } +// validateExclusiveSQLBackends errors and thereby rejects startup when the +// inactive SQL backend still has data at its default location. This prevents +// silently switching to a different SQL store and starting against an empty +// database. +func validateExclusiveSQLBackends(cfg *Config, litDir string) error { + switch cfg.DatabaseBackend { + case DatabaseBackendPostgres: + sqlitePath := filepath.Join( + litDir, cfg.Network, defaultSqliteDatabaseFileName, + ) + + exists, err := sqliteDatabaseExists(sqlitePath) + if err != nil { + return fmt.Errorf("unable to check for existing "+ + "sqlite database file at %s: %w", sqlitePath, + err) + } + if exists { + return fmt.Errorf("cannot start litd with postgres "+ + "backend: sqlite database file already exists "+ + "at %s. If you really intend to switch to "+ + "postgres you must delete the sqlite "+ + "database (effectively deleting all stored "+ + "data) before restarting with postgres again", + sqlitePath) + } + + case DatabaseBackendSqlite: + exists, err := postgresDatabaseExists(cfg.Postgres) + if err != nil { + return fmt.Errorf("unable to check for existing "+ + "postgres database %q at %s:%d for user %q. "+ + "Note that a postgres configuration is set "+ + "despite sqlite being set as the "+ + "databasebackend: %w", cfg.Postgres.DBName, + cfg.Postgres.Host, cfg.Postgres.Port, + cfg.Postgres.User, err) + } + if exists { + return fmt.Errorf("cannot start litd with sqlite "+ + "backend: postgres database %q already exists "+ + "at %s:%d for user %q; If you really intend "+ + "to switch to sqlite you must delete the "+ + "postgres database (effectively deleting all "+ + "stored data) before restarting with sqlite "+ + "again", cfg.Postgres.DBName, cfg.Postgres.Host, + cfg.Postgres.Port, cfg.Postgres.User) + } + } + + return nil +} + +// sqliteDatabaseExists reports whether a SQLite database file exists at the +// given path. +func sqliteDatabaseExists(path string) (bool, error) { + _, err := os.Stat(path) + switch { + case err == nil: + return true, nil + + case os.IsNotExist(err): + return false, nil + + default: + return false, err + } +} + +// postgresDatabaseExists reports whether a Postgres database can be reached +// with the configured connection info. If the configuration does not identify +// a concrete database, the check is skipped. +func postgresDatabaseExists(cfg *db.PostgresConfig) (bool, error) { + if !hasPostgresConnectionInfo(cfg) { + return false, nil + } + + dbConn, err := sql.Open("postgres", cfg.DSN(false)) + if err != nil { + return false, fmt.Errorf("unable to check for existing "+ + "postgres database %q: %w", cfg.DBName, err) + } + defer dbConn.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = dbConn.PingContext(ctx) + switch { + case err == nil: + return true, nil + + case isMissingPostgresDatabase(err): + return false, nil + + default: + return false, fmt.Errorf("unable to check for existing "+ + "postgres database %q: %w", cfg.DBName, err) + } +} + +// hasPostgresConnectionInfo reports whether the config identifies a concrete +// Postgres database to probe. +func hasPostgresConnectionInfo(cfg *db.PostgresConfig) bool { + return cfg != nil && cfg.Host != "" && cfg.Port != 0 && + cfg.User != "" && cfg.DBName != "" +} + +// isMissingPostgresDatabase reports whether the probe failed because the +// target database does not exist. The lib/pq driver surfaces that as SQLSTATE +// 3D000 (invalid_catalog_name). +func isMissingPostgresDatabase(err error) bool { + var pqErr *pq.Error + if errors.As(err, &pqErr) { + return string(pqErr.Code) == "3D000" + } + + return false +} + func buildTLSConfigForHttp2(config *Config) (*tls.Config, error) { var tlsConfig *tls.Config diff --git a/config_test.go b/config_test.go new file mode 100644 index 00000000..03a7c6a4 --- /dev/null +++ b/config_test.go @@ -0,0 +1,143 @@ +package terminal + +import ( + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/lightninglabs/lightning-terminal/db" + "github.com/stretchr/testify/require" +) + +// TestBlockStartupForPostgresIfSqliteDBExists verifies that a Postgres startup +// is rejected when the default SQLite database file still exists for the +// selected network. +func TestBlockStartupForPostgresIfSqliteDBExists(t *testing.T) { + litDir := t.TempDir() + sqlitePath := filepath.Join( + litDir, "regtest", defaultSqliteDatabaseFileName, + ) + + fixture := db.NewTestPgFixture( + t, db.DefaultPostgresFixtureLifetime, true, + ) + t.Cleanup(func() { + fixture.TearDown(t) + }) + + require.NoError(t, os.MkdirAll(filepath.Dir(sqlitePath), 0700)) + require.NoError(t, os.WriteFile(sqlitePath, []byte("sqlite"), 0600)) + + cfg := &Config{ + DatabaseBackend: DatabaseBackendPostgres, + LitDir: litDir, + Network: "regtest", + Postgres: fixture.GetConfig(), + } + + err := validateExclusiveSQLBackends(cfg, litDir) + require.Error(t, err) + require.Contains(t, err.Error(), "sqlite database file already exists") + require.Contains(t, err.Error(), sqlitePath) +} + +// TestBlockStartupForSqliteIfPostgresDBExists verifies that a SQLite startup is +// rejected when the configured Postgres database already exists. +func TestBlockStartupForSqliteIfPostgresDBExists(t *testing.T) { + fixture := db.NewTestPgFixture( + t, db.DefaultPostgresFixtureLifetime, true, + ) + t.Cleanup(func() { + fixture.TearDown(t) + }) + + cfg := &Config{ + DatabaseBackend: DatabaseBackendSqlite, + Postgres: fixture.GetConfig(), + } + + err := validateExclusiveSQLBackends(cfg, "") + require.Error(t, err) + require.Contains(t, err.Error(), "postgres database") + require.Contains(t, err.Error(), cfg.Postgres.DBName) +} + +// TestDontBlockSqliteOnlyStartup verifies that SQLite startup is allowed when +// no concrete Postgres database is configured. +func TestDontBlockSqliteOnlyStartup(t *testing.T) { + cfg := &Config{ + DatabaseBackend: DatabaseBackendSqlite, + } + + require.NoError(t, validateExclusiveSQLBackends(cfg, "")) +} + +// TestDontBlockSqliteStartupIfConfiguredPostgresDoesntExist verifies that a +// SQLite startup is allowed when the configured Postgres database does not +// exist. +func TestDontBlockSqliteStartupIfConfiguredPostgresDoesntExist(t *testing.T) { + fixture := db.NewTestPgFixture( + t, db.DefaultPostgresFixtureLifetime, true, + ) + t.Cleanup(func() { + fixture.TearDown(t) + }) + + pgCfg := fixture.GetConfig() + pgCfg.DBName = "does_not_exist_for_config_validation" + + cfg := &Config{ + DatabaseBackend: DatabaseBackendSqlite, + Postgres: pgCfg, + } + + require.NoError(t, validateExclusiveSQLBackends(cfg, "")) + + // We also validate that the validateExclusiveSQLBackends passed because + // the db with the configured DBName doesn't exist and not because the + // connection parameters are wrong. This proves that we're ok with a + // postgres setup existing, as long as the specific database doesn't + // exist. + dbConn, err := sql.Open("postgres", pgCfg.DSN(false)) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, dbConn.Close()) + }) + + err = dbConn.Ping() + require.Error(t, err) + require.True(t, isMissingPostgresDatabase(err)) +} + +// TestDontBlockPostgresOnlyStartup verifies that a Postgres is allowed when +// no sqlite database file exists for the selected network, despite the actual +// folder where the file would be placed exists. +func TestDontBlockPostgresOnlyStartup(t *testing.T) { + litDir := t.TempDir() + sqlitePath := filepath.Join( + litDir, "regtest", defaultSqliteDatabaseFileName, + ) + + fixture := db.NewTestPgFixture( + t, db.DefaultPostgresFixtureLifetime, true, + ) + t.Cleanup(func() { + fixture.TearDown(t) + }) + + require.NoError(t, os.MkdirAll(filepath.Dir(sqlitePath), 0700)) + + // NOTE: we don't write any file to the sqlite path here, so the sqlite + // database file never exists, only the default directory where it would + // be located. + + cfg := &Config{ + DatabaseBackend: DatabaseBackendPostgres, + LitDir: litDir, + Network: "regtest", + Postgres: fixture.GetConfig(), + } + + require.NoError(t, validateExclusiveSQLBackends(cfg, litDir)) +}