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

PowerDNS / pdns / 19741624072

27 Nov 2025 03:45PM UTC coverage: 73.086% (+0.02%) from 73.065%
19741624072

Pull #16570

github

web-flow
Merge 08a2cdb1d into f94a3f63f
Pull Request #16570: rec: rewrite all unwrap calls in web.rs

38523 of 63408 branches covered (60.75%)

Branch coverage included in aggregate %.

128044 of 164496 relevant lines covered (77.84%)

6531485.83 hits per line

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

68.99
/pdns/dnsdistdist/dnsdist-dynblocks.cc
1
#include "dnsdist.hh"
2
#include "dnsdist-dynblocks.hh"
3
#include "dnsdist-metrics.hh"
4
#include "sholder.hh"
5

6
#ifndef DISABLE_DYNBLOCKS
7
static GlobalStateHolder<ClientAddressDynamicRules> s_dynblockNMG;
8
static GlobalStateHolder<SuffixDynamicRules> s_dynblockSMT;
9

10
void DynBlockRulesGroup::apply(const timespec& now)
11
{
299✔
12
  counts_t counts;
299✔
13
  StatNode statNodeRoot;
299✔
14

15
  size_t entriesCount = 0;
299✔
16
  if (hasQueryRules()) {
299✔
17
    entriesCount += g_rings.getNumberOfQueryEntries();
154✔
18
  }
154✔
19
  if (hasResponseRules()) {
299✔
20
    entriesCount += g_rings.getNumberOfResponseEntries();
141✔
21
  }
141✔
22
  counts.reserve(entriesCount);
299✔
23

24
  processQueryRules(counts, now);
299✔
25
  processResponseRules(counts, statNodeRoot, now);
299✔
26

27
  if (counts.empty() && statNodeRoot.empty()) {
299!
28
    return;
119✔
29
  }
119✔
30

31
  boost::optional<ClientAddressDynamicRules> blocks;
180✔
32
  bool updated = false;
180✔
33

34
  for (const auto& entry : counts) {
692✔
35
    const auto& requestor = entry.first;
692✔
36
    const auto& counters = entry.second;
692✔
37

38
    if (d_queryRateRule.warningRateExceeded(counters.queries, now)) {
692✔
39
      handleWarning(blocks, now, requestor, d_queryRateRule, updated);
14✔
40
    }
14✔
41

42
    if (d_queryRateRule.rateExceeded(counters.queries, now)) {
692✔
43
      addBlock(blocks, now, requestor, d_queryRateRule, updated);
562✔
44
      continue;
562✔
45
    }
562✔
46

47
    if (d_respRateRule.warningRateExceeded(counters.respBytes, now)) {
130!
48
      handleWarning(blocks, now, requestor, d_respRateRule, updated);
×
49
    }
×
50

51
    if (d_respRateRule.rateExceeded(counters.respBytes, now)) {
130✔
52
      addBlock(blocks, now, requestor, d_respRateRule, updated);
6✔
53
      continue;
6✔
54
    }
6✔
55

56
    if (d_respCacheMissRatioRule.warningRatioExceeded(counters.responses, counters.cacheMisses)) {
124!
57
      handleWarning(blocks, now, requestor, d_respCacheMissRatioRule, updated);
×
58
      continue;
×
59
    }
×
60

61
    if (d_respCacheMissRatioRule.ratioExceeded(counters.responses, counters.cacheMisses)) {
124✔
62
      addBlock(blocks, now, requestor, d_respCacheMissRatioRule, updated);
10✔
63
      continue;
10✔
64
    }
10✔
65

66
    for (const auto& pair : d_qtypeRules) {
114✔
67
      const auto qtype = pair.first;
13✔
68

69
      const auto& typeIt = counters.d_qtypeCounts.find(qtype);
13✔
70
      if (typeIt != counters.d_qtypeCounts.cend()) {
13!
71

72
        if (pair.second.warningRateExceeded(typeIt->second, now)) {
13!
73
          handleWarning(blocks, now, requestor, pair.second, updated);
×
74
        }
×
75

76
        if (pair.second.rateExceeded(typeIt->second, now)) {
13✔
77
          addBlock(blocks, now, requestor, pair.second, updated);
6✔
78
          break;
6✔
79
        }
6✔
80
      }
13✔
81
    }
13✔
82

83
    for (const auto& pair : d_rcodeRules) {
114✔
84
      const auto rcode = pair.first;
25✔
85

86
      const auto& rcodeIt = counters.d_rcodeCounts.find(rcode);
25✔
87
      if (rcodeIt != counters.d_rcodeCounts.cend()) {
25✔
88
        if (pair.second.warningRateExceeded(rcodeIt->second, now)) {
12!
89
          handleWarning(blocks, now, requestor, pair.second, updated);
×
90
        }
×
91

92
        if (pair.second.rateExceeded(rcodeIt->second, now)) {
12✔
93
          addBlock(blocks, now, requestor, pair.second, updated);
10✔
94
          break;
10✔
95
        }
10✔
96
      }
12✔
97
    }
25✔
98

99
    for (const auto& pair : d_rcodeRatioRules) {
114✔
100
      const auto rcode = pair.first;
24✔
101

102
      const auto& rcodeIt = counters.d_rcodeCounts.find(rcode);
24✔
103
      if (rcodeIt != counters.d_rcodeCounts.cend()) {
24✔
104
        if (pair.second.warningRatioExceeded(counters.responses, rcodeIt->second)) {
18!
105
          handleWarning(blocks, now, requestor, pair.second, updated);
×
106
        }
×
107

108
        if (pair.second.ratioExceeded(counters.responses, rcodeIt->second)) {
18✔
109
          addBlock(blocks, now, requestor, pair.second, updated);
10✔
110
          break;
10✔
111
        }
10✔
112
      }
18✔
113
    }
24✔
114
  }
114✔
115

116
  if (updated && blocks) {
180!
117
    s_dynblockNMG.setState(std::move(*blocks));
92✔
118
  }
92✔
119

120
  applySMT(now, statNodeRoot);
180✔
121
}
180✔
122

