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

lightningnetwork / lnd / 15325172502

29 May 2025 01:38PM UTC coverage: 58.328% (+0.001%) from 58.327%
15325172502

Pull #9876

github

web-flow
Merge fb5563b27 into bff2f2440
Pull Request #9876: accessman: remove restrictions on protected/temporary peers

11 of 27 new or added lines in 1 file covered. (40.74%)

54 existing lines in 7 files now uncovered.

97409 of 167003 relevant lines covered (58.33%)

1.81 hits per line

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

71.81
/accessman.go
1
package lnd
2

3
import (
4
        "context"
5
        "fmt"
6
        "maps"
7
        "sync"
8

9
        "github.com/btcsuite/btcd/btcec/v2"
10
        "github.com/btcsuite/btclog/v2"
11
        "github.com/lightningnetwork/lnd/channeldb"
12
        "github.com/lightningnetwork/lnd/lnutils"
13
)
14

15
// accessMan is responsible for managing the server's access permissions.
16
type accessMan struct {
17
        cfg *accessManConfig
18

19
        // banScoreMtx is used for the server's ban tracking. If the server
20
        // mutex is also going to be locked, ensure that this is locked after
21
        // the server mutex.
22
        banScoreMtx sync.RWMutex
23

24
        // peerCounts is a mapping from remote public key to {bool, uint64}
25
        // where the bool indicates that we have an open/closed channel with
26
        // the peer and where the uint64 indicates the number of pending-open
27
        // channels we currently have with them. This mapping will be used to
28
        // determine access permissions for the peer. The map key is the
29
        // string-version of the serialized public key.
30
        //
31
        // NOTE: This MUST be accessed with the banScoreMtx held.
32
        peerCounts map[string]channeldb.ChanCount
33

34
        // peerScores stores each connected peer's access status. The map key
35
        // is the string-version of the serialized public key.
36
        //
37
        // NOTE: This MUST be accessed with the banScoreMtx held.
38
        peerScores map[string]peerSlotStatus
39

40
        // numRestricted tracks the number of peers with restricted access in
41
        // peerScores. This MUST be accessed with the banScoreMtx held.
42
        numRestricted int64
43
}
44

45
type accessManConfig struct {
46
        // initAccessPerms checks the channeldb for initial access permissions
47
        // and then populates the peerCounts and peerScores maps.
48
        initAccessPerms func() (map[string]channeldb.ChanCount, error)
49

50
        // shouldDisconnect determines whether we should disconnect a peer or
51
        // not.
52
        shouldDisconnect func(*btcec.PublicKey) (bool, error)
53

54
        // maxRestrictedSlots is the number of restricted slots we'll allocate.
55
        maxRestrictedSlots int64
56
}
57

58
func newAccessMan(cfg *accessManConfig) (*accessMan, error) {
3✔
59
        a := &accessMan{
3✔
60
                cfg:        cfg,
3✔
61
                peerCounts: make(map[string]channeldb.ChanCount),
3✔
62
                peerScores: make(map[string]peerSlotStatus),
3✔
63
        }
3✔
64

3✔
65
        counts, err := a.cfg.initAccessPerms()
3✔
66
        if err != nil {
3✔
67
                return nil, err
×
68
        }
×
69

70
        // We'll populate the server's peerCounts map with the counts fetched
71
        // via initAccessPerms. Also note that we haven't yet connected to the
72
        // peers.
73
        maps.Copy(a.peerCounts, counts)
3✔
74

3✔
75
        acsmLog.Info("Access Manager initialized")
3✔
76

3✔
77
        return a, nil
3✔
78
}
79

