From f0dace5d7e6ef12da6f7129f2470355c0a0e8560 Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Thu, 26 Dec 2024 17:48:39 +0900 Subject: [PATCH] feat(metrics): Implement pushUsageMetrics method --- CHANGELOG.md | 3 +- composer.json | 2 +- docs/USER_GUIDE.md | 41 ++- src/AbstractRemediation.php | 10 +- src/CacheStorage/AbstractCache.php | 2 + src/CapiRemediation.php | 2 +- src/LapiRemediation.php | 167 +++++++++-- tests/Constants.php | 1 + tests/Unit/AbstractRemediation.php | 2 +- tests/Unit/AppSecLapiRemediationTest.php | 2 +- tests/Unit/CapiRemediationTest.php | 6 +- tests/Unit/LapiRemediationTest.php | 341 +++++++++++++++++++++- tests/scripts/clear-cache-lapi.php | 2 +- tests/scripts/get-remediation-lapi.php | 2 +- tests/scripts/push-lapi-usage-metrics.php | 60 ++++ 15 files changed, 595 insertions(+), 48 deletions(-) create mode 100644 tests/scripts/push-lapi-usage-metrics.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f6ae59c..1508c3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ As far as possible, we try to adhere to [Symfony guidelines](https://symfony.com ### Added +- Add `LapiRemediation::pushUsageMetrics` method to push usage metrics to LAPI - Add `bouncing_level` configuration to cap maximum remediation level - Add `AbstractRemediation::resetRemediationOriginCount` method to reset origin count cache item for a remediation @@ -29,7 +30,7 @@ As far as possible, we try to adhere to [Symfony guidelines](https://symfony.com - For `lists` origin, store also the list name (scenario) in origins count cache item - **Breaking change**: Rename `AbstractRemediation::updateRemediationOriginCount` method to - `incrementRemediationOriginCount` and add a `$remediation` parameter. + `updateRemediationOriginCount` with new `$remediation` and `$delta` parameters. - **Breaking change**: Store `clean` as origin in cache for `bypass` remediation even if original retrieved remediation was not a bypass (unhandled or capped remediation ). If `bypass` is the result of AppSec remediation, we continue to store `clean_appsec` as origin in cache. diff --git a/composer.json b/composer.json index 72dbfb1..799907a 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "symfony/cache": "^5.4.11|| ^6.0.11", "crowdsec/common": "^2.3.2", "crowdsec/capi-client": "^3.2.0", - "crowdsec/lapi-client": "^3.3.0", + "crowdsec/lapi-client": "^4.0.0", "monolog/monolog": "^1.17 || ^2.1", "mlocati/ip-lib": "^1.18", "geoip2/geoip2": "^2.13.0" diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 24c0ac5..d4800dd 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -67,11 +67,13 @@ This kind of action is called a remediation and can be: - Use the cached decisions for CAPI and for LAPI in stream mode - For LAPI in live mode, call LAPI if there is no cached decision - Use customizable remediation priorities - - Determine AppSec (LAPI) remediation for a given request + - Determine AppSec (LAPI) remediation for a given request + +- CrowdSec metrics + - Push usage metrics to LAPI - Overridable cache handler (built-in support for `Redis`, `Memcached` and `PhpFiles` caches) - - Large PHP matrix compatibility: from 7.2 to 8.4 @@ -341,6 +343,16 @@ The `$rawBody` parameter is optional and must be used if the forwarded request c Please see the [CrowdSec AppSec documentation](https://docs.crowdsec.net/docs/appsec/intro) for more details. +##### Push usage metrics to LAPI + +To push usage metrics to LAPI, you can do the following call: + +```php + $remediationEngine->pushUsageMetrics($bouncerName, $bouncerVersion, $bouncerType); +``` + +Metrics are retrieved from the cache and sent to LAPI. + #### Example scripts @@ -437,6 +449,20 @@ php tests/scripts/get-remediation-appsec.php +``` + +###### Example usage + +```bash + php tests/scripts/push-lapi-usage-metrics.php 68c2b479830c89bfd48926f9d764da39 https://crowdsec:8080 +``` + ## CAPI remediation engine configurations @@ -744,10 +770,11 @@ the origin. When the retrieved remediation is a `bypass` (i.e. no active decisio $originsCount = $remediation->getOriginsCount(); /*$originsCount = [ - 'appsec' => 6, - 'clean' => 150, - 'clean_appsec' => 2, - 'capi' => 28, - 'lists' => 16, + 'appsec' => ['ban' => 10], + 'clean' => ['bypass' =>150], + 'clean_appsec' => ['bypass' =>2], + 'CAPI' => ['ban' => 28], + 'cscli' => ['ban' => 5, 'captcha' => 3], + 'lists:tor' => ['custom' => 16], ]*/ ``` diff --git a/src/AbstractRemediation.php b/src/AbstractRemediation.php index d4f4219..e25007c 100644 --- a/src/AbstractRemediation.php +++ b/src/AbstractRemediation.php @@ -99,25 +99,27 @@ public function getOriginsCount(): array * @throws CacheException * @throws InvalidArgumentException */ - public function incrementRemediationOriginCount(string $origin, string $remediation): int + public function updateRemediationOriginCount(string $origin, string $remediation, int $delta = 1): int { $cacheOriginCount = $this->getOriginsCountItem(); $count = isset($cacheOriginCount[$origin][$remediation]) ? (int) $cacheOriginCount[$origin][$remediation] : 0; + $count += $delta; + $finalCount = max(0, $count); $this->cacheStorage->upsertItem( AbstractCache::ORIGINS_COUNT, [ $origin => [ - $remediation => ++$count, + $remediation => $finalCount, ], ], 0, [AbstractCache::ORIGINS_COUNT] ); - return $count; + return $finalCount; } /** @@ -311,7 +313,7 @@ protected function processCachedDecisions(array $cacheDecisions): string $remediation = !empty($remediationData[self::INDEX_REM]) ? (string) $remediationData[self::INDEX_REM] : Constants::REMEDIATION_BYPASS; if ($origin) { - $this->incrementRemediationOriginCount($origin, $remediation); + $this->updateRemediationOriginCount($origin, $remediation); } return $remediation; diff --git a/src/CacheStorage/AbstractCache.php b/src/CacheStorage/AbstractCache.php index 4698fee..832651c 100644 --- a/src/CacheStorage/AbstractCache.php +++ b/src/CacheStorage/AbstractCache.php @@ -42,6 +42,8 @@ abstract class AbstractCache public const INDEX_ORIGIN = 3; /** @var string The cache key prefix for a IPV4 range bucket */ public const IPV4_BUCKET_KEY = 'range_bucket_ipv4'; + /** @var string Internal name for last metrics sent timestamp */ + public const LAST_METRICS_SENT = 'last_metrics_sent'; /** @var string Internal name for last pull */ public const LAST_PULL = 'last_pull'; /** @var string Internal name for list */ diff --git a/src/CapiRemediation.php b/src/CapiRemediation.php index a620523..68233bd 100644 --- a/src/CapiRemediation.php +++ b/src/CapiRemediation.php @@ -56,7 +56,7 @@ public function getIpRemediation(string $ip): string 'ip' => $ip, ]); $remediation = Constants::REMEDIATION_BYPASS; - $this->incrementRemediationOriginCount(AbstractCache::CLEAN, $remediation); + $this->updateRemediationOriginCount(AbstractCache::CLEAN, $remediation); // As CAPI is always in stream_mode, we do not store this bypass return $remediation; diff --git a/src/LapiRemediation.php b/src/LapiRemediation.php index 2da146e..5ef1096 100644 --- a/src/LapiRemediation.php +++ b/src/LapiRemediation.php @@ -6,6 +6,7 @@ use CrowdSec\LapiClient\Bouncer; use CrowdSec\LapiClient\ClientException; +use CrowdSec\LapiClient\Constants as LapiConstants; use CrowdSec\LapiClient\TimeoutException; use CrowdSec\RemediationEngine\CacheStorage\AbstractCache; use CrowdSec\RemediationEngine\CacheStorage\CacheStorageException; @@ -86,7 +87,7 @@ public function getAppSecRemediation(array $headers, string $rawBody = ''): stri } $rawRemediation = $this->parseAppSecDecision($rawAppSecDecision); if (Constants::REMEDIATION_BYPASS === $rawRemediation) { - $this->incrementRemediationOriginCount(AbstractCache::CLEAN_APPSEC, $rawRemediation); + $this->updateRemediationOriginCount(AbstractCache::CLEAN_APPSEC, $rawRemediation); return $rawRemediation; } @@ -105,6 +106,13 @@ public function getClient(): Bouncer } /** + * Retrieve the remediation for a given IP. + * + * It will first check the cache for the IP decisions. + * If no decisions are found, it will call LAPI to get the decisions. + * The decisions are then stored in the cache. + * The remediation is then processed and returned. + * * @throws CacheStorageException * @throws InvalidArgumentException * @throws RemediationException @@ -123,12 +131,12 @@ public function getIpRemediation(string $ip): string // In stream_mode, we do not store this bypass, and we do not call LAPI directly if ($this->getConfig('stream_mode')) { $remediation = Constants::REMEDIATION_BYPASS; - $this->incrementRemediationOriginCount(AbstractCache::CLEAN, $remediation); + $this->updateRemediationOriginCount(AbstractCache::CLEAN, $remediation); return $remediation; } // In live mode, ask LAPI (Retrieve Ip AND Range scoped decisions) - $this->storeFirstCall(); + $this->storeFirstCall(time()); $rawIpDecisions = $this->client->getFilteredDecisions(['ip' => $ip]); $ipDecisions = $this->convertRawDecisionsToDecisions($rawIpDecisions); // IPV6 range scoped decisions are not yet stored in cache, so we store it as IP scoped decisions @@ -160,6 +168,74 @@ public function getIpRemediation(string $ip): string } /** + * Push usage metrics to LAPI. + * + * The metrics are built from the cache and then sent to LAPI. + * The cache is then updated to reflect the metrics sent. + * Returns the metrics items sent to LAPI. + * + * @throws CacheException + * @throws InvalidArgumentException + */ + public function pushUsageMetrics( + string $bouncerName, + string $bouncerVersion, + string $bouncerType = LapiConstants::METRICS_TYPE + ): array { + $cacheConfigItem = $this->cacheStorage->getItem(AbstractCache::CONFIG); + $cacheConfig = $cacheConfigItem->isHit() ? $cacheConfigItem->get() : []; + $start = $cacheConfig[AbstractCache::FIRST_LAPI_CALL] ?? 0; + $now = time(); + $lastSent = $cacheConfig[AbstractCache::LAST_METRICS_SENT] ?? $start; + + $originsCount = $this->getOriginsCount(); + $build = $this->buildMetricsItems($originsCount); + $metricsItems = $build['items'] ?? []; + $originsToUpdate = $build['origins'] ?? []; + if (empty($metricsItems)) { + $this->logger->info('No metrics to send', [ + 'type' => 'LAPI_REM_NO_METRICS', + ]); + + return []; + } + + $properties = [ + 'name' => $bouncerName, + 'type' => $bouncerType, + 'version' => $bouncerVersion, + 'utc_startup_timestamp' => $start, + ]; + $meta = [ + 'window_size_seconds' => max(0, $now - $lastSent), + 'utc_now_timestamp' => $now, + ]; + + $metrics = $this->client->buildUsageMetrics($properties, $meta, $metricsItems); + + $this->client->pushUsageMetrics($metrics); + + // Decrement the count of each origin/remediation + foreach ($originsToUpdate as $origin => $remediationCount) { + foreach ($remediationCount as $remediation => $delta) { + // We update the count of each origin/remediation, one by one + // because we want to handle the case where an origin/remediation/count has been updated + // between the time we get the count and the time we update it + $this->updateRemediationOriginCount($origin, $remediation, $delta); + } + } + + $this->storeMetricsLastSent($now); + + return $metrics; + } + + /** + * Refresh the decisions from LAPI. + * + * This method is only available in stream mode. + * Depending on the warmup status, it will either process a startup or a regular refresh. + * * @SuppressWarnings(PHPMD.BooleanArgumentFlag) * * @throws CacheException @@ -189,6 +265,46 @@ public function refreshDecisions(): array return $this->getStreamDecisions(false, $filter); } + private function buildMetricsItems(array $originsCount): array + { + $metricsItems = []; + $processed = 0; + $originsToUpdate = []; + foreach ($originsCount as $origin => $remediationCount) { + foreach ($remediationCount as $remediation => $count) { + if ($count <= 0) { + continue; + } + // Count all processed metrics, even clean ones + $processed += $count; + // Prepare data to update origins count item after processing + $originsToUpdate[$origin][$remediation] = -$count; + if (Constants::REMEDIATION_BYPASS === $remediation) { + continue; + } + // Create "dropped" metrics + $metricsItems[] = [ + 'name' => 'dropped', + 'value' => $count, + 'unit' => 'request', + 'labels' => [ + 'origin' => $origin, + 'remediation' => $remediation, + ], + ]; + } + } + if ($processed > 0) { + $metricsItems[] = [ + 'name' => 'processed', + 'value' => $processed, + 'unit' => 'request', + ]; + } + + return ['items' => $metricsItems, 'origins' => $originsToUpdate]; + } + /** * Process and validate input configurations. */ @@ -235,6 +351,7 @@ private function getScopes(): array */ private function getStreamDecisions(bool $startup = false, array $filter = []): array { + $this->storeFirstCall(time()); $rawDecisions = $this->client->getStreamDecisions($startup, $filter); $newDecisions = $this->convertRawDecisionsToDecisions($rawDecisions[self::CS_NEW] ?? []); $deletedDecisions = $this->convertRawDecisionsToDecisions($rawDecisions[self::CS_DEL] ?? []); @@ -299,19 +416,41 @@ private function parseAppSecDecision(array $rawAppSecDecision): string * @throws CacheException * @throws InvalidArgumentException */ - private function storeFirstCall(): void + private function storeFirstCall(int $timestamp): void { $firstCall = $this->getFirstCall(); if (0 !== $firstCall) { return; } - $time = time(); - $content = [AbstractCache::FIRST_LAPI_CALL => $time]; + $content = [AbstractCache::FIRST_LAPI_CALL => $timestamp]; $this->logger->info( 'Flag LAPI first call', [ 'type' => 'LAPI_REM_CACHE_FIRST_CALL', - 'time' => $time, + 'time' => $timestamp, + ] + ); + + $this->cacheStorage->upsertItem( + AbstractCache::CONFIG, + $content, + 0, + [AbstractCache::CONFIG] + ); + } + + /** + * @throws CacheException + * @throws InvalidArgumentException + */ + private function storeMetricsLastSent(int $timestamp): void + { + $content = [AbstractCache::LAST_METRICS_SENT => $timestamp]; + $this->logger->debug( + 'Flag metrics last sent', + [ + 'type' => 'LAPI_REM_CACHE_METRICS_LAST_SENT', + 'time' => $timestamp, ] ); @@ -372,21 +511,9 @@ private function warmUp(array $filter): array $result = $this->getStreamDecisions(true, $filter); // Store the fact that the cache has been warmed up. $this->logger->info('Flag cache warmup', ['type' => 'LAPI_REM_CACHE_WARMUP']); - $content = [AbstractCache::WARMUP => true]; - if (0 === $this->getFirstCall()) { - $time = time(); - $content[AbstractCache::FIRST_LAPI_CALL] = $time; - $this->logger->info( - 'Flag LAPI first call', - [ - 'type' => 'LAPI_REM_CACHE_FIRST_CALL', - 'time' => $time, - ] - ); - } $this->cacheStorage->upsertItem( AbstractCache::CONFIG, - $content, + [AbstractCache::WARMUP => true], 0, [AbstractCache::CONFIG] ); diff --git a/tests/Constants.php b/tests/Constants.php index 12eaab3..b3237a1 100644 --- a/tests/Constants.php +++ b/tests/Constants.php @@ -24,6 +24,7 @@ class Constants public const IP_V4_2 = '5.6.7.8'; public const IP_V4_3 = '9.10.11.12'; public const IP_V4_4 = '12.13.14.15'; + public const IP_V4_5 = '16.17.18.19'; public const IP_V4_2_CACHE_KEY = RemConstants::SCOPE_IP . AbstractCache::SEP . self::IP_V4_2; /* * 66051 = intdiv(ip2long(IP_V4),256) diff --git a/tests/Unit/AbstractRemediation.php b/tests/Unit/AbstractRemediation.php index a8a82ae..842039f 100644 --- a/tests/Unit/AbstractRemediation.php +++ b/tests/Unit/AbstractRemediation.php @@ -61,7 +61,7 @@ protected function getBouncerMock() { return $this->getMockBuilder('CrowdSec\LapiClient\Bouncer') ->disableOriginalConstructor() - ->onlyMethods(['getStreamDecisions', 'getFilteredDecisions', 'getAppSecDecision']) + ->onlyMethods(['getStreamDecisions', 'getFilteredDecisions', 'getAppSecDecision', 'pushUsageMetrics']) ->getMock(); } diff --git a/tests/Unit/AppSecLapiRemediationTest.php b/tests/Unit/AppSecLapiRemediationTest.php index 60ecdc1..de54f4f 100644 --- a/tests/Unit/AppSecLapiRemediationTest.php +++ b/tests/Unit/AppSecLapiRemediationTest.php @@ -66,7 +66,7 @@ * @uses \CrowdSec\RemediationEngine\AbstractRemediation::capRemediationLevel * @uses \CrowdSec\RemediationEngine\AbstractRemediation::getOriginsCountItem * - * @covers \CrowdSec\RemediationEngine\AbstractRemediation::incrementRemediationOriginCount + * @covers \CrowdSec\RemediationEngine\AbstractRemediation::updateRemediationOriginCount * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getCacheStorage * @covers \CrowdSec\RemediationEngine\LapiRemediation::handleIpV6RangeDecisions * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getIpType diff --git a/tests/Unit/CapiRemediationTest.php b/tests/Unit/CapiRemediationTest.php index b4832bc..38d0bed 100644 --- a/tests/Unit/CapiRemediationTest.php +++ b/tests/Unit/CapiRemediationTest.php @@ -54,8 +54,7 @@ * @uses \CrowdSec\RemediationEngine\Configuration\AbstractRemediation::addGeolocationNodes * @uses \CrowdSec\RemediationEngine\AbstractRemediation::getCountryForIp * @uses \CrowdSec\RemediationEngine\Configuration\AbstractCache::addCommonNodes - * - * @uses \CrowdSec\RemediationEngine\AbstractRemediation::incrementRemediationOriginCount + * @uses \CrowdSec\RemediationEngine\AbstractRemediation::updateRemediationOriginCount * * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getCacheStorage * @@ -124,9 +123,8 @@ * @covers \CrowdSec\RemediationEngine\AbstractRemediation::retrieveRemediationFromCachedDecisions * @covers \CrowdSec\RemediationEngine\AbstractRemediation::sortDecisionsByPriority * @covers \CrowdSec\RemediationEngine\AbstractRemediation::capRemediationLevel - * @uses \CrowdSec\RemediationEngine\AbstractRemediation::getOriginsCountItem - * * + * @uses \CrowdSec\RemediationEngine\AbstractRemediation::getOriginsCountItem */ final class CapiRemediationTest extends AbstractRemediation { diff --git a/tests/Unit/LapiRemediationTest.php b/tests/Unit/LapiRemediationTest.php index d5be1e5..7ae0ea8 100644 --- a/tests/Unit/LapiRemediationTest.php +++ b/tests/Unit/LapiRemediationTest.php @@ -66,13 +66,13 @@ * @covers \CrowdSec\RemediationEngine\AbstractRemediation::resetRemediationOriginCount * * @uses \CrowdSec\RemediationEngine\AbstractRemediation::sortDecisionsByPriority + * * @covers \CrowdSec\RemediationEngine\AbstractRemediation::capRemediationLevel * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getOriginsCountItem * @covers \CrowdSec\RemediationEngine\LapiRemediation::getFirstCall * @covers \CrowdSec\RemediationEngine\LapiRemediation::storeFirstCall - * * @covers \CrowdSec\RemediationEngine\AbstractRemediation::handleDecisionOrigin - * @covers \CrowdSec\RemediationEngine\AbstractRemediation::incrementRemediationOriginCount + * @covers \CrowdSec\RemediationEngine\AbstractRemediation::updateRemediationOriginCount * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getCacheStorage * @covers \CrowdSec\RemediationEngine\LapiRemediation::handleIpV6RangeDecisions * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getIpType @@ -134,6 +134,12 @@ * @covers \CrowdSec\RemediationEngine\Configuration\Lapi::validateAppSec * @covers \CrowdSec\RemediationEngine\AbstractRemediation::processCachedDecisions * @covers \CrowdSec\RemediationEngine\AbstractRemediation::retrieveRemediationFromCachedDecisions + * + * @covers \CrowdSec\RemediationEngine\LapiRemediation::buildMetricsItems + * @covers \CrowdSec\RemediationEngine\LapiRemediation::pushUsageMetrics + * @covers \CrowdSec\RemediationEngine\LapiRemediation::storeMetricsLastSent + * + * */ final class LapiRemediationTest extends AbstractRemediation { @@ -695,6 +701,332 @@ public function testGetIpRemediationInLiveMode($cacheType) $originsCount, 'Origin count should be updated' ); + } + + /** + * @dataProvider cacheTypeProvider + */ + public function testPushUsageMetricsInLiveMode($cacheType) + { + $this->setCache($cacheType); + $remediationConfigs = ['stream_mode' => false]; + // Prepare next tests + $currentTime = time(); + $this->cacheStorage->method('retrieveDecisionsForIp')->will( + // We simulate that cache never contains any decision + $this->onConsecutiveCalls( + [AbstractCache::STORED => []], // Test 1 / Call1 : retrieve empty IP decisions + [AbstractCache::STORED => []], // Test 1 / Call1 : retrieve empty range decisions + [AbstractCache::STORED => []], // Test 1 / Call2 : retrieve empty IP decisions + [AbstractCache::STORED => []], // Test 1 / Call2 : retrieve empty range decisions + [AbstractCache::STORED => []], // Test 1 / Call3 : retrieve empty IP decisions + [AbstractCache::STORED => []], // Test 1 / Call3 : retrieve empty range decisions + [AbstractCache::STORED => []], // Test 2 / Call1 : retrieve empty IP decisions + [AbstractCache::STORED => []], // Test 2 / Call1 : retrieve empty range decisions + [AbstractCache::STORED => []], // Test 3 / Call1 : retrieve empty IP decisions + [AbstractCache::STORED => []], // Test 3 / Call1 : retrieve empty range decisions + [AbstractCache::STORED => []], // Test 3 / Call2 : retrieve empty IP decisions + [AbstractCache::STORED => []] // Test 3 / Call2 : retrieve empty range decisions + ) + ); + $this->bouncer->method('getFilteredDecisions')->will( + $this->onConsecutiveCalls( + [], // Test 1 / Call1 : retrieve empty IP decisions (final metrics count will be a bypass) + [ + [ + 'scope' => 'ip', + 'value' => TestConstants::IP_V4, + 'type' => 'captcha', + 'origin' => 'cscli', + 'duration' => '1h', + ], + [ + 'scope' => 'ip', + 'value' => TestConstants::IP_V4, + 'type' => 'ban', + 'origin' => 'CAPI', + 'duration' => '1h', + ], // Test 1 / Call2 : retrieve ban and captcha (final metrics count will be a ban from CAPI) + ], + [ + [ + 'scope' => 'ip', + 'value' => TestConstants::IP_V4_2, + 'type' => 'captcha', + 'origin' => 'lists:tor', + 'duration' => '1h', + ], // Test 1 / Call3 : retrieve captcha (final metrics count will be a captcha from lists-tor) + ], + [ + [ + 'scope' => 'ip', + 'value' => TestConstants::IP_V4_3, + 'type' => 'captcha', + 'origin' => 'lists:tor', + 'duration' => '1h', + ], // Test 2 / Call1 : retrieve captcha (final metrics count will be a captcha from lists-tor) + ], + [ + [ + 'scope' => 'ip', + 'value' => TestConstants::IP_V4_4, + 'type' => 'captcha', + 'origin' => 'lists:tor', + 'duration' => '1h', + ], // Test 3 / Call1 : retrieve captcha (final metrics count will be a captcha from lists-tor) + ], + [ + [ + 'scope' => 'ip', + 'value' => TestConstants::IP_V4_5, + 'type' => 'captcha', + 'origin' => 'lists:tor', + 'duration' => '1h', + ], // Test 3 / Call2 : retrieve captcha (final metrics count will be a captcha from lists-tor) + ] + ) + ); + // Test 1 : push metrics + $remediation = new LapiRemediation($remediationConfigs, $this->bouncer, $this->cacheStorage, null); + // Call 1 + $remediation->getIpRemediation(TestConstants::IP_V4); + $item = $this->cacheStorage->getItem(AbstractCache::CONFIG); + $configItem = $item->get(); + $this->assertEqualsWithDelta( + [ + AbstractCache::FIRST_LAPI_CALL => $currentTime, + ], + $configItem, + 1, // 1 second delta to avoid false negative + 'First call should have been cached' + ); + $originalFirstCall = $configItem[AbstractCache::FIRST_LAPI_CALL]; + $this->assertArrayNotHasKey( + AbstractCache::LAST_METRICS_SENT, + $configItem, + 'Last sent Usage metrics should not be cached'); + // Call 2 + $remediation->getIpRemediation(TestConstants::IP_V4); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + ['clean' => ['bypass' => 1], 'CAPI' => ['ban' => 1]], + $originsCount, + 'Origin count should be cached' + ); + // Call 3 + $remediation->getIpRemediation(TestConstants::IP_V4_2); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + [ + 'clean' => ['bypass' => 1], + 'CAPI' => ['ban' => 1], + 'lists:tor' => ['captcha' => 1], + ], + $originsCount, + 'Origin count should be cached' + ); + + $result = $remediation->pushUsageMetrics('test-remediation-php-unit', 'v0.0.0', 'crowdsec-php-bouncer-unit-test'); + $this->assertArrayHasKey('remediation_components', $result, 'Should return a remediation_components key'); + + $firstPushTime = time(); + $item = $this->cacheStorage->getItem(AbstractCache::CONFIG); + $configItem = $item->get(); + $this->assertEqualsWithDelta( + [ + AbstractCache::LAST_METRICS_SENT => $firstPushTime, + AbstractCache::FIRST_LAPI_CALL => $originalFirstCall, + ], + $configItem, + 1, // 1 second delta to avoid false negative + 'Last sent should have been cached' + ); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + [ + 'clean' => ['bypass' => 0], + 'CAPI' => ['ban' => 0], + 'lists:tor' => ['captcha' => 0], + ], + $originsCount, + 'Origin count should be reset' + ); + + // Test 2 : push metrics again after some delay + // Call 1 + sleep(1); + $remediation->getIpRemediation(TestConstants::IP_V4_3); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + [ + 'clean' => ['bypass' => 0], + 'CAPI' => ['ban' => 0], + 'lists:tor' => ['captcha' => 1], + ], + $originsCount, + 'Origin count should be updated' + ); + $secondPushTime = time(); + $result = $remediation->pushUsageMetrics('test-remediation-php-unit', 'v0.0.0', 'crowdsec-php-bouncer-unit-test'); + $item = $this->cacheStorage->getItem(AbstractCache::CONFIG); + $configItem = $item->get(); + $this->assertEqualsWithDelta( + [ + AbstractCache::LAST_METRICS_SENT => $secondPushTime, + AbstractCache::FIRST_LAPI_CALL => $originalFirstCall, + ], + $configItem, + 1, // 1 second delta to avoid false negative + 'Last sent should have been cached' + ); + $this->assertEqualsWithDelta( + 1, + $result['remediation_components'][0]['metrics'][0]['meta']['window_size_seconds'], + 1, // 1s to avoid false negative + 'window_size_seconds should be 1 seconds' + ); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + [ + 'clean' => ['bypass' => 0], + 'CAPI' => ['ban' => 0], + 'lists:tor' => ['captcha' => 0], + ], + $originsCount, + 'Origin count should be reset' + ); + // Test 3 : push metrics and concurrent getRemediationIp call + $remediation->getIpRemediation(TestConstants::IP_V4_4); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + [ + 'clean' => ['bypass' => 0], + 'CAPI' => ['ban' => 0], + 'lists:tor' => ['captcha' => 1], + ], + $originsCount, + 'Origin count should be updated' + ); + $thirdPushTime = time(); + // Trying to test simultaneous call + $result = $remediation->pushUsageMetrics('test-remediation-php-unit', 'v0.0.0', 'crowdsec-php-bouncer-unit-test'); + $remediation->getIpRemediation(TestConstants::IP_V4_5); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + [ + 'clean' => ['bypass' => 0], + 'CAPI' => ['ban' => 0], + 'lists:tor' => ['captcha' => 1], + ], + $originsCount, + 'Origin count should be updated with -1 + 1 (i.e same as before)' + ); + $item = $this->cacheStorage->getItem(AbstractCache::CONFIG); + $configItem = $item->get(); + $this->assertEqualsWithDelta( + [ + AbstractCache::LAST_METRICS_SENT => $thirdPushTime, + AbstractCache::FIRST_LAPI_CALL => $originalFirstCall, + ], + $configItem, + 1, // 1 second delta to avoid false negative + 'Last sent should have been cached' + ); + } + + /** + * @dataProvider cacheTypeProvider + */ + public function testPushUsageMetricsInStreamMode($cacheType) + { + $this->setCache($cacheType); + $remediationConfigs = ['stream_mode' => true]; + // Prepare next tests + $currentTime = time(); + $this->cacheStorage->method('retrieveDecisionsForIp')->will( + $this->onConsecutiveCalls( + [AbstractCache::STORED => [[ + 'bypass', + 999999999999, + 'clean-bypass-ip-' . TestConstants::IP_V4, + 'clean', + ]]], // Test 1/Call 1 : retrieve cached bypass + [AbstractCache::STORED => []], // Test 1/Call 1 : retrieve empty range + [AbstractCache::STORED => [[ + 'bypass', + 999999999999, + 'clean-bypass-ip-' . TestConstants::IP_V4, + 'clean', + ]]], // Test 1/Call 2 : retrieve cached bypass + [AbstractCache::STORED => []] // Test 1/Call 2 : retrieve empty range + ) + ); + $this->bouncer->method('getStreamDecisions')->will( + $this->onConsecutiveCalls( + MockedData::DECISIONS['new_ip_v4'] // Test 1 : new IP decision (ban) + ) + ); + + // Test 1 : push metrics + $remediation = new LapiRemediation($remediationConfigs, $this->bouncer, $this->cacheStorage, null); + // Call 1 + $remediation->refreshDecisions(); + $remediation->getIpRemediation(TestConstants::IP_V4); + $item = $this->cacheStorage->getItem(AbstractCache::CONFIG); + $configItem = $item->get(); + $this->assertEqualsWithDelta( + [ + AbstractCache::FIRST_LAPI_CALL => $currentTime, + AbstractCache::WARMUP => true, + ], + $configItem, + 1, // 1 second delta to avoid false negative + 'First call should have been cached' + ); + $originalFirstCall = $configItem[AbstractCache::FIRST_LAPI_CALL]; + $this->assertArrayNotHasKey( + AbstractCache::LAST_METRICS_SENT, + $configItem, + 'Last sent Usage metrics should not be cached'); + // Call 2 + $remediation->getIpRemediation(TestConstants::IP_V4); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + ['clean' => ['bypass' => 2]], + $originsCount, + 'Origin count should be cached' + ); + $result = $remediation->pushUsageMetrics('test-remediation-php-unit', 'v0.0.0', 'crowdsec-php-bouncer-unit-test'); + $this->assertArrayHasKey('remediation_components', $result, 'Should return a remediation_components key'); + + $firstPushTime = time(); + $item = $this->cacheStorage->getItem(AbstractCache::CONFIG); + $configItem = $item->get(); + $this->assertEqualsWithDelta( + [ + AbstractCache::LAST_METRICS_SENT => $firstPushTime, + AbstractCache::FIRST_LAPI_CALL => $originalFirstCall, + AbstractCache::WARMUP => true, + ], + $configItem, + 1, // 1 second delta to avoid false negative + 'Last sent should have been cached' + ); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + [ + 'clean' => ['bypass' => 0], + ], + $originsCount, + 'Origin count should be reset' + ); + // Test 2: nothing to send + $result = $remediation->pushUsageMetrics('test-remediation-php-unit', 'v0.0.0', 'crowdsec-php-bouncer-unit-test'); + $this->assertEquals( + [], + $result, + 'Should return an empty array' + ); } @@ -790,7 +1122,6 @@ public function testResetRemediationOriginCount($cacheType) ); } - /** * @dataProvider cacheTypeProvider */ @@ -1548,8 +1879,7 @@ public function testPrivateOrProtectedMethods() ); $this->assertEquals('bypass', $result, 'Remediation should be capped as bypass'); - $remediationConfigs = ['bouncing_level' => - Constants::BOUNCING_LEVEL_FLEX]; + $remediationConfigs = ['bouncing_level' => Constants::BOUNCING_LEVEL_FLEX]; $remediation = new LapiRemediation($remediationConfigs, $this->bouncer, $this->cacheStorage, $this->logger); $result = PHPUnitUtil::callMethod( @@ -1558,7 +1888,6 @@ public function testPrivateOrProtectedMethods() ['ban'] ); $this->assertEquals('captcha', $result, 'Remediation should be capped as captcha'); - } protected function tearDown(): void diff --git a/tests/scripts/clear-cache-lapi.php b/tests/scripts/clear-cache-lapi.php index 39b6e1b..e276ea6 100644 --- a/tests/scripts/clear-cache-lapi.php +++ b/tests/scripts/clear-cache-lapi.php @@ -17,7 +17,7 @@ . \PHP_EOL); } // Init logger -$logger = new FileLog(['debug_mode' => true], 'remediation-engine-logger'); +$logger = new FileLog(['debug_mode' => true, 'log_directory_path' => __DIR__ . '/.logs'], 'remediation-engine-logger'); // Init client $clientConfigs = [ 'auth_type' => 'api_key', diff --git a/tests/scripts/get-remediation-lapi.php b/tests/scripts/get-remediation-lapi.php index 06593bd..8ed2885 100644 --- a/tests/scripts/get-remediation-lapi.php +++ b/tests/scripts/get-remediation-lapi.php @@ -28,7 +28,7 @@ } // Init logger -$logger = new FileLog(['debug_mode' => true], 'remediation-engine-logger'); +$logger = new FileLog(['debug_mode' => true, 'log_directory_path' => __DIR__ . '/.logs'], 'remediation-engine-logger'); // Init client $clientConfigs = [ 'auth_type' => 'api_key', diff --git a/tests/scripts/push-lapi-usage-metrics.php b/tests/scripts/push-lapi-usage-metrics.php new file mode 100644 index 0000000..5f61d4e --- /dev/null +++ b/tests/scripts/push-lapi-usage-metrics.php @@ -0,0 +1,60 @@ + ' . \PHP_EOL . + 'Example: php push-lapi-usage-metrics.php c580ebdff45da6e01415ed0e9bc9c06b https://crowdsec:8080' . + \PHP_EOL + ); +} +$bouncerKey = $argv[2] ?? false; +$lapiUrl = $argv[3] ?? false; +if (!$bouncerKey || !$lapiUrl) { + exit('Params and are required' . \PHP_EOL + . 'Usage: php push-lapi-usage-metrics.php ' + . \PHP_EOL); +} + +// Init logger +$logger = new FileLog(['debug_mode' => true, 'log_directory_path' => __DIR__ . '/.logs'], 'remediation-engine-logger'); +// Init client +$clientConfigs = [ + 'auth_type' => 'api_key', + 'api_url' => $lapiUrl, + 'api_key' => $bouncerKey, +]; +$lapiClient = new Bouncer($clientConfigs, null, $logger); + +// Init PhpFiles cache storage +$cacheFileConfigs = [ + 'fs_cache_path' => __DIR__ . '/.cache/lapi', +]; +$phpFileCache = new PhpFiles($cacheFileConfigs, $logger); +// Init Memcached cache storage +$cacheMemcachedConfigs = [ + 'memcached_dsn' => 'memcached://memcached:11211', +]; +$memcachedCache = new Memcached($cacheMemcachedConfigs, $logger); +// Init Redis cache storage +$cacheRedisConfigs = [ + 'redis_dsn' => 'redis://redis:6379', +]; +$redisCache = new Redis($cacheRedisConfigs, $logger); +// Init LAPI remediation +$remediationConfigs = []; +$remediationEngine = new LapiRemediation($remediationConfigs, $lapiClient, $phpFileCache, $logger); + +// Send usage metrics +$sentMetrics = $remediationEngine->pushUsageMetrics('test-remediation-php', 'v0.0.0', 'crowdsec-php-bouncer-test'); +echo 'Sent metrics: ' . json_encode($sentMetrics) . \PHP_EOL;