123
void DynBlockRulesGroup::applySMT(const struct timespec& now, StatNode& statNodeRoot)
124
{
180✔
125
  if (statNodeRoot.empty()) {
180✔
126
    return;
174✔
127
  }
174✔
128

129
  bool updated = false;
6✔
130
  StatNode::Stat node;
6✔
131
  std::unordered_map<DNSName, SMTBlockParameters> namesToBlock;
6✔
132
  statNodeRoot.visit([this, &namesToBlock](const StatNode* node_, const StatNode::Stat& self, const StatNode::Stat& children) {
1,048✔
133
    bool block = false;
1,048✔
134
    SMTBlockParameters blockParameters;
1,048✔
135
    if (d_smtVisitorFFI) {
1,048!
136
      dnsdist_ffi_stat_node_t tmp(*node_, self, children, blockParameters);
×
137
      block = d_smtVisitorFFI(&tmp);
×
138
    }
×
139
    else {
1,048✔
140
      auto ret = d_smtVisitor(*node_, self, children);
1,048✔
141
      block = std::get<0>(ret);
1,048✔
142
      if (block) {
1,048✔
143
        if (boost::optional<std::string> tmp = std::get<1>(ret)) {
1,024✔
144
          blockParameters.d_reason = std::move(*tmp);
512✔
145
        }
512✔
146
        if (boost::optional<int> tmp = std::get<2>(ret)) {
1,024✔
147
          blockParameters.d_action = static_cast<DNSAction::Action>(*tmp);
512✔
148
        }
512✔
149
      }
1,024✔
150
    }
1,048✔
151
    if (block) {
1,048✔
152
      namesToBlock.insert({DNSName(node_->fullname), std::move(blockParameters)});
1,024✔
153
    }
1,024✔
154
  },
1,048✔
155
                     node);
6✔
156

157
  if (!namesToBlock.empty()) {
6✔
158
    updated = false;
4✔
159
    auto smtBlocks = dnsdist::DynamicBlocks::getSuffixDynamicRulesCopy();
4✔
160
    for (auto& [name, parameters] : namesToBlock) {
1,024✔
161
      if (parameters.d_reason || parameters.d_action) {
1,024!
162
        DynBlockRule rule(d_suffixMatchRule);
512✔
163
        if (parameters.d_reason) {
512!
164
          rule.d_blockReason = std::move(*parameters.d_reason);
512✔
165
        }
512✔
166
        if (parameters.d_action) {
512!
167
          rule.d_action = *parameters.d_action;
512✔
168
        }
512✔
169
        addOrRefreshBlockSMT(smtBlocks, now, name, rule, updated);
512✔
170
      }
512✔
171
      else {
512✔
172
        addOrRefreshBlockSMT(smtBlocks, now, name, d_suffixMatchRule, updated);
512✔
173
      }
512✔
174
    }
1,024✔
175
    if (updated) {
4!
176
      s_dynblockSMT.setState(std::move(smtBlocks));
4✔
177
    }
4✔
178
  }
4✔
179
}
6✔
180

181
bool DynBlockRulesGroup::checkIfQueryTypeMatches(const Rings::Query& query)
182
{
36,976✔
183
  auto rule = d_qtypeRules.find(query.qtype);
36,976✔
184
  if (rule == d_qtypeRules.end()) {
36,976✔
185
    return false;
34,703✔
186
  }
34,703✔
187

188
  return rule->second.matches(query.when);
2,273✔
189
}
36,976✔
190

191
bool DynBlockRulesGroup::checkIfResponseCodeMatches(const Rings::Response& response)
192
{
10,632✔
193
  auto rule = d_rcodeRules.find(response.dh.rcode);
10,632✔
194
  if (rule != d_rcodeRules.end() && rule->second.matches(response.when)) {
10,632!
195
    return true;
2,070✔
196
  }
2,070✔
197

198
  auto ratio = d_rcodeRatioRules.find(response.dh.rcode);
8,562✔
199
  return ratio != d_rcodeRatioRules.end() && ratio->second.matches(response.when);
8,562!
200
}
10,632✔
201

202
/* return the actual action that will be taken by that block:
203
   - either the one set on that block, if any
204
   - or the one set with setDynBlocksAction
205
*/
206
static DNSAction::Action getActualAction(const DynBlock& block)
207
{
567✔
208
  if (block.action != DNSAction::Action::None) {
567✔
209
    return block.action;
553✔
210
  }
553✔
211
  return dnsdist::configuration::getCurrentRuntimeConfiguration().d_dynBlockAction;
14✔
212
}
567✔
213

