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
| Option | Meaning |
|---|---|
--foreground | Run in the foreground: the default and only mode |
--config PATH | The service's settings (default $FORTI_HOME/pihole/daemon.json; absent: every default) |
--profile NAME | The forti profile (default: daemon.json's profile, else forti's default) |
--check-config | Validate 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
| Reason | When |
|---|---|
startup | Once at start, unless a recorded failure's backoff is still running |
event | After a FortiGate change: debounce_seconds (10) of quiet, but never more than max_delay_seconds (60) after the first event of a burst |
periodic | full_reconcile_minutes (15) after the last run finished, events or not |
retry | After 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:
| Endpoint | Purpose |
|---|---|
POST /v1/fortigate/events | A FortiGate config-change event. Authorization: Bearer <token>, from allow_sources only. Answers 202 for every accepted event (suppressed or not) |
GET or HEAD /v1/health | Liveness 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.
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
| File | What |
|---|---|
$FORTI_HOME/pihole/daemon-state.json | The 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.jsonl | One 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:
| Object | Name | What |
|---|---|---|
system/automation-trigger | fp-sync-event | Event-based, on every config change |
system/automation-action | fp-sync-webhook | A 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-stitch | fp-sync | Connects 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 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).