Skip to content

Diagnosing an Installation

bin/firewall-doctor runs against the environment it is in and reports what is wrong with it.

vendor/bin/firewall-doctor firewall.yml
  ✓ Config loads
      2 rules configured
  ! Trusted proxies could not be verified from the command line
      Request::setTrustedProxies() is called by your application bootstrap, which a
      command-line process does not run — so this cannot be checked from here.
      See docs/configuration/global.md#trusted-proxies
  ✗ The block list is running without its store
      Kanopi\Firewall\Storage\RedisStorage could not be reached: Connection refused
  ✗ GeoIP database not found
      /srv/geoip/GeoLite2-City.mmdb — every rule reading location or ASN will not match.

  2 errors, 1 warning, 1 check passed
Option
--json A parseable document on stdout, and nothing else on it.
--quiet Only warnings and errors.
Exit code
0 Nothing wrong, or warnings only
1 At least one error — something configured is not happening
2 The configuration itself could not be read

A warning does not fail the command. A stale GeoIP database should not block a deploy; a rule that is not running should.

Making a stale rule source fail the deploy

One second past its ttl is a refresh that has not run yet. A week past it is a sync that has stopped working — and the rule is still matching, on a list nobody has updated since. Those need different reactions, and global.stale_source_error_after is where you say which is which:

global:
  stale_source_error_after: 604800    # a week — a reasonable starting point
  ! Rule source cache is stale
      https://example.org/tor.txt — last fetched 2 hours ago, past its ttl of 3600s.

  ✗ Rule source has not refreshed in a long time
      https://example.org/tor.txt — last fetched 9 days ago, past its ttl of 3600s.
      Past the 7 days you set as global.stale_source_error_after, this is a sync that has
      stopped working rather than one that has not run yet: the rule is still matching, on
      a list nobody has updated since. Raise that value, or set it to 0, to stop this
      failing a deploy.

Off unless you set it, so upgrading changes no exit code. Where the line falls is a question about your own refresh cycle, and the bound is absolute rather than a multiple of each source's ttl — ten times a 60-second ttl is ten minutes, and a ten-minute-old rule list is not an incident.

A cache entry that records no fetch time stays a warning however low the bound is set. There is nothing to measure, and escalating it would assert an age nobody knows.

It is not a config linter

It runs against the environment, not against the file: it opens the storage path, reaches the database, reads the GeoIP database's mtime, and builds every rule to find out whether it can be built. The same configuration diagnoses differently on a laptop and in production, which is the point.

Two consequences worth knowing:

  • Building a rule opens its storage connection, so reachability is tested rather than assumed. That is the cost, and it is why this belongs in a deploy hook or a status page rather than on a request path.
  • A table that does not exist yet is created, because the declared schema lives on the class that owns it and there is no way to ask without building it.

Whether a rule can ever match is a static question, and belongs to firewall-check --lint.

What it checks

Config Every file loaded; anything that degraded on the way
Trusted proxies Whether the client IP can be trusted — see below
Rule sources Fetched, and still inside their ttl — a stale one reports how long since it was last fetched, and becomes an error past a week
Rules Every configured rule constructed, via getFailedRules()
Backends Anything running without its store, via getDegradedBackends()
Schema Tables behind what this release declares, via pendingSchemaChanges()
Storage File-backed paths that exist and can be written
GeoIP Databases present, and how old they are

Sources are checked before the rules, deliberately: building a rule is what fetches its sources, so asking afterwards would report the cache this command had just warmed rather than the one it found.

The trusted-proxy check, and what it cannot tell you

Getting trusted proxies wrong is the sharpest edge in the product: every IP rule, allow list and per-IP rate limit becomes spoofable through a forged X-Forwarded-For.

It is also the one thing this command cannot settle from a terminal. Request::setTrustedProxies() is called by your application's bootstrap — a settings.php, a middleware, a functions.php — and a CLI process runs none of that. So from the command line it reports what it can see and says plainly that it cannot verify the rest:

  ! Trusted proxies could not be verified from the command line
      … Config asserts behind_proxy: true with require_trusted_proxies: true,
      so the firewall will refuse to start without them.

That is a warning rather than a pass even when the configuration looks right, because an ok there would read as confirmation the site is not spoofable, which this cannot give you.

Run through a web SAPI — from a status page, where the bootstrap has run — and it answers properly: an error when behind_proxy: true and no proxies are configured, a warning when nothing is asserted either way.

In a deploy pipeline

vendor/bin/firewall-doctor firewall.yml --quiet || exit 1

Or, to act on the detail:

vendor/bin/firewall-doctor firewall.yml --json > diagnosis.json

Errors are written to stderr and everything else to stdout, so a CI log shows the failures even when stdout is captured — and --json keeps stdout parseable.

From your own code

The command is a thin shell over Kanopi\Firewall\Diagnostics\Doctor, which is what an integration with its own status report should use:

use Kanopi\Firewall\Diagnostics\Diagnosis;
use Kanopi\Firewall\Diagnostics\Doctor;

$findings = (new Doctor([__DIR__ . '/firewall.yml']))->run();

foreach ($findings as $finding) {
    // $finding->status is Diagnosis::OK | WARNING | ERROR
    $status->add($finding->status, $finding->title, $finding->detail);
}

$tally = Doctor::tally($findings);

Running it under a web SAPI is also what makes the trusted-proxy check meaningful, so a status page gets a better answer than the terminal does.