214
namespace dnsdist::DynamicBlocks
215
{
216
bool addOrRefreshBlock(ClientAddressDynamicRules& blocks, const timespec& now, const AddressAndPortRange& requestor, DynBlock&& dblock, bool beQuiet)
217
{
612✔
218
  unsigned int count = 0;
612✔
219
  bool expired = false;
612✔
220
  bool wasWarning = false;
612✔
221
  bool bpf = false;
612✔
222

223
  const auto& got = blocks.lookup(requestor);
612✔
224
  if (got != nullptr) {
612✔
225
    bpf = got->second.bpf;
58✔
226

227
    if (dblock.warning && !got->second.warning) {
58✔
228
      /* we have an existing entry which is not a warning,
229
         don't override it */
230
      return false;
5✔
231
    }
5✔
232
    if (!dblock.warning && got->second.warning) {
53✔
233
      wasWarning = true;
5✔
234
    }
5✔
235
    else {
48✔
236
      if (dblock.until < got->second.until) {
48!
237
        // had a longer policy
238
        return false;
×
239
      }
×
240
    }
48✔
241

242
    if (now < got->second.until) {
53✔
243
      // only inherit count on fresh query we are extending
244
      count = got->second.blocks;
45✔
245
    }
45✔
246
    else {
8✔
247
      expired = true;
8✔
248
    }
8✔
249
  }
53✔
250

251
  dblock.blocks = count;
607✔
252

253
  if (got == nullptr || expired || wasWarning) {
607✔
254
    const auto actualAction = getActualAction(dblock);
567✔
255
    if (g_defaultBPFFilter && ((requestor.isIPv4() && requestor.getBits() == 32) || (requestor.isIPv6() && requestor.getBits() == 128)) && (actualAction == DNSAction::Action::Drop || actualAction == DNSAction::Action::Truncate)) {
567!
256
      try {
2✔
257
        BPFFilter::MatchAction bpfAction = actualAction == DNSAction::Action::Drop ? BPFFilter::MatchAction::Drop : BPFFilter::MatchAction::Truncate;
2!
258
        if (g_defaultBPFFilter->supportsMatchAction(bpfAction)) {
2!
259
          /* the current BPF filter implementation only supports full addresses (/32 or /128) and no port */
260
          g_defaultBPFFilter->block(requestor.getNetwork(), bpfAction);
2✔
261
          bpf = true;
2✔
262
        }
2✔
263
      }
2✔
264
      catch (const std::exception& e) {
2✔
265
        vinfolog("Unable to insert eBPF dynamic block for %s, falling back to regular dynamic block: %s", requestor.toString(), e.what());
×
266
      }
×
267
    }
2✔
268

269
    if (!beQuiet) {
567✔
270
      warnlog("Inserting %s%sdynamic block for %s for %d seconds: %s", dblock.warning ? "(warning) " : "", bpf ? "eBPF " : "", requestor.toString(), dblock.until.tv_sec - now.tv_sec, dblock.reason);
25✔
271
    }
25✔
272
  }
567✔
273

274
  dblock.bpf = bpf;
607✔
275

276
  blocks.insert(requestor).second = std::move(dblock);
607✔
277

278
  return true;
607✔
279
}
607✔
280

281
bool addOrRefreshBlockSMT(SuffixDynamicRules& blocks, const timespec& now, DynBlock&& dblock, bool beQuiet)
282
{
1,024✔
283
  unsigned int count = 0;
1,024✔
284
  /* be careful, if you try to insert a longer suffix
285
     lookup() might return a shorter one if it is
286
     already in the tree as a final node */
287
  const DynBlock* got = blocks.lookup(dblock.domain);
1,024✔
288
  if (got != nullptr && got->domain != dblock.domain) {
1,024!
289
    got = nullptr;
×
290
  }
×
291
  bool expired = false;
1,024✔
292

293
  if (got != nullptr) {
1,024!
294
    if (dblock.until < got->until) {
×
295
      // had a longer policy
296
      return false;
×
297
    }
×
298

299
    if (now < got->until) {
×
300
      // only inherit count on fresh query we are extending
301
      count = got->blocks;
×
302
    }
×
303
    else {
×
304
      expired = true;
×
305
    }
×
306
  }
×
307

308
  dblock.blocks = count;
1,024✔
309

310
  if (!beQuiet && (got == nullptr || expired)) {
1,024!
311
    warnlog("Inserting dynamic block for %s for %d seconds: %s", dblock.domain, dblock.until.tv_sec - now.tv_sec, dblock.reason);
×
312
  }
×
313
  auto domain = dblock.domain;
1,024✔
314
  blocks.add(domain, std::move(dblock));
1,024✔
315
  return true;
1,024✔
316
}
1,024✔
317
}
318

319
void DynBlockRulesGroup::addOrRefreshBlock(boost::optional<ClientAddressDynamicRules>& blocks, const struct timespec& now, const AddressAndPortRange& requestor, const DynBlockRule& rule, bool& updated, bool warning)
320
{
618✔
321
  /* network exclusions are address-based only (no port) */
322
  if (d_excludedSubnets.match(requestor.getNetwork())) {
618✔
323
    /* do not add a block for excluded subnets */
324
    return;
6✔
325
  }
6✔
326

327
  timespec until{now};
612✔
328
  until.tv_sec += rule.d_blockDuration;
612✔
329
  DynBlock dblock{rule.d_blockReason, until, DNSName(), warning ? DNSAction::Action::NoOp : rule.d_action};
612✔
330
  dblock.warning = warning;
612✔
331
  if (!warning && rule.d_action == DNSAction::Action::SetTag) {
612✔
332
    dblock.tagSettings = rule.d_tagSettings;
2✔
333
  }
2✔
334
  if (!blocks) {
612✔
335
    blocks = dnsdist::DynamicBlocks::getClientAddressDynamicRulesCopy();
92✔
336
  }
92✔
337

338
  updated = dnsdist::DynamicBlocks::addOrRefreshBlock(*blocks, now, requestor, std::move(dblock), d_beQuiet);
612✔
339
  if (updated && d_newBlockHook) {
612!
340
    try {
×
341
      d_newBlockHook(dnsdist_ffi_dynamic_block_type_nmt, requestor.toString().c_str(), rule.d_blockReason.c_str(), static_cast<uint8_t>(rule.d_action), rule.d_blockDuration, warning);
×
342
    }
×
343
    catch (const std::exception& exp) {
×
344
      warnlog("Error calling the Lua hook after a dynamic block insertion: %s", exp.what());
×
345
    }
×
346
  }
×
347
}
612✔
348

349
void DynBlockRulesGroup::addOrRefreshBlockSMT(SuffixDynamicRules& blocks, const struct timespec& now, const DNSName& name, const DynBlockRule& rule, bool& updated)
350
{
1,024✔
351
  if (d_excludedDomains.check(name)) {
1,024!
352
    /* do not add a block for excluded domains */
353
    return;
×
354
  }
×
355

356
  timespec until{now};
1,024✔
357
  until.tv_sec += rule.d_blockDuration;
1,024✔
358
  DynBlock dblock{rule.d_blockReason, until, name.makeLowerCase(), rule.d_action};
1,024✔
359
  if (rule.d_action == DNSAction::Action::SetTag) {
1,024!
360
    dblock.tagSettings = rule.d_tagSettings;
×
361
  }
×
362
  updated = dnsdist::DynamicBlocks::addOrRefreshBlockSMT(blocks, now, std::move(dblock), d_beQuiet);
1,024✔
363
  if (updated && d_newBlockHook) {
1,024!
364
    try {
×
365
      d_newBlockHook(dnsdist_ffi_dynamic_block_type_smt, name.toString().c_str(), rule.d_blockReason.c_str(), static_cast<uint8_t>(rule.d_action), rule.d_blockDuration, false);
×
366
    }
×
367
    catch (const std::exception& exp) {
×
368
      warnlog("Error calling the Lua hook after a dynamic block insertion: %s", exp.what());
×
369
    }
×
370
  }
×
371
}
1,024✔
372

