diff --git a/server/cmd/migrate/main.go b/server/cmd/migrate/main.go index d41c0f5..dfac877 100644 --- a/server/cmd/migrate/main.go +++ b/server/cmd/migrate/main.go @@ -27,12 +27,13 @@ func main() { } cmd := os.Args[1] + driver := os.Getenv("DB_DRIVER") dsn := os.Getenv("DB_DSN") if dsn == "" { log.Fatal("migrate: DB_DSN is required") } - db, err := store.Open(&config.Config{DSN: dsn}) + db, err := store.Open(&config.Config{Driver: driver, DSN: dsn}) if err != nil { log.Fatalf("migrate: db: %v", err) } @@ -40,19 +41,19 @@ func main() { switch cmd { case "up": - if err := store.MigrateUp(db); err != nil { + if err := store.MigrateUp(db, driver); err != nil { log.Fatalf("migrate up: %v", err) } log.Println("migrate: up — done") case "down": - if err := store.MigrateDown(db); err != nil { + if err := store.MigrateDown(db, driver); err != nil { log.Fatalf("migrate down: %v", err) } log.Println("migrate: down — done") case "version": - version, dirty, err := store.MigrateVersion(db) + version, dirty, err := store.MigrateVersion(db, driver) if err != nil { log.Fatalf("migrate version: %v", err) } @@ -69,5 +70,6 @@ func usage() { fmt.Fprintln(os.Stderr, "Usage: migrate ") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Environment variables:") - fmt.Fprintln(os.Stderr, " DB_DSN full MySQL DSN (required)") + fmt.Fprintln(os.Stderr, " DB_DRIVER mysql (default) | sqlite") + fmt.Fprintln(os.Stderr, " DB_DSN mysql: full DSN · sqlite: file path or :memory: (required)") } diff --git a/server/go.mod b/server/go.mod index 120e78b..e136238 100644 --- a/server/go.mod +++ b/server/go.mod @@ -22,6 +22,8 @@ require ( google.golang.org/grpc v1.81.1 google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.18.1 ) require ( @@ -281,7 +283,6 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20260523011958-0a33c5d7ca68 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/uint128 v1.2.0 // indirect modernc.org/b v1.0.0 // indirect modernc.org/cc/v3 v3.36.3 // indirect @@ -298,7 +299,6 @@ require ( modernc.org/opt v0.1.3 // indirect modernc.org/ql v1.0.0 // indirect modernc.org/sortutil v1.1.0 // indirect - modernc.org/sqlite v1.18.1 // indirect modernc.org/strutil v1.1.3 // indirect modernc.org/token v1.0.0 // indirect modernc.org/zappy v1.0.0 // indirect diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 180a963..0e43c06 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -10,8 +10,13 @@ import ( // Config holds all application-level configuration. type Config struct { - // DSN is the MySQL connection string. - // Format: user:password@tcp(host:port)/dbname?parseTime=true&loc=UTC&time_zone=%%27UTC%%27 + // Driver selects the database engine: "mysql" (default) or "sqlite". + // Read from DB_DRIVER. Switching engines is a single env var change. + Driver string + + // DSN is the database connection string. + // mysql: user:password@tcp(host:port)/dbname?parseTime=true&loc=UTC&time_zone=%%27UTC%%27 + // sqlite: a file path (e.g. /var/lib/pangolin/pangolin.db) or :memory: DSN string // RedisAddr is the Redis server address (host:port). @@ -72,6 +77,7 @@ type Config struct { // Returns an error if required variables are missing. func FromEnv() (*Config, error) { c := &Config{ + Driver: os.Getenv("DB_DRIVER"), DSN: os.Getenv("DB_DSN"), RedisAddr: getEnvDefault("REDIS_ADDR", "127.0.0.1:6379"), RedisPassword: os.Getenv("REDIS_PASSWORD"), diff --git a/server/internal/db/db.go b/server/internal/db/db.go index 868cea9..034e0b7 100644 --- a/server/internal/db/db.go +++ b/server/internal/db/db.go @@ -1,29 +1,104 @@ -// Package db provides a MySQL connection helper. +// Package db provides a database connection helper that dispatches on the +// configured driver (MySQL or SQLite). Switching engines is a single env var: +// +// DB_DRIVER=mysql (default) → DB_DSN is a full MySQL DSN +// DB_DRIVER=sqlite → DB_DSN is the SQLite file path (or :memory:) package db import ( "database/sql" "fmt" + "os" + "strings" "time" - // Register mysql driver. + // Register the database drivers with database/sql. _ "github.com/go-sql-driver/mysql" + _ "modernc.org/sqlite" ) -// Open opens a *sql.DB backed by MySQL and pings the server. -// The DSN must include parseTime=true and time_zone='+00:00' (UTC). -func Open(dsn string) (*sql.DB, error) { - db, err := sql.Open("mysql", dsn) +// Normalize canonicalizes a driver name. Empty → "mysql"; "sqlite3" → "sqlite". +func Normalize(driver string) string { + switch strings.ToLower(strings.TrimSpace(driver)) { + case "", "mysql": + return "mysql" + case "sqlite", "sqlite3": + return "sqlite" + default: + return strings.ToLower(strings.TrimSpace(driver)) + } +} + +// Driver returns the configured driver from DB_DRIVER (default "mysql"). +func Driver() string { return Normalize(os.Getenv("DB_DRIVER")) } + +// Open opens a *sql.DB using the driver from DB_DRIVER (default mysql) and pings it. +func Open(dsn string) (*sql.DB, error) { return OpenDriver(Driver(), dsn) } + +// OpenDriver opens a *sql.DB for the given driver and DSN, configures the pool, +// and pings. For mysql the DSN must already carry parseTime=true and UTC params +// (see internal/store.Open which builds them); for sqlite the DSN is a file path. +func OpenDriver(driver, dsn string) (*sql.DB, error) { + switch Normalize(driver) { + case "sqlite": + return openSQLite(dsn) + default: + return openMySQL(dsn) + } +} + +func openMySQL(dsn string) (*sql.DB, error) { + database, err := sql.Open("mysql", dsn) if err != nil { return nil, fmt.Errorf("db.Open: %w", err) } - db.SetMaxOpenConns(30) - db.SetMaxIdleConns(10) - db.SetConnMaxLifetime(5 * time.Minute) + database.SetMaxOpenConns(30) + database.SetMaxIdleConns(10) + database.SetConnMaxLifetime(5 * time.Minute) - if err := db.Ping(); err != nil { - db.Close() + if err := database.Ping(); err != nil { + database.Close() return nil, fmt.Errorf("db.Open ping: %w", err) } - return db, nil + return database, nil +} + +func openSQLite(dsn string) (*sql.DB, error) { + database, err := sql.Open("sqlite", sqliteDSN(dsn)) + if err != nil { + return nil, fmt.Errorf("db.Open(sqlite): %w", err) + } + // SQLite is a single-writer engine; readers/writers serialize via the + // busy_timeout pragma (set in the DSN). A modest pool is safe under WAL. + database.SetMaxOpenConns(10) + database.SetMaxIdleConns(10) + database.SetConnMaxLifetime(0) + + if err := database.Ping(); err != nil { + database.Close() + return nil, fmt.Errorf("db.Open(sqlite) ping: %w", err) + } + return database, nil +} + +// sqliteDSN appends the pragmas the server relies on (busy_timeout for write +// contention, foreign_keys enforcement, WAL for file-backed DBs). An empty DSN +// or ":memory:" maps to a shared-cache in-memory DB (so the pool's connections +// see the same database — used by tests). +func sqliteDSN(dsn string) string { + // _txlock=immediate makes every transaction BEGIN IMMEDIATE (acquire the + // write lock up front), giving the pessimistic exclusivity that the MySQL + // "SELECT … FOR UPDATE" paths rely on. busy_timeout lets contended writers + // wait instead of failing with SQLITE_BUSY. + const filePragmas = "_txlock=immediate&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)" + const memPragmas = "_txlock=immediate&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)" + + if dsn == "" || dsn == ":memory:" { + return "file::memory:?cache=shared&" + memPragmas + } + sep := "?" + if strings.Contains(dsn, "?") { + sep = "&" + } + return dsn + sep + filePragmas } diff --git a/server/internal/db/sqlite_smoke_test.go b/server/internal/db/sqlite_smoke_test.go new file mode 100644 index 0000000..2c024b4 --- /dev/null +++ b/server/internal/db/sqlite_smoke_test.go @@ -0,0 +1,61 @@ +package db + +import ( + "testing" + "time" +) + +// TestSQLiteTimeRoundTrip de-risks the migration translation: confirm modernc +// SQLite reads a DATETIME column back into a Go time.Time (UTC) correctly. +func TestSQLiteTimeRoundTrip(t *testing.T) { + d, err := OpenDriver("sqlite", ":memory:") + if err != nil { + t.Fatalf("open: %v", err) + } + defer d.Close() + + if _, err := d.Exec(`CREATE TABLE t ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + flag INTEGER NOT NULL DEFAULT 0, + meta TEXT NULL + )`); err != nil { + t.Fatalf("create: %v", err) + } + + want := time.Date(2026, 6, 17, 8, 30, 45, 123456000, time.UTC) + if _, err := d.Exec(`INSERT INTO t (expires_at, flag, meta) VALUES (?, ?, ?)`, + want, true, `{"k":"v"}`); err != nil { + t.Fatalf("insert: %v", err) + } + + var created, expires time.Time + var flag bool + var meta string + if err := d.QueryRow(`SELECT created_at, expires_at, flag, meta FROM t WHERE id = 1`). + Scan(&created, &expires, &flag, &meta); err != nil { + t.Fatalf("scan: %v", err) + } + + t.Logf("created=%v expires=%v flag=%v meta=%s", created, expires, flag, meta) + if !expires.Equal(want) { + t.Errorf("expires round-trip: got %v, want %v", expires.UTC(), want) + } + if created.IsZero() { + t.Errorf("created_at default not populated") + } + if !flag || meta != `{"k":"v"}` { + t.Errorf("flag/meta mismatch: flag=%v meta=%s", flag, meta) + } + + // WHERE comparison against a time param (used by expires_at > ? queries). + var n int + if err := d.QueryRow(`SELECT COUNT(*) FROM t WHERE expires_at > ?`, + want.Add(-time.Hour)).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 1 { + t.Errorf("time comparison: got %d rows, want 1", n) + } +} diff --git a/server/internal/nodes/lifecycle_test.go b/server/internal/nodes/lifecycle_test.go index adf2f93..c6bb63c 100644 --- a/server/internal/nodes/lifecycle_test.go +++ b/server/internal/nodes/lifecycle_test.go @@ -74,7 +74,7 @@ func setupLifecycle(t *testing.T) *lifecycleFixture { if err != nil { t.Fatalf("store.Open (migrate): %v", err) } - if err := store.MigrateUp(migDB); err != nil { + if err := store.MigrateUp(migDB, "mysql"); err != nil { _ = migDB.Close() t.Fatalf("MigrateUp: %v", err) } diff --git a/server/internal/store/migrate.go b/server/internal/store/migrate.go index ce06038..3d8d428 100644 --- a/server/internal/store/migrate.go +++ b/server/internal/store/migrate.go @@ -4,26 +4,29 @@ import ( "database/sql" "errors" "fmt" + "io/fs" "github.com/golang-migrate/migrate/v4" + migratedb "github.com/golang-migrate/migrate/v4/database" migratemysql "github.com/golang-migrate/migrate/v4/database/mysql" + migratesqlite "github.com/golang-migrate/migrate/v4/database/sqlite" "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/wangjia/pangolin/server/internal/db" "github.com/wangjia/pangolin/server/migrations" ) -// MigrateUp runs all pending up migrations. -// migrate.ErrNoChange is treated as success (idempotent). -func MigrateUp(db *sql.DB) error { - return runMigration(db, func(m *migrate.Migrate) error { +// MigrateUp runs all pending up migrations for the given driver +// ("mysql" | "sqlite"). migrate.ErrNoChange is treated as success (idempotent). +func MigrateUp(database *sql.DB, driver string) error { + return runMigration(database, driver, func(m *migrate.Migrate) error { return m.Up() }) } -// MigrateDown rolls back all applied migrations. -// migrate.ErrNoChange is treated as success. -func MigrateDown(db *sql.DB) error { - return runMigration(db, func(m *migrate.Migrate) error { +// MigrateDown rolls back all applied migrations. ErrNoChange = success. +func MigrateDown(database *sql.DB, driver string) error { + return runMigration(database, driver, func(m *migrate.Migrate) error { return m.Down() }) } @@ -31,8 +34,8 @@ func MigrateDown(db *sql.DB) error { // MigrateVersion returns the currently applied migration version and whether // the schema is in a dirty state. Returns version 0 and no error when no // migrations have been applied yet. -func MigrateVersion(db *sql.DB) (uint, bool, error) { - m, cleanup, err := newMigrator(db) +func MigrateVersion(database *sql.DB, driver string) (uint, bool, error) { + m, cleanup, err := newMigrator(database, driver) if err != nil { return 0, false, err } @@ -49,8 +52,8 @@ func MigrateVersion(db *sql.DB) (uint, bool, error) { } // runMigration opens a migrator, calls fn, and handles ErrNoChange. -func runMigration(db *sql.DB, fn func(*migrate.Migrate) error) error { - m, cleanup, err := newMigrator(db) +func runMigration(database *sql.DB, driver string, fn func(*migrate.Migrate) error) error { + m, cleanup, err := newMigrator(database, driver) if err != nil { return err } @@ -63,30 +66,52 @@ func runMigration(db *sql.DB, fn func(*migrate.Migrate) error) error { } // newMigrator creates a golang-migrate instance backed by the embedded SQL -// files (iofs source) and the provided *sql.DB (mysql driver instance). -// The caller must invoke cleanup() to release source and driver resources. -func newMigrator(db *sql.DB) (*migrate.Migrate, func(), error) { - src, err := iofs.New(migrations.FS, ".") +// files for the chosen dialect and the provided *sql.DB. The caller must invoke +// cleanup() to release source and driver resources. +func newMigrator(database *sql.DB, driver string) (*migrate.Migrate, func(), error) { + embedFS, sub, dbName := migrationSource(driver) + + src, err := iofs.New(embedFS, sub) if err != nil { return nil, nil, fmt.Errorf("store.migrate: iofs source: %w", err) } - driver, err := migratemysql.WithInstance(db, &migratemysql.Config{}) + mdriver, err := newMigrateDriver(driver, database) if err != nil { _ = src.Close() - return nil, nil, fmt.Errorf("store.migrate: mysql driver: %w", err) + return nil, nil, fmt.Errorf("store.migrate: %s driver: %w", dbName, err) } - m, err := migrate.NewWithInstance("iofs", src, "mysql", driver) + m, err := migrate.NewWithInstance("iofs", src, dbName, mdriver) if err != nil { _ = src.Close() - _ = driver.Close() + _ = mdriver.Close() return nil, nil, fmt.Errorf("store.migrate: new migrator: %w", err) } + // Close only the embedded source, NOT the database driver: m.Close() would + // close the caller-owned *sql.DB (the WithInstance driver closes the handle + // it was given — fatal for a shared/in-memory connection the caller reuses). + // The caller owns the *sql.DB and closes it when done. cleanup := func() { - // Close source and driver; errors here are non-fatal cleanup. - _, _ = m.Close() + _ = src.Close() } return m, cleanup, nil } + +// migrationSource returns the embedded FS, its subdir, and the golang-migrate +// database name for the given driver. +func migrationSource(driver string) (fs.FS, string, string) { + if db.Normalize(driver) == "sqlite" { + return migrations.SQLiteFS, "sqlite", "sqlite" + } + return migrations.MySQLFS, "mysql", "mysql" +} + +// newMigrateDriver builds the golang-migrate database driver for the dialect. +func newMigrateDriver(driver string, database *sql.DB) (migratedb.Driver, error) { + if db.Normalize(driver) == "sqlite" { + return migratesqlite.WithInstance(database, &migratesqlite.Config{}) + } + return migratemysql.WithInstance(database, &migratemysql.Config{}) +} diff --git a/server/internal/store/mysql.go b/server/internal/store/mysql.go index 9df85f5..e8b0197 100644 --- a/server/internal/store/mysql.go +++ b/server/internal/store/mysql.go @@ -5,60 +5,55 @@ import ( "fmt" "time" - // Register the mysql driver with database/sql. + // mysql.ParseDSN is used to structurally rewrite the DSN for UTC. "github.com/go-sql-driver/mysql" - _ "github.com/go-sql-driver/mysql" "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/db" ) -// Open is the single canonical MySQL entry point for the Pangolin server. +// Open is the canonical database entry point for the Pangolin server. It +// dispatches on cfg.Driver: // -// It: -// 1. Parses the DSN from cfg.DSN. -// 2. Structurally overrides UTC parameters (ParseTime, Loc, Collation, -// time_zone session variable) — no string manipulation. -// 3. Configures the connection pool. -// 4. Pings the server. -// 5. Asserts SELECT @@session.time_zone = "+00:00" (startup fatal if the -// server ignores our DSN override — e.g. SQL mode forces a different TZ). +// - sqlite: opens the file/in-memory DB (UTC is native; no session assertion). +// - mysql (default): structurally overrides the DSN for UTC (ParseTime, Loc, +// Collation, time_zone), opens the pool, and asserts +// SELECT @@session.time_zone = "+00:00" (startup-fatal if the server ignores +// our UTC override — e.g. SQL mode forces a different TZ). +// +// The actual connection/pool/ping lives in internal/db; this layer adds only the +// MySQL-specific UTC rigor. func Open(cfg *config.Config) (*sql.DB, error) { + if db.Normalize(cfg.Driver) == "sqlite" { + return db.OpenDriver("sqlite", cfg.DSN) + } + dsn, err := buildDSN(cfg) if err != nil { return nil, fmt.Errorf("store.Open: %w", err) } - db, err := sql.Open("mysql", dsn) + database, err := db.OpenDriver("mysql", dsn) if err != nil { return nil, fmt.Errorf("store.Open: %w", err) } - // Connection pool. - db.SetMaxOpenConns(30) - db.SetMaxIdleConns(10) - db.SetConnMaxLifetime(5 * time.Minute) - - if err := db.Ping(); err != nil { - _ = db.Close() - return nil, fmt.Errorf("store.Open: ping: %w", err) - } - // Hard assertion: the session time_zone must be "+00:00". // This catches MySQL servers that ignore client-supplied time_zone params. var tz string - if err := db.QueryRow("SELECT @@session.time_zone").Scan(&tz); err != nil { - _ = db.Close() + if err := database.QueryRow("SELECT @@session.time_zone").Scan(&tz); err != nil { + _ = database.Close() return nil, fmt.Errorf("store.Open: query @@session.time_zone: %w", err) } if tz != "+00:00" { - _ = db.Close() + _ = database.Close() return nil, fmt.Errorf( "store.Open: session time_zone=%q, want +00:00; UTC DSN override failed", tz, ) } - return db, nil + return database, nil } // buildDSN parses cfg.DSN and structurally overrides UTC and utf8mb4 diff --git a/server/internal/store/mysql_integration_test.go b/server/internal/store/mysql_integration_test.go index 67b3971..2760a0a 100644 --- a/server/internal/store/mysql_integration_test.go +++ b/server/internal/store/mysql_integration_test.go @@ -53,11 +53,11 @@ func TestIntegration_TimeZoneAssertionAndMigrateUp(t *testing.T) { defer db.Close() // ── 2. First MigrateUp ─────────────────────────────────────────────────── - if err := store.MigrateUp(db); err != nil { + if err := store.MigrateUp(db, "mysql"); err != nil { t.Fatalf("MigrateUp (first): %v", err) } - v, dirty, err := store.MigrateVersion(db) + v, dirty, err := store.MigrateVersion(db, "mysql") if err != nil { t.Fatalf("MigrateVersion after up: %v", err) } @@ -70,16 +70,16 @@ func TestIntegration_TimeZoneAssertionAndMigrateUp(t *testing.T) { t.Logf("after MigrateUp: version=%d dirty=%v", v, dirty) // ── 3. Idempotent second MigrateUp ─────────────────────────────────────── - if err := store.MigrateUp(db); err != nil { + if err := store.MigrateUp(db, "mysql"); err != nil { t.Fatalf("MigrateUp (idempotent): %v", err) } // ── 4. MigrateDown ─────────────────────────────────────────────────────── - if err := store.MigrateDown(db); err != nil { + if err := store.MigrateDown(db, "mysql"); err != nil { t.Fatalf("MigrateDown: %v", err) } - v2, _, err := store.MigrateVersion(db) + v2, _, err := store.MigrateVersion(db, "mysql") if err != nil { t.Fatalf("MigrateVersion after down: %v", err) } diff --git a/server/internal/store/sqlite_migrate_test.go b/server/internal/store/sqlite_migrate_test.go new file mode 100644 index 0000000..2f95768 --- /dev/null +++ b/server/internal/store/sqlite_migrate_test.go @@ -0,0 +1,93 @@ +package store_test + +import ( + "testing" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" +) + +// TestSQLiteMigrateUpDown verifies the SQLite migration set applies cleanly, +// is idempotent, seeds correctly, and rolls back — no container required, so it +// runs in normal CI (unlike the MySQL integration test behind //go:build integration). +func TestSQLiteMigrateUpDown(t *testing.T) { + cfg := &config.Config{Driver: "sqlite", DSN: ":memory:"} + db, err := store.Open(cfg) + if err != nil { + t.Fatalf("store.Open(sqlite): %v", err) + } + defer db.Close() + + // 1. MigrateUp. + if err := store.MigrateUp(db, "sqlite"); err != nil { + t.Fatalf("MigrateUp: %v", err) + } + v, dirty, err := store.MigrateVersion(db, "sqlite") + if err != nil { + t.Fatalf("MigrateVersion: %v", err) + } + if dirty { + t.Fatalf("schema dirty after MigrateUp") + } + if v != 13 { + t.Errorf("version = %d, want 13", v) + } + + // 2. Core tables exist. + for _, tbl := range []string{ + "users", "devices", "plans", "subscriptions", "code_batches", "codes", + "usage_daily", "audit_log", "providers", "nodes", "node_events", + "directory_version", "provision_idempotency", "replacements", "admins", + "connect_credentials", + } { + var name string + err := db.QueryRow( + `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, tbl, + ).Scan(&name) + if err != nil { + t.Errorf("table %q missing: %v", tbl, err) + } + } + + // 3. Seed: 3 plans + directory_version singleton. + var plans int + if err := db.QueryRow(`SELECT COUNT(*) FROM plans`).Scan(&plans); err != nil { + t.Fatalf("count plans: %v", err) + } + if plans != 3 { + t.Errorf("plans seeded = %d, want 3", plans) + } + var dv int + if err := db.QueryRow(`SELECT version FROM directory_version WHERE id=1`).Scan(&dv); err != nil { + t.Errorf("directory_version singleton missing: %v", err) + } + + // 4. Added columns from later migrations are present (000011 / 000013). + if _, err := db.Exec(`SELECT reality_prk, reality_short_id, sub_token, totp_enabled FROM nodes + LEFT JOIN users ON 0=1 LIMIT 0`); err != nil { + // Separate queries — the join above is just a cheap column-existence probe. + if _, e := db.Exec(`SELECT reality_prk, reality_short_id FROM nodes LIMIT 0`); e != nil { + t.Errorf("nodes reality cols missing: %v", e) + } + if _, e := db.Exec(`SELECT sub_token, totp_secret_enc, totp_enabled FROM users LIMIT 0`); e != nil { + t.Errorf("users totp cols missing: %v", e) + } + } + + // 5. Idempotent second MigrateUp. + if err := store.MigrateUp(db, "sqlite"); err != nil { + t.Fatalf("MigrateUp (idempotent): %v", err) + } + + // 6. MigrateDown returns to baseline. + if err := store.MigrateDown(db, "sqlite"); err != nil { + t.Fatalf("MigrateDown: %v", err) + } + v2, _, err := store.MigrateVersion(db, "sqlite") + if err != nil { + t.Fatalf("MigrateVersion after down: %v", err) + } + if v2 != 0 { + t.Errorf("version after down = %d, want 0", v2) + } +} diff --git a/server/migrations/embed.go b/server/migrations/embed.go index b371f50..0e25179 100644 --- a/server/migrations/embed.go +++ b/server/migrations/embed.go @@ -1,15 +1,22 @@ // Package migrations provides the embedded SQL migration files for golang-migrate. -// Files are named in the golang-migrate convention: +// Files are named in the golang-migrate convention, under one subdir per dialect: // -// {version}_{title}.up.sql -// {version}_{title}.down.sql +// mysql/{version}_{title}.{up,down}.sql +// sqlite/{version}_{title}.{up,down}.sql // -// The FS is consumed by internal/store.MigrateUp / MigrateDown. +// The two FS values are consumed by internal/store.MigrateUp / MigrateDown, +// selected by the configured DB driver. Version numbers are kept aligned across +// dialects so schema evolution adds one file to each set. package migrations import "embed" -// FS contains all *.sql files in this directory, embedded at compile time. +// MySQLFS holds the MySQL-dialect migrations. // -//go:embed *.sql -var FS embed.FS +//go:embed mysql/*.sql +var MySQLFS embed.FS + +// SQLiteFS holds the SQLite-dialect migrations. +// +//go:embed sqlite/*.sql +var SQLiteFS embed.FS diff --git a/server/migrations/000001_users_devices.down.sql b/server/migrations/mysql/000001_users_devices.down.sql similarity index 100% rename from server/migrations/000001_users_devices.down.sql rename to server/migrations/mysql/000001_users_devices.down.sql diff --git a/server/migrations/000001_users_devices.up.sql b/server/migrations/mysql/000001_users_devices.up.sql similarity index 100% rename from server/migrations/000001_users_devices.up.sql rename to server/migrations/mysql/000001_users_devices.up.sql diff --git a/server/migrations/000002_plans_subscriptions.down.sql b/server/migrations/mysql/000002_plans_subscriptions.down.sql similarity index 100% rename from server/migrations/000002_plans_subscriptions.down.sql rename to server/migrations/mysql/000002_plans_subscriptions.down.sql diff --git a/server/migrations/000002_plans_subscriptions.up.sql b/server/migrations/mysql/000002_plans_subscriptions.up.sql similarity index 100% rename from server/migrations/000002_plans_subscriptions.up.sql rename to server/migrations/mysql/000002_plans_subscriptions.up.sql diff --git a/server/migrations/000003_codes.down.sql b/server/migrations/mysql/000003_codes.down.sql similarity index 100% rename from server/migrations/000003_codes.down.sql rename to server/migrations/mysql/000003_codes.down.sql diff --git a/server/migrations/000003_codes.up.sql b/server/migrations/mysql/000003_codes.up.sql similarity index 100% rename from server/migrations/000003_codes.up.sql rename to server/migrations/mysql/000003_codes.up.sql diff --git a/server/migrations/000004_usage_audit.down.sql b/server/migrations/mysql/000004_usage_audit.down.sql similarity index 100% rename from server/migrations/000004_usage_audit.down.sql rename to server/migrations/mysql/000004_usage_audit.down.sql diff --git a/server/migrations/000004_usage_audit.up.sql b/server/migrations/mysql/000004_usage_audit.up.sql similarity index 100% rename from server/migrations/000004_usage_audit.up.sql rename to server/migrations/mysql/000004_usage_audit.up.sql diff --git a/server/migrations/000005_providers_nodes.down.sql b/server/migrations/mysql/000005_providers_nodes.down.sql similarity index 100% rename from server/migrations/000005_providers_nodes.down.sql rename to server/migrations/mysql/000005_providers_nodes.down.sql diff --git a/server/migrations/000005_providers_nodes.up.sql b/server/migrations/mysql/000005_providers_nodes.up.sql similarity index 100% rename from server/migrations/000005_providers_nodes.up.sql rename to server/migrations/mysql/000005_providers_nodes.up.sql diff --git a/server/migrations/000006_node_events_dirver.down.sql b/server/migrations/mysql/000006_node_events_dirver.down.sql similarity index 100% rename from server/migrations/000006_node_events_dirver.down.sql rename to server/migrations/mysql/000006_node_events_dirver.down.sql diff --git a/server/migrations/000006_node_events_dirver.up.sql b/server/migrations/mysql/000006_node_events_dirver.up.sql similarity index 100% rename from server/migrations/000006_node_events_dirver.up.sql rename to server/migrations/mysql/000006_node_events_dirver.up.sql diff --git a/server/migrations/000007_seed.down.sql b/server/migrations/mysql/000007_seed.down.sql similarity index 100% rename from server/migrations/000007_seed.down.sql rename to server/migrations/mysql/000007_seed.down.sql diff --git a/server/migrations/000007_seed.up.sql b/server/migrations/mysql/000007_seed.up.sql similarity index 100% rename from server/migrations/000007_seed.up.sql rename to server/migrations/mysql/000007_seed.up.sql diff --git a/server/migrations/000008_provision.down.sql b/server/migrations/mysql/000008_provision.down.sql similarity index 100% rename from server/migrations/000008_provision.down.sql rename to server/migrations/mysql/000008_provision.down.sql diff --git a/server/migrations/000008_provision.up.sql b/server/migrations/mysql/000008_provision.up.sql similarity index 100% rename from server/migrations/000008_provision.up.sql rename to server/migrations/mysql/000008_provision.up.sql diff --git a/server/migrations/000009_admins.down.sql b/server/migrations/mysql/000009_admins.down.sql similarity index 100% rename from server/migrations/000009_admins.down.sql rename to server/migrations/mysql/000009_admins.down.sql diff --git a/server/migrations/000009_admins.up.sql b/server/migrations/mysql/000009_admins.up.sql similarity index 100% rename from server/migrations/000009_admins.up.sql rename to server/migrations/mysql/000009_admins.up.sql diff --git a/server/migrations/000010_node_blocked_statuses.down.sql b/server/migrations/mysql/000010_node_blocked_statuses.down.sql similarity index 100% rename from server/migrations/000010_node_blocked_statuses.down.sql rename to server/migrations/mysql/000010_node_blocked_statuses.down.sql diff --git a/server/migrations/000010_node_blocked_statuses.up.sql b/server/migrations/mysql/000010_node_blocked_statuses.up.sql similarity index 100% rename from server/migrations/000010_node_blocked_statuses.up.sql rename to server/migrations/mysql/000010_node_blocked_statuses.up.sql diff --git a/server/migrations/000011_node_reality_private.down.sql b/server/migrations/mysql/000011_node_reality_private.down.sql similarity index 100% rename from server/migrations/000011_node_reality_private.down.sql rename to server/migrations/mysql/000011_node_reality_private.down.sql diff --git a/server/migrations/000011_node_reality_private.up.sql b/server/migrations/mysql/000011_node_reality_private.up.sql similarity index 100% rename from server/migrations/000011_node_reality_private.up.sql rename to server/migrations/mysql/000011_node_reality_private.up.sql diff --git a/server/migrations/000012_connect_credentials.down.sql b/server/migrations/mysql/000012_connect_credentials.down.sql similarity index 100% rename from server/migrations/000012_connect_credentials.down.sql rename to server/migrations/mysql/000012_connect_credentials.down.sql diff --git a/server/migrations/000012_connect_credentials.up.sql b/server/migrations/mysql/000012_connect_credentials.up.sql similarity index 100% rename from server/migrations/000012_connect_credentials.up.sql rename to server/migrations/mysql/000012_connect_credentials.up.sql diff --git a/server/migrations/000013_user_sub_token_totp.down.sql b/server/migrations/mysql/000013_user_sub_token_totp.down.sql similarity index 100% rename from server/migrations/000013_user_sub_token_totp.down.sql rename to server/migrations/mysql/000013_user_sub_token_totp.down.sql diff --git a/server/migrations/000013_user_sub_token_totp.up.sql b/server/migrations/mysql/000013_user_sub_token_totp.up.sql similarity index 100% rename from server/migrations/000013_user_sub_token_totp.up.sql rename to server/migrations/mysql/000013_user_sub_token_totp.up.sql diff --git a/server/migrations/sqlite/000001_users_devices.down.sql b/server/migrations/sqlite/000001_users_devices.down.sql new file mode 100644 index 0000000..7a04df0 --- /dev/null +++ b/server/migrations/sqlite/000001_users_devices.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS devices; +DROP TABLE IF EXISTS users; diff --git a/server/migrations/sqlite/000001_users_devices.up.sql b/server/migrations/sqlite/000001_users_devices.up.sql new file mode 100644 index 0000000..46e9804 --- /dev/null +++ b/server/migrations/sqlite/000001_users_devices.up.sql @@ -0,0 +1,22 @@ +-- 账户与设备(SQLite 方言) +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + pw_hash TEXT NOT NULL, -- argon2id + dp_uuid TEXT NOT NULL, -- 数据面凭证 UUID(可轮换) + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','banned')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + platform TEXT NOT NULL CHECK (platform IN ('ios','android','windows','macos')), + last_seen DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); +CREATE INDEX idx_devices_user ON devices (user_id); diff --git a/server/migrations/sqlite/000002_plans_subscriptions.down.sql b/server/migrations/sqlite/000002_plans_subscriptions.down.sql new file mode 100644 index 0000000..f84e736 --- /dev/null +++ b/server/migrations/sqlite/000002_plans_subscriptions.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS subscriptions; +DROP TABLE IF EXISTS plans; diff --git a/server/migrations/sqlite/000002_plans_subscriptions.up.sql b/server/migrations/sqlite/000002_plans_subscriptions.up.sql new file mode 100644 index 0000000..2f85282 --- /dev/null +++ b/server/migrations/sqlite/000002_plans_subscriptions.up.sql @@ -0,0 +1,20 @@ +-- 套餐与订阅 +CREATE TABLE plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE CHECK (code IN ('free','pro','team')), + max_devices INTEGER NOT NULL, + daily_minutes INTEGER NULL, + ad_gate INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + plan_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + source TEXT NOT NULL CHECK (source IN ('trial','code')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (plan_id) REFERENCES plans(id) +); +CREATE INDEX idx_subs_user_exp ON subscriptions (user_id, expires_at); diff --git a/server/migrations/sqlite/000003_codes.down.sql b/server/migrations/sqlite/000003_codes.down.sql new file mode 100644 index 0000000..eff8a4c --- /dev/null +++ b/server/migrations/sqlite/000003_codes.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS codes; +DROP TABLE IF EXISTS code_batches; diff --git a/server/migrations/sqlite/000003_codes.up.sql b/server/migrations/sqlite/000003_codes.up.sql new file mode 100644 index 0000000..37c151a --- /dev/null +++ b/server/migrations/sqlite/000003_codes.up.sql @@ -0,0 +1,22 @@ +-- 激活码 +CREATE TABLE code_batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel TEXT NOT NULL CHECK (channel IN ('store','tg','line','manual')), + created_by TEXT NOT NULL, + note TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code_hash TEXT NOT NULL UNIQUE, -- SHA-256 + plan_id INTEGER NOT NULL, + duration_days INTEGER NOT NULL, + batch_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'unused' CHECK (status IN ('unused','redeemed','void')), + redeemed_by INTEGER NULL, + redeemed_at DATETIME NULL, + FOREIGN KEY (plan_id) REFERENCES plans(id), + FOREIGN KEY (batch_id) REFERENCES code_batches(id) +); +CREATE INDEX idx_codes_status ON codes (status); diff --git a/server/migrations/sqlite/000004_usage_audit.down.sql b/server/migrations/sqlite/000004_usage_audit.down.sql new file mode 100644 index 0000000..bf639bd --- /dev/null +++ b/server/migrations/sqlite/000004_usage_audit.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS audit_log; +DROP TABLE IF EXISTS usage_daily; diff --git a/server/migrations/sqlite/000004_usage_audit.up.sql b/server/migrations/sqlite/000004_usage_audit.up.sql new file mode 100644 index 0000000..f690a88 --- /dev/null +++ b/server/migrations/sqlite/000004_usage_audit.up.sql @@ -0,0 +1,21 @@ +-- 用量(仅字节/分钟,无目的地) +CREATE TABLE usage_daily ( + user_id INTEGER NOT NULL, + date DATE NOT NULL, + bytes_up INTEGER NOT NULL DEFAULT 0, + bytes_down INTEGER NOT NULL DEFAULT 0, + minutes_used INTEGER NOT NULL DEFAULT 0, + ad_unlocked_at DATETIME NULL, + PRIMARY KEY (user_id, date) +); + +-- 审计 +CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT NOT NULL, + meta TEXT NULL, -- JSON + at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_audit_at ON audit_log (at); diff --git a/server/migrations/sqlite/000005_providers_nodes.down.sql b/server/migrations/sqlite/000005_providers_nodes.down.sql new file mode 100644 index 0000000..8291aa5 --- /dev/null +++ b/server/migrations/sqlite/000005_providers_nodes.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS nodes; +DROP TABLE IF EXISTS providers; diff --git a/server/migrations/sqlite/000005_providers_nodes.up.sql b/server/migrations/sqlite/000005_providers_nodes.up.sql new file mode 100644 index 0000000..e49856a --- /dev/null +++ b/server/migrations/sqlite/000005_providers_nodes.up.sql @@ -0,0 +1,33 @@ +-- VPS 厂商池 +CREATE TABLE providers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + api_kind TEXT NOT NULL, + regions TEXT NOT NULL, -- JSON + pool TEXT NOT NULL CHECK (pool IN ('consumable','premium')), + enabled INTEGER NOT NULL DEFAULT 1, + note TEXT NULL +); + +-- 节点。status CHECK 预置最终全集(含 blocked_*,使 000010 在 SQLite 成为 no-op)。 +CREATE TABLE nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + region TEXT NOT NULL, + name_zh TEXT NOT NULL, + name_en TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'entry' CHECK (role IN ('entry','relay','exit')), + tier TEXT NOT NULL CHECK (tier IN ('free','pro')), + endpoint TEXT NOT NULL, + hy2_port INTEGER NULL, + reality_pbk TEXT NOT NULL, + reality_sni TEXT NOT NULL, + provider_id INTEGER NOT NULL, + tags TEXT NULL, -- JSON + status TEXT NOT NULL DEFAULT 'provisioning' + CHECK (status IN ('provisioning','probing','up','draining','down','destroyed','blocked_suspect','blocked_confirmed')), + weight INTEGER NOT NULL DEFAULT 100, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (provider_id) REFERENCES providers(id) +); +CREATE INDEX idx_nodes_status_tier ON nodes (status, tier); diff --git a/server/migrations/sqlite/000006_node_events_dirver.down.sql b/server/migrations/sqlite/000006_node_events_dirver.down.sql new file mode 100644 index 0000000..71bff15 --- /dev/null +++ b/server/migrations/sqlite/000006_node_events_dirver.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS directory_version; +DROP TABLE IF EXISTS node_events; diff --git a/server/migrations/sqlite/000006_node_events_dirver.up.sql b/server/migrations/sqlite/000006_node_events_dirver.up.sql new file mode 100644 index 0000000..c3d83d5 --- /dev/null +++ b/server/migrations/sqlite/000006_node_events_dirver.up.sql @@ -0,0 +1,16 @@ +-- 节点事件。event CHECK 预置最终全集(含 ip_rotated,使 000008 的 enum 扩展在 SQLite 成为 no-op)。 +CREATE TABLE node_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL, + event TEXT NOT NULL CHECK (event IN ('provisioned','probe_pass','probe_fail','marked_up','draining','blocked_suspect','blocked_confirmed','replaced','destroyed','ip_rotated')), + detail TEXT NULL, -- JSON + at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (node_id) REFERENCES nodes(id) +); +CREATE INDEX idx_node_events_node_at ON node_events (node_id, at); + +-- 目录版本单例 +CREATE TABLE directory_version ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + version INTEGER NOT NULL +); diff --git a/server/migrations/sqlite/000007_seed.down.sql b/server/migrations/sqlite/000007_seed.down.sql new file mode 100644 index 0000000..a76a388 --- /dev/null +++ b/server/migrations/sqlite/000007_seed.down.sql @@ -0,0 +1,2 @@ +DELETE FROM directory_version WHERE id = 1; +DELETE FROM plans WHERE code IN ('free', 'pro', 'team'); diff --git a/server/migrations/sqlite/000007_seed.up.sql b/server/migrations/sqlite/000007_seed.up.sql new file mode 100644 index 0000000..551ef67 --- /dev/null +++ b/server/migrations/sqlite/000007_seed.up.sql @@ -0,0 +1,13 @@ +-- plans seed(free/pro/team) +INSERT INTO plans (code, max_devices, daily_minutes, ad_gate) VALUES + ('free', 1, 10, 1), + ('pro', 5, NULL, 0), + ('team', 10, NULL, 0) +ON CONFLICT(code) DO UPDATE SET + max_devices = excluded.max_devices, + daily_minutes = excluded.daily_minutes, + ad_gate = excluded.ad_gate; + +-- directory_version 单例初始化 +INSERT INTO directory_version (id, version) VALUES (1, 1) +ON CONFLICT(id) DO UPDATE SET version = excluded.version; diff --git a/server/migrations/sqlite/000008_provision.down.sql b/server/migrations/sqlite/000008_provision.down.sql new file mode 100644 index 0000000..b373ca2 --- /dev/null +++ b/server/migrations/sqlite/000008_provision.down.sql @@ -0,0 +1,5 @@ +DROP TRIGGER IF EXISTS trg_replacements_updated_at; +DROP TABLE IF EXISTS replacements; +DROP TABLE IF EXISTS provision_idempotency; +ALTER TABLE nodes DROP COLUMN elastic_ip_id; +ALTER TABLE nodes DROP COLUMN provider_instance_id; diff --git a/server/migrations/sqlite/000008_provision.up.sql b/server/migrations/sqlite/000008_provision.up.sql new file mode 100644 index 0000000..90b4ea9 --- /dev/null +++ b/server/migrations/sqlite/000008_provision.up.sql @@ -0,0 +1,33 @@ +-- 弹性节点基建。附加式变更。 +ALTER TABLE nodes ADD COLUMN provider_instance_id TEXT NULL; +ALTER TABLE nodes ADD COLUMN elastic_ip_id TEXT NULL; + +-- node_events.event 的 'ip_rotated' 已在 SQLite 基线(000006)预置,无需扩展。 + +CREATE TABLE provision_idempotency ( + idempotency_key TEXT NOT NULL PRIMARY KEY, + node_uuid TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE replacements ( + uuid TEXT NOT NULL PRIMARY KEY, + old_node_id INTEGER NOT NULL, + new_node_id INTEGER NULL, + pool TEXT NOT NULL CHECK (pool IN ('consumable','premium')), + status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running','done','failed')), + step TEXT NOT NULL DEFAULT 'open_new', + detail TEXT NULL, -- JSON + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_repl_status ON replacements (status); +CREATE INDEX idx_repl_old_node ON replacements (old_node_id); + +-- 等价 MySQL 的 ON UPDATE CURRENT_TIMESTAMP(recursive_triggers 默认关,不会自递归)。 +CREATE TRIGGER trg_replacements_updated_at +AFTER UPDATE ON replacements +FOR EACH ROW +BEGIN + UPDATE replacements SET updated_at = CURRENT_TIMESTAMP WHERE uuid = NEW.uuid; +END; diff --git a/server/migrations/sqlite/000009_admins.down.sql b/server/migrations/sqlite/000009_admins.down.sql new file mode 100644 index 0000000..a86e790 --- /dev/null +++ b/server/migrations/sqlite/000009_admins.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS admins; diff --git a/server/migrations/sqlite/000009_admins.up.sql b/server/migrations/sqlite/000009_admins.up.sql new file mode 100644 index 0000000..fd8a231 --- /dev/null +++ b/server/migrations/sqlite/000009_admins.up.sql @@ -0,0 +1,10 @@ +-- 管理端账户(密码 argon2id;TOTP 密钥 AES-GCM 后以 BLOB 存储) +CREATE TABLE admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + pw_hash TEXT NOT NULL, + totp_secret BLOB NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_login_at DATETIME NULL +); diff --git a/server/migrations/sqlite/000010_node_blocked_statuses.down.sql b/server/migrations/sqlite/000010_node_blocked_statuses.down.sql new file mode 100644 index 0000000..e003f64 --- /dev/null +++ b/server/migrations/sqlite/000010_node_blocked_statuses.down.sql @@ -0,0 +1 @@ +-- no-op(SQLite):SQLite 无法收紧 CHECK;保留预置全集。 diff --git a/server/migrations/sqlite/000010_node_blocked_statuses.up.sql b/server/migrations/sqlite/000010_node_blocked_statuses.up.sql new file mode 100644 index 0000000..7ec6b26 --- /dev/null +++ b/server/migrations/sqlite/000010_node_blocked_statuses.up.sql @@ -0,0 +1 @@ +-- no-op(SQLite):nodes.status 的 blocked_suspect/blocked_confirmed 已在基线 000005 的 CHECK 中预置。 diff --git a/server/migrations/sqlite/000011_node_reality_private.down.sql b/server/migrations/sqlite/000011_node_reality_private.down.sql new file mode 100644 index 0000000..be9bea1 --- /dev/null +++ b/server/migrations/sqlite/000011_node_reality_private.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE nodes DROP COLUMN reality_short_id; +ALTER TABLE nodes DROP COLUMN reality_prk; diff --git a/server/migrations/sqlite/000011_node_reality_private.up.sql b/server/migrations/sqlite/000011_node_reality_private.up.sql new file mode 100644 index 0000000..2eedf47 --- /dev/null +++ b/server/migrations/sqlite/000011_node_reality_private.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE nodes ADD COLUMN reality_prk TEXT NOT NULL DEFAULT ''; +ALTER TABLE nodes ADD COLUMN reality_short_id TEXT NOT NULL DEFAULT ''; diff --git a/server/migrations/sqlite/000012_connect_credentials.down.sql b/server/migrations/sqlite/000012_connect_credentials.down.sql new file mode 100644 index 0000000..cfaa8cc --- /dev/null +++ b/server/migrations/sqlite/000012_connect_credentials.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS connect_credentials; diff --git a/server/migrations/sqlite/000012_connect_credentials.up.sql b/server/migrations/sqlite/000012_connect_credentials.up.sql new file mode 100644 index 0000000..1c5924d --- /dev/null +++ b/server/migrations/sqlite/000012_connect_credentials.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE connect_credentials ( + node_id INTEGER NOT NULL, + dp_uuid TEXT NOT NULL, + protocol INTEGER NOT NULL DEFAULT 3, -- agentv1.ProtocolBoth = 3 + flow TEXT NOT NULL DEFAULT 'xtls-rprx-vision', + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (node_id, dp_uuid), + CONSTRAINT fk_cc_node FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE +); +CREATE INDEX idx_cc_node_expires ON connect_credentials (node_id, expires_at); diff --git a/server/migrations/sqlite/000013_user_sub_token_totp.down.sql b/server/migrations/sqlite/000013_user_sub_token_totp.down.sql new file mode 100644 index 0000000..d53057d --- /dev/null +++ b/server/migrations/sqlite/000013_user_sub_token_totp.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_users_sub_token; +ALTER TABLE users DROP COLUMN totp_enabled; +ALTER TABLE users DROP COLUMN totp_secret_enc; +ALTER TABLE users DROP COLUMN sub_token; diff --git a/server/migrations/sqlite/000013_user_sub_token_totp.up.sql b/server/migrations/sqlite/000013_user_sub_token_totp.up.sql new file mode 100644 index 0000000..5330f75 --- /dev/null +++ b/server/migrations/sqlite/000013_user_sub_token_totp.up.sql @@ -0,0 +1,5 @@ +-- Web 用户中心字段。sub_token 唯一(SQLite 需 ADD COLUMN 后再建唯一索引)。 +ALTER TABLE users ADD COLUMN sub_token TEXT NULL; +ALTER TABLE users ADD COLUMN totp_secret_enc BLOB NULL; +ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0; +CREATE UNIQUE INDEX idx_users_sub_token ON users (sub_token);