|
| 1 | +package session |
| 2 | + |
| 3 | +import ( |
| 4 | + "bytes" |
| 5 | + "context" |
| 6 | + "database/sql" |
| 7 | + "errors" |
| 8 | + "fmt" |
| 9 | + "reflect" |
| 10 | + "time" |
| 11 | + |
| 12 | + "github.com/davecgh/go-spew/spew" |
| 13 | + "github.com/lightninglabs/lightning-terminal/accounts" |
| 14 | + "github.com/lightninglabs/lightning-terminal/db/sqlc" |
| 15 | + "github.com/lightningnetwork/lnd/sqldb" |
| 16 | + "github.com/pmezard/go-difflib/difflib" |
| 17 | + "go.etcd.io/bbolt" |
| 18 | +) |
| 19 | + |
| 20 | +var ( |
| 21 | + // ErrMigrationMismatch is returned when the migrated session does not |
| 22 | + // match the original session. |
| 23 | + ErrMigrationMismatch = fmt.Errorf("migrated session does not match " + |
| 24 | + "original session") |
| 25 | +) |
| 26 | + |
| 27 | +// MigrateSessionStoreToSQL runs the migration of all sessions from the KV |
| 28 | +// database to the SQL database. The migration is done in a single transaction |
| 29 | +// to ensure that all sessions are migrated or none at all. |
| 30 | +// |
| 31 | +// NOTE: As sessions may contain linked accounts, the accounts sql migration |
| 32 | +// MUST be run prior to this migration. |
| 33 | +func MigrateSessionStoreToSQL(ctx context.Context, kvStore *bbolt.DB, |
| 34 | + tx SQLQueries) error { |
| 35 | + |
| 36 | + log.Infof("Starting migration of the KV sessions store to SQL") |
| 37 | + |
| 38 | + kvSessions, err := getBBoltSessions(kvStore) |
| 39 | + if err != nil { |
| 40 | + return err |
| 41 | + } |
| 42 | + |
| 43 | + // If sessions are linked to a group, we must insert the initial session |
| 44 | + // of each group before the other sessions in that group. This ensures |
| 45 | + // we can retrieve the SQL group ID when inserting the remaining |
| 46 | + // sessions. Therefore, we first insert all initial group sessions, |
| 47 | + // allowing us to fetch the group IDs and insert the rest of the |
| 48 | + // sessions afterward. |
| 49 | + // We therefore filter out the initial sessions first, and then migrate |
| 50 | + // them prior to the rest of the sessions. |
| 51 | + var ( |
| 52 | + initialGroupSessions []*Session |
| 53 | + linkedSessions []*Session |
| 54 | + ) |
| 55 | + |
| 56 | + for _, kvSession := range kvSessions { |
| 57 | + if kvSession.GroupID == kvSession.ID { |
| 58 | + initialGroupSessions = append( |
| 59 | + initialGroupSessions, kvSession, |
| 60 | + ) |
| 61 | + } else { |
| 62 | + linkedSessions = append(linkedSessions, kvSession) |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + err = migrateSessionsToSQLAndValidate(ctx, tx, initialGroupSessions) |
| 67 | + if err != nil { |
| 68 | + return fmt.Errorf("migration of non-linked session failed: %w", |
| 69 | + err) |
| 70 | + } |
| 71 | + |
| 72 | + err = migrateSessionsToSQLAndValidate(ctx, tx, linkedSessions) |
| 73 | + if err != nil { |
| 74 | + return fmt.Errorf("migration of linked session failed: %w", err) |
| 75 | + } |
| 76 | + |
| 77 | + total := len(initialGroupSessions) + len(linkedSessions) |
| 78 | + log.Infof("All sessions migrated from KV to SQL. Total number of "+ |
| 79 | + "sessions migrated: %d", total) |
| 80 | + |
| 81 | + return nil |
| 82 | +} |
| 83 | + |
| 84 | +// getBBoltSessions is a helper function that fetches all sessions from the |
| 85 | +// Bbolt store, by iterating directly over the buckets, without needing to |
| 86 | +// use any public functions of the BoltStore struct. |
| 87 | +func getBBoltSessions(db *bbolt.DB) ([]*Session, error) { |
| 88 | + var sessions []*Session |
| 89 | + |
| 90 | + err := db.View(func(tx *bbolt.Tx) error { |
| 91 | + sessionBucket, err := getBucket(tx, sessionBucketKey) |
| 92 | + if err != nil { |
| 93 | + return err |
| 94 | + } |
| 95 | + |
| 96 | + return sessionBucket.ForEach(func(k, v []byte) error { |
| 97 | + // We'll also get buckets here, skip those (identified |
| 98 | + // by nil value). |
| 99 | + if v == nil { |
| 100 | + return nil |
| 101 | + } |
| 102 | + |
| 103 | + session, err := DeserializeSession(bytes.NewReader(v)) |
| 104 | + if err != nil { |
| 105 | + return err |
| 106 | + } |
| 107 | + |
| 108 | + sessions = append(sessions, session) |
| 109 | + |
| 110 | + return nil |
| 111 | + }) |
| 112 | + }) |
| 113 | + |
| 114 | + return sessions, err |
| 115 | +} |
| 116 | + |
| 117 | +// migrateSessionsToSQLAndValidate runs the migration for the passed sessions |
| 118 | +// from the KV database to the SQL database, and validates that the migrated |
| 119 | +// sessions match the original sessions. |
| 120 | +func migrateSessionsToSQLAndValidate(ctx context.Context, |
| 121 | + tx SQLQueries, kvSessions []*Session) error { |
| 122 | + |
| 123 | + for _, kvSession := range kvSessions { |
| 124 | + err := migrateSingleSessionToSQL(ctx, tx, kvSession) |
| 125 | + if err != nil { |
| 126 | + return fmt.Errorf("unable to migrate session(%v): %w", |
| 127 | + kvSession.ID, err) |
| 128 | + } |
| 129 | + |
| 130 | + // Validate that the session was correctly migrated and matches |
| 131 | + // the original session in the kv store. |
| 132 | + sqlSess, err := tx.GetSessionByAlias(ctx, kvSession.ID[:]) |
| 133 | + if err != nil { |
| 134 | + if errors.Is(err, sql.ErrNoRows) { |
| 135 | + err = ErrSessionNotFound |
| 136 | + } |
| 137 | + return fmt.Errorf("unable to get migrated session "+ |
| 138 | + "from sql store: %w", err) |
| 139 | + } |
| 140 | + |
| 141 | + migratedSession, err := unmarshalSession(ctx, tx, sqlSess) |
| 142 | + if err != nil { |
| 143 | + return fmt.Errorf("unable to unmarshal migrated "+ |
| 144 | + "session: %w", err) |
| 145 | + } |
| 146 | + |
| 147 | + overrideSessionTimeZone(kvSession) |
| 148 | + overrideSessionTimeZone(migratedSession) |
| 149 | + overrideMacaroonRecipe(kvSession, migratedSession) |
| 150 | + |
| 151 | + if !reflect.DeepEqual(kvSession, migratedSession) { |
| 152 | + diff := difflib.UnifiedDiff{ |
| 153 | + A: difflib.SplitLines( |
| 154 | + spew.Sdump(kvSession), |
| 155 | + ), |
| 156 | + B: difflib.SplitLines( |
| 157 | + spew.Sdump(migratedSession), |
| 158 | + ), |
| 159 | + FromFile: "Expected", |
| 160 | + FromDate: "", |
| 161 | + ToFile: "Actual", |
| 162 | + ToDate: "", |
| 163 | + Context: 3, |
| 164 | + } |
| 165 | + diffText, _ := difflib.GetUnifiedDiffString(diff) |
| 166 | + |
| 167 | + return fmt.Errorf("%w: %v.\n%v", ErrMigrationMismatch, |
| 168 | + kvSession.ID, diffText) |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + return nil |
| 173 | +} |
| 174 | + |
| 175 | +// migrateSingleSessionToSQL runs the migration for a single session from the |
| 176 | +// KV database to the SQL database. Note that if the session links to an |
| 177 | +// account, the linked accounts store MUST have been migrated before that |
| 178 | +// session is migrated. |
| 179 | +func migrateSingleSessionToSQL(ctx context.Context, |
| 180 | + tx SQLQueries, session *Session) error { |
| 181 | + |
| 182 | + var ( |
| 183 | + acctID sql.NullInt64 |
| 184 | + err error |
| 185 | + remotePubKey []byte |
| 186 | + ) |
| 187 | + |
| 188 | + session.AccountID.WhenSome(func(alias accounts.AccountID) { |
| 189 | + // Fetch the SQL ID for the account from the SQL store. |
| 190 | + var acctAlias int64 |
| 191 | + acctAlias, err = alias.ToInt64() |
| 192 | + if err != nil { |
| 193 | + return |
| 194 | + } |
| 195 | + |
| 196 | + var acctDBID int64 |
| 197 | + acctDBID, err = tx.GetAccountIDByAlias(ctx, acctAlias) |
| 198 | + if errors.Is(err, sql.ErrNoRows) { |
| 199 | + err = accounts.ErrAccNotFound |
| 200 | + return |
| 201 | + } else if err != nil { |
| 202 | + return |
| 203 | + } |
| 204 | + |
| 205 | + acctID = sqldb.SQLInt64(acctDBID) |
| 206 | + }) |
| 207 | + if err != nil { |
| 208 | + return err |
| 209 | + } |
| 210 | + |
| 211 | + if session.RemotePublicKey != nil { |
| 212 | + remotePubKey = session.RemotePublicKey.SerializeCompressed() |
| 213 | + } |
| 214 | + |
| 215 | + // Proceed to insert the session into the sql db. |
| 216 | + sqlId, err := tx.InsertSession(ctx, sqlc.InsertSessionParams{ |
| 217 | + Alias: session.ID[:], |
| 218 | + Label: session.Label, |
| 219 | + State: int16(session.State), |
| 220 | + Type: int16(session.Type), |
| 221 | + Expiry: session.Expiry.UTC(), |
| 222 | + CreatedAt: session.CreatedAt.UTC(), |
| 223 | + ServerAddress: session.ServerAddr, |
| 224 | + DevServer: session.DevServer, |
| 225 | + MacaroonRootKey: int64(session.MacaroonRootKey), |
| 226 | + PairingSecret: session.PairingSecret[:], |
| 227 | + LocalPrivateKey: session.LocalPrivateKey.Serialize(), |
| 228 | + LocalPublicKey: session.LocalPublicKey.SerializeCompressed(), |
| 229 | + RemotePublicKey: remotePubKey, |
| 230 | + Privacy: session.WithPrivacyMapper, |
| 231 | + AccountID: acctID, |
| 232 | + }) |
| 233 | + if err != nil { |
| 234 | + return err |
| 235 | + } |
| 236 | + |
| 237 | + // Since the InsertSession query doesn't support that we set the revoked |
| 238 | + // field during the insert, we need to set the field after the session |
| 239 | + // has been created. |
| 240 | + if !session.RevokedAt.IsZero() { |
| 241 | + err = tx.SetSessionRevokedAt( |
| 242 | + ctx, sqlc.SetSessionRevokedAtParams{ |
| 243 | + ID: sqlId, |
| 244 | + RevokedAt: sqldb.SQLTime( |
| 245 | + session.RevokedAt.UTC(), |
| 246 | + ), |
| 247 | + }, |
| 248 | + ) |
| 249 | + if err != nil { |
| 250 | + return err |
| 251 | + } |
| 252 | + } |
| 253 | + |
| 254 | + // After the session has been inserted, we need to update the session |
| 255 | + // with the group ID if it is linked to a group. We need to do this |
| 256 | + // after the session has been inserted, because the group ID can be the |
| 257 | + // session itself, and therefore the SQL id for the session won't exist |
| 258 | + // prior to inserting the session. |
| 259 | + groupID, err := tx.GetSessionIDByAlias(ctx, session.GroupID[:]) |
| 260 | + if errors.Is(err, sql.ErrNoRows) { |
| 261 | + return ErrUnknownGroup |
| 262 | + } else if err != nil { |
| 263 | + return fmt.Errorf("unable to fetch group(%x): %w", |
| 264 | + session.GroupID[:], err) |
| 265 | + } |
| 266 | + |
| 267 | + // Now lets set the group ID for the session. |
| 268 | + err = tx.SetSessionGroupID(ctx, sqlc.SetSessionGroupIDParams{ |
| 269 | + ID: sqlId, |
| 270 | + GroupID: sqldb.SQLInt64(groupID), |
| 271 | + }) |
| 272 | + if err != nil { |
| 273 | + return fmt.Errorf("unable to set group Alias: %w", err) |
| 274 | + } |
| 275 | + |
| 276 | + // Once we have the sqlID for the session, we can proceed to insert rows |
| 277 | + // into the linked child tables. |
| 278 | + if session.MacaroonRecipe != nil { |
| 279 | + // We start by inserting the macaroon permissions. |
| 280 | + for _, sessionPerm := range session.MacaroonRecipe.Permissions { |
| 281 | + err = tx.InsertSessionMacaroonPermission( |
| 282 | + ctx, sqlc.InsertSessionMacaroonPermissionParams{ |
| 283 | + SessionID: sqlId, |
| 284 | + Entity: sessionPerm.Entity, |
| 285 | + Action: sessionPerm.Action, |
| 286 | + }, |
| 287 | + ) |
| 288 | + if err != nil { |
| 289 | + return err |
| 290 | + } |
| 291 | + } |
| 292 | + |
| 293 | + // Next we insert the macaroon caveats. |
| 294 | + for _, caveat := range session.MacaroonRecipe.Caveats { |
| 295 | + err = tx.InsertSessionMacaroonCaveat( |
| 296 | + ctx, sqlc.InsertSessionMacaroonCaveatParams{ |
| 297 | + SessionID: sqlId, |
| 298 | + CaveatID: caveat.Id, |
| 299 | + VerificationID: caveat.VerificationId, |
| 300 | + Location: sqldb.SQLStr( |
| 301 | + caveat.Location, |
| 302 | + ), |
| 303 | + }, |
| 304 | + ) |
| 305 | + if err != nil { |
| 306 | + return err |
| 307 | + } |
| 308 | + } |
| 309 | + } |
| 310 | + |
| 311 | + // That's followed by the feature config. |
| 312 | + if session.FeatureConfig != nil { |
| 313 | + for featureName, config := range *session.FeatureConfig { |
| 314 | + err = tx.InsertSessionFeatureConfig( |
| 315 | + ctx, sqlc.InsertSessionFeatureConfigParams{ |
| 316 | + SessionID: sqlId, |
| 317 | + FeatureName: featureName, |
| 318 | + Config: config, |
| 319 | + }, |
| 320 | + ) |
| 321 | + if err != nil { |
| 322 | + return err |
| 323 | + } |
| 324 | + } |
| 325 | + } |
| 326 | + |
| 327 | + // Finally we insert the privacy flags. |
| 328 | + for _, privacyFlag := range session.PrivacyFlags { |
| 329 | + err = tx.InsertSessionPrivacyFlag( |
| 330 | + ctx, sqlc.InsertSessionPrivacyFlagParams{ |
| 331 | + SessionID: sqlId, |
| 332 | + Flag: int32(privacyFlag), |
| 333 | + }, |
| 334 | + ) |
| 335 | + if err != nil { |
| 336 | + return err |
| 337 | + } |
| 338 | + } |
| 339 | + |
| 340 | + return nil |
| 341 | +} |
| 342 | + |
| 343 | +// overrideSessionTimeZone overrides the time zone of the session to the local |
| 344 | +// time zone and chops off the nanosecond part for comparison. This is needed |
| 345 | +// because KV database stores times as-is which as an unwanted side effect would |
| 346 | +// fail migration due to time comparison expecting both the original and |
| 347 | +// migrated sessions to be in the same local time zone and in microsecond |
| 348 | +// precision. Note that PostgresSQL stores times in microsecond precision while |
| 349 | +// SQLite can store times in nanosecond precision if using TEXT storage class. |
| 350 | +func overrideSessionTimeZone(session *Session) { |
| 351 | + fixTime := func(t time.Time) time.Time { |
| 352 | + return t.In(time.Local).Truncate(time.Microsecond) |
| 353 | + } |
| 354 | + |
| 355 | + if !session.Expiry.IsZero() { |
| 356 | + session.Expiry = fixTime(session.Expiry) |
| 357 | + } |
| 358 | + |
| 359 | + if !session.CreatedAt.IsZero() { |
| 360 | + session.CreatedAt = fixTime(session.CreatedAt) |
| 361 | + } |
| 362 | + |
| 363 | + if !session.RevokedAt.IsZero() { |
| 364 | + session.RevokedAt = fixTime(session.RevokedAt) |
| 365 | + } |
| 366 | +} |
| 367 | + |
| 368 | +// overrideMacaroonRecipe overrides the MacaroonRecipe for the SQL session in a |
| 369 | +// certain scenario: |
| 370 | +// In the bbolt store, a session can have a non-nil macaroon struct, despite |
| 371 | +// both the permissions and caveats being nil. There is no way to represent this |
| 372 | +// in the SQL store, as the macaroon permissions and caveats are separate |
| 373 | +// tables. Therefore, in the scenario where a MacaroonRecipe exists for the |
| 374 | +// bbolt version, but both the permissions and caveats are nil, we override the |
| 375 | +// MacaroonRecipe for the SQL version and set it to a MacaroonRecipe with |
| 376 | +// nil permissions and caveats. This is needed to ensure that the deep equals |
| 377 | +// check in the migration validation does not fail in this scenario. |
| 378 | +// Additionally, if either the permissions or caveats aren't set, for the |
| 379 | +// MacaroonRecipe, that is represented as empty array in the SQL store, but |
| 380 | +// as nil in the bbolt store. Therefore, we also override the permissions |
| 381 | +// or caveats to nil for the migrated session in that scenario, so that the |
| 382 | +// deep equals check does not fail in this scenario either. |
| 383 | +func overrideMacaroonRecipe(kvSession *Session, migratedSession *Session) { |
| 384 | + if kvSession.MacaroonRecipe != nil { |
| 385 | + kvPerms := kvSession.MacaroonRecipe.Permissions |
| 386 | + kvCaveats := kvSession.MacaroonRecipe.Caveats |
| 387 | + |
| 388 | + if kvPerms == nil && kvCaveats == nil { |
| 389 | + migratedSession.MacaroonRecipe = &MacaroonRecipe{} |
| 390 | + } else if kvPerms == nil { |
| 391 | + migratedSession.MacaroonRecipe.Permissions = nil |
| 392 | + } else if kvCaveats == nil { |
| 393 | + migratedSession.MacaroonRecipe.Caveats = nil |
| 394 | + } |
| 395 | + } |
| 396 | +} |
0 commit comments