373
void DynBlockRulesGroup::processQueryRules(counts_t& counts, const struct timespec& now)
374
{
299✔
375
  if (!hasQueryRules()) {
299✔
376
    return;
145✔
377
  }
145✔
378

379
  d_queryRateRule.d_cutOff = d_queryRateRule.d_minTime = now;
154✔
380
  d_queryRateRule.d_cutOff.tv_sec -= d_queryRateRule.d_seconds;
154✔
381

382
  for (auto& rule : d_qtypeRules) {
154✔
383
    rule.second.d_cutOff = rule.second.d_minTime = now;
23✔
384
    rule.second.d_cutOff.tv_sec -= rule.second.d_seconds;
23✔
385
  }
23✔
386

387
  for (const auto& shard : g_rings.d_shards) {
1,504✔
388
    auto queryRing = shard->queryRing.lock();
1,504✔
389
    for (const auto& ringEntry : *queryRing) {
36,976✔
390
      if (now < ringEntry.when) {
36,976!
391
        continue;
×
392
      }
×
393

394
      bool qRateMatches = d_queryRateRule.matches(ringEntry.when);
36,976✔
395
      bool typeRuleMatches = checkIfQueryTypeMatches(ringEntry);
36,976✔
396

397
      if (qRateMatches || typeRuleMatches) {
36,976✔
398
        auto& entry = counts[AddressAndPortRange(ringEntry.requestor, ringEntry.requestor.isIPv4() ? d_v4Mask : d_v6Mask, d_portMask)];
20,973✔
399
        if (qRateMatches) {
20,973✔
400
          ++entry.queries;
18,982✔
401
        }
18,982✔
402
        if (typeRuleMatches) {
20,973✔
403
          ++entry.d_qtypeCounts[ringEntry.qtype];
1,991✔
404
        }
1,991✔
405
      }
20,973✔
406
    }
36,976✔
407
  }
1,504✔
408
}
154✔
409

410
void DynBlockRulesGroup::processResponseRules(counts_t& counts, StatNode& root, const struct timespec& now)
411
{
299✔
412
  if (!hasResponseRules() && !hasSuffixMatchRules()) {
299✔
413
    return;
152✔
414
  }
152✔
415

416
  struct timespec responseCutOff = now;
147✔
417

418
  d_respRateRule.d_cutOff = d_respRateRule.d_minTime = now;
147✔
419
  d_respRateRule.d_cutOff.tv_sec -= d_respRateRule.d_seconds;
147✔
420
  if (d_respRateRule.d_cutOff < responseCutOff) {
147✔
421
    responseCutOff = d_respRateRule.d_cutOff;
21✔
422
  }
21✔
423

424
  d_suffixMatchRule.d_cutOff = d_suffixMatchRule.d_minTime = now;
147✔
425
  d_suffixMatchRule.d_cutOff.tv_sec -= d_suffixMatchRule.d_seconds;
147✔
426
  if (d_suffixMatchRule.d_cutOff < responseCutOff) {
147✔
427
    responseCutOff = d_suffixMatchRule.d_cutOff;
6✔
428
  }
6✔
429

430
  d_respCacheMissRatioRule.d_cutOff = d_respCacheMissRatioRule.d_minTime = now;
147✔
431
  d_respCacheMissRatioRule.d_cutOff.tv_sec -= d_respCacheMissRatioRule.d_seconds;
147✔
432
  if (d_respCacheMissRatioRule.d_cutOff < responseCutOff) {
147✔
433
    responseCutOff = d_respCacheMissRatioRule.d_cutOff;
32✔
434
  }
32✔
435

436
  for (auto& rule : d_rcodeRules) {
147✔
437
    rule.second.d_cutOff = rule.second.d_minTime = now;
51✔
438
    rule.second.d_cutOff.tv_sec -= rule.second.d_seconds;
51✔
439
    if (rule.second.d_cutOff < responseCutOff) {
51!
440
      responseCutOff = rule.second.d_cutOff;
51✔
441
    }
51✔
442
  }
51✔
443

444
  for (auto& rule : d_rcodeRatioRules) {
147✔
445
    rule.second.d_cutOff = rule.second.d_minTime = now;
37✔
446
    rule.second.d_cutOff.tv_sec -= rule.second.d_seconds;
37✔
447
    if (rule.second.d_cutOff < responseCutOff) {
37!
448
      responseCutOff = rule.second.d_cutOff;
37✔
449
    }
37✔
450
  }
37✔
451

452
  for (const auto& shard : g_rings.d_shards) {
1,416✔
453
    auto responseRing = shard->respRing.lock();
1,416✔
454
    for (const auto& ringEntry : *responseRing) {
22,309✔
455
      if (now < ringEntry.when) {
22,309!
456
        continue;
×
457
      }
×
458

459
      if (ringEntry.when < responseCutOff) {
22,309✔
460
        continue;
11,677✔
461
      }
11,677✔
462

463
      auto& entry = counts[AddressAndPortRange(ringEntry.requestor, ringEntry.requestor.isIPv4() ? d_v4Mask : d_v6Mask, d_portMask)];
10,632!
464
      ++entry.responses;
10,632✔
465

466
      bool respRateMatches = d_respRateRule.matches(ringEntry.when);
10,632✔
467
      bool suffixMatchRuleMatches = d_suffixMatchRule.matches(ringEntry.when);
10,632✔
468
      bool rcodeRuleMatches = checkIfResponseCodeMatches(ringEntry);
10,632✔
469
      bool respCacheMissRatioRuleMatches = d_respCacheMissRatioRule.matches(ringEntry.when);
10,632✔
470

471
      if (respRateMatches) {
10,632✔
472
        entry.respBytes += ringEntry.size;
4,008✔
473
      }
4,008✔
474
      if (rcodeRuleMatches) {
10,632✔
475
        ++entry.d_rcodeCounts[ringEntry.dh.rcode];
2,294✔
476
      }
2,294✔
477
      if (respCacheMissRatioRuleMatches && !ringEntry.isACacheHit()) {
10,632✔
478
        ++entry.cacheMisses;
528✔
479
      }
528✔
480

481
      if (suffixMatchRuleMatches) {
10,632✔
482
        const bool hit = ringEntry.isACacheHit();
1,027✔
483
        root.submit(ringEntry.name, ((ringEntry.dh.rcode == 0 && ringEntry.usec == std::numeric_limits<unsigned int>::max()) ? -1 : ringEntry.dh.rcode), ringEntry.size, hit, std::nullopt);
1,027!
484
      }
1,027✔
485
    }
10,632✔
486
  }
1,416✔
487
}
147✔
488

