mirror of
https://github.com/lightninglabs/lightning-terminal.git
synced 2026-08-13 12:33:36 +02:00
497 lines
15 KiB
Go
497 lines
15 KiB
Go
package session
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/davecgh/go-spew/spew"
|
|
"github.com/lightninglabs/lightning-terminal/accounts"
|
|
"github.com/lightninglabs/lightning-terminal/db/sqlc"
|
|
"github.com/lightningnetwork/lnd/sqldb"
|
|
"github.com/pmezard/go-difflib/difflib"
|
|
"go.etcd.io/bbolt"
|
|
)
|
|
|
|
var (
|
|
// ErrMigrationMismatch is returned when the migrated session does not
|
|
// match the original session.
|
|
ErrMigrationMismatch = fmt.Errorf("migrated session does not match " +
|
|
"original session")
|
|
)
|
|
|
|
// MigrateSessionStoreToSQL runs the migration of all sessions from the KV
|
|
// database to the SQL database. The migration is done in a single transaction
|
|
// to ensure that all sessions are migrated or none at all.
|
|
//
|
|
// NOTE: As sessions may contain linked accounts, the accounts sql migration
|
|
// MUST be run prior to this migration.
|
|
func MigrateSessionStoreToSQL(ctx context.Context, kvStore *bbolt.DB,
|
|
tx SQLQueries) error {
|
|
|
|
log.Infof("Starting migration of the KV sessions store to SQL")
|
|
|
|
kvSessions, err := getBBoltSessions(kvStore)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
initialGroupSessions, linkedSessions := filterSessions(kvSessions)
|
|
|
|
// Migrate the non-linked sessions first.
|
|
err = migrateSessionsToSQLAndValidate(ctx, tx, initialGroupSessions)
|
|
if err != nil {
|
|
return fmt.Errorf("migration of non-linked session failed: %w",
|
|
err)
|
|
}
|
|
|
|
// Then migrate the linked sessions.
|
|
err = migrateSessionsToSQLAndValidate(ctx, tx, linkedSessions)
|
|
if err != nil {
|
|
return fmt.Errorf("migration of linked session failed: %w", err)
|
|
}
|
|
|
|
total := len(initialGroupSessions) + len(linkedSessions)
|
|
log.Infof("All sessions migrated from KV to SQL. Total number of "+
|
|
"sessions migrated: %d", total)
|
|
|
|
return nil
|
|
}
|
|
|
|
// filterSessions categorizes the sessions into two groups: initial group
|
|
// sessions and linked sessions. The initial group sessions are the first
|
|
// sessions in a session group, while the linked sessions are those that have a
|
|
// linked parent session. These are separated to ensure that we can insert the
|
|
// initial group sessions first, which allows us to fetch the SQL group ID when
|
|
// inserting the rest of the linked sessions afterward.
|
|
//
|
|
// Additionally, it checks for duplicate session IDs and drops all but
|
|
// one session with the same ID, keeping the one with the latest CreatedAt
|
|
// timestamp. Note that users with duplicate session IDs should be extremely
|
|
// rare, as it could only occur if colliding session IDs were created prior to
|
|
// the introduction of the session linking functionality.
|
|
func filterSessions(kvSessions []*Session) ([]*Session, []*Session) {
|
|
// First map sessions by their ID.
|
|
sessionsByID := make(map[ID][]*Session)
|
|
for _, s := range kvSessions {
|
|
sessionsByID[s.ID] = append(sessionsByID[s.ID], s)
|
|
}
|
|
|
|
var (
|
|
initialGroupSessions []*Session
|
|
linkedSessions []*Session
|
|
)
|
|
|
|
// Process the mapped sessions. If there are duplicate sessions with the
|
|
// same ID, we will only iterate the session with the latest CreatedAt
|
|
// timestamp, and drop the other sessions. This is to ensure that we can
|
|
// keep a UNIQUE constraint for the session ID (alias) in the SQL db.
|
|
for id, sessions := range sessionsByID {
|
|
sessionToKeep := sessions[0]
|
|
if len(sessions) > 1 {
|
|
log.Warnf("Found %d sessions with duplicate ID %x, "+
|
|
"keeping only the latest one", len(sessions),
|
|
id)
|
|
|
|
// Find the session with the latest timestamp.
|
|
latestSession := sessions[0]
|
|
for _, s := range sessions[1:] {
|
|
if s.CreatedAt.After(latestSession.CreatedAt) {
|
|
latestSession = s
|
|
}
|
|
}
|
|
sessionToKeep = latestSession
|
|
|
|
// Log the sessions that will be dropped.
|
|
for _, s := range sessions {
|
|
if s == sessionToKeep {
|
|
continue
|
|
}
|
|
log.Warnf("Dropping duplicate session with ID "+
|
|
"%x created at %v", id, s.CreatedAt)
|
|
}
|
|
}
|
|
|
|
// Categorize the session that we are keeping.
|
|
if sessionToKeep.GroupID == sessionToKeep.ID {
|
|
initialGroupSessions = append(
|
|
initialGroupSessions, sessionToKeep,
|
|
)
|
|
} else {
|
|
linkedSessions = append(linkedSessions, sessionToKeep)
|
|
}
|
|
}
|
|
|
|
return initialGroupSessions, linkedSessions
|
|
}
|
|
|
|
// getBBoltSessions is a helper function that fetches all sessions from the
|
|
// Bbolt store, by iterating directly over the buckets, without needing to
|
|
// use any public functions of the BoltStore struct.
|
|
func getBBoltSessions(db *bbolt.DB) ([]*Session, error) {
|
|
var sessions []*Session
|
|
|
|
err := db.View(func(tx *bbolt.Tx) error {
|
|
sessionBucket, err := getBucket(tx, sessionBucketKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return sessionBucket.ForEach(func(k, v []byte) error {
|
|
// We'll also get buckets here, skip those (identified
|
|
// by nil value).
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
|
|
session, err := DeserializeSession(bytes.NewReader(v))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sessions = append(sessions, session)
|
|
|
|
return nil
|
|
})
|
|
})
|
|
|
|
return sessions, err
|
|
}
|
|
|
|
// migrateSessionsToSQLAndValidate runs the migration for the passed sessions
|
|
// from the KV database to the SQL database, and validates that the migrated
|
|
// sessions match the original sessions.
|
|
func migrateSessionsToSQLAndValidate(ctx context.Context,
|
|
tx SQLQueries, kvSessions []*Session) error {
|
|
|
|
for _, kvSession := range kvSessions {
|
|
err := migrateSingleSessionToSQL(ctx, tx, kvSession)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to migrate session(%v): %w",
|
|
kvSession.ID, err)
|
|
}
|
|
|
|
// Validate that the session was correctly migrated and matches
|
|
// the original session in the kv store.
|
|
sqlSess, err := tx.GetSessionByAlias(ctx, kvSession.ID[:])
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
err = ErrSessionNotFound
|
|
}
|
|
return fmt.Errorf("unable to get migrated session "+
|
|
"from sql store: %w", err)
|
|
}
|
|
|
|
migratedSession, err := unmarshalSession(ctx, tx, sqlSess)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to unmarshal migrated "+
|
|
"session: %w", err)
|
|
}
|
|
|
|
overrideSessionTimeZone(kvSession)
|
|
overrideSessionTimeZone(migratedSession)
|
|
overrideMacaroonRecipe(kvSession, migratedSession)
|
|
|
|
if !reflect.DeepEqual(kvSession, migratedSession) {
|
|
diff := difflib.UnifiedDiff{
|
|
A: difflib.SplitLines(
|
|
spew.Sdump(kvSession),
|
|
),
|
|
B: difflib.SplitLines(
|
|
spew.Sdump(migratedSession),
|
|
),
|
|
FromFile: "Expected",
|
|
FromDate: "",
|
|
ToFile: "Actual",
|
|
ToDate: "",
|
|
Context: 3,
|
|
}
|
|
diffText, _ := difflib.GetUnifiedDiffString(diff)
|
|
|
|
return fmt.Errorf("%w: %v.\n%v", ErrMigrationMismatch,
|
|
kvSession.ID, diffText)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// migrateSingleSessionToSQL runs the migration for a single session from the
|
|
// KV database to the SQL database. Note that if the session links to an
|
|
// account, the linked accounts store MUST have been migrated before that
|
|
// session is migrated.
|
|
func migrateSingleSessionToSQL(ctx context.Context,
|
|
tx SQLQueries, session *Session) error {
|
|
|
|
var (
|
|
acctID sql.NullInt64
|
|
err error
|
|
remotePubKey []byte
|
|
)
|
|
|
|
session.AccountID.WhenSome(func(alias accounts.AccountID) {
|
|
// Fetch the SQL ID for the account from the SQL store.
|
|
var acctAlias int64
|
|
acctAlias, err = alias.ToInt64()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
var acctDBID int64
|
|
acctDBID, err = tx.GetAccountIDByAlias(ctx, acctAlias)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
err = accounts.ErrAccNotFound
|
|
return
|
|
} else if err != nil {
|
|
return
|
|
}
|
|
|
|
acctID = sqldb.SQLInt64(acctDBID)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if session.RemotePublicKey != nil {
|
|
remotePubKey = session.RemotePublicKey.SerializeCompressed()
|
|
}
|
|
|
|
// Proceed to insert the session into the sql db.
|
|
sqlId, err := tx.InsertSession(ctx, sqlc.InsertSessionParams{
|
|
Alias: session.ID[:],
|
|
Label: session.Label,
|
|
State: int16(session.State),
|
|
Type: int16(session.Type),
|
|
Expiry: session.Expiry.UTC(),
|
|
CreatedAt: session.CreatedAt.UTC(),
|
|
ServerAddress: session.ServerAddr,
|
|
DevServer: session.DevServer,
|
|
MacaroonRootKey: int64(session.MacaroonRootKey),
|
|
PairingSecret: session.PairingSecret[:],
|
|
LocalPrivateKey: session.LocalPrivateKey.Serialize(),
|
|
LocalPublicKey: session.LocalPublicKey.SerializeCompressed(),
|
|
RemotePublicKey: remotePubKey,
|
|
Privacy: session.WithPrivacyMapper,
|
|
AccountID: acctID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Since the InsertSession query doesn't support that we set the revoked
|
|
// field during the insert, we need to set the field after the session
|
|
// has been created.
|
|
if !session.RevokedAt.IsZero() {
|
|
err = tx.SetSessionRevokedAt(
|
|
ctx, sqlc.SetSessionRevokedAtParams{
|
|
ID: sqlId,
|
|
RevokedAt: sqldb.SQLTime(
|
|
session.RevokedAt.UTC(),
|
|
),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// After the session has been inserted, we need to update the session
|
|
// with the group ID if it is linked to a group. We need to do this
|
|
// after the session has been inserted, because the group ID can be the
|
|
// session itself, and therefore the SQL id for the session won't exist
|
|
// prior to inserting the session.
|
|
groupID, err := tx.GetSessionIDByAlias(ctx, session.GroupID[:])
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrUnknownGroup
|
|
} else if err != nil {
|
|
return fmt.Errorf("unable to fetch group(%x): %w",
|
|
session.GroupID[:], err)
|
|
}
|
|
|
|
// Now lets set the group ID for the session.
|
|
err = tx.SetSessionGroupID(ctx, sqlc.SetSessionGroupIDParams{
|
|
ID: sqlId,
|
|
GroupID: sqldb.SQLInt64(groupID),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("unable to set group Alias: %w", err)
|
|
}
|
|
|
|
// Once we have the sqlID for the session, we can proceed to insert rows
|
|
// into the linked child tables.
|
|
if session.MacaroonRecipe != nil {
|
|
// We start by inserting the macaroon permissions.
|
|
for _, sessionPerm := range session.MacaroonRecipe.Permissions {
|
|
err = tx.InsertSessionMacaroonPermission(
|
|
ctx, sqlc.InsertSessionMacaroonPermissionParams{
|
|
SessionID: sqlId,
|
|
Entity: sessionPerm.Entity,
|
|
Action: sessionPerm.Action,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Next we insert the macaroon caveats.
|
|
for _, caveat := range session.MacaroonRecipe.Caveats {
|
|
err = tx.InsertSessionMacaroonCaveat(
|
|
ctx, sqlc.InsertSessionMacaroonCaveatParams{
|
|
SessionID: sqlId,
|
|
CaveatID: caveat.Id,
|
|
VerificationID: caveat.VerificationId,
|
|
Location: sqldb.SQLStr(
|
|
caveat.Location,
|
|
),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// That's followed by the feature config.
|
|
if session.FeatureConfig != nil {
|
|
for featureName, config := range *session.FeatureConfig {
|
|
err = tx.InsertSessionFeatureConfig(
|
|
ctx, sqlc.InsertSessionFeatureConfigParams{
|
|
SessionID: sqlId,
|
|
FeatureName: featureName,
|
|
Config: config,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Finally we insert the privacy flags.
|
|
for _, privacyFlag := range session.PrivacyFlags {
|
|
err = tx.InsertSessionPrivacyFlag(
|
|
ctx, sqlc.InsertSessionPrivacyFlagParams{
|
|
SessionID: sqlId,
|
|
Flag: int32(privacyFlag),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// overrideSessionTimeZone overrides the time zone of the session to the local
|
|
// time zone and chops off the nanosecond part for comparison. This is needed
|
|
// because KV database stores times as-is which as an unwanted side effect would
|
|
// fail migration due to time comparison expecting both the original and
|
|
// migrated sessions to be in the same local time zone and in microsecond
|
|
// precision. Note that PostgresSQL stores times in microsecond precision while
|
|
// SQLite can store times in nanosecond precision if using TEXT storage class.
|
|
func overrideSessionTimeZone(session *Session) {
|
|
fixTime := func(t time.Time) time.Time {
|
|
return t.In(time.Local).Truncate(time.Microsecond)
|
|
}
|
|
|
|
if !session.Expiry.IsZero() {
|
|
session.Expiry = fixTime(session.Expiry)
|
|
}
|
|
|
|
if !session.CreatedAt.IsZero() {
|
|
session.CreatedAt = fixTime(session.CreatedAt)
|
|
}
|
|
|
|
if !session.RevokedAt.IsZero() {
|
|
session.RevokedAt = fixTime(session.RevokedAt)
|
|
}
|
|
}
|
|
|
|
// overrideMacaroonRecipe overrides the MacaroonRecipe for the SQL session in a
|
|
// certain scenario:
|
|
// In the bbolt store, a session can have a non-nil macaroon struct, despite
|
|
// both the permissions and caveats being nil. There is no way to represent this
|
|
// in the SQL store, as the macaroon permissions and caveats are separate
|
|
// tables. Therefore, in the scenario where a MacaroonRecipe exists for the
|
|
// bbolt version, but both the permissions and caveats are nil, we override the
|
|
// MacaroonRecipe for the SQL version and set it to a MacaroonRecipe with
|
|
// nil permissions and caveats. This is needed to ensure that the deep equals
|
|
// check in the migration validation does not fail in this scenario.
|
|
// Additionally, if either the permissions or caveats aren't set, for the
|
|
// MacaroonRecipe, that is represented as empty array in the SQL store, but
|
|
// as nil in the bbolt store. Therefore, we also override the permissions
|
|
// or caveats to nil for the migrated session in that scenario, so that the
|
|
// deep equals check does not fail in this scenario either.
|
|
//
|
|
// Additionally, we sort the caveats & permissions of both the kv and sql
|
|
// sessions by their ID, so that they are always comparable in a deterministic
|
|
// way with deep equals.
|
|
func overrideMacaroonRecipe(kvSession *Session, migratedSession *Session) {
|
|
if kvSession.MacaroonRecipe != nil {
|
|
kvPerms := kvSession.MacaroonRecipe.Permissions
|
|
kvCaveats := kvSession.MacaroonRecipe.Caveats
|
|
|
|
// If the kvSession has a MacaroonRecipe with nil set for any
|
|
// of the fields, we need to override the migratedSession
|
|
// MacaroonRecipe to match that.
|
|
if kvPerms == nil && kvCaveats == nil {
|
|
migratedSession.MacaroonRecipe = &MacaroonRecipe{}
|
|
} else if kvPerms == nil {
|
|
migratedSession.MacaroonRecipe.Permissions = nil
|
|
} else if kvCaveats == nil {
|
|
migratedSession.MacaroonRecipe.Caveats = nil
|
|
}
|
|
|
|
sqlCaveats := migratedSession.MacaroonRecipe.Caveats
|
|
sqlPerms := migratedSession.MacaroonRecipe.Permissions
|
|
|
|
// If there have been caveats set for the MacaroonRecipe,
|
|
// the order of the postgres db caveats will in very rare cases
|
|
// differ from the kv store caveats. Therefore, we sort
|
|
// both the kv and sql caveats by their ID, so that we can
|
|
// compare them in a deterministic way.
|
|
if kvCaveats != nil {
|
|
sort.Slice(kvCaveats, func(i, j int) bool {
|
|
return bytes.Compare(
|
|
kvCaveats[i].Id, kvCaveats[j].Id,
|
|
) < 0
|
|
})
|
|
|
|
sort.Slice(sqlCaveats, func(i, j int) bool {
|
|
return bytes.Compare(
|
|
sqlCaveats[i].Id, sqlCaveats[j].Id,
|
|
) < 0
|
|
})
|
|
}
|
|
|
|
// Similarly, we sort the macaroon permissions for both the kv
|
|
// and sql sessions, so that we can compare them in a
|
|
// deterministic way.
|
|
if kvPerms != nil {
|
|
sort.Slice(kvPerms, func(i, j int) bool {
|
|
if kvPerms[i].Entity == kvPerms[j].Entity {
|
|
return kvPerms[i].Action <
|
|
kvPerms[j].Action
|
|
}
|
|
|
|
return kvPerms[i].Entity < kvPerms[j].Entity
|
|
})
|
|
|
|
sort.Slice(sqlPerms, func(i, j int) bool {
|
|
if sqlPerms[i].Entity == sqlPerms[j].Entity {
|
|
return sqlPerms[i].Action <
|
|
sqlPerms[j].Action
|
|
}
|
|
|
|
return sqlPerms[i].Entity < sqlPerms[j].Entity
|
|
})
|
|
}
|
|
}
|
|
}
|