Skip to content

Commit 99a1b48

Browse files
authored
Merge pull request #7 from crowdsecurity/feature/persist-cache-warm-up
Feature/persist cache warm up
2 parents b5d6e3a + f92bbc2 commit 99a1b48

File tree

8 files changed

+200
-116
lines changed

8 files changed

+200
-116
lines changed

src/ApiCache.php

Lines changed: 78 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class ApiCache
3535
private $apiClient;
3636

3737
/** @var bool */
38-
private $warmedUp = false;
38+
private $warmedUp;
3939

4040
public function __construct(ApiClient $apiClient = null, LoggerInterface $logger)
4141
{
@@ -46,14 +46,26 @@ public function __construct(ApiClient $apiClient = null, LoggerInterface $logger
4646
/**
4747
* Configure this instance.
4848
*/
49-
public function configure(AbstractAdapter $adapter, bool $liveMode, string $apiUrl, int $timeout, string $userAgent, string $token, int $cacheExpirationForCleanIp): void
50-
{
49+
public function configure(
50+
AbstractAdapter $adapter,
51+
bool $liveMode,
52+
string $apiUrl,
53+
int $timeout,
54+
string $userAgent,
55+
string $token,
56+
int $cacheExpirationForCleanIp
57+
): void {
5158
$this->adapter = $adapter;
5259
$this->liveMode = $liveMode;
5360
$this->cacheExpirationForCleanIp = $cacheExpirationForCleanIp;
54-
$this->logger->debug('Api Cache adapter: '.get_class($adapter));
55-
$this->logger->debug('Api Cache mode: '.($liveMode ? 'live' : 'stream'));
61+
$this->logger->debug('Api Cache adapter: ' . get_class($adapter));
62+
$this->logger->debug('Api Cache mode: ' . ($liveMode ? 'live' : 'stream'));
5663
$this->logger->debug("Api Cache expiration for clean ips: $cacheExpirationForCleanIp sec");
64+
$cacheConfigItem = $this->adapter->getItem('cacheConfig');
65+
$cacheConfig = $cacheConfigItem->get();
66+
$warmedUp = (is_array($cacheConfig) && isset($cacheConfig['warmed_up']) && $cacheConfig['warmed_up'] === true);
67+
$this->warmedUp = $warmedUp;
68+
$this->logger->debug("Api Cache already warmed up: " . ($this->warmedUp ? 'true' : 'false'));
5769

5870
$this->apiClient->configure($apiUrl, $timeout, $userAgent, $token);
5971
}
@@ -66,13 +78,15 @@ private function addRemediationToCacheItem(string $ip, string $type, int $expira
6678
$item = $this->adapter->getItem($ip);
6779

6880
// Merge with existing remediations (if any).
69-
$remediations = $item->get();
70-
$remediations = $remediations ?: [];
81+
$remediations = $item->isHit() ? $item->get() : [];
7182

7283
$index = array_search(Constants::REMEDIATION_BYPASS, array_column($remediations, 0));
7384
if (false !== $index) {
74-
$this->logger->debug("cache#$ip: Previously clean IP but now bad, remove the ".Constants::REMEDIATION_BYPASS." remediation immediately");
75-
unset($remediations[$index]);
85+
$this->logger->debug(
86+
"cache#$ip: Previously clean IP but now bad, remove the " .
87+
Constants::REMEDIATION_BYPASS . " remediation immediately"
88+
);
89+
unset($remediations[$index]);
7690
}
7791

7892
$remediations[] = [
@@ -84,45 +98,44 @@ private function addRemediationToCacheItem(string $ip, string $type, int $expira
8498
// Build the item lifetime in cache and sort remediations by priority
8599
$maxLifetime = max(array_column($remediations, 1));
86100
$prioritizedRemediations = Remediation::sortRemediationByPriority($remediations);
87-
88-
//$this->logger->debug("Decision $decisionId added to cache item $ip with lifetime $maxLifetime. Now it looks like:");
89-
//dump($prioritizedRemediations);
101+
90102
$item->set($prioritizedRemediations);
91103
$item->expiresAfter($maxLifetime);
92104

93105
// Save the cache without committing it to the cache system.
94106
// Useful to improve performance when updating the cache.
95107
if (!$this->adapter->saveDeferred($item)) {
96-
throw new BouncerException("cache#$ip: Unable to save this deferred item in cache: $type for $expiration sec, (decision $decisionId)");
108+
throw new BouncerException(
109+
"cache#$ip: Unable to save this deferred item in cache: " .
110+
"$type for $expiration sec, (decision $decisionId)"
111+
);
97112
}
98113
}
99114

100115
/**
101116
* Remove a decision from a Symfony Cache Item identified by ip
102117
*/
103-
private function removeDecisionFromRemediationItem(string $ip, int $decisionId): void
118+
private function removeDecisionFromRemediationItem(string $ip, int $decisionId): bool
104119
{
105120
//$this->logger->debug("Remove decision $decisionId from the cache item matching ip ".$ip);
106121
$item = $this->adapter->getItem($ip);
107122
$remediations = $item->get();
108-
//dump($remediations);
109123

110124
$index = false;
111125
if ($remediations) {
112126
$index = array_search($decisionId, array_column($remediations, 2));
113127
}
114-
128+
129+
// If decision was not found for this cache item early return.
115130
if (false === $index) {
116-
// TODO P3 this seems to be a bug from LAPI;-. Investigate.
117-
$this->logger->info("cache#$ip: decision $decisionId not found in cache.");
118-
return;
131+
return false;
119132
}
120133
unset($remediations[$index]);
121134

122135
if (!$remediations) {
123136
$this->logger->debug("cache#$ip: No more remediation for cache. Let's remove the cache item");
124137
$this->adapter->delete($ip);
125-
return;
138+
return true;
126139
}
127140
// Build the item lifetime in cache and sort remediations by priority
128141
$maxLifetime = max(array_column($remediations, 1));
@@ -136,6 +149,7 @@ private function removeDecisionFromRemediationItem(string $ip, int $decisionId):
136149
throw new BouncerException("cache#$ip: Unable to save item");
137150
}
138151
$this->logger->debug("cache#$ip: Decision $decisionId successfuly removed -deferred-");
152+
return true;
139153
}
140154

141155
/**
@@ -156,19 +170,19 @@ private static function parseDurationToSeconds(string $duration): int
156170
throw new BouncerException("Unable to parse the following duration: ${$duration}.");
157171
};
158172
$seconds = 0;
159-
if (null !== $matches[2]) {
173+
if (isset($matches[2])) {
160174
$seconds += ((int) $matches[1]) * 3600; // hours
161175
}
162-
if (null !== $matches[3]) {
176+
if (isset($matches[3])) {
163177
$seconds += ((int) $matches[2]) * 60; // minutes
164178
}
165-
if (null !== $matches[4]) {
179+
if (isset($matches[4])) {
166180
$seconds += ((int) $matches[1]); // seconds
167181
}
168-
if (null !== $matches[5]) { // units in milliseconds
182+
if (isset($matches[5])) { // units in milliseconds
169183
$seconds *= 0.001;
170184
}
171-
if (null !== $matches[1]) { // negative
185+
if (isset($matches[1])) { // negative
172186
$seconds *= -1;
173187
}
174188
$seconds = round($seconds);
@@ -197,6 +211,15 @@ private function formatRemediationFromDecision(?array $decision): array
197211
];
198212
}
199213

214+
private function defferUpdateCacheConfig(array $config): void
215+
{
216+
$cacheConfigItem = $this->adapter->getItem('cacheConfig');
217+
$cacheConfig = $cacheConfigItem->isHit() ? $cacheConfigItem->get() : [];
218+
$cacheConfig = array_replace_recursive($cacheConfig, $config);
219+
$cacheConfigItem->set($cacheConfig);
220+
$this->adapter->saveDeferred($cacheConfigItem);
221+
}
222+
200223
/**
201224
* Update the cached remediations from these new decisions.
202225
@@ -209,24 +232,40 @@ private function formatRemediationFromDecision(?array $decision): array
209232
private function saveRemediations(array $decisions): bool
210233
{
211234
foreach ($decisions as $decision) {
212-
$ipRange = array_map('long2ip', range($decision['start_ip'], $decision['end_ip']));
213-
$remediation = $this->formatRemediationFromDecision($decision);
214-
foreach ($ipRange as $ip) {
215-
$this->addRemediationToCacheItem($ip, $remediation[0], $remediation[1], $remediation[2]);
235+
if (is_int($decision['start_ip']) && is_int($decision['end_ip'])) {
236+
$ipRange = array_map('long2ip', range($decision['start_ip'], $decision['end_ip']));
237+
$remediation = $this->formatRemediationFromDecision($decision);
238+
foreach ($ipRange as $ip) {
239+
$this->addRemediationToCacheItem($ip, $remediation[0], $remediation[1], $remediation[2]);
240+
}
216241
}
217242
}
218243

219-
return $this->adapter->commit();
244+
$warmedUp = $this->adapter->commit();
245+
246+
// Store the fact that the cache has been warmed up.
247+
$this->defferUpdateCacheConfig(['warmed_up' => $warmedUp]);
248+
249+
return $warmedUp;
220250
}
221251

222252
private function removeRemediations(array $decisions): bool
223253
{
224254
foreach ($decisions as $decision) {
225-
$ipRange = array_map('long2ip', range($decision['start_ip'], $decision['end_ip']));
226-
$this->logger->debug('decision#'.$decision['id'].': remove for IPs '.join(', ', $ipRange));
227-
$remediation = $this->formatRemediationFromDecision($decision);
228-
foreach ($ipRange as $ip) {
229-
$this->removeDecisionFromRemediationItem($ip, $remediation[2]);
255+
if (is_int($decision['start_ip']) && is_int($decision['end_ip'])) {
256+
$ipRange = array_map('long2ip', range($decision['start_ip'], $decision['end_ip']));
257+
$this->logger->debug('decision#' . $decision['id'] . ': remove for IPs ' . join(', ', $ipRange));
258+
$success = true;
259+
foreach ($ipRange as $ip) {
260+
if (!$this->removeDecisionFromRemediationItem($ip, $decision['id'])) {
261+
$success = false;
262+
}
263+
}
264+
if (!$success) {
265+
// The API may return stale deletion events due to API design.
266+
// Ignoring them is therefore not a problem.
267+
$this->logger->debug("Decision " . $decision['id'] . " not found in cache for one or more items.");
268+
}
230269
}
231270
}
232271
return $this->adapter->commit();
@@ -261,7 +300,6 @@ public function warmUp(): void
261300
$this->logger->info('Warming the cache up');
262301
$startup = true;
263302
$decisionsDiff = $this->apiClient->getStreamedDecisions($startup);
264-
//dump($decisionsDiff);
265303
$newDecisions = $decisionsDiff['new'];
266304

267305
$this->adapter->clear();
@@ -288,7 +326,6 @@ public function pullUpdates(): void
288326
}
289327

290328
$decisionsDiff = $this->apiClient->getStreamedDecisions();
291-
//dump($decisionsDiff);
292329
$newDecisions = $decisionsDiff['new'];
293330
$deletedDecisions = $decisionsDiff['deleted'];
294331

@@ -346,9 +383,11 @@ private function hit(string $ip): string
346383
*/
347384
public function get(string $ip): string
348385
{
349-
$this->logger->debug('IP to check: '.$ip);
386+
$this->logger->debug('IP to check: ' . $ip);
350387
if (!$this->liveMode && !$this->warmedUp) {
351-
throw new BouncerException('CrowdSec Bouncer configured in "stream" mode. Please warm the cache up before trying to access it.');
388+
throw new BouncerException(
389+
'CrowdSec Bouncer configured in "stream" mode. Please warm the cache up before trying to access it.'
390+
);
352391
}
353392

354393
if ($this->adapter->hasItem($ip)) {
@@ -358,7 +397,5 @@ public function get(string $ip): string
358397
$this->logger->debug("Cache miss for IP: $ip");
359398
return $this->miss($ip);
360399
}
361-
362-
return $this->formatRemediationFromDecision(null)[0];
363400
}
364401
}

src/ApiClient.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,14 @@ class ApiClient
2929
public function __construct(LoggerInterface $logger)
3030
{
3131
$this->logger = $logger;
32+
$this->restClient = new RestClient($this->logger);
3233
}
3334

3435
/**
3536
* Configure this instance.
3637
*/
3738
public function configure(string $baseUri, int $timeout, string $userAgent, string $token): void
3839
{
39-
$this->restClient = new RestClient($this->logger);
4040
$this->restClient->configure($baseUri, [
4141
'User-Agent' => $userAgent,
4242
'X-Api-Key' => $token,

src/Bouncer.php

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
use Symfony\Component\Cache\Adapter\AbstractAdapter;
77
use Symfony\Component\Config\Definition\Processor;
88
use Psr\Log\LoggerInterface;
9-
use \Monolog\Logger;
9+
use Monolog\Logger;
1010

1111
/**
1212
* The main Class of this package. This is the first entry point of any PHP Bouncers using this library.
@@ -38,6 +38,7 @@ public function __construct(ApiCache $apiCache = null, LoggerInterface $logger =
3838
$loggger = new Logger('null');
3939
$loggger->pushHandler(new NullHandler());
4040
}
41+
/** @var LoggerInterface */
4142
$this->logger = $logger;
4243
$this->apiCache = $apiCache ?: new ApiCache(new ApiClient($logger), $logger);
4344
}
@@ -52,7 +53,12 @@ public function configure(array $config, AbstractAdapter $cacheAdapter): void
5253
$processor = new Processor();
5354
$this->config = $processor->processConfiguration($configuration, [$config]);
5455

55-
$this->maxRemediationLevelIndex = array_search($this->config['max_remediation_level'], Constants::ORDERED_REMEDIATIONS);
56+
/** @var int */
57+
$index = array_search(
58+
$this->config['max_remediation_level'],
59+
Constants::ORDERED_REMEDIATIONS
60+
);
61+
$this->maxRemediationLevelIndex = $index;
5662

5763
// Configure Api Cache.
5864
$this->apiCache->configure(
@@ -68,8 +74,8 @@ public function configure(array $config, AbstractAdapter $cacheAdapter): void
6874

6975
/**
7076
* Cap the remediation to a fixed value given in configuration
71-
*/
72-
private function capRemediationLevel($remediation): string
77+
*/
78+
private function capRemediationLevel(string $remediation): string
7379
{
7480
$currentIndex = array_search($remediation, Constants::ORDERED_REMEDIATIONS);
7581
if ($currentIndex < $this->maxRemediationLevelIndex) {
@@ -80,7 +86,8 @@ private function capRemediationLevel($remediation): string
8086

8187
/**
8288
* Get the remediation for the specified IP. This method use the cache layer.
83-
* In live mode, when no remediation was found in cache, the cache system will call the API to check if there is a decision.
89+
* In live mode, when no remediation was found in cache,
90+
* the cache system will call the API to check if there is a decision.
8491
*
8592
* @return string the remediation to apply (ex: 'ban', 'captcha', 'bypass')
8693
*/
@@ -100,7 +107,8 @@ public function getRemediationForIp(string $ip): string
100107
*/
101108
public function getDefault403Template(): string
102109
{
103-
return '<html><body><h1>Access forbidden.</h1><p>You have been blocked by CrowdSec. Please contact our technical support if you think it is an error.</p></body></html>';
110+
return '<html><body><h1>Access forbidden.</h1><p>You have been blocked by CrowdSec.' .
111+
'Please contact our technical support if you think it is an error.</p></body></html>';
104112
}
105113

106114
/**
@@ -127,7 +135,8 @@ public function refreshBlocklistCache(): void
127135
public function loadPaginatedBlocklistFromCache(int $page = 1, int $itemPerPage = 10): array
128136
{
129137
// TODO P3 Implement this.
130-
// TODO P3 Implement advanced filters, ex: sort_by=[], filters[type[], origin[], scope[], value[], ip_range[], duration_range[], scenario[], simulated]=null
138+
// TODO P3 Implement advanced filters, ex:
139+
// sort_by=[], filters[type[], origin[], scope[], value[], ip_range[], duration_range[], scenario[], simulated]
131140
return [];
132141
}
133142

src/Configuration.php

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
66
use Symfony\Component\Config\Definition\ConfigurationInterface;
7+
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
78

89
/**
910
* The Library configuration. You'll find here all configuration possible. Used when instanciating the library.
@@ -23,17 +24,22 @@ class Configuration implements ConfigurationInterface
2324
public function getConfigTreeBuilder()
2425
{
2526
$treeBuilder = new TreeBuilder('config');
27+
/** @var $rootNode ArrayNodeDefinition */
2628
$rootNode = $treeBuilder->getRootNode();
27-
/* @phpstan-ignore-next-line */
2829
$rootNode
2930
->children()
3031
->scalarNode('api_token')->isRequired()->end()
3132
->scalarNode('api_url')->defaultValue(Constants::CAPI_URL)->end()
3233
->scalarNode('api_user_agent')->defaultValue(Constants::BASE_USER_AGENT)->end()
3334
->integerNode('api_timeout')->defaultValue(Constants::API_TIMEOUT)->end()
3435
->booleanNode('live_mode')->defaultValue(true)->end()
35-
->enumNode('max_remediation_level')->values(Constants::ORDERED_REMEDIATIONS)->defaultValue(Constants::REMEDIATION_BAN)->end()
36-
->integerNode('cache_expiration_for_clean_ip')->defaultValue(Constants::CACHE_EXPIRATION_FOR_CLEAN_IP)->end()
36+
->enumNode('max_remediation_level')
37+
->values(Constants::ORDERED_REMEDIATIONS)
38+
->defaultValue(Constants::REMEDIATION_BAN)
39+
->end()
40+
->integerNode('cache_expiration_for_clean_ip')
41+
->defaultValue(Constants::CACHE_EXPIRATION_FOR_CLEAN_IP)
42+
->end()
3743
->end();
3844

3945
return $treeBuilder;

0 commit comments

Comments
 (0)