Valkyrian Labs logo

Configuration

Every key of config.json and daemon.json, with defaults and validation.

Configuration

forti-pihole reads $FORTI_HOME/pihole/config.json (default ~/.forti/pihole/config.json; --config PATH for another), and forti-piholed also reads $FORTI_HOME/pihole/daemon.json. Both are JSON, validated strictly: an unknown key, a wrong type or a value out of range is refused with the offending key named (exit 3 for forti-pihole). A $comment key is allowed at the top level of each and ignored.

forti-pihole init writes the starting config.json below; forti-pihole setup writes it too when there is none, and adds networks and Pi-hole mappings to it.

config.json

Top level

KeyTypeDefaultValidation and meaning
enabledbooleanfalseNothing is written to the FortiGate or Pi-hole until true
profilestring""The forti profile; empty: forti's default profile. --profile overrides it
vdomstring""The VDOM; empty: the profile's
prefixstring"fp-"2-24 characters of [A-Za-z0-9_-]. Every managed FortiGate object's name starts with it
interfacesarray of stringsevery network's interfaceDevices detected on these interfaces are classified. At least one network or interface is required
networksarray[]How each interface maps to Pi-hole (networks)
piholeobjectThe Pi-hole connection (pihole)
daemonobject{}Accepted (must be an object) and kept, but not read: forti-piholed's settings are in daemon.json
default_groupstring"default"Must be a listed group, and not privileged. Unknown and ambiguous devices go here
groupsarrayRequired, at least one (groups). Listed in priority order
rulesarray[]Presumptive classification (rules)
protectedarray of MACs[]Devices never classified, moved or touched
assignmentsobject, MAC → group{}Explicit placements; any listed group, privileged ones included
retire_after_daysinteger301-3650. A device's managed object is retired after it has not been seen this long
native_rule_priorityinteger500-10000. The priority of fp-rule-* objects made in the GUI
adopt_existingbooleantrueReference an administrator's existing single-MAC address object as a device's group member instead of creating one (it is never modified)
max_changesinteger501-1000. Devices that change group, and reservations added, per run

MACs (here and on the command line) are accepted in any letter case with : or - separators or none (AA-BB-CC-DD-EE-FF, aabbccddeeff) and normalized to aa:bb:cc:dd:ee:ff.

groups

