• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

lightningnetwork / lnd / 12199391122

06 Dec 2024 01:10PM UTC coverage: 49.807% (-9.1%) from 58.933%
12199391122

push

github

web-flow
Merge pull request #9337 from Guayaba221/patch-1

chore: fix typo in ruby.md

100137 of 201051 relevant lines covered (49.81%)

2.07 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

64.46
/watchtower/wtdb/tower_db.go
1
package wtdb
2

3
import (
4
        "bytes"
5
        "errors"
6

7
        "github.com/btcsuite/btcd/chaincfg/chainhash"
8
        "github.com/lightningnetwork/lnd/chainntnfs"
9
        "github.com/lightningnetwork/lnd/kvdb"
10
        "github.com/lightningnetwork/lnd/watchtower/blob"
11
)
12

13
var (
14
        // sessionsBkt is a bucket containing all negotiated client sessions.
15
        //  session id -> session
16
        sessionsBkt = []byte("sessions-bucket")
17

18
        // updatesBkt is a bucket containing all state updates sent by clients.
19
        // The updates are further bucketed by session id to prevent clients
20
        // from overwrite each other.
21
        //   hint => session id -> update
22
        updatesBkt = []byte("updates-bucket")
23

24
        // updateIndexBkt is a bucket that indexes all state updates by their
25
        // overarching session id. This allows for efficient lookup of updates
26
        // by their session id, which is currently used to aide deletion
27
        // performance.
28
        //  session id => hint1 -> []byte{}
29
        //             => hint2 -> []byte{}
30
        updateIndexBkt = []byte("update-index-bucket")
31

32
        // lookoutTipBkt is a bucket containing the last block epoch processed
33
        // by the lookout subsystem. It has one key, lookoutTipKey.
34
        //   lookoutTipKey -> block epoch
35
        lookoutTipBkt = []byte("lookout-tip-bucket")
36

37
        // lookoutTipKey is a static key used to retrieve lookout tip's block
38
        // epoch from the lookoutTipBkt.
39
        lookoutTipKey = []byte("lookout-tip")
40

41
        // ErrNoSessionHintIndex signals that an active session does not have an
42
        // initialized index for tracking its own state updates.
43
        ErrNoSessionHintIndex = errors.New("session hint index missing")
44

45
        // ErrInvalidBlobSize indicates that the encrypted blob provided by the
46
        // client is not valid according to the blob type of the session.
47
        ErrInvalidBlobSize = errors.New("invalid blob size")
48
)
49

50
// TowerDB is single database providing a persistent storage engine for the
51
// wtserver and lookout subsystems.
52
type TowerDB struct {
53
        db kvdb.Backend
54
}
55

56
// OpenTowerDB opens the tower database given the path to the database's
57
// directory. If no such database exists, this method will initialize a fresh
58
// one using the latest version number and bucket structure. If a database
59
// exists but has a lower version number than the current version, any necessary
60
// migrations will be applied before returning. Any attempt to open a database
61
// with a version number higher that the latest version will fail to prevent
62
// accidental reversion.
63
func OpenTowerDB(db kvdb.Backend) (*TowerDB, error) {
4✔
64
        firstInit, err := isFirstInit(db)
4✔
65
        if err != nil {
4✔
66
                return nil, err
×
67
        }
×
68

69
        towerDB := &TowerDB{
4✔
70
                db: db,
4✔
71
        }
4✔
72

4✔
73
        err = initOrSyncVersions(towerDB, firstInit, towerDBVersions)
4✔
74
        if err != nil {
4✔
75
                db.Close()
×
76
                return nil, err
×
77
        }
×
78

79
        // Now that the database version fully consistent with our latest known
80
        // version, ensure that all top-level buckets known to this version are
81
        // initialized. This allows us to assume their presence throughout all
82
        // operations. If an known top-level bucket is expected to exist but is
83
        // missing, this will trigger a ErrUninitializedDB error.
84
        err = kvdb.Update(towerDB.db, initTowerDBBuckets, func() {})
8✔
85
        if err != nil {
4✔
86
                db.Close()
×
87
                return nil, err
×
88
        }
×
89

90
        return towerDB, nil
4✔
91
}
92

