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.
This commit is contained in:
Viktor Torstensson 2026-06-11 12:35:00 +02:00
parent 1903010be5
commit 6d2bf233e8
No known key found for this signature in database
GPG key ID: 961CC8259AE675D4
2 changed files with 270 additions and 0 deletions

127
config.go
View file

@ -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

143
config_test.go Normal file
View file

@ -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))
}