Skip to content

Configuration Loading & Includes

The firewall supports modular configuration via a top‑level configs: key in any YAML file. Paths listed under configs: are loaded and merged into the current file.

Rules & behavior

  • Paths in configs: can be:
  • Relative (resolved against the directory of the YAML file that declares them)
  • Absolute
  • Remote URLs (e.g., https://example.com/firewall-rules.yml; cached locally with configurable TTL)
  • Use the {config_dir} token (expanded to the current YAML's directory)
  • Use the {presets_dir} token (expanded to this package's presets/ directory inside vendor/, so you can include a shipped preset without knowing your vendor layout — e.g. "{presets_dir}/malicious-requests.yml")
  • Glob patterns (e.g., more/*.yml; matched files are sorted alphabetically)
  • Environment-driven using %env(...)% (must resolve to a string path)
  • Merge semantics:
  • Objects (associative arrays) are merged deeply; later files override earlier keys
  • Lists (numeric arrays) are replaced as a whole by later files — with one exception: a root-level plugins: list appends, so several included files can each contribute plugin entries
  • Safety: circular includes are prevented and excessive include depth is rejected.

Remote Configuration Files

Configuration files can be loaded from remote URLs, which is useful for centralized management across multiple servers:

configs:
  - "https://cdn.example.com/firewall/base-rules.yml"
  - "https://cdn.example.com/firewall/ip-blocklist.yml"

configs: is for configuration documents, not rule lists

An included document's top-level plugins: list appends to yours — the two rules both run, each keeping its own config:. Every other list is replaced wholesale, including the config: inside a rule and anything under storage:.

So a remote file that re-declares one of your rules to extend its address list does not extend it. It gives you a second rule alongside the first, which the linter warns about as a duplicate name — and if instead the include lands on the same key by another route, your list is overwritten rather than added to, quietly.

Pulling a list of addresses, user agents, or paths is what Rule Sources are for: they append into one rule, declare their own format, and carry their own TTL and failure policy. Keep configs: for whole configuration documents.

A configs: entry naming a file that does not exist empties the document

A missing include is a load failure, not a skipped line: Config::load() records the error and returns nothing, so every rule in the configuration stops being configured.

That matters most when adding an include for a file you are about to create. Write the file first — firewall-rule init does exactly that, in that order — and add the configs: line afterwards.

global.require_config: true turns the failure into a startup exception instead, which is the loud version of the same thing and generally what you want in production.

Remote files are cached locally to improve performance and reduce external dependencies. You can control caching behavior using PHP constants:

<?php
// Define before initializing the firewall
define('KANOPI_FIREWALL_CACHE_DIR', '/var/cache/firewall');  // Default: /tmp/cache
define('KANOPI_FIREWALL_CACHE_TTL', 7200);                   // Default: 3600 (1 hour)
define('KANOPI_FIREWALL_CACHE_TIMEOUT', 10.0);               // Default: 5.0 seconds
define('KANOPI_FIREWALL_CACHE_MAX_STALE', 86400);            // Default: unbounded

\Kanopi\Firewall\Firewall::create([__DIR__ . '/config.yml'])->evaluate();

The compiled configuration cache

Parsing and merging the configuration costs about 2.2 ms on the shipped presets, and it produces the same answer on every request. So the merged result is cached as PHP, keyed on the files it was built from, and a hit costs about 0.058 ms.

Nothing needs configuring. It lives in KANOPI_FIREWALL_CACHE_DIR/compiled when that constant is defined, and in a kanopi-firewall-config directory inside the system temp directory otherwise.

What invalidates an entry

Each file is fingerprinted by a hash of its content. An entry is discarded when any file it was built from changes, is deleted, or becomes unreadable.

Content rather than modification time, because a rewrite inside the same second that keeps the byte count identical is invisible to an mtime — and an application that compiles its settings into a YAML file does exactly that. The consequence was a firewall enforcing the previous configuration while reporting itself perfectly healthy. It also means a deploy that rewrites identical files keeps the cache rather than discarding it.

Two things are never cached: a configuration containing an object, which cannot be written as PHP source without serialize() — deliberately not used anywhere this library writes and reads back — and a load that reported an error or a warning, which would otherwise freeze a degraded result in place.

Old entries are removed

An entry is keyed on the paths it was built from, so a deployment using dated release directories produces a new key on every deploy and orphans the previous entry. Nothing read those orphans again, and until 2.23.0 nothing deleted them either.

Entries older than 30 days are now swept whenever a new one is written — which is exactly when an orphan is created, and never on a request that hit the cache.

define('KANOPI_FIREWALL_CACHE_MAX_AGE', 7 * 86400);  // Default: 30 days. 0 disables sweeping.

The age is time since the entry was written, not since it was last read. So a configuration that never changes has its entry swept eventually and reparsed once — which is deliberate: tracking "time since read" means touching the file on every cache hit, measured at 0.0143 ms against a 0.058 ms warm load. A quarter again on every request the firewall serves, to avoid one 2.2 ms reparse a month.

When the fetch fails

A remote include that cannot be fetched falls back to its cached copy, even after the TTL has expired, and reports the fallback as a warning rather than an error:

firewall.WARNING: Firewall config loaded in a degraded state
    {"file":"https://cdn.example.com/firewall/base-rules.yml",
     "reason":"Remote config could not be fetched; served a cached copy 7412s old.
               The rules are active, but they are not necessarily current."}

The alternative — discarding a copy that worked an hour ago because a CDN returned a 503 — drops the whole ruleset over a momentary failure. For a response: block include that fails open. For a response: allow include at negative weight it fails closed, and starts blocking the monitoring and deploy traffic the include existed to admit.

Three things follow from this being a warning rather than an error:

  • It does not trip global.require_config. The config loaded; it is just older than you asked for. A transient DNS blip should not refuse to start a site that has perfectly usable rules on disk.
  • The cache file's timestamp is not refreshed. Restamping would reset the TTL and hide the age, so an upstream that has been dead for a month would look healthy.
  • Read it yourself with Config::getLoadWarnings(), alongside getLoadErrors().

With no cached copy to fall back to, the fetch failure stays an error and the include contributes nothing.

KANOPI_FIREWALL_CACHE_MAX_STALE bounds how far past the TTL a copy may be served. Past that age the fallback becomes a hard failure and is reported as an error. It is unbounded by default, on the grounds that stale rules beat no rules — set it when you would rather be told loudly that an upstream has gone away.

When a file parses to something that is not configuration

YAML folds a newline-delimited list into a single scalar, so a file like this parses successfully and yields no configuration at all:

216.144.248.16/28
69.162.124.224/28

That is reported rather than passed over in silence:

firewall.ERROR: Firewall config file failed to load — its rules are NOT active
    {"file":"/srv/app/config/ips.txt",
     "reason":"Parsed as a single string, not a configuration mapping. A newline-delimited
               list folds into one YAML scalar — if this is a rule list, load it through a
               plugin source (metadata.sources) rather than as configuration."}

An empty file is still silent: a file with nothing in it, only comments, or an explicit ~ is legitimately no configuration, not a mistake. A YAML sequence still loads normally, since plugin rule files are sequences.

A bad include costs only that include. The file that included it still loads, so one stray .txt caught by a configs: glob does not take the ruleset with it.

Example

# base: config/firewall.yml
configs:
  - "{config_dir}/sites/*.yml"       # include all site-specific configs
  - "config/extra.yml"               # include another file relative to this YAML
  - "%env(string:EXTRA_CFG)%"        # include a path from env var

logger:
  - class: Monolog\Handler\StreamHandler
    args: ["logs/firewall.log", "Monolog\\Level::Info"]

plugins:
  - plugin: "Kanopi\\Firewall\\Plugins\\GeoLocation"
    response: block
    enable: true
    metadata:
      reader:
        type: reader
        db: "geo/GeoLite2-City.mmdb"   # relative path resolved against this file's directory

In the example above, the log file and GeoIP database paths are relative to the YAML file (not the PHP current working directory). This makes configs portable regardless of where your app bootstraps from.