489
void DynBlockMaintenance::purgeExpired(const struct timespec& now)
490
{
24✔
491
  // we need to increase the dynBlocked counter when removing
492
  // eBPF blocks, as otherwise it does not get incremented for these
493
  // since the block happens in kernel space.
494
  uint64_t bpfBlocked = 0;
24✔
495
  {
24✔
496
    auto blocks = s_dynblockNMG.getLocal();
24✔
497
    std::vector<AddressAndPortRange> toRemove;
24✔
498
    for (const auto& entry : *blocks) {
530✔
499
      if (!(now < entry.second.until)) {
523✔
500
        toRemove.push_back(entry.first);
514✔
501
        if (g_defaultBPFFilter && entry.second.bpf) {
514!
502
          const auto& network = entry.first.getNetwork();
2✔
503
          try {
2✔
504
            bpfBlocked += g_defaultBPFFilter->getHits(network);
2✔
505
          }
2✔
506
          catch (const std::exception& e) {
2✔
507
            vinfolog("Error while getting block count before removing eBPF dynamic block for %s: %s", entry.first.toString(), e.what());
×
508
          }
×
509
          try {
2✔
510
            g_defaultBPFFilter->unblock(network);
2✔
511
          }
2✔
512
          catch (const std::exception& e) {
2✔
513
            vinfolog("Error while removing eBPF dynamic block for %s: %s", entry.first.toString(), e.what());
×
514
          }
×
515
        }
2✔
516
      }
514✔
517
    }
523✔
518
    if (!toRemove.empty()) {
24✔
519
      auto updated = dnsdist::DynamicBlocks::getClientAddressDynamicRulesCopy();
4✔
520
      for (const auto& entry : toRemove) {
514✔
521
        updated.erase(entry);
514✔
522
      }
514✔
523
      s_dynblockNMG.setState(std::move(updated));
4✔
524
      dnsdist::metrics::g_stats.dynBlocked += bpfBlocked;
4✔
525
    }
4✔
526
  }
24✔
527

528
  {
24✔
529
    std::vector<DNSName> toRemove;
24✔
530
    auto blocks = s_dynblockSMT.getLocal();
24✔
531
    blocks->visit([&toRemove, now](const SuffixDynamicRules& node) {
1,042✔
532
      if (!(now < node.d_value.until)) {
1,024!
533
        toRemove.push_back(node.d_value.domain);
1,024✔
534
      }
1,024✔
535
    });
1,024✔
536
    if (!toRemove.empty()) {
24✔
537
      auto updated = dnsdist::DynamicBlocks::getSuffixDynamicRulesCopy();
4✔
538
      for (const auto& entry : toRemove) {
1,024✔
539
        updated.remove(entry);
1,024✔
540
      }
1,024✔
541
      s_dynblockSMT.setState(std::move(updated));
4✔
542
    }
4✔
543
  }
24✔
544
}
24✔
545

546
std::map<std::string, std::list<std::pair<AddressAndPortRange, unsigned int>>> DynBlockMaintenance::getTopNetmasks(size_t topN)
547
{
38✔
548
  std::map<std::string, std::list<std::pair<AddressAndPortRange, unsigned int>>> results;
38✔
549
  if (topN == 0) {
38!
550
    return results;
×
551
  }
×
552

553
  auto blocks = s_dynblockNMG.getLocal();
38✔
554
  for (const auto& entry : *blocks) {
548✔
555
    auto& topsForReason = results[entry.second.reason];
536✔
556
    uint64_t value = entry.second.blocks.load();
536✔
557

558
    if (g_defaultBPFFilter && entry.second.bpf) {
536!
559
      value += g_defaultBPFFilter->getHits(entry.first.getNetwork());
1✔
560
    }
1✔
561

562
    if (topsForReason.size() < topN || topsForReason.front().second < value) {
536!
563
      auto newEntry = std::pair(entry.first, value);
536✔
564

565
      if (topsForReason.size() >= topN) {
536✔
566
        topsForReason.pop_front();
472✔
567
      }
472✔
568

569
      topsForReason.insert(std::lower_bound(topsForReason.begin(), topsForReason.end(), newEntry, [](const std::pair<AddressAndPortRange, unsigned int>& rhs, const std::pair<AddressAndPortRange, unsigned int>& lhs) {
2,020✔
570
                             return rhs.second < lhs.second;
1,996✔
571
                           }),
1,996✔
572
                           newEntry);
536✔
573
    }
536✔
574
  }
536✔
575

576
  return results;
38✔
577
}
38✔
578

579
std::map<std::string, std::list<std::pair<DNSName, unsigned int>>> DynBlockMaintenance::getTopSuffixes(size_t topN)
580
{
40✔
581
  std::map<std::string, std::list<std::pair<DNSName, unsigned int>>> results;
40✔
582
  if (topN == 0) {
40!
583
    return results;
×
584
  }
×
585

586
  auto blocks = s_dynblockSMT.getLocal();
40✔
587
  blocks->visit([&results, topN](const SuffixDynamicRules& node) {
1,060✔
588
    auto& topsForReason = results[node.d_value.reason];
1,024✔
589
    if (topsForReason.size() < topN || topsForReason.front().second < node.d_value.blocks) {
1,024✔
590
      auto newEntry = std::pair(node.d_value.domain, node.d_value.blocks.load());
640✔
591

592
      if (topsForReason.size() >= topN) {
640✔
593
        topsForReason.pop_front();
560✔
594
      }
560✔
595

596
      topsForReason.insert(std::lower_bound(topsForReason.begin(), topsForReason.end(), newEntry, [](const std::pair<DNSName, unsigned int>& rhs, const std::pair<DNSName, unsigned int>& lhs) {
2,460✔
597
                             return rhs.second < lhs.second;
2,460✔
598
                           }),
2,460✔
599
                           newEntry);
640✔
600
    }
640✔
601
  });
1,024✔
602

603
  return results;
40✔
604
}
40✔
605

606
struct DynBlockEntryStat
607
{
608
  size_t sum{0};
609
  unsigned int lastSeenValue{0};
610
};
611

612
std::list<DynBlockMaintenance::MetricsSnapshot> DynBlockMaintenance::s_metricsData;
613

614
LockGuarded<DynBlockMaintenance::Tops> DynBlockMaintenance::s_tops;
615

616
void DynBlockMaintenance::collectMetrics()
617
{
36✔
618
  MetricsSnapshot snapshot;
36✔
619
  /* over sampling to get entries that are not in the top N
620
     every time a chance to be at the end */
621
  snapshot.smtData = getTopSuffixes(s_topN * 5);
36✔
622
  snapshot.nmgData = getTopNetmasks(s_topN * 5);
36✔
623

624
  if (s_metricsData.size() >= 7) {
36!
625
    s_metricsData.pop_front();
×
626
  }
×
627
  s_metricsData.push_back(std::move(snapshot));
36✔
628
}
36✔
629

