diff --git a/docs/configuration.rst b/docs/configuration.rst index 3c16d8a..56478a9 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -21,6 +21,9 @@ Full configuration reference // Optional. Cap the remediation to the selected one. Select from 'bypass' (minimum remediation), 'captcha' or 'ban' (maximum remediation). Defaults to 'ban'. 'max_remediation'=> 'ban', + + // Optional. Set the duration we keep in cache the fact that an IP is clean. In seconds. Defaults to 600 (10 minutes). + 'cache_expiration_for_clean_ip'=> '600', ] $cacheAdapter = (...) $bouncer = new Bouncer(); diff --git a/src/ApiCache.php b/src/ApiCache.php index 731801f..0ec9c59 100644 --- a/src/ApiCache.php +++ b/src/ApiCache.php @@ -25,6 +25,9 @@ class ApiCache /** @var bool */ private $ruptureMode; + /** @var int */ + private $cacheExpirationForCleanIp; + /** @var ApiClient */ private $apiClient; @@ -39,10 +42,11 @@ public function __construct(ApiClient $apiClient = null) /** * Configure this instance. */ - public function configure(AbstractAdapter $adapter, bool $ruptureMode, string $apiUrl, int $timeout, string $userAgent, string $token): void + public function configure(AbstractAdapter $adapter, bool $ruptureMode, string $apiUrl, int $timeout, string $userAgent, string $token, int $cacheExpirationForCleanIp): void { $this->adapter = $adapter; $this->ruptureMode = $ruptureMode; + $this->cacheExpirationForCleanIp = $cacheExpirationForCleanIp; $this->apiClient->configure($apiUrl, $timeout, $userAgent, $token); } @@ -85,22 +89,89 @@ private function saveDeferred(CacheItem $item, int $ip, string $type, int $expir } } - /* + /** + * Parse "duration" entries returned from API to a number of seconds. + * + * TODO P3 TEST + * 9999h59m56.603445s + * 10m33.3465483s + * 33.3465483s + * -285.876962ms + * 33s'// should break!; + */ + private static function parseDurationToSeconds(string $duration): int + { + $re = '/(-?)(?:(?:(\d+)h)?(\d+)m)?(\d+).\d+(m?)s/m'; + preg_match($re, $duration, $matches); + if (!count($matches)) { + throw new BouncerException("Unable to parse the following duration: ${$duration}."); + }; + $seconds = 0; + if (null !== $matches[2]) { + $seconds += ((int) $matches[1]) * 3600; // hours + } + if (null !== $matches[3]) { + $seconds += ((int) $matches[2]) * 60; // minutes + } + if (null !== $matches[4]) { + $seconds += ((int) $matches[1]); // seconds + } + if (null !== $matches[5]) { // units in milliseconds + $seconds *= 0.001; + } + if (null !== $matches[1]) { // negative + $seconds *= -1; + } + $seconds = round($seconds); + + return (int)$seconds; + } + + + + /** + * Format a remediation item of a cache item. + * This format use a minimal amount of data allowing less cache data consumption. + * + * TODO P3 TESTS + */ + private function formatRemediationFromDecision(?array $decision): array + { + if (!$decision) { + return ['clean', time() + $this->cacheExpirationForCleanIp, 0]; + } + + return [ + $decision['type'], // ex: captcha + time() + self::parseDurationToSeconds($decision['duration']), // expiration timestamp + $decision['id'], + + /* + TODO P3 useful to keep in cache? + [ + $decision['origin'],// ex cscli + $decision['scenario'],//ex: "manual 'captcha' from '25b9f1216f9344b780963bd281ae5573UIxCiwc74i2mFqK4'" + $decision['scope'],// ex: IP + ] + */ + ]; + } - Update the cached remediations from these new decisions. + /** + * Update the cached remediations from these new decisions. - TODO P2 WRITE TESTS - 0 decisions - 3 known remediation type - 3 decisions but 1 unknown remediation type - 3 unknown remediation type + * TODO P2 WRITE TESTS + * 0 decisions + * 3 known remediation type + * 3 decisions but 1 unknown remediation type + * 3 unknown remediation type */ private function saveRemediations(array $decisions): bool { foreach ($decisions as $decision) { $ipRange = range($decision['start_ip'], $decision['end_ip']); foreach ($ipRange as $ip) { - $remediation = Remediation::formatFromDecision($decision); + $remediation = $this->formatRemediationFromDecision($decision); $item = $this->buildRemediationCacheItem($ip, $remediation[0], $remediation[1], $remediation[2]); $this->saveDeferred($item, $ip, $remediation[0], $remediation[1], $remediation[2]); } @@ -114,8 +185,14 @@ private function saveRemediations(array $decisions): bool */ private function saveRemediationsForIp(array $decisions, int $ip): void { - foreach ($decisions as $decision) { - $remediation = Remediation::formatFromDecision($decision); + if (\count($decisions)) { + foreach ($decisions as $decision) { + $remediation = $this->formatRemediationFromDecision($decision); + $item = $this->buildRemediationCacheItem($ip, $remediation[0], $remediation[1], $remediation[2]); + $this->saveDeferred($item, $ip, $remediation[0], $remediation[1], $remediation[2]); + } + } else { + $remediation = $this->formatRemediationFromDecision(null); $item = $this->buildRemediationCacheItem($ip, $remediation[0], $remediation[1], $remediation[2]); $this->saveDeferred($item, $ip, $remediation[0], $remediation[1], $remediation[2]); } @@ -164,17 +241,7 @@ public function pullUpdates(): void private function miss(int $ip): string { $decisions = $this->apiClient->getFilteredDecisions(['ip' => long2ip($ip)]); - - if (!\count($decisions)) { - // TODO P1 cache also the clean IP. - //$item = $this->buildRemediationCacheItem($ip, $remediation[0], $remediation[1], $remediation[2]); - //$this->saveDeferred($item, $ip, $remediation[0], $remediation[1], $remediation[2]); - - return Remediation::formatFromDecision(null)[0]; - } - $this->saveRemediationsForIp($decisions, $ip); - return $this->hit($ip); } @@ -213,6 +280,6 @@ public function get(int $ip): ?string return $this->miss($ip); } - return Remediation::formatFromDecision(null)[0]; + return $this->formatRemediationFromDecision(null)[0]; } } diff --git a/src/Bouncer.php b/src/Bouncer.php index 3cb9216..0b97c43 100644 --- a/src/Bouncer.php +++ b/src/Bouncer.php @@ -45,7 +45,8 @@ public function configure(array $config, AbstractAdapter $cacheAdapter): void $this->config['api_url'], $this->config['api_timeout'], $this->config['api_user_agent'], - $this->config['api_token'] + $this->config['api_token'], + $this->config['cache_expiration_for_clean_ip'] ); } diff --git a/src/Configuration.php b/src/Configuration.php index 5d159f8..5061653 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -33,6 +33,7 @@ public function getConfigTreeBuilder() ->integerNode('api_timeout')->defaultValue(Constants::API_TIMEOUT)->end() ->booleanNode('rupture_mode')->defaultValue(true)->end() ->enumNode('max_remediation')->values(['bypass', 'captcha', 'ban'])->defaultValue('ban')->end() + ->integerNode('cache_expiration_for_clean_ip')->defaultValue(Constants::CACHE_EXPIRATION_FOR_CLEAN_IP)->end() ->end(); return $treeBuilder; diff --git a/src/Constants.php b/src/Constants.php index 7bd5e3e..d194c63 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -23,6 +23,9 @@ class Constants /** @var int The timeout when calling LAPI or CAPI */ const API_TIMEOUT = 1; // TODO P2 get the correct one + /** @var int The duration we keep a clean IP in cache 600s = 10m */ + const CACHE_EXPIRATION_FOR_CLEAN_IP = 600; // TODO P2 get the correct one + /** @var array The list of each known remediation, sorted by priority */ const ORDERED_REMEDIATIONS = ['ban', 'captcha']; // TODO P2 get the correct one } diff --git a/src/Remediation.php b/src/Remediation.php index 8e9bd7e..5d29216 100644 --- a/src/Remediation.php +++ b/src/Remediation.php @@ -59,71 +59,4 @@ public static function sortRemediationByPriority(array $remediations): array return $remediationsWithPriorities; } - - /** - * Parse "duration" entries returned from API to a number of seconds. - * - * TODO P3 TEST - * 9999h59m56.603445s - * 10m33.3465483s - * 33.3465483s - * -285.876962ms - * 33s'// should break!; - */ - private static function parseDurationToSeconds(string $duration): int - { - $re = '/(-?)(?:(?:(\d+)h)?(\d+)m)?(\d+).\d+(m?)s/m'; - preg_match($re, $duration, $matches); - if (!count($matches)) { - throw new BouncerException("Unable to parse the following duration: ${$duration}."); - }; - $seconds = 0; - if (null !== $matches[2]) { - $seconds += ((int) $matches[1]) * 3600;// hours - } - if (null !== $matches[3]) { - $seconds += ((int) $matches[2]) * 60;// minutes - } - if (null !== $matches[4]) { - $seconds += ((int) $matches[1]);// seconds - } - if (null !== $matches[5]) {// units in milliseconds - $seconds *= 0.001; - } - if (null !== $matches[1]) {// negative - $seconds *= -1; - } - $seconds = round($seconds); - - return $seconds; - } - - /** - * Format a remediation item of a cache item. - * This format use a minimal amount of data allowing less cache data consumption. - * - * TODO P3 TESTS - */ - public static function formatFromDecision(?array $decision): array - { - if (!$decision) { - return ['clear', 0, null]; - } - - return [ - $decision['type'], // ex: captcha - time() + self::parseDurationToSeconds($decision['duration']), // expiration - $decision['id'], - - /* - TODO P3 useful to keep in cache? - [ - $decision['id'],// id from API - $decision['origin'],// ex cscli - $decision['scenario'],//ex: "manual 'captcha' from '25b9f1216f9344b780963bd281ae5573UIxCiwc74i2mFqK4'" - $decision['scope'],// ex: IP - ] - */ - ]; - } } diff --git a/tests/IpVerificationTest.php b/tests/IpVerificationTest.php index 2d9b97b..c35ebba 100644 --- a/tests/IpVerificationTest.php +++ b/tests/IpVerificationTest.php @@ -19,8 +19,6 @@ TODO P2 testThrowErrorWhenMissAndApiIsNotReachable() TODO P2 testThrowErrorWhenMissAndApiTimeout() TODO P2 testCanVerifyCaptchableIp() -TODO P1 testCanVerifyCleanIp() -TODO P1 testCanCacheTheCleanIp() TODO P2 testCanHandleCacheSaturation() TODO P2 testCanNotUseCapiInRuptureMode() TODO P2 testCanVerifyIpInStreamModeWithCacheSystemBeforeWarmingTheCacheUp() https://stackoverflow.com/questions/5683592/phpunit-assert-that-an-exception-was-thrown @@ -48,16 +46,15 @@ public function testCanVerifyIpInRuptureModeWithoutCacheSystem(): void { // Init bouncer $basicLapiContext = TestHelpers::setupBasicLapiInRuptureModeContext(); - $blockedIp = $basicLapiContext['blocked_ip']; + $badIp = $basicLapiContext['bad_ip']; $config = $basicLapiContext['config']; $bouncer = new Bouncer(); $bouncer->configure($config); - // Get decisions for a blocked IP - $remediation = $bouncer->getRemediationForIp($blockedIp); + // Get decisions for a bad IP + $remediation = $bouncer->getRemediationForIp($badIp); $this->assertEquals($remediation, 'ban'); }*/ - /** * @group integration * @covers Bouncer @@ -66,6 +63,7 @@ public function testCanVerifyIpInRuptureModeWithoutCacheSystem(): void */ public function testCanVerifyIpInRuptureModeWithCacheSystem(AbstractAdapter $cacheAdapter): void { + $cacheAdapter->clear(); // Init bouncer /** @var ApiClient */ $apiClientMock = $this->getMockBuilder(ApiClient::class) @@ -73,29 +71,45 @@ public function testCanVerifyIpInRuptureModeWithCacheSystem(AbstractAdapter $cac ->getMock(); $apiCache = new ApiCache($apiClientMock); $basicLapiContext = TestHelpers::setupBasicLapiInRuptureModeContext(); - $blockedIp = $basicLapiContext['blocked_ip']; + $badIp = $basicLapiContext['bad_ip']; + $cleanIp = $basicLapiContext['clean_ip']; $config = $basicLapiContext['config']; $bouncer = new Bouncer($apiCache); $bouncer->configure($config, $cacheAdapter); - // A the end of test, we shoud have exactly 2 "cache miss") + // A the end of test, we shoud have exactly 3 "cache miss") /** @var MockObject $apiClientMock */ - $apiClientMock->expects($this->exactly(2))->method('getFilteredDecisions'); - - // Get decisions for a Blocked IP (for the first time, it should be a cache miss) - $remediation1stCall = $bouncer->getRemediationForIp($blockedIp); - $this->assertEquals('ban', $remediation1stCall); - - // Call the same thing for the second time (now it should be a cache miss) - $remediation2ndCall = $bouncer->getRemediationForIp($blockedIp); - $this->assertEquals('ban', $remediation2ndCall); + $apiClientMock->expects($this->exactly(3))->method('getFilteredDecisions'); + + $this->assertEquals( + 'ban', + $bouncer->getRemediationForIp($badIp), + 'Get decisions for a bad IP (for the first time, it should be a cache miss)' + ); + + $this->assertEquals( + 'ban', + $bouncer->getRemediationForIp($badIp), + 'Call the same thing for the second time (now it should be a cache hit)' + ); + + $cleanRemediation1stCall = $bouncer->getRemediationForIp($cleanIp); + $this->assertEquals( + 'clean', + $cleanRemediation1stCall, + 'Get decisions for a clean IP for the first time (it should be a cache miss)' + ); + + // Call the same thing for the second time (now it should be a cache hit) + $cleanRemediation2ndCall = $bouncer->getRemediationForIp($cleanIp); + $this->assertEquals('clean', $cleanRemediation2ndCall); // Clear cache $cacheAdapter->clear(); // Call one more time (should miss as the cache has been cleared) - $remediation3rdCall = $bouncer->getRemediationForIp($blockedIp); + $remediation3rdCall = $bouncer->getRemediationForIp($badIp); $this->assertEquals('ban', $remediation3rdCall); } @@ -108,7 +122,7 @@ public function testCanVerifyIpInRuptureModeWithCacheSystem(AbstractAdapter $cac */ public function testCanVerifyIpInStreamModeWithCacheSystem(AbstractAdapter $cacheAdapter): void { - + $cacheAdapter->clear(); // Init bouncer /** @var ApiClient */ $apiClientMock = $this->getMockBuilder(ApiClient::class) @@ -116,22 +130,31 @@ public function testCanVerifyIpInStreamModeWithCacheSystem(AbstractAdapter $cach ->getMock(); $apiCache = new ApiCache($apiClientMock); $basicLapiContext = TestHelpers::setupBasicLapiInRuptureModeContext(); - $blockedIp = $basicLapiContext['blocked_ip']; + $badIp = $basicLapiContext['bad_ip']; + $cleanIp = $basicLapiContext['clean_ip']; $config = $basicLapiContext['config']; $config['rupture_mode'] = false; $bouncer = new Bouncer($apiCache); $bouncer->configure($config, $cacheAdapter); - // A the end of test, we shoud have exactly 2 "cache miss") + // A the end of test, we shoud have exactly 0 "cache miss") /** @var MockObject $apiClientMock */ $apiClientMock->expects($this->exactly(0))->method('getFilteredDecisions'); // Warm BlockList cache up $bouncer->warmBlocklistCacheUp(); - // Get decisions for a Blocked IP (for the first time, but as the cache has been warmed up should be a cache hit!) - $remediation1stCall = $bouncer->getRemediationForIp($blockedIp); - $this->assertEquals('ban', $remediation1stCall); + $this->assertEquals( + 'ban', + $bouncer->getRemediationForIp($badIp), + 'Get decisions for a bad IP for the first time (as the cache has been warmed up should be a cache hit)' + ); + + $this->assertEquals( + 'clean', + $bouncer->getRemediationForIp($cleanIp), + 'Get decisions for a clean IP for the first time (as the cache has been warmed up should be a cache hit)' + ); // TODO P1 Add and remove decision and try updating cache with refreshBlocklistCache() @@ -139,7 +162,7 @@ public function testCanVerifyIpInStreamModeWithCacheSystem(AbstractAdapter $cach //$cacheAdapter->clear(); // Call the same thing for the second time (now it should be a cache miss) - //$remediation2ndCall = $bouncer->getRemediationForIp($blockedIp); + //$remediation2ndCall = $bouncer->getRemediationForIp($badIp); //$this->assertEquals('ban', $remediation2ndCall); } diff --git a/tests/TestHelpers.php b/tests/TestHelpers.php index 6f6815e..70a212d 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -10,51 +10,32 @@ class TestHelpers { - const TEST_IP = '1.2.3.4'; + const BAD_IP = '1.2.3.4'; + const CLEAN_IP = '2.3.4.5'; const FS_CACHE_ADAPTER_DIR = __DIR__ . '/../var/fs.cache'; const PHP_FILES_CACHE_ADAPTER_DIR = __DIR__ . '/../var/phpFiles.cache'; const WATCHER_LOGIN = 'PhpUnitTestMachine'; const WATCHER_PASSWORD = 'PhpUnitTestMachinePassword'; - private static function delTree(string $dir): bool - { - if (file_exists($dir)) { - /** @var array $items */ - $items = scandir($dir); - $files = array_diff($items, ['.', '..']); - foreach ($files as $file) { - (is_dir("$dir/$file")) ? self::delTree("$dir/$file") : unlink("$dir/$file"); - } - return rmdir($dir); - } - return true; - } - public static function cacheAdapterProvider(): array { - // Init and clear all adapters - - + // Init all adapters /* - TODO P3 Fail on CI. Investigates. + TODO P3 Failed on CI but some fixes may fix this bug. Just retry it could work! Else investigates. $fileSystemAdapter = new FilesystemAdapter('fs_adapter_cache', 0, self::FS_CACHE_ADAPTER_DIR); - self::delTree(self::FS_CACHE_ADAPTER_DIR); */ $phpFilesAdapter = new PhpFilesAdapter('php_array_adapter_backup_cache', 0, self::PHP_FILES_CACHE_ADAPTER_DIR); - self::delTree(self::PHP_FILES_CACHE_ADAPTER_DIR); /** @var string */ $memcachedCacheAdapterDsn = getenv('MEMCACHED_DSN'); $memcachedAdapter = new MemcachedAdapter(MemcachedAdapter::createConnection($memcachedCacheAdapterDsn)); - $memcachedAdapter->clear(); /** @var string */ $redisCacheAdapterDsn = getenv('REDIS_DSN'); /** @var ClientInterface */ $redisClient = RedisAdapter::createConnection($redisCacheAdapterDsn); $redisAdapter = new RedisAdapter($redisClient); - $redisAdapter->clear(); return [ /*'FilesystemAdapter' => [$fileSystemAdapter],*/ @@ -75,7 +56,8 @@ public static function setupBasicLapiInRuptureModeContext(): array $apiToken = file_get_contents($path); return [ 'config' => ['api_token' => $apiToken, 'api_url' => $apiUrl], - 'blocked_ip' => self::TEST_IP + 'bad_ip' => self::BAD_IP, + 'clean_ip' => self::CLEAN_IP ]; }