80
// assignPeerPerms assigns a new peer its permissions. This does not track the
81
// access in the maps. This is intentional.
82
func (a *accessMan) assignPeerPerms(remotePub *btcec.PublicKey) (
83
        peerAccessStatus, error) {
3✔
84

3✔
85
        ctx := btclog.WithCtx(
3✔
86
                context.TODO(), lnutils.LogPubKey("peer", remotePub),
3✔
87
        )
3✔
88

3✔
89
        peerMapKey := string(remotePub.SerializeCompressed())
3✔
90

3✔
91
        acsmLog.DebugS(ctx, "Assigning permissions")
3✔
92

3✔
93
        // Default is restricted unless the below filters say otherwise.
3✔
94
        access := peerStatusRestricted
3✔
95

3✔
96
        // Lock banScoreMtx for reading so that we can update the banning maps
3✔
97
        // below.
3✔
98
        a.banScoreMtx.RLock()
3✔
99
        if count, found := a.peerCounts[peerMapKey]; found {
6✔
100
                if count.HasOpenOrClosedChan {
6✔
101
                        acsmLog.DebugS(ctx, "Peer has open/closed channel, "+
3✔
102
                                "assigning protected access")
3✔
103

3✔
104
                        access = peerStatusProtected
3✔
105
                } else if count.PendingOpenCount != 0 {
9✔
106
                        acsmLog.DebugS(ctx, "Peer has pending channel(s), "+
3✔
107
                                "assigning temporary access")
3✔
108

3✔
109
                        access = peerStatusTemporary
3✔
110
                }
3✔
111
        }
112
        a.banScoreMtx.RUnlock()
3✔
113

3✔
114
        // Exit early if the peer status is no longer restricted.
3✔
115
        if access != peerStatusRestricted {
6✔
116
                return access, nil
3✔
117
        }
3✔
118

119
        // Check whether this peer is banned.
120
        shouldDisconnect, err := a.cfg.shouldDisconnect(remotePub)
3✔
121
        if err != nil {
3✔
NEW
122
                acsmLog.ErrorS(ctx, "Error checking disconnect status", err)
×
NEW
123

×
NEW
124
                // Access is restricted here.
×
NEW
125
                return access, err
×
NEW
126
        }
×
127

128
        if shouldDisconnect {
3✔
NEW
129
                acsmLog.WarnS(ctx, "Peer is banned, assigning restricted access",
×
NEW
130
                        ErrGossiperBan)
×
NEW
131

×
NEW
132
                // Access is restricted here.
×
NEW
133
                return access, ErrGossiperBan
×
NEW
134
        }
×
135

136
        // If we've reached this point and access hasn't changed from
137
        // restricted, then we need to check if we even have a slot for this
138
        // peer.
139
        acsmLog.DebugS(ctx, "Peer has no channels, assigning restricted access")
3✔
140

3✔
141
        if a.numRestricted >= a.cfg.maxRestrictedSlots {
3✔
NEW
142
                acsmLog.WarnS(ctx, "No more restricted slots available, "+
×
NEW
143
                        "denying peer", ErrNoMoreRestrictedAccessSlots,
×
NEW
144
                        "num_restricted", a.numRestricted, "max_restricted",
×
NEW
145
                        a.cfg.maxRestrictedSlots)
×
146

×
NEW
147
                return access, ErrNoMoreRestrictedAccessSlots
×
148
        }
×
149

150
        return access, nil
3✔
151
}
152