1{"name": "tvs", "description": "TVs and streaming sticks", "privileged": false, "pihole_group": "TVs"}
KeyTypeDefaultValidation and meaning
namestringRequired. 1-32 characters of [a-z0-9_], unique. The FortiGate address group is <prefix>grp-<name>
descriptionstring""Written into the address group's comment
privilegedbooleanfalseOnly explicit assignments, pins and confirmed reassociations can put a device here; rules naming it are refused
pihole_groupstring""The Pi-hole group, by name, its addresses resolve to. Empty: not mapped (Pi-hole's Default group applies)

When one device is in several groups' address groups, the earlier group in this list wins.

rules

1{"name": "kasa-plugs", "group": "iot_untrusted", "priority": 200, "match": {"vendor": "TP-Link"}}
KeyTypeDefaultValidation and meaning
namestring"<group>#<index>"Shown in reports
groupstringRequired. A listed group that is not privileged
priorityinteger1000-10000. Higher wins; equal-priority matches in different groups are ambiguous
matchobjectRequired, at least one field set

match fields, all optional; every set field must match. Strings compare case-insensitively and accept * and ?:

FieldCompared with
vendorThe FortiGate's hardware_vendor (TP-Link)
familyhardware_family (Plug, Fire TV*)
typehardware_type (IoT, Television)
osos_name (Fire OS)
hostnameThe inventory hostname, else the DHCP hostname (amazon-*)
vciThe DHCP vendor class identifier (udhcp*)
mac_prefixAn OUI or longer prefix, aa:bb:cc (or with -); a prefix match, no globs

networks

1{2  "interface": "internal",3  "mode": "slices",4  "group": "default",5  "slices": [{"group": "tvs", "cidr": "192.168.1.200/29"}],6  "manage_reservations": false7}
KeyTypeDefaultValidation and meaning
interfacestringRequired, unique. The FortiGate interface name
modestring"observe"subnet, slices, hybrid or observe (strategies)
groupstringdefault_groupA listed group. In subnet and hybrid, the subnet maps to its Pi-hole group; its members need no slice here
slicesarray[]{"group", "cidr"} pairs
manage_reservationsbooleanfalseMay sync add reservations and start migrations on this network

Each slice's group must be a listed group; its cidr an IPv4 CIDR (a.b.c.d/n) with the host bits zero, not overlapping another slice of the same network. Further rules:

  • subnet mode takes no slices (use hybrid).
  • In slices mode the rest of the subnet stays with Pi-hole's Default group, so the network's group must not have a pihole_group (use hybrid).
  • Whether a slice lies inside the interface subnet and outside the DHCP pools is checked against the FortiGate by slices and plan, which report conflicts.

pihole

1{"url": "https://pihole.example.net", "name": "default", "timeout_ms": 10000}
KeyTypeDefaultValidation and meaning
urlstring""Empty: Pi-hole sync is off. Must start with https://; http:// is accepted only for a loopback host. A trailing / (and /api) is dropped; a path prefix behind a reverse proxy is kept. No credentials, query or fragment
ca_bundlestring""A PEM file of extra trusted CAs; verification is never turned off
namestring"default"2-24 characters of [A-Za-z0-9_-]. Names the stored password: pihole/<name>/password
timeout_msinteger100001000-120000. The whole request; connecting takes at most 10 s of it

The starting configuration

forti-pihole init writes exactly this (also installed as share/forti-pihole/config.example.json):

1{2  "$comment": "forti-pihole configuration. Groups are FortiGate address groups <prefix>grp-<name> (edit membership in the FortiGate GUI) mapped to Pi-hole groups. Nothing is written until enabled is true.",3  "adopt_existing": true,4  "assignments": {},5  "default_group": "default",6  "enabled": false,7  "groups": [8    {"description": "The operator's own devices", "name": "trusted", "privileged": true},9    {"description": "Work computers", "name": "work", "privileged": true},10    {"description": "TVs and streaming sticks", "name": "tvs"},11    {"description": "IoT devices with a known, needed cloud", "name": "iot_trusted"},12    {"description": "Commodity IoT (plugs, bulbs)", "name": "iot_untrusted"},13    {"description": "Everything not yet classified", "name": "default"}14  ],15  "max_changes": 50,16  "networks": [17    {"group": "default", "interface": "internal", "manage_reservations": false, "mode": "observe", "slices": []}18  ],19  "pihole": {"name": "default", "url": ""},20  "prefix": "fp-",21  "profile": "",22  "protected": [],23  "retire_after_days": 30,24  "rules": [25    {"group": "iot_untrusted", "match": {"vendor": "TP-Link"}, "name": "kasa-plugs", "priority": 200},26    {"group": "iot_untrusted", "match": {"family": "Hue", "vendor": "Philips"}, "name": "hue"},27    {"group": "tvs", "match": {"family": "Fire TV*", "vendor": "Amazon"}, "name": "fire-tv"},28    {"group": "tvs", "match": {"type": "Television"}, "name": "smart-tvs"},29    {"group": "iot_trusted", "match": {"vendor": "Ring"}, "name": "ring"},30    {"group": "iot_trusted", "match": {"vendor": "Rain Bird"}, "name": "rain-bird"},31    {"group": "iot_trusted", "match": {"vendor": "Chamberlain"}, "name": "myq"}32  ]33}

daemon.json

$FORTI_HOME/pihole/daemon.json, read by forti-piholed at start and on SIGHUP. Absent: every default. It holds no secret. forti-piholed --check-config validates it and prints the effective settings.

KeyTypeDefaultValidation and meaning
profilestring""1-64 characters of [A-Za-z0-9_-]; empty: forti's default profile. --profile overrides it. Gives the FortiGate's address (the default allowlist) and the token's binding
listen.addressstring"127.0.0.1"An IPv4 or IPv6 address literal, never a name. IPv6 sockets are v6-only
listen.portinteger87351-65535
allow_sourcesarray of strings[]Addresses or CIDR blocks (host bits zero) allowed to send events. Empty: the profile FortiGate's resolved address
limits.max_header_bytesinteger81921024-65536
limits.max_body_bytesinteger655361024-1048576
limits.read_timeout_msinteger5000100-60000. The whole life of a connection, accept to last byte
limits.max_connectionsinteger161-256
rate_limit.burstinteger301-10000, per source address
rate_limit.per_minuteinteger1201-60000, per source address
schedule.debounce_secondsinteger101-3600. Quiet time after an event before a run
schedule.max_delay_secondsinteger601-86400, at least debounce_seconds. The longest a burst of events delays a run
schedule.full_reconcile_minutesinteger151-10080. A full sync this long after the last run
schedule.backoff_initial_secondsinteger101-3600. The first retry delay after a failure
schedule.backoff_max_secondsinteger9001-86400, at least backoff_initial_seconds
schedule.suppression_secondsinteger600-3600. How long events naming objects a run wrote are treated as its echo
event_log.max_mibinteger41-1024. events.jsonl rotates to events.jsonl.1 at this size
token_accountstring""A secret-store account name (segments of [A-Za-z0-9_.-], at most 128 characters). Empty: pihole/<profile>/webhook-token, where events install stores it
1{2  "$comment": "forti-piholed settings; copy to $FORTI_HOME/pihole/daemon.json. Plain HTTP: bind to the management interface only. The webhook token lives in forti's secret store, never here.",3  "profile": "",4  "listen": {"address": "127.0.0.1", "port": 8735},5  "allow_sources": [],6  "limits": {"max_header_bytes": 8192, "max_body_bytes": 65536, "read_timeout_ms": 5000, "max_connections": 16},7  "rate_limit": {"burst": 30, "per_minute": 120},8  "schedule": {9    "debounce_seconds": 10,10    "max_delay_seconds": 60,11    "full_reconcile_minutes": 15,12    "backoff_initial_seconds": 10,13    "backoff_max_seconds": 900,14    "suppression_seconds": 6015  },16  "event_log": {"max_mib": 4},17  "token_account": ""18}

That is share/forti-pihole/daemon.example.json: every key with its default.