Files
LinkPort/internal/store/database_snapshot.go
曾志威 9da95beb42
continuous-integration/drone Build is failing
feat: refine management UI, connection diagnostics and local startup
Migrate the workspace to React and Ant Design, improve account forms and responsive layouts, support outbound diagnostics and SSH fingerprint confirmation, and persist the management startup port. Preserve database migrations, remove S3 settings storage, and improve gateway audit handling.
2026-10-13 19:43:23 +08:00

293 lines
8.8 KiB
Go

package store
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"linkport/internal/security"
"modernc.org/sqlite"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
// DatabaseSnapshotInfo deliberately contains no decrypted configuration or identity.
type DatabaseSnapshotInfo struct {
Format string `json:"format"`
UserVersion int `json:"userVersion"`
SchemaSHA256 string `json:"schemaSha256"`
SHA256 string `json:"sha256"`
Bytes int64 `json:"bytes"`
Resources map[string]int64 `json:"resources"`
}
func snapshotURI(path string, immutable bool) string {
absolute, _ := filepath.Abs(path)
uriPath := filepath.ToSlash(absolute)
if !strings.HasPrefix(uriPath, "/") {
uriPath = "/" + uriPath
}
value := &url.URL{Scheme: "file", Path: uriPath}
if immutable {
value.RawQuery = "mode=ro&immutable=1"
}
return value.String()
}
// SQLite's online backup API includes committed WAL pages without copying live files.
func copySQLite(ctx context.Context, db *sql.DB, path string, restore bool) error {
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
return conn.Raw(func(raw any) (result error) {
driver, ok := raw.(interface {
NewBackup(string) (*sqlite.Backup, error)
NewRestore(string) (*sqlite.Backup, error)
})
if !ok {
return errors.New("SQLite backup API unavailable")
}
var backup *sqlite.Backup
if restore {
backup, err = driver.NewRestore(snapshotURI(path, true))
} else {
backup, err = driver.NewBackup(snapshotURI(path, false))
}
if err != nil {
return err
}
defer func() {
if finishErr := backup.Finish(); result == nil {
result = finishErr
}
}()
for {
if err := ctx.Err(); err != nil {
return err
}
more, err := backup.Step(128)
if err != nil {
return err
}
if !more {
return nil
}
}
})
}
func createSnapshot(ctx context.Context, db *sql.DB, path string) error {
file, err := security.CreatePrivateFile(path)
if err != nil {
return err
}
if err = file.Close(); err != nil {
return err
}
if err = copySQLite(ctx, db, path, false); err != nil {
os.Remove(path)
return err
}
return nil
}
func (s *Store) DatabaseSnapshot(ctx context.Context, path string) (DatabaseSnapshotInfo, error) {
if !s.portable {
return DatabaseSnapshotInfo{}, errors.New("configuration snapshots require an embedded-key LinkPort SQLite database")
}
if err := createSnapshot(ctx, s.db, path); err != nil {
return DatabaseSnapshotInfo{}, err
}
return InspectDatabaseSnapshot(ctx, path)
}
// Inspect reads an immutable, standalone snapshot and never initializes or migrates it.
func InspectDatabaseSnapshot(ctx context.Context, path string) (DatabaseSnapshotInfo, error) {
info := DatabaseSnapshotInfo{Format: "linkport-config-sqlite-v2", Resources: map[string]int64{"commands": 0, "events": 0}}
stat, err := os.Lstat(path)
if err != nil {
return info, err
}
if !stat.Mode().IsRegular() || stat.Size() < 100 {
return info, errors.New("a regular SQLite snapshot file is required")
}
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
if _, err := os.Lstat(path + suffix); !os.IsNotExist(err) {
return info, errors.New("snapshot must be standalone; use online backup or stop the source service first")
}
}
file, err := os.Open(path)
if err != nil {
return info, err
}
hash := sha256.New()
_, err = io.Copy(hash, file)
file.Close()
if err != nil {
return info, err
}
info.Bytes, info.SHA256 = stat.Size(), hex.EncodeToString(hash.Sum(nil))
db, err := sql.Open("sqlite", snapshotURI(path, true))
if err != nil {
return info, err
}
db.SetMaxOpenConns(1)
defer db.Close()
if _, err = db.ExecContext(ctx, "PRAGMA query_only=ON; PRAGMA trusted_schema=OFF"); err != nil {
return info, errors.New("invalid SQLite snapshot")
}
var integrity string
if err = db.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&integrity); err != nil || integrity != "ok" {
return info, errors.New("SQLite integrity check failed")
}
if err = db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&info.UserVersion); err != nil {
return info, err
}
if info.UserVersion != currentSchemaVersion {
return info, errors.New("unsupported LinkPort database version")
}
rows, err := db.QueryContext(ctx, "SELECT type,name,tbl_name,coalesce(sql,'') FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name")
if err != nil {
return info, err
}
schema := sha256.New()
tables := map[string]bool{}
for rows.Next() {
var kind, name, table, definition string
if err = rows.Scan(&kind, &name, &table, &definition); err != nil {
rows.Close()
return info, err
}
fmt.Fprintf(schema, "%s\x00%s\x00%s\x00%s\n", kind, name, table, definition)
if kind == "table" {
tables[name] = true
}
}
err = rows.Err()
rows.Close()
if err != nil {
return info, err
}
info.SchemaSHA256 = hex.EncodeToString(schema.Sum(nil))
for _, name := range []string{"local_metadata", "admin", "targets", "gateways", "outbounds", "assets", "log_settings"} {
if !tables[name] {
return info, errors.New("not a complete LinkPort configuration snapshot")
}
}
var key string
if err = db.QueryRowContext(ctx, "SELECT value FROM local_metadata WHERE name='master-key'").Scan(&key); err != nil {
return info, errors.New("snapshot has no embedded master key")
}
vault, err := security.NewVault(key)
if err != nil {
return info, errors.New("snapshot master key is invalid")
}
if err = validateStoredKey(db, vault); err != nil {
return info, errors.New("snapshot encrypted data does not match its embedded key")
}
for _, name := range []string{"targets", "gateways", "outbounds", "assets", "admin"} {
var count int64
if err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+name).Scan(&count); err != nil {
return info, errors.New("cannot inspect snapshot resources")
}
info.Resources[name] = count
}
return info, nil
}
// RestoreDatabaseOffline MUST be called while holding desktop.Prepare's exclusive
// service lock, before opening Store, TLS identities, sessions or background tasks.
// SQLite's backup transaction replaces all pages atomically, without renaming a
// locked inode. The current database is backed up and validated before any write.
func RestoreDatabaseOffline(ctx context.Context, current, source, expectedSHA string) (string, error) {
if len(expectedSHA) != 64 {
return "", errors.New("explicit SHA256 confirmation is required")
}
if _, err := hex.DecodeString(expectedSHA); err != nil {
return "", errors.New("invalid SHA256 confirmation")
}
sourceInfo, err := InspectDatabaseSnapshot(ctx, source)
if err != nil {
return "", err
}
if !strings.EqualFold(sourceInfo.SHA256, expectedSHA) {
return "", errors.New("snapshot changed; inspect and confirm again")
}
// Stage immutable input privately; inspect again to reject modification during copying.
stageDir, err := os.MkdirTemp(filepath.Dir(current), ".restore-")
if err != nil {
return "", err
}
defer os.RemoveAll(stageDir)
staged := filepath.Join(stageDir, "snapshot.sqlite")
input, err := os.Open(source)
if err != nil {
return "", err
}
output, err := security.CreatePrivateFile(staged)
if err != nil {
input.Close()
return "", err
}
_, err = io.Copy(output, input)
input.Close()
syncErr := output.Sync()
closeErr := output.Close()
if err != nil {
return "", err
}
if syncErr != nil {
return "", syncErr
}
if closeErr != nil {
return "", closeErr
}
stagedInfo, err := InspectDatabaseSnapshot(ctx, staged)
if err != nil {
return "", err
}
if stagedInfo.SHA256 != sourceInfo.SHA256 {
return "", errors.New("snapshot changed during staging")
}
db, err := sql.Open("sqlite", snapshotURI(current, false))
if err != nil {
return "", err
}
db.SetMaxOpenConns(1)
defer db.Close()
if _, err = db.ExecContext(ctx, "PRAGMA busy_timeout=5000"); err != nil {
return "", err
}
backupRoot := filepath.Join(filepath.Dir(current), "backups")
if err = os.MkdirAll(backupRoot, 0700); err != nil {
return "", err
}
backupDir, err := os.MkdirTemp(backupRoot, "before-restore-"+time.Now().UTC().Format("20060102T150405")+"-")
if err != nil {
return "", err
}
backupPath := filepath.Join(backupDir, "linkport.sqlite")
if err = createSnapshot(ctx, db, backupPath); err != nil {
return "", err
}
currentInfo, err := InspectDatabaseSnapshot(ctx, backupPath)
if err != nil {
return backupPath, fmt.Errorf("current database cannot be safely restored: %w", err)
}
if currentInfo.SchemaSHA256 != stagedInfo.SchemaSHA256 || currentInfo.UserVersion != stagedInfo.UserVersion {
return backupPath, errors.New("snapshot schema differs; use the matching LinkPort version before restoring")
}
if err = copySQLite(ctx, db, staged, true); err != nil {
return backupPath, err
}
return backupPath, nil
}