153
// newPendingOpenChan is called after the pending-open channel has been
154
// committed to the database. This may transition a restricted-access peer to a
155
// temporary-access peer.
156
func (a *accessMan) newPendingOpenChan(remotePub *btcec.PublicKey) error {
3✔
157
        a.banScoreMtx.Lock()
3✔
158
        defer a.banScoreMtx.Unlock()
3✔
159

3✔
160
        ctx := btclog.WithCtx(
3✔
161
                context.TODO(), lnutils.LogPubKey("peer", remotePub),
3✔
162
        )
3✔
163

3✔
164
        acsmLog.DebugS(ctx, "Processing new pending open channel")
3✔
165

3✔
166
        peerMapKey := string(remotePub.SerializeCompressed())
3✔
167

3✔
168
        // Fetch the peer's access status from peerScores.
3✔
169
        status, found := a.peerScores[peerMapKey]
3✔
170
        if !found {
3✔
171
                acsmLog.ErrorS(ctx, "Peer score not found", ErrNoPeerScore)
×
172

×
173
                // If we didn't find the peer, we'll return an error.
×
174
                return ErrNoPeerScore
×
175
        }
×
176

177
        switch status.state {
3✔
178
        case peerStatusProtected:
3✔
179
                acsmLog.DebugS(ctx, "Peer already protected, no change")
3✔
180

3✔
181
                // If this peer's access status is protected, we don't need to
3✔
182
                // do anything.
3✔
183
                return nil
3✔
184

185
        case peerStatusTemporary:
3✔
186
                // If this peer's access status is temporary, we'll need to
3✔
187
                // update the peerCounts map. The peer's access status will stay
3✔
188
                // temporary.
3✔
189
                peerCount, found := a.peerCounts[peerMapKey]
3✔
190
                if !found {
3✔
191
                        // Error if we did not find any info in peerCounts.
×
192
                        acsmLog.ErrorS(ctx, "Pending peer info not found",
×
193
                                ErrNoPendingPeerInfo)
×
194

×
195
                        return ErrNoPendingPeerInfo
×
196
                }
×
197

198
                // Increment the pending channel amount.
199
                peerCount.PendingOpenCount += 1
3✔
200
                a.peerCounts[peerMapKey] = peerCount
3✔
201

3✔
202
                acsmLog.DebugS(ctx, "Peer is temporary, incremented "+
3✔
203
                        "pending count",
3✔
204
                        "pending_count", peerCount.PendingOpenCount)
3✔
205

206
        case peerStatusRestricted:
3✔
207
                // If the peer's access status is restricted, then we can
3✔
208
                // transition it to a temporary-access peer. We'll need to
3✔
209
                // update numRestricted and also peerScores. We'll also need to
3✔
210
                // update peerCounts.
3✔
211
                peerCount := channeldb.ChanCount{
3✔
212
                        HasOpenOrClosedChan: false,
3✔
213
                        PendingOpenCount:    1,
3✔
214
                }
3✔
215

3✔
216
                a.peerCounts[peerMapKey] = peerCount
3✔
217

3✔
218
                // A restricted-access slot has opened up.
3✔
219
                oldRestricted := a.numRestricted
3✔
220
                a.numRestricted -= 1
3✔
221

3✔
222
                a.peerScores[peerMapKey] = peerSlotStatus{
3✔
223
                        state: peerStatusTemporary,
3✔
224
                }
3✔
225

3✔
226
                acsmLog.InfoS(ctx, "Peer transitioned restricted -> "+
3✔
227
                        "temporary (pending open)",
3✔
228
                        "old_restricted", oldRestricted,
3✔
229
                        "new_restricted", a.numRestricted)
3✔
230

231
        default:
×
232
                // This should not be possible.
×
233
                err := fmt.Errorf("invalid peer access status %v for %x",
×
234
                        status.state, peerMapKey)
×
235
                acsmLog.ErrorS(ctx, "Invalid peer access status", err)
×
236

×
237
                return err
×
238
        }
239

240
        return nil
3✔
241
}
242