93
// initTowerDBBuckets creates all top-level buckets required to handle database
94
// operations required by the latest version.
95
func initTowerDBBuckets(tx kvdb.RwTx) error {
4✔
96
        buckets := [][]byte{
4✔
97
                sessionsBkt,
4✔
98
                updateIndexBkt,
4✔
99
                updatesBkt,
4✔
100
                lookoutTipBkt,
4✔
101
        }
4✔
102

4✔
103
        for _, bucket := range buckets {
8✔
104
                _, err := tx.CreateTopLevelBucket(bucket)
4✔
105
                if err != nil {
4✔
106
                        return err
×
107
                }
×
108
        }
109

110
        return nil
4✔
111
}
112

113
// bdb returns the backing bbolt.DB instance.
114
//
115
// NOTE: Part of the versionedDB interface.
116
func (t *TowerDB) bdb() kvdb.Backend {
4✔
117
        return t.db
4✔
118
}
4✔
119

120
// Version returns the database's current version number.
121
//
122
// NOTE: Part of the versionedDB interface.
123
func (t *TowerDB) Version() (uint32, error) {
×
124
        var version uint32
×
125
        err := kvdb.View(t.db, func(tx kvdb.RTx) error {
×
126
                var err error
×
127
                version, err = getDBVersion(tx)
×
128
                return err
×
129
        }, func() {
×
130
                version = 0
×
131
        })
×
132
        if err != nil {
×
133
                return 0, err
×
134
        }
×
135

136
        return version, nil
×
137
}
138

139
// Close closes the underlying database.
140
func (t *TowerDB) Close() error {
×
141
        return t.db.Close()
×
142
}
×
143

144
// GetSessionInfo retrieves the session for the passed session id. An error is
145
// returned if the session could not be found.
146
func (t *TowerDB) GetSessionInfo(id *SessionID) (*SessionInfo, error) {
4✔
147
        var session *SessionInfo
4✔
148
        err := kvdb.View(t.db, func(tx kvdb.RTx) error {
8✔
149
                sessions := tx.ReadBucket(sessionsBkt)
4✔
150
                if sessions == nil {
4✔
151
                        return ErrUninitializedDB
×
152
                }
×
153

154
                var err error
4✔
155
                session, err = getSession(sessions, id[:])
4✔
156
                return err
4✔
157
        }, func() {
4✔
158
                session = nil
4✔
159
        })
4✔
160
        if err != nil {
8✔
161
                return nil, err
4✔
162
        }
4✔
163

164
        return session, nil
×
165
}
166

167
// InsertSessionInfo records a negotiated session in the tower database. An
168
// error is returned if the session already exists.
169
func (t *TowerDB) InsertSessionInfo(session *SessionInfo) error {
4✔
170
        return kvdb.Update(t.db, func(tx kvdb.RwTx) error {
8✔
171
                sessions := tx.ReadWriteBucket(sessionsBkt)
4✔
172
                if sessions == nil {
4✔
173
                        return ErrUninitializedDB
×
174
                }
×
175

176
                updateIndex := tx.ReadWriteBucket(updateIndexBkt)
4✔
177
                if updateIndex == nil {
4✔
178
                        return ErrUninitializedDB
×
179
                }
×
180

181
                dbSession, err := getSession(sessions, session.ID[:])
4✔
182
                switch {
4✔
183
                case err == ErrSessionNotFound:
4✔
184
                        // proceed.
185

186
                case err != nil:
×
187
                        return err
×
188

189
                case dbSession.LastApplied > 0:
×
190
                        return ErrSessionAlreadyExists
×
191
                }
192

193
                // Perform a quick sanity check on the session policy before
194
                // accepting.
195
                if err := session.Policy.Validate(); err != nil {
4✔
196
                        return err
×
197
                }
×
198

199
                err = putSession(sessions, session)
4✔
200
                if err != nil {
4✔
201
                        return err
×
202
                }
×
203

204
                // Initialize the session-hint index which will be used to track
205
                // all updates added for this session. Upon deletion, we will
206
                // consult the index to determine exactly which updates should
207
                // be deleted without needing to iterate over the entire
208
                // database.
209
                return touchSessionHintBkt(updateIndex, &session.ID)
4✔
210
        }, func() {})
4✔
211
}
212