630
void DynBlockMaintenance::generateMetrics()
631
{
×
632
  if (s_metricsData.empty()) {
×
633
    return;
×
634
  }
×
635

636
  /* do NMG */
637
  std::map<std::string, std::map<AddressAndPortRange, DynBlockEntryStat>> netmasks;
×
638
  for (const auto& reason : s_metricsData.front().nmgData) {
×
639
    auto& reasonStat = netmasks[reason.first];
×
640

641
    /* prepare the counters by scanning the oldest entry (N+1) */
642
    for (const auto& entry : reason.second) {
×
643
      auto& stat = reasonStat[entry.first];
×
644
      stat.sum = 0;
×
645
      stat.lastSeenValue = entry.second;
×
646
    }
×
647
  }
×
648

649
  /* scan all the N entries, updating the counters */
650
  bool first = true;
×
651
  for (const auto& snap : s_metricsData) {
×
652
    if (first) {
×
653
      first = false;
×
654
      continue;
×
655
    }
×
656

657
    const auto& nmgData = snap.nmgData;
×
658
    for (const auto& reason : nmgData) {
×
659
      auto& reasonStat = netmasks[reason.first];
×
660
      for (const auto& entry : reason.second) {
×
661
        auto& stat = reasonStat[entry.first];
×
662
        if (entry.second < stat.lastSeenValue) {
×
663
          /* it wrapped, or we did not have a last value */
664
          stat.sum += entry.second;
×
665
        }
×
666
        else {
×
667
          stat.sum += entry.second - stat.lastSeenValue;
×
668
        }
×
669
        stat.lastSeenValue = entry.second;
×
670
      }
×
671
    }
×
672
  }
×
673

674
  /* now we need to get the top N entries (for each "reason") based on our counters (sum of the last N entries) */
675
  std::map<std::string, std::list<std::pair<AddressAndPortRange, unsigned int>>> topNMGs;
×
676
  {
×
677
    for (const auto& reason : netmasks) {
×
678
      auto& topsForReason = topNMGs[reason.first];
×
679
      for (const auto& entry : reason.second) {
×
680
        if (topsForReason.size() < s_topN || topsForReason.front().second < entry.second.sum) {
×
681
          /* Note that this is a gauge, so we need to divide by the number of elapsed seconds */
682
          auto newEntry = std::pair<AddressAndPortRange, unsigned int>(entry.first, std::round(static_cast<double>(entry.second.sum) / 60.0));
×
683
          if (topsForReason.size() >= s_topN) {
×
684
            topsForReason.pop_front();
×
685
          }
×
686

687
          topsForReason.insert(std::lower_bound(topsForReason.begin(), topsForReason.end(), newEntry, [](const std::pair<AddressAndPortRange, unsigned int>& rhs, const std::pair<AddressAndPortRange, unsigned int>& lhs) {
×
688
                                 return rhs.second < lhs.second;
×
689
                               }),
×
690
                               newEntry);
×
691
        }
×
692
      }
×
693
    }
×
694
  }
×
695

696
  /* do SMT */
697
  std::map<std::string, std::map<DNSName, DynBlockEntryStat>> smt;
×
698
  for (const auto& reason : s_metricsData.front().smtData) {
×
699
    auto& reasonStat = smt[reason.first];
×
700

701
    /* prepare the counters by scanning the oldest entry (N+1) */
702
    for (const auto& entry : reason.second) {
×
703
      auto& stat = reasonStat[entry.first];
×
704
      stat.sum = 0;
×
705
      stat.lastSeenValue = entry.second;
×
706
    }
×
707
  }
×
708

709
  /* scan all the N entries, updating the counters */
710
  first = true;
×
711
  for (const auto& snap : s_metricsData) {
×
712
    if (first) {
×
713
      first = false;
×
714
      continue;
×
715
    }
×
716

717
    const auto& smtData = snap.smtData;
×
718
    for (const auto& reason : smtData) {
×
719
      auto& reasonStat = smt[reason.first];
×
720
      for (const auto& entry : reason.second) {
×
721
        auto& stat = reasonStat[entry.first];
×
722
        if (entry.second < stat.lastSeenValue) {
×
723
          /* it wrapped, or we did not have a last value */
724
          stat.sum = entry.second;
×
725
        }
×
726
        else {
×
727
          stat.sum = entry.second - stat.lastSeenValue;
×
728
        }
×
729
        stat.lastSeenValue = entry.second;
×
730
      }
×
731
    }
×
732
  }
×
733

734
  /* now we need to get the top N entries (for each "reason") based on our counters (sum of the last N entries) */
735
  std::map<std::string, std::list<std::pair<DNSName, unsigned int>>> topSMTs;
×
736
  {
×
737
    for (const auto& reason : smt) {
×
738
      auto& topsForReason = topSMTs[reason.first];
×
739
      for (const auto& entry : reason.second) {
×
740
        if (topsForReason.size() < s_topN || topsForReason.front().second < entry.second.sum) {
×
741
          /* Note that this is a gauge, so we need to divide by the number of elapsed seconds */
742
          auto newEntry = std::pair<DNSName, unsigned int>(entry.first, std::round(static_cast<double>(entry.second.sum) / 60.0));
×
743
          if (topsForReason.size() >= s_topN) {
×
744
            topsForReason.pop_front();
×
745
          }
×
746

747
          topsForReason.insert(std::lower_bound(topsForReason.begin(), topsForReason.end(), newEntry, [](const std::pair<DNSName, unsigned int>& lhs, const std::pair<DNSName, unsigned int>& rhs) {
×
748
                                 return lhs.second < rhs.second;
×
749
                               }),
×
750
                               newEntry);
×
751
        }
×
752
      }
×
753
    }
×
754
  }
×
755

756
  {
×
757
    auto tops = s_tops.lock();
×
758
    tops->topNMGsByReason = std::move(topNMGs);
×
759
    tops->topSMTsByReason = std::move(topSMTs);
×
760
  }
×
761
}
×
762

