Valkyrian Labs logo

Runtime

Running forti-pihole unattended with the timer or with forti-piholed, the webhook receiver and scheduler, its daemon.json, the FortiGate automation stitch, loop prevention and pause.

Runtime

forti-pihole runs unattended in one of two ways. The timer runs a sync every 15 minutes. The service, forti-piholed, runs a sync promptly after every FortiGate configuration change, plus a full one periodically, with backoff on failure. Both run the same sync as forti-pihole sync, under the same run lock, so they never overlap with each other or with you. Use one, not both.

The timer (Linux)

1systemctl --user enable --now forti-pihole.timer2loginctl enable-linger "$USER"     # keep it running while you are logged out3journalctl --user -u forti-pihole  # what each run did, and why one failed

forti-pihole.service runs forti-pihole apply --quiet (apply is sync): it prints only problems. The timer fires every 15 minutes with up to two minutes of random delay, and catches up once after the machine was off. Both are user units: the program runs as you, with your forti home, never as root. The package installs them and never enables them.

For another forti home, profile or binary path: systemctl --user edit forti-pihole.service, e.g.

1[Service]2Environment=FORTI_HOME=/srv/forti3ExecStart=4ExecStart=/usr/local/bin/forti-pihole apply --quiet --profile fw01

forti-piholed

forti-piholed is a small HTTP receiver and scheduler. It runs in the foreground under a supervisor (systemd or launchd); it never daemonizes itself.

1forti-piholed [--foreground] [--config PATH] [--profile NAME]2forti-piholed --check-config [--config PATH] [--profile NAME]3forti-piholed --version | --help
OptionMeaning
--foregroundRun in the foreground: the default and only mode
--config PATHThe service's settings (default $FORTI_HOME/pihole/daemon.json; absent: every default)
--profile NAMEThe forti profile (default: daemon.json's profile, else forti's default)
--check-configValidate the settings, print the effective ones (listener, allowlist, token, schedule, limits) and warnings, and exit

It exits 2 on a usage error, 3 when daemon.json is invalid, and 1 when it cannot start (for example, the address is in use).

When it runs a sync

ReasonWhen
startupOnce at start, unless a recorded failure's backoff is still running
eventAfter a FortiGate change: debounce_seconds (10) of quiet, but never more than max_delay_seconds (60) after the first event of a burst
periodicfull_reconcile_minutes (15) after the last run finished, events or not
retryAfter a failure: from backoff_initial_seconds (10), doubled per consecutive failure, capped at backoff_max_seconds (900), with jitter. Events during a backoff do not cut it short

At most one run at a time. Every run is a full, idempotent sync, so a missed event costs at most a delay, never divergence. Before every run the service reads config.json fresh (an edit applies at the next run) and checks the pause flag. When "enabled" is false, a run does nothing.

The run is exactly forti-pihole sync with the configuration's max_changes, migrations on and at most one new migration per run.

Endpoints

One listener, default 127.0.0.1:8735:

EndpointPurpose
POST /v1/fortigate/eventsA FortiGate config-change event. Authorization: Bearer <token>, from allow_sources only. Answers 202 for every accepted event (suppressed or not)
GET or HEAD /v1/healthLiveness only ({"status":"ok"}), no authentication; from the allowlist and from loopback

Checks run in this order, each before anything later is read: source allowlist (403) and per-source rate limit (429 with Retry-After) at accept; then path (404), method (405 with Allow), source for the events endpoint (403), a token configured (503), the bearer token (401), Content-Length (411); oversized heads and bodies are refused (431, 413). Nothing about a refused request is echoed back, and refusals are logged at most once per source and status every 60 seconds.

The token

The webhook token is 256 random bits (64 hex digits), generated by forti-pihole events install and stored in forti's secret store as pihole/<profile>/webhook-token, bound to the profile's FortiGate (host:port). It is never written to a configuration file, the service's state, the event log or its output. daemon.json's token_account names another account; leave it empty unless you stored the token under another name, since events install always uses pihole/<profile>/webhook-token.

Without a token the events endpoint answers 503 and the service still runs its periodic syncs.

The allowlist

allow_sources lists the addresses or CIDR blocks allowed to send events. Empty (the default) means the FortiGate's own address, resolved from the profile's host at start and on reload. When that does not resolve, nothing is allowed until you set allow_sources.

Plain HTTP only