243
// newPendingCloseChan is called when a pending-open channel prematurely closes
244
// before the funding transaction has confirmed. This potentially demotes a
245
// temporary-access peer to a restricted-access peer. If no restricted-access
246
// slots are available, the peer will be disconnected.
247
func (a *accessMan) newPendingCloseChan(remotePub *btcec.PublicKey) error {
3✔
248
        a.banScoreMtx.Lock()
3✔
249
        defer a.banScoreMtx.Unlock()
3✔
250

3✔
251
        ctx := btclog.WithCtx(
3✔
252
                context.TODO(), lnutils.LogPubKey("peer", remotePub),
3✔
253
        )
3✔
254

3✔
255
        acsmLog.DebugS(ctx, "Processing pending channel close")
3✔
256

3✔
257
        peerMapKey := string(remotePub.SerializeCompressed())
3✔
258

3✔
259
        // Fetch the peer's access status from peerScores.
3✔
260
        status, found := a.peerScores[peerMapKey]
3✔
261
        if !found {
3✔
262
                acsmLog.ErrorS(ctx, "Peer score not found", ErrNoPeerScore)
×
263

×
264
                return ErrNoPeerScore
×
265
        }
×
266

267
        switch status.state {
3✔
268
        case peerStatusProtected:
×
269
                // If this peer is protected, we don't do anything.
×
270
                acsmLog.DebugS(ctx, "Peer is protected, no change")
×
271

×
272
                return nil
×
273

274
        case peerStatusTemporary:
3✔
275
                // If this peer is temporary, we need to check if it will
3✔
276
                // revert to a restricted-access peer.
3✔
277
                peerCount, found := a.peerCounts[peerMapKey]
3✔
278
                if !found {
3✔
279
                        acsmLog.ErrorS(ctx, "Pending peer info not found",
×
280
                                ErrNoPendingPeerInfo)
×
281

×
282
                        // Error if we did not find any info in peerCounts.
×
283
                        return ErrNoPendingPeerInfo
×
284
                }
×
285

286
                currentNumPending := peerCount.PendingOpenCount - 1
3✔
287

3✔
288
                acsmLog.DebugS(ctx, "Peer is temporary, decrementing "+
3✔
289
                        "pending count",
3✔
290
                        "pending_count", currentNumPending)
3✔
291

3✔
292
                if currentNumPending == 0 {
6✔
293
                        // Remove the entry from peerCounts.
3✔
294
                        delete(a.peerCounts, peerMapKey)
3✔
295

3✔
296
                        // If this is the only pending-open channel for this
3✔
297
                        // peer and it's getting removed, attempt to demote
3✔
298
                        // this peer to a restricted peer.
3✔
299
                        if a.numRestricted == a.cfg.maxRestrictedSlots {
3✔
300
                                // There are no available restricted slots, so
×
301
                                // we need to disconnect this peer. We leave
×
302
                                // this up to the caller.
×
303
                                acsmLog.WarnS(ctx, "Peer last pending "+
×
304
                                        "channel closed: ",
×
305
                                        ErrNoMoreRestrictedAccessSlots,
×
306
                                        "num_restricted", a.numRestricted,
×
307
                                        "max_restricted", a.cfg.maxRestrictedSlots)
×
308

×
309
                                return ErrNoMoreRestrictedAccessSlots
×
310
                        }
×
311

312
                        // Otherwise, there is an available restricted-access
313
                        // slot, so we can demote this peer.
314
                        a.peerScores[peerMapKey] = peerSlotStatus{
3✔
315
                                state: peerStatusRestricted,
3✔
316
                        }
3✔
317

3✔
318
                        // Update numRestricted.
3✔
319
                        oldRestricted := a.numRestricted
3✔
320
                        a.numRestricted++
3✔
321

3✔
322
                        acsmLog.InfoS(ctx, "Peer transitioned "+
3✔
323
                                "temporary -> restricted "+
3✔
324
                                "(last pending closed)",
3✔
325
                                "old_restricted", oldRestricted,
3✔
326
                                "new_restricted", a.numRestricted)
3✔
327

3✔
328
                        return nil
3✔
329
                }
330

331
                // Else, we don't need to demote this peer since it has other
332
                // pending-open channels with us.
333
                peerCount.PendingOpenCount = currentNumPending
×
334
                a.peerCounts[peerMapKey] = peerCount
×
335

×
336
                acsmLog.DebugS(ctx, "Peer still has other pending channels",
×
337
                        "pending_count", currentNumPending)
×
338

×
339
                return nil
×
340

341
        case peerStatusRestricted:
×
342
                // This should not be possible. This indicates an error.
×
343
                err := fmt.Errorf("invalid peer access state transition: "+
×
344
                        "pending close for restricted peer %x", peerMapKey)
×
345
                acsmLog.ErrorS(ctx, "Invalid peer access state transition", err)
×
346

×
347
                return err
×
348

349
        default:
×
350
                // This should not be possible.
×
351
                err := fmt.Errorf("invalid peer access status %v for %x",
×
352
                        status.state, peerMapKey)
×
353
                acsmLog.ErrorS(ctx, "Invalid peer access status", err)
×
354

×
355
                return err
×
356
        }
357
}
358