763
void DynBlockMaintenance::run()
764
{
400✔
765
  /* alright, so the main idea is to:
766
     1/ clean up the NMG and SMT from expired entries from time to time
767
     2/ generate metrics that can be used in the API and prometheus endpoints
768
  */
769

770
  static const time_t metricsCollectionInterval = 10;
400✔
771
  static const time_t metricsGenerationInterval = 60;
400✔
772

773
  time_t now = time(nullptr);
400✔
774
  auto purgeInterval = dnsdist::configuration::getCurrentRuntimeConfiguration().d_dynBlocksPurgeInterval;
400✔
775
  time_t nextExpiredPurge = now + static_cast<time_t>(purgeInterval);
400✔
776
  time_t nextMetricsCollect = now + static_cast<time_t>(metricsCollectionInterval);
400✔
777
  time_t nextMetricsGeneration = now + metricsGenerationInterval;
400✔
778

779
  while (true) {
453✔
780
    time_t sleepDelay = std::numeric_limits<time_t>::max();
453✔
781
    if (purgeInterval > 0) {
453!
782
      sleepDelay = std::min(sleepDelay, (nextExpiredPurge - now));
453✔
783
    }
453✔
784
    sleepDelay = std::min(sleepDelay, (nextMetricsCollect - now));
453✔
785
    sleepDelay = std::min(sleepDelay, (nextMetricsGeneration - now));
453✔
786

787
    // coverity[store_truncates_time_t]
788
    std::this_thread::sleep_for(std::chrono::seconds(sleepDelay));
453✔
789

790
    try {
453✔
791
      now = time(nullptr);
453✔
792
      if (now >= nextMetricsCollect) {
453✔
793
        /* every ten seconds we store the top N entries */
794
        collectMetrics();
36✔
795

796
        now = time(nullptr);
36✔
797
        nextMetricsCollect = now + metricsCollectionInterval;
36✔
798
      }
36✔
799

800
      if (now >= nextMetricsGeneration) {
453!
801
        generateMetrics();
×
802

803
        now = time(nullptr);
×
804
        /* every minute we compute the averaged top N entries of the last 60 seconds,
805
           and update the cached entry. */
806
        nextMetricsGeneration = now + metricsGenerationInterval;
×
807
      }
×
808

809
      purgeInterval = dnsdist::configuration::getCurrentRuntimeConfiguration().d_dynBlocksPurgeInterval;
453✔
810
      if (purgeInterval > 0 && now >= nextExpiredPurge) {
453✔
811
        timespec tspec{};
18✔
812
        gettime(&tspec);
18✔
813
        purgeExpired(tspec);
18✔
814

815
        now = time(nullptr);
18✔
816
        nextExpiredPurge = now + static_cast<time_t>(purgeInterval);
18✔
817
      }
18✔
818
    }
453✔
819
    catch (const std::exception& e) {
453✔
820
      warnlog("Error in the dynamic block maintenance thread: %s", e.what());
×
821
    }
×
822
    catch (...) {
453✔
823
      vinfolog("Unhandled error in the dynamic block maintenance thread");
×
824
    }
×
825
  }
453✔
826
}
400✔
827

828
std::map<std::string, std::list<std::pair<AddressAndPortRange, unsigned int>>> DynBlockMaintenance::getHitsForTopNetmasks()
829
{
2✔
830
  return s_tops.lock()->topNMGsByReason;
2✔
831
}
2✔
832

833
std::map<std::string, std::list<std::pair<DNSName, unsigned int>>> DynBlockMaintenance::getHitsForTopSuffixes()
834
{
2✔
835
  return s_tops.lock()->topSMTsByReason;
2✔
836
}
2✔
837

838
std::string DynBlockRulesGroup::DynBlockRule::toString() const
839
{
×
840
  if (!isEnabled()) {
×
841
    return "";
×
842
  }
×
843

844
  std::stringstream result;
×
845
  if (d_action != DNSAction::Action::None) {
×
846
    result << DNSAction::typeToString(d_action) << " ";
×
847
  }
×
848
  else {
×
849
    result << "Apply the global DynBlock action ";
×
850
  }
×
851
  result << "for " << std::to_string(d_blockDuration) << " seconds when over " << std::to_string(d_rate) << " during the last " << d_seconds << " seconds, reason: '" << d_blockReason << "'";
×
852

853
  return result.str();
×
854
}
×
855

856
bool DynBlockRulesGroup::DynBlockRule::matches(const struct timespec& when)
857
{
73,439✔
858
  if (!d_enabled) {
73,439✔
859
    return false;
29,178✔
860
  }
29,178✔
861

862
  if (d_seconds > 0 && when < d_cutOff) {
44,261!
863
    return false;
15,001✔
864
  }
15,001✔
865

866
  if (when < d_minTime) {
29,260✔
867
    d_minTime = when;
133✔
868
  }
133✔
869

870
  return true;
29,260✔
871
}
44,261✔
872

873
bool DynBlockRulesGroup::DynBlockRule::rateExceeded(unsigned int count, const struct timespec& now) const
874
{
847✔
875
  if (!d_enabled) {
847✔
876
    return false;
218✔
877
  }
218✔
878

879
  double delta = d_seconds > 0 ? d_seconds : DiffTime(now, d_minTime);
629!
880
  double limit = delta * d_rate;
629✔
881
  return (count > limit);
629✔
882
}
847✔
883

884
bool DynBlockRulesGroup::DynBlockRule::warningRateExceeded(unsigned int count, const struct timespec& now) const
885
{
847✔
886
  if (!d_enabled) {
847✔
887
    return false;
218✔
888
  }
218✔
889

890
  if (d_warningRate == 0) {
629✔
891
    return false;
609✔
892
  }
609✔
893

894
  double delta = d_seconds > 0 ? d_seconds : DiffTime(now, d_minTime);
20!
895
  double limit = delta * d_warningRate;
20✔
896
  return (count > limit);
20✔
897
}
629✔
898

899
bool DynBlockRulesGroup::DynBlockRatioRule::ratioExceeded(unsigned int total, unsigned int count) const
900
{
142✔
901
  if (!d_enabled) {
142✔
902
    return false;
102✔
903
  }
102✔
904

905
  if (total < d_minimumNumberOfResponses) {
40✔
906
    return false;
10✔
907
  }
10✔
908

909
  double allowed = d_ratio * static_cast<double>(total);
30✔
910
  return (count > allowed);
30✔
911
}
40✔
912