forti-piholed speaks plain HTTP: the bearer token crosses the network in cleartext. Bind listen.address to the host's management-network address, keep allow_sources to the FortiGate, and keep the path between them on a network you trust. For HTTPS, put a local TLS proxy in front and give the stitch an https:// URL.

The default listener, 127.0.0.1, is reachable only from the host itself: the FortiGate cannot deliver events until daemon.json binds the management address.

daemon.json

1{2  "profile": "fw01",3  "listen": {"address": "192.0.2.10", "port": 8735},4  "allow_sources": ["192.0.2.1"]5}

Every key is optional; /usr/share/forti-pihole/daemon.example.json lists them all with their defaults, and Configuration describes each. forti-piholed --check-config validates the file and shows the effective settings. SIGHUP (systemctl --user reload forti-piholed) re-reads daemon.json, the allowlist and the token without a restart; on an error the current settings stay. SIGTERM and SIGINT close the listener at once and stop after the current run.

State and logs

FileWhat
$FORTI_HOME/pihole/daemon-state.jsonThe last event and run, the failure count and next retry (a restart resumes the backoff), the pause flag. A corrupt file is moved to daemon-state.json.corrupt and the service starts fresh with a full sync
$FORTI_HOME/pihole/events.jsonlOne line per accepted event (its attribution fields only, never the raw body) and per finished run; rotates to events.jsonl.1 at event_log.max_mib

The service logs to standard error in logfmt (run.started, run.finished, run.failed, fortigate.event, config.warning, ...). journald stamps each line; elsewhere the service adds the time itself.

Notifying the service: the automation stitch

1forti-pihole events plan    --url http://192.0.2.10:8735   # show the operations (token not included)2forti-pihole events install --url http://192.0.2.10:8735   # generate the token, create the stitch3forti-pihole events remove  --url http://192.0.2.10:8735   # delete the stitch

--url is the address the FortiGate uses to reach the service: http://HOST:PORT (port 8735 when omitted), or https://... behind a TLS proxy. install creates three FortiGate objects, each with the description managed by forti-pihole:

ObjectNameWhat
system/automation-triggerfp-sync-eventEvent-based, on every config change
system/automation-actionfp-sync-webhookA webhook: POST to the URL's host and /v1/fortigate/events, with Authorization: Bearer <token> and a JSON body of the event's log fields (logid, eventtime, vd, user, ui, action, cfgtid, cfgpath, cfgobj, logdesc)
system/automation-stitchfp-syncConnects the trigger to the action

remove deletes them in reverse order. These are the only automation objects forti-pihole ever writes, and only inside events install|remove: the guard transport refuses them anywhere else. To change the URL, remove then install again; the stored token is reused. With a guarded profile install exits 7 with the plan id; approve and apply it with forti approve <id> and forti apply <id>.

The token is in the action

The webhook action holds the token in its HTTP headers, so the FortiGate's configuration (and its backups) contains it. events plan prints <generated at install> in its place.

Loop prevention

Every sync that writes to the FortiGate makes the FortiGate send config-change events about those same objects. The service remembers what each run wrote for suppression_seconds (60): an event that names one of those objects is the echo of its own write and is ignored. Events that arrive during a run are held and judged when it finishes: echoes are dropped, and anything else becomes exactly one follow-up run, however many events there were. An event that cannot be attributed to an object is never suppressed.

Pause

1forti-pihole pause     # sync and forti-piholed write nothing until resume2forti-pihole resume

pause sets a flag in daemon-state.json. forti-pihole sync then plans and reports but writes nothing (and exits 0), migrate refuses, and the service postpones its runs and re-checks the flag every 30 seconds, without a restart. Events are still accepted while paused, so one run follows the resume.

Running the service

systemd (Linux)

1systemctl --user enable --now forti-piholed.service2systemctl --user disable --now forti-pihole.timer   # redundant with the service3loginctl enable-linger "$USER"4journalctl --user -u forti-piholed -f

The unit restarts the service after a crash (Restart=on-failure), lets a run in progress finish on stop, and is hardened with seccomp and NoNewPrivileges; core dumps are off, so the token never lands in one.

Homebrew services

1brew services start forti-pihole2tail -f "$(brew --prefix)/var/log/forti-piholed.log"

Under launchd (macOS) or a systemd user unit (Linuxbrew), forti-piholed --foreground is restarted if it crashes. On macOS, run forti-piholed once in a terminal first and choose Always Allow at each Keychain prompt: launchd cannot show one (Troubleshooting).