diff --git a/docs/advanced/presets.md b/docs/advanced/presets.md index c074af7..c489453 100644 --- a/docs/advanced/presets.md +++ b/docs/advanced/presets.md @@ -63,6 +63,40 @@ if (version_compare(Presets::VERSION, $latestFromYourFeed, '<')) { Fetching `$latestFromYourFeed` is the integrator's job; phirewall hardcodes no remote endpoint. +## Caching expensive preset data + +This section only matters when a preset parses a large data source on construction - a rule-set file, an IP feed. phirewall's own presets parse no such sources and need no cache; the companion preset packages that do - OWASP CRS rule files, the bad-IP snapshot - use exactly this mechanism. Because a `Config` is built on every request under PHP-FPM, such parsing would run per request; `Flowd\Phirewall\Support\CompiledDataCache` removes that cost with a two-level cache for `var_export`-able plain data: + +```php +use Flowd\Phirewall\Support\CompiledDataCache; + +// The underlying primitive; in a preset matcher the instance +// arrives via CompiledDataCacheAware instead (see below). +$cache = new CompiledDataCache($cacheDirectory); +$sourceFiles = glob($rulesPath . '/*.conf') ?: []; + +$ruleData = $cache->load( + 'my-preset-rules', // developer-defined identifier + $sourceFiles, // newest mtime invalidates the cache + fn(): array => parseRuleFiles($sourceFiles) // runs only on a cache miss +); +// $ruleData is the plain array your preset builds its matchers from. +``` + +The first level memoizes per process, so a warm PHP-FPM worker pays no parsing cost after its first request. The second level persists a compiled PHP artifact in the given directory and loads it via `include`, so OPcache serves it from shared memory even for cold workers. Editing a source file rebuilds on the next request; long-running workers (FrankenPHP, RoadRunner, Swoole) can additionally drop the in-process level with `CompiledDataCache::clearProcessCache()` when a deployment does not change the source mtimes. + +The cache stores only plain arrays: cache the parsed data your objects are built from, not the objects themselves, and rebuild the objects from the returned array on each load. A builder returning anything but scalars, `null`, or nested arrays makes `load()` throw an `InvalidArgumentException` - unlike the silently degrading cache failures (an unwritable directory, a corrupt artifact), this surfaces a programming error instead of reviving objects through `__set_state()`. + +The artifact is executed as PHP, so the directory needs the same trust as a compiled DI container: use a framework cache directory outside the web root, writable by the PHP-FPM user - for example `var/cache/phirewall` in TYPO3 or Symfony, `storage/framework/phirewall` in Laravel. + +Wiring is a single integrator step: + +```php +$config->setCompiledDataCache(new CompiledDataCache($cacheDirectory)); +``` + +The cache travels as Config infrastructure (composition inherits it from the base layer, like the PSR-16 store). A preset matcher that builds its data lazily implements `Flowd\Phirewall\Matchers\CompiledDataCacheAware`; the `Firewall` hands the cache to every aware matcher, filter, and throttle scope before evaluation - mirroring how `ClientIpResolverAware` late-binds the IP resolver. Without a configured cache nothing is injected and the matcher builds its data directly, so caching stays an opt-in feature with no per-package wiring. + ## Example See [`examples/31-presets.php`](https://github.com/flowd/phirewall/blob/main/examples/31-presets.php) for standalone use, inspecting a preset as portable data, composing a preset with a user `Config` (overriding a rule by name), and comparing `Presets::VERSION` against your own release feed with `version_compare()`. diff --git a/docs/features/bad-ip-preset.md b/docs/features/bad-ip-preset.md index 2bd61d2..170019e 100644 --- a/docs/features/bad-ip-preset.md +++ b/docs/features/bad-ip-preset.md @@ -29,6 +29,18 @@ $config = (new Config($cache))->with(Presets::blocklist()); | `Presets::blocklist()` | Blocks requests whose client IP is in the bundled snapshot. | | `Presets::track(period)` | Counts matches without blocking, to measure false positives first. | +The preset loads its ~18k-address snapshot lazily on the first request. Parsing the list and compiling it into IP lookup tables costs a few milliseconds; give the `Config` a compiled-data cache and both steps are served from OPcache-backed artifacts instead, re-parsed only when the data file changes: + +```php +use Flowd\Phirewall\Support\CompiledDataCache; +use Flowd\PhirewallPresetBadIps\Presets; + +$config->setCompiledDataCache(new CompiledDataCache('/path/to/var/cache/phirewall')); +$config = $config->with(Presets::blocklist()); +``` + +See [Presets › Caching expensive preset data](/advanced/presets#caching-expensive-preset-data). + ## Updating the list The snapshot is stamparm/ipsum `levels/3.txt` (addresses on at least three source blacklists), diff --git a/docs/features/owasp-crs.md b/docs/features/owasp-crs.md index 2a79711..c72fe70 100644 --- a/docs/features/owasp-crs.md +++ b/docs/features/owasp-crs.md @@ -548,7 +548,24 @@ final readonly class RequestBodyCollector implements VariableCollectorInterface ### Caching -Each operator evaluator and variable collector is instantiated once per rule at construction time and reused across requests. Regular expressions are compiled on first use (with PCRE's internal JIT cache), phrase lists from `@pmFromFile` are loaded and cached per file path, and all other operators use simple string operations with no additional overhead. There is no need to cache the `CoreRuleSet` externally. +Each operator evaluator and variable collector is instantiated once per rule at construction time and reused across requests. Regular expressions are compiled on first use (with PCRE's internal JIT cache), phrase lists from `@pmFromFile` are loaded and cached per file path, and all other operators use simple string operations with no additional overhead. + +What *is* worth caching is the one-time cost of **parsing** the rule files into the `CoreRuleSet` - several milliseconds that, under PHP-FPM, would otherwise be paid on every request. Build the matcher with the lazy factory and give the `Config` a compiled-data cache; the parsed rules are then served from an OPcache-backed artifact and re-parsed only when a rule file changes: + +```php +use Flowd\Phirewall\Config\Rule\BlocklistRule; +use Flowd\Phirewall\Support\CompiledDataCache; +use Flowd\PhirewallPresetOwaspCrs\Engine\CoreRuleSetMatcher; +use Flowd\PhirewallPresetOwaspCrs\ParanoiaLevel; + +$config->setCompiledDataCache(new CompiledDataCache('/path/to/var/cache/phirewall')); + +$matcher = CoreRuleSetMatcher::fromRuleFiles(ParanoiaLevel::Level1); +$matcher->disable(942100); // toggles before the first request are queued +$config->blocklists->addRule(new BlocklistRule('owasp', $matcher)); +``` + +`Presets::blocklist()` and `Presets::fail2ban()` already build lazily, so they pick up the cache automatically. A matcher constructed eagerly with an already parsed `CoreRuleSet` keeps parsing at construction and ignores the cache. See [Presets › Caching expensive preset data](/advanced/presets#caching-expensive-preset-data). ### Operator Performance