359
// newOpenChan is called when a pending-open channel becomes an open channel
360
// (i.e. the funding transaction has confirmed). If the remote peer is a
361
// temporary-access peer, it will be promoted to a protected-access peer.
362
func (a *accessMan) newOpenChan(remotePub *btcec.PublicKey) error {
3✔
363
        a.banScoreMtx.Lock()
3✔
364
        defer a.banScoreMtx.Unlock()
3✔
365

3✔
366
        ctx := btclog.WithCtx(
3✔
367
                context.TODO(), lnutils.LogPubKey("peer", remotePub),
3✔
368
        )
3✔
369

3✔
370
        acsmLog.DebugS(ctx, "Processing new open channel")
3✔
371

3✔
372
        peerMapKey := string(remotePub.SerializeCompressed())
3✔
373

3✔
374
        // Fetch the peer's access status from peerScores.
3✔
375
        status, found := a.peerScores[peerMapKey]
3✔
376
        if !found {
6✔
377
                // If we didn't find the peer, we'll return an error.
3✔
378
                acsmLog.ErrorS(ctx, "Peer score not found", ErrNoPeerScore)
3✔
379

3✔
380
                return ErrNoPeerScore
3✔
381
        }
3✔
382

383
        switch status.state {
3✔
384
        case peerStatusProtected:
3✔
385
                acsmLog.DebugS(ctx, "Peer already protected, no change")
3✔
386

3✔
387
                // If the peer's state is already protected, we don't need to do
3✔
388
                // anything more.
3✔
389
                return nil
3✔
390

391
        case peerStatusTemporary:
3✔
392
                // If the peer's state is temporary, we'll upgrade the peer to
3✔
393
                // a protected peer.
3✔
394
                peerCount, found := a.peerCounts[peerMapKey]
3✔
395
                if !found {
3✔
396
                        // Error if we did not find any info in peerCounts.
×
397
                        acsmLog.ErrorS(ctx, "Pending peer info not found",
×
398
                                ErrNoPendingPeerInfo)
×
399

×
400
                        return ErrNoPendingPeerInfo
×
401
                }
×
402

403
                peerCount.HasOpenOrClosedChan = true
3✔
404
                a.peerCounts[peerMapKey] = peerCount
3✔
405

3✔
406
                newStatus := peerSlotStatus{
3✔
407
                        state: peerStatusProtected,
3✔
408
                }
3✔
409
                a.peerScores[peerMapKey] = newStatus
3✔
410

3✔
411
                acsmLog.InfoS(ctx, "Peer transitioned temporary -> "+
3✔
412
                        "protected (channel opened)")
3✔
413

3✔
414
                return nil
3✔
415

416
        case peerStatusRestricted:
×
417
                // This should not be possible. For the server to receive a
×
418
                // state-transition event via NewOpenChan, the server must have
×
419
                // previously granted this peer "temporary" access. This
×
420
                // temporary access would not have been revoked or downgraded
×
421
                // without `CloseChannel` being called with the pending
×
422
                // argument set to true. This means that an open-channel state
×
423
                // transition would be impossible. Therefore, we can return an
×
424
                // error.
×
425
                err := fmt.Errorf("invalid peer access status: new open "+
×
426
                        "channel for restricted peer %x", peerMapKey)
×
427

×
428
                acsmLog.ErrorS(ctx, "Invalid peer access status", err)
×
429

×
430
                return err
×
431

432
        default:
×
433
                // This should not be possible.
×
434
                err := fmt.Errorf("invalid peer access status %v for %x",
×
435
                        status.state, peerMapKey)
×
436

×
437
                acsmLog.ErrorS(ctx, "Invalid peer access status", err)
×
438

×
439
                return err
×
440
        }
441
}
442