213
// InsertStateUpdate stores an update sent by the client after validating that
214
// the update is well-formed in the context of other updates sent for the same
215
// session. This include verifying that the sequence number is incremented
216
// properly and the last applied values echoed by the client are sane.
217
func (t *TowerDB) InsertStateUpdate(update *SessionStateUpdate) (uint16, error) {
4✔
218
        var lastApplied uint16
4✔
219
        err := kvdb.Update(t.db, func(tx kvdb.RwTx) error {
8✔
220
                sessions := tx.ReadWriteBucket(sessionsBkt)
4✔
221
                if sessions == nil {
4✔
222
                        return ErrUninitializedDB
×
223
                }
×
224

225
                updates := tx.ReadWriteBucket(updatesBkt)
4✔
226
                if updates == nil {
4✔
227
                        return ErrUninitializedDB
×
228
                }
×
229

230
                updateIndex := tx.ReadWriteBucket(updateIndexBkt)
4✔
231
                if updateIndex == nil {
4✔
232
                        return ErrUninitializedDB
×
233
                }
×
234

235
                // Fetch the session corresponding to the update's session id.
236
                // This will be used to validate that the update's sequence
237
                // number and last applied values are sane.
238
                session, err := getSession(sessions, update.ID[:])
4✔
239
                if err != nil {
4✔
240
                        return err
×
241
                }
×
242

243
                commitType, err := session.Policy.BlobType.CommitmentType(nil)
4✔
244
                if err != nil {
4✔
245
                        return err
×
246
                }
×
247

248
                kit, err := commitType.EmptyJusticeKit()
4✔
249
                if err != nil {
4✔
250
                        return err
×
251
                }
×
252

253
                // Assert that the blob is the correct size for the session's
254
                // blob type.
255
                expBlobSize := blob.Size(kit)
4✔
256
                if len(update.EncryptedBlob) != expBlobSize {
4✔
257
                        return ErrInvalidBlobSize
×
258
                }
×
259

260
                // Validate the update against the current state of the session.
261
                err = session.AcceptUpdateSequence(
4✔
262
                        update.SeqNum, update.LastApplied,
4✔
263
                )
4✔
264
                if err != nil {
4✔
265
                        return err
×
266
                }
×
267

268
                // Validation succeeded, therefore the update is committed and
269
                // the session's last applied value is equal to the update's
270
                // sequence number.
271
                lastApplied = session.LastApplied
4✔
272

4✔
273
                // Store the updated session to persist the updated last applied
4✔
274
                // values.
4✔
275
                err = putSession(sessions, session)
4✔
276
                if err != nil {
4✔
277
                        return err
×
278
                }
×
279

280
                // Create or load the hint bucket for this state update's hint
281
                // and write the given update.
282
                hints, err := updates.CreateBucketIfNotExists(update.Hint[:])
4✔
283
                if err != nil {
4✔
284
                        return err
×
285
                }
×
286

287
                var b bytes.Buffer
4✔
288
                err = update.Encode(&b)
4✔
289
                if err != nil {
4✔
290
                        return err
×
291
                }
×
292

293
                err = hints.Put(update.ID[:], b.Bytes())
4✔
294
                if err != nil {
4✔
295
                        return err
×
296
                }
×
297

298
                // Finally, create an entry in the update index to track this
299
                // hint under its session id. This will allow us to delete the
300
                // entries efficiently if the session is ever removed.
301
                return putHintForSession(updateIndex, &update.ID, update.Hint)
4✔
302
        }, func() {
4✔
303
                lastApplied = 0
4✔
304
        })
4✔
305
        if err != nil {
4✔
306
                return 0, err
×
307
        }
×
308

309
        return lastApplied, nil
4✔
310
}
311