913
bool DynBlockRulesGroup::DynBlockRatioRule::warningRatioExceeded(unsigned int total, unsigned int count) const
914
{
142✔
915
  if (!d_enabled) {
142✔
916
    return false;
102✔
917
  }
102✔
918

919
  if (d_warningRatio == 0.0) {
40!
920
    return false;
40✔
921
  }
40✔
922

923
  if (total < d_minimumNumberOfResponses) {
×
924
    return false;
×
925
  }
×
926

927
  double allowed = d_warningRatio * static_cast<double>(total);
×
928
  return (count > allowed);
×
929
}
×
930

931
std::string DynBlockRulesGroup::DynBlockRatioRule::toString() const
932
{
×
933
  if (!isEnabled()) {
×
934
    return "";
×
935
  }
×
936

937
  std::stringstream result;
×
938
  if (d_action != DNSAction::Action::None) {
×
939
    result << DNSAction::typeToString(d_action) << " ";
×
940
  }
×
941
  else {
×
942
    result << "Apply the global DynBlock action ";
×
943
  }
×
944
  result << "for " << std::to_string(d_blockDuration) << " seconds when over " << std::to_string(d_ratio) << " ratio during the last " << d_seconds << " seconds, reason: '" << d_blockReason << "'";
×
945

946
  return result.str();
×
947
}
×
948

949
bool DynBlockRulesGroup::DynBlockCacheMissRatioRule::checkGlobalCacheHitRatio() const
950
{
12✔
951
  auto globalMisses = dnsdist::metrics::g_stats.cacheMisses.load();
12✔
952
  auto globalHits = dnsdist::metrics::g_stats.cacheHits.load();
12✔
953
  if (globalMisses == 0 || globalHits == 0) {
12!
954
    return false;
×
955
  }
×
956
  double globalCacheHitRatio = static_cast<double>(globalHits) / static_cast<double>(globalHits + globalMisses);
12✔
957
  return globalCacheHitRatio >= d_minimumGlobalCacheHitRatio;
12✔
958
}
12✔
959

960
bool DynBlockRulesGroup::DynBlockCacheMissRatioRule::ratioExceeded(unsigned int total, unsigned int count) const
961
{
124✔
962
  if (!DynBlockRulesGroup::DynBlockRatioRule::ratioExceeded(total, count)) {
124✔
963
    return false;
112✔
964
  }
112✔
965

966
  return checkGlobalCacheHitRatio();
12✔
967
}
124✔
968

969
bool DynBlockRulesGroup::DynBlockCacheMissRatioRule::warningRatioExceeded(unsigned int total, unsigned int count) const
970
{
124✔
971
  if (!DynBlockRulesGroup::DynBlockRatioRule::warningRatioExceeded(total, count)) {
124!
972
    return false;
124✔
973
  }
124✔
974

975
  return checkGlobalCacheHitRatio();
×
976
}
124✔
977

978
std::string DynBlockRulesGroup::DynBlockCacheMissRatioRule::toString() const
979
{
×
980
  if (!isEnabled()) {
×
981
    return "";
×
982
  }
×
983

984
  std::stringstream result;
×
985
  if (d_action != DNSAction::Action::None) {
×
986
    result << DNSAction::typeToString(d_action) << " ";
×
987
  }
×
988
  else {
×
989
    result << "Apply the global DynBlock action ";
×
990
  }
×
991
  result << "for " << std::to_string(d_blockDuration) << " seconds when over " << std::to_string(d_ratio) << " ratio during the last " << d_seconds << " seconds, with a global cache-hit ratio of at least " << d_minimumGlobalCacheHitRatio << ", reason: '" << d_blockReason << "'";
×
992

993
  return result.str();
×
994
}
×
995

996
namespace dnsdist::DynamicBlocks
997
{
998
const ClientAddressDynamicRules& getClientAddressDynamicRules()
999
{
7,092✔
1000
  static thread_local auto t_localRules = s_dynblockNMG.getLocal();
7,092✔
1001
  return *t_localRules;
7,092✔
1002
}
7,092✔
1003

1004
ClientAddressDynamicRules getClientAddressDynamicRulesCopy()
1005
{
126✔
1006
  return s_dynblockNMG.getCopy();
126✔
1007
}
126✔
1008

1009
const SuffixDynamicRules& getSuffixDynamicRules()
1010
{
7,127✔
1011
  static thread_local auto t_localRules = s_dynblockSMT.getLocal();
7,127✔
1012
  return *t_localRules;
7,127✔
1013
}
7,127✔
1014

1015
SuffixDynamicRules getSuffixDynamicRulesCopy()
1016
{
8✔
1017
  return s_dynblockSMT.getCopy();
8✔
1018
}
8✔
1019

1020
void setClientAddressDynamicRules(ClientAddressDynamicRules&& rules)
1021
{
98✔
1022
  s_dynblockNMG.setState(std::move(rules));
98✔
1023
}
98✔
1024

1025
void setSuffixDynamicRules(SuffixDynamicRules&& rules)
1026
{
4✔
1027
  s_dynblockSMT.setState(std::move(rules));
4✔
1028
}
4✔
1029

1030
void clearClientAddressDynamicRules()
1031
{
68✔
1032
  ClientAddressDynamicRules emptyNMG;
68✔
1033
  setClientAddressDynamicRules(std::move(emptyNMG));
68✔
1034
}
68✔
1035

1036
void clearSuffixDynamicRules()
1037
{
4✔
1038
  SuffixDynamicRules emptySMT;
4✔
1039
  setSuffixDynamicRules(std::move(emptySMT));
4✔
1040
}
4✔
1041

1042
LockGuarded<std::vector<std::shared_ptr<DynBlockRulesGroup>>> s_registeredDynamicBlockGroups;
1043

1044
void registerGroup(std::shared_ptr<DynBlockRulesGroup>& group)
1045
{
6✔
1046
  s_registeredDynamicBlockGroups.lock()->push_back(group);
6✔
1047
}
6✔
1048

1049
void runRegisteredGroups(LuaContext& luaCtx)
1050
{
1,241✔
1051
  // only used to make sure we hold the Lua context lock
1052
  (void)luaCtx;
1,241✔
1053
  timespec now{};
1,241✔
1054
  gettime(&now);
1,241✔
1055
  for (auto& group : *s_registeredDynamicBlockGroups.lock()) {
1,241✔
1056
    group->apply(now);
33✔
1057
  }
33✔
1058
}
1,241✔
1059

1060
}
1061

1062
#endif /* DISABLE_DYNBLOCKS */
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