443
// checkIncomingConnBanScore checks whether, given the remote's public hex-
444
// encoded key, we should not accept this incoming connection or immediately
445
// disconnect. This does not assign to the server's peerScores maps. This is
446
// just an inbound filter that the brontide listeners use.
447
func (a *accessMan) checkIncomingConnBanScore(remotePub *btcec.PublicKey) (
448
        bool, error) {
3✔
449

3✔
450
        ctx := btclog.WithCtx(
3✔
451
                context.TODO(), lnutils.LogPubKey("peer", remotePub),
3✔
452
        )
3✔
453

3✔
454
        peerMapKey := string(remotePub.SerializeCompressed())
3✔
455

3✔
456
        acsmLog.TraceS(ctx, "Checking incoming connection ban score")
3✔
457

3✔
458
        a.banScoreMtx.RLock()
3✔
459
        defer a.banScoreMtx.RUnlock()
3✔
460

3✔
461
        if _, found := a.peerCounts[peerMapKey]; !found {
6✔
462
                acsmLog.DebugS(ctx, "Peer not found in counts, "+
3✔
463
                        "checking restricted slots")
3✔
464

3✔
465
                // Check numRestricted to see if there is an available slot. In
3✔
466
                // the future, it's possible to add better heuristics.
3✔
467
                if a.numRestricted < a.cfg.maxRestrictedSlots {
6✔
468
                        // There is an available slot.
3✔
469
                        acsmLog.DebugS(ctx, "Restricted slot available, "+
3✔
470
                                "accepting",
3✔
471
                                "num_restricted", a.numRestricted,
3✔
472
                                "max_restricted", a.cfg.maxRestrictedSlots)
3✔
473

3✔
474
                        return true, nil
3✔
475
                }
3✔
476

477
                // If there are no slots left, then we reject this connection.
478
                acsmLog.WarnS(ctx, "No restricted slots available, "+
3✔
479
                        "rejecting",
3✔
480
                        ErrNoMoreRestrictedAccessSlots,
3✔
481
                        "num_restricted", a.numRestricted,
3✔
482
                        "max_restricted", a.cfg.maxRestrictedSlots)
3✔
483

3✔
484
                return false, ErrNoMoreRestrictedAccessSlots
3✔
485
        }
486

487
        // Else, the peer is either protected or temporary.
488
        acsmLog.DebugS(ctx, "Peer found (protected/temporary), accepting")
3✔
489

3✔
490
        return true, nil
3✔
491
}
492

493
// addPeerAccess tracks a peer's access in the maps. This should be called when
494
// the peer has fully connected.
495
func (a *accessMan) addPeerAccess(remotePub *btcec.PublicKey,
496
        access peerAccessStatus) {
3✔
497

3✔
498
        ctx := btclog.WithCtx(
3✔
499
                context.TODO(), lnutils.LogPubKey("peer", remotePub),
3✔
500
        )
3✔
501

3✔
502
        acsmLog.DebugS(ctx, "Adding peer access", "access", access)
3✔
503

3✔
504
        // Add the remote public key to peerScores.
3✔
505
        a.banScoreMtx.Lock()
3✔
506
        defer a.banScoreMtx.Unlock()
3✔
507

3✔
508
        peerMapKey := string(remotePub.SerializeCompressed())
3✔
509

3✔
510
        a.peerScores[peerMapKey] = peerSlotStatus{state: access}
3✔
511

3✔
512
        // Increment numRestricted.
3✔
513
        if access == peerStatusRestricted {
6✔
514
                oldRestricted := a.numRestricted
3✔
515
                a.numRestricted++
3✔
516

3✔
517
                acsmLog.DebugS(ctx, "Incremented restricted slots",
3✔
518
                        "old_restricted", oldRestricted,
3✔
519
                        "new_restricted", a.numRestricted)
3✔
520
        }
3✔
521
}
522

523
// removePeerAccess removes the peer's access from the maps. This should be
524
// called when the peer has been disconnected.
525
func (a *accessMan) removePeerAccess(remotePub *btcec.PublicKey) {
3✔
526
        a.banScoreMtx.Lock()
3✔
527
        defer a.banScoreMtx.Unlock()
3✔
528

3✔
529
        ctx := btclog.WithCtx(
3✔
530
                context.TODO(), lnutils.LogPubKey("peer", remotePub),
3✔
531
        )
3✔
532

3✔
533
        acsmLog.DebugS(ctx, "Removing peer access")
3✔
534

3✔
535
        peerMapKey := string(remotePub.SerializeCompressed())
3✔
536

3✔
537
        status, found := a.peerScores[peerMapKey]
3✔
538
        if !found {
3✔
539
                acsmLog.InfoS(ctx, "Peer score not found during removal")
×
540
                return
×
541
        }
×
542

543
        if status.state == peerStatusRestricted {
6✔
544
                // If the status is restricted, then we decrement from
3✔
545
                // numRestrictedSlots.
3✔
546
                oldRestricted := a.numRestricted
3✔
547
                a.numRestricted--
3✔
548

3✔
549
                acsmLog.DebugS(ctx, "Decremented restricted slots",
3✔
550
                        "old_restricted", oldRestricted,
3✔
551
                        "new_restricted", a.numRestricted)
3✔
552
        }
3✔
553

554
        acsmLog.TraceS(ctx, "Deleting peer from peerScores")
3✔
555

3✔
556
        delete(a.peerScores, peerMapKey)
3✔
557
}
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