312
// DeleteSession removes all data associated with a particular session id from
313
// the tower's database.
314
func (t *TowerDB) DeleteSession(target SessionID) error {
4✔
315
        return kvdb.Update(t.db, func(tx kvdb.RwTx) error {
8✔
316
                sessions := tx.ReadWriteBucket(sessionsBkt)
4✔
317
                if sessions == nil {
4✔
318
                        return ErrUninitializedDB
×
319
                }
×
320

321
                updates := tx.ReadWriteBucket(updatesBkt)
4✔
322
                if updates == nil {
4✔
323
                        return ErrUninitializedDB
×
324
                }
×
325

326
                updateIndex := tx.ReadWriteBucket(updateIndexBkt)
4✔
327
                if updateIndex == nil {
4✔
328
                        return ErrUninitializedDB
×
329
                }
×
330

331
                // Fail if the session doesn't exit.
332
                _, err := getSession(sessions, target[:])
4✔
333
                if err != nil {
4✔
334
                        return err
×
335
                }
×
336

337
                // Remove the target session.
338
                err = sessions.Delete(target[:])
4✔
339
                if err != nil {
4✔
340
                        return err
×
341
                }
×
342

343
                // Next, check the update index for any hints that were added
344
                // under this session.
345
                hints, err := getHintsForSession(updateIndex, &target)
4✔
346
                if err != nil {
4✔
347
                        return err
×
348
                }
×
349

350
                for _, hint := range hints {
8✔
351
                        // Remove the state updates for any blobs stored under
4✔
352
                        // the target session identifier.
4✔
353
                        updatesForHint := updates.NestedReadWriteBucket(hint[:])
4✔
354
                        if updatesForHint == nil {
4✔
355
                                continue
×
356
                        }
357

358
                        update := updatesForHint.Get(target[:])
4✔
359
                        if update == nil {
4✔
360
                                continue
×
361
                        }
362

363
                        err := updatesForHint.Delete(target[:])
4✔
364
                        if err != nil {
4✔
365
                                return err
×
366
                        }
×
367

368
                        // If this was the last state update, we can also remove
369
                        // the hint that would map to an empty set.
370
                        err = isBucketEmpty(updatesForHint)
4✔
371
                        switch {
4✔
372

373
                        // Other updates exist for this hint, keep the bucket.
374
                        case err == errBucketNotEmpty:
×
375
                                continue
×
376

377
                        // Unexpected error.
378
                        case err != nil:
×
379
                                return err
×
380

381
                        // No more updates for this hint, prune hint bucket.
382
                        default:
4✔
383
                                err = updates.DeleteNestedBucket(hint[:])
4✔
384
                                if err != nil {
4✔
385
                                        return err
×
386
                                }
×
387
                        }
388
                }
389

390
                // Finally, remove this session from the update index, which
391
                // also removes any of the indexed hints beneath it.
392
                return removeSessionHintBkt(updateIndex, &target)
4✔
393
        }, func() {})
4✔
394
}
395

396
// QueryMatches searches against all known state updates for any that match the
397
// passed breachHints. More than one Match will be returned for a given hint if
398
// they exist in the database.
399
func (t *TowerDB) QueryMatches(breachHints []blob.BreachHint) ([]Match, error) {
4✔
400
        var matches []Match
4✔
401
        err := kvdb.View(t.db, func(tx kvdb.RTx) error {
8✔
402
                sessions := tx.ReadBucket(sessionsBkt)
4✔
403
                if sessions == nil {
4✔
404
                        return ErrUninitializedDB
×
405
                }
×
406

407
                updates := tx.ReadBucket(updatesBkt)
4✔
408
                if updates == nil {
4✔
409
                        return ErrUninitializedDB
×
410
                }
×
411

412
                // Iterate through the target breach hints, appending any
413
                // matching updates to the set of matches.
414
                for _, hint := range breachHints {
8✔
415
                        // If a bucket does not exist for this hint, no matches
4✔
416
                        // are known.
4✔
417
                        updatesForHint := updates.NestedReadBucket(hint[:])
4✔
418
                        if updatesForHint == nil {
8✔
419
                                continue
4✔
420
                        }
421

422
                        // Otherwise, iterate through all (session id, update)
423
                        // pairs, creating a Match for each.
424
                        err := updatesForHint.ForEach(func(k, v []byte) error {
8✔
425
                                // Load the session via the session id for this
4✔
426
                                // update. The session info contains further
4✔
427
                                // instructions for how to process the state
4✔
428
                                // update.
4✔
429
                                session, err := getSession(sessions, k)
4✔
430
                                switch {
4✔
431
                                case err == ErrSessionNotFound:
×
432
                                        log.Warnf("Missing session=%x for "+
×
433
                                                "matched state update hint=%x",
×
434
                                                k, hint)
×
435
                                        return nil
×
436

437
                                case err != nil:
×
438
                                        return err
×
439
                                }
440

441
                                // Decode the state update containing the
442
                                // encrypted blob.
443
                                update := &SessionStateUpdate{}
4✔
444
                                err = update.Decode(bytes.NewReader(v))
4✔
445
                                if err != nil {
4✔
446
                                        return err
×
447
                                }
×
448

449
                                var id SessionID
4✔
450
                                copy(id[:], k)
4✔
451

4✔
452
                                // Construct the final match using the found
4✔
453
                                // update and its session info.
4✔
454
                                match := Match{
4✔
455
                                        ID:            id,
4✔
456
                                        SeqNum:        update.SeqNum,
4✔
457
                                        Hint:          hint,
4✔
458
                                        EncryptedBlob: update.EncryptedBlob,
4✔
459
                                        SessionInfo:   session,
4✔
460
                                }
4✔
461

4✔
462
                                matches = append(matches, match)
4✔
463

4✔
464
                                return nil
4✔
465
                        })
466
                        if err != nil {
4✔
467
                                return err
×
468
                        }
×
469
                }
470

471
                return nil
4✔
472
        }, func() {
4✔
473
                matches = nil
4✔
474
        })
4✔
475
        if err != nil {
4✔
476
                return nil, err
×
477
        }
×
478

479
        return matches, nil
4✔
480
}
481

482
// SetLookoutTip stores the provided epoch as the latest lookout tip epoch in
483
// the tower database.
484
func (t *TowerDB) SetLookoutTip(epoch *chainntnfs.BlockEpoch) error {
4✔
485
        return kvdb.Update(t.db, func(tx kvdb.RwTx) error {
8✔
486
                lookoutTip := tx.ReadWriteBucket(lookoutTipBkt)
4✔
487
                if lookoutTip == nil {
4✔
488
                        return ErrUninitializedDB
×
489
                }
×
490

491
                return putLookoutEpoch(lookoutTip, epoch)
4✔
492
        }, func() {})
4✔
493
}
494

495
// GetLookoutTip retrieves the current lookout tip block epoch from the tower
496
// database.
497
func (t *TowerDB) GetLookoutTip() (*chainntnfs.BlockEpoch, error) {
4✔
498
        var epoch *chainntnfs.BlockEpoch
4✔
499
        err := kvdb.View(t.db, func(tx kvdb.RTx) error {
8✔
500
                lookoutTip := tx.ReadBucket(lookoutTipBkt)
4✔
501
                if lookoutTip == nil {
4✔
502
                        return ErrUninitializedDB
×
503
                }
×
504

505
                epoch = getLookoutEpoch(lookoutTip)
4✔
506

4✔
507
                return nil
4✔
508
        }, func() {
4✔
509
                epoch = nil
4✔
510
        })
4✔
511
        if err != nil {
4✔
512
                return nil, err
×
513
        }
×
514

515
        return epoch, nil
4✔
516
}
517

518
// getSession retrieves the session info from the sessions bucket identified by
519
// its session id. An error is returned if the session is not found or a
520
// deserialization error occurs.
521
func getSession(sessions kvdb.RBucket, id []byte) (*SessionInfo, error) {
4✔
522
        sessionBytes := sessions.Get(id)
4✔
523
        if sessionBytes == nil {
8✔
524
                return nil, ErrSessionNotFound
4✔
525
        }
4✔
526

527
        var session SessionInfo
4✔
528
        err := session.Decode(bytes.NewReader(sessionBytes))
4✔
529
        if err != nil {
4✔
530
                return nil, err
×
531
        }
×
532

533
        return &session, nil
4✔
534
}
535

536
// putSession stores the session info in the sessions bucket identified by its
537
// session id. An error is returned if a serialization error occurs.
538
func putSession(sessions kvdb.RwBucket, session *SessionInfo) error {
4✔
539
        var b bytes.Buffer
4✔
540
        err := session.Encode(&b)
4✔
541
        if err != nil {
4✔
542
                return err
×
543
        }
×
544

545
        return sessions.Put(session.ID[:], b.Bytes())
4✔
546
}
547

548
// touchSessionHintBkt initializes the session-hint bucket for a particular
549
// session id. This ensures that future calls to getHintsForSession or
550
// putHintForSession can rely on the bucket already being created, and fail if
551
// index has not been initialized as this points to improper usage.
552
func touchSessionHintBkt(updateIndex kvdb.RwBucket, id *SessionID) error {
4✔
553
        _, err := updateIndex.CreateBucketIfNotExists(id[:])
4✔
554
        return err
4✔
555
}
4✔
556

557
// removeSessionHintBkt prunes the session-hint bucket for the given session id
558
// and all of the hints contained inside. This should be used to clean up the
559
// index upon session deletion.
560
func removeSessionHintBkt(updateIndex kvdb.RwBucket, id *SessionID) error {
4✔
561
        return updateIndex.DeleteNestedBucket(id[:])
4✔
562
}
4✔
563

564
// getHintsForSession returns all known hints belonging to the given session id.
565
// If the index for the session has not been initialized, this method returns
566
// ErrNoSessionHintIndex.
567
func getHintsForSession(updateIndex kvdb.RBucket,
568
        id *SessionID) ([]blob.BreachHint, error) {
4✔
569

4✔
570
        sessionHints := updateIndex.NestedReadBucket(id[:])
4✔
571
        if sessionHints == nil {
4✔
572
                return nil, ErrNoSessionHintIndex
×
573
        }
×
574

575
        var hints []blob.BreachHint
4✔
576
        err := sessionHints.ForEach(func(k, _ []byte) error {
8✔
577
                if len(k) != blob.BreachHintSize {
4✔
578
                        return nil
×
579
                }
×
580

581
                var hint blob.BreachHint
4✔
582
                copy(hint[:], k)
4✔
583
                hints = append(hints, hint)
4✔
584
                return nil
4✔
585
        })
586
        if err != nil {
4✔
587
                return nil, err
×
588
        }
×
589

590
        return hints, nil
4✔
591
}
592

593
// putHintForSession inserts a record into the update index for a given
594
// (session, hint) pair. The hints are coalesced under a bucket for the target
595
// session id, and used to perform efficient removal of updates. If the index
596
// for the session has not been initialized, this method returns
597
// ErrNoSessionHintIndex.
598
func putHintForSession(updateIndex kvdb.RwBucket, id *SessionID,
599
        hint blob.BreachHint) error {
4✔
600

4✔
601
        sessionHints := updateIndex.NestedReadWriteBucket(id[:])
4✔
602
        if sessionHints == nil {
4✔
603
                return ErrNoSessionHintIndex
×
604
        }
×
605

606
        return sessionHints.Put(hint[:], []byte{})
4✔
607
}
608

609
// putLookoutEpoch stores the given lookout tip block epoch in provided bucket.
610
func putLookoutEpoch(bkt kvdb.RwBucket, epoch *chainntnfs.BlockEpoch) error {
4✔
611
        epochBytes := make([]byte, 36)
4✔
612
        copy(epochBytes, epoch.Hash[:])
4✔
613
        byteOrder.PutUint32(epochBytes[32:], uint32(epoch.Height))
4✔
614

4✔
615
        return bkt.Put(lookoutTipKey, epochBytes)
4✔
616
}
4✔
617

618
// getLookoutEpoch retrieves the lookout tip block epoch from the given bucket.
619
// A nil epoch is returned if no update exists.
620
func getLookoutEpoch(bkt kvdb.RBucket) *chainntnfs.BlockEpoch {
4✔
621
        epochBytes := bkt.Get(lookoutTipKey)
4✔
622
        if len(epochBytes) != 36 {
8✔
623
                return nil
4✔
624
        }
4✔
625

626
        var hash chainhash.Hash
×
627
        copy(hash[:], epochBytes[:32])
×
628
        height := byteOrder.Uint32(epochBytes[32:])
×
629

×
630
        return &chainntnfs.BlockEpoch{
×
631
                Hash:   &hash,
×
632
                Height: int32(height),
×
633
        }
×
634
}
635

636
// errBucketNotEmpty is a helper error returned when testing whether a bucket is
637
// empty or not.
638
var errBucketNotEmpty = errors.New("bucket not empty")
639

640
// isBucketEmpty returns errBucketNotEmpty if the bucket is not empty.
641
func isBucketEmpty(bkt kvdb.RBucket) error {
4✔
642
        return bkt.ForEach(func(_, _ []byte) error {
4✔
643
                return errBucketNotEmpty
×
644
        })
×
645
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc