Q-Feeds delivers curated indicators of compromise (IPs and domains) on a schedule. The OPNsense plugin is purpose-built to consume the IP feeds, and the official documentation assumes you’ll feed the domain side into Unbound. If you’re running AdGuard Home as your primary DNS resolver instead of Unbound β as I am β that integration path doesn’t apply directly, and you have to wire the domain feeds in manually.
This post documents the working setup I landed on:
- DNS layer: Q-Feeds domain blocklist consumed directly by AdGuard Home as a custom blocklist URL.
- Packet layer: Q-Feeds malicious IP feed loaded as a pf table via the OPNsense plugin, blocked at the firewall through an Interface Group rule that covers every VLAN at once.
The two layers catch different evasion paths. DNS-layer blocks stop a device that uses your resolver from ever resolving a known-bad domain. Firewall-layer blocks catch traffic that bypasses your DNS β hardcoded IPs in malware, devices using DoH to external resolvers, scripts dialing IPs directly. Run both and the gaps narrow.
Prerequisites
- OPNsense 25.x or newer
- AdGuard Home running on OPNsense (e.g. via
os-adguardhome-maxit) or on a separate host - A Q-Feeds account at tip.qfeeds.com β the Community tier is free and includes OSINT-derived IP and domain feeds
- An API token generated from the Q-Feeds portal under Manage API Keys
Token hygiene: the token grants read access to your subscribed feeds. Treat it like a password. Don’t paste it into screenshots, chat tools, or pull request descriptions. Rotate it if it leaks.
Part 1 β AdGuard Home (DNS layer)
Verify the API response format
Before configuring anything, confirm what the API actually returns for your tier. From any machine with curl:
curl -s "https://api.qfeeds.com/api?feed_type=malware_domains&download=0&api_token=YOUR_TOKEN" | head -20
For the Community tier the response is a plain list of domains, one per line:
wave.taro5lin.surf
star.brightreef.surf
spark.taro5lin.surf
sky.brightreef.surf
...
This is exactly the format AdGuard Home parses natively as a domains-only blocklist β no transform script needed.
Add as a custom blocklist
In the AdGuard Home web UI:
Go to Filters β DNS blocklists β Add blocklist β Add a custom list.
Set the name to something obvious, e.g.
Q-Feeds Malware Domains.Paste the URL with your token:
https://api.qfeeds.com/api?feed_type=malware_domains&download=0&api_token=YOUR_TOKENSave. AdGuard fetches immediately. Check the Rules count column β it should show a number in the tens of thousands. If it’s 0, the URL didn’t return parseable content.
Set a sensible refresh interval
Settings β General settings β Filters update interval. Q-Feeds asks for a 20-minute minimum on their end; AdGuard’s shortest preset above that threshold is 1 hour. Pick that. The Community tier only updates every 24 hours upstream anyway, so anything more frequent just wastes calls.
Verify
Pick a domain from your curl output and resolve it through AdGuard:
nslookup wave.taro5lin.surf <your-adguard-ip>
Expected response: 0.0.0.0 (AdGuard’s default for blocks). In AdGuard Home β Query Log, the entry should show Blocked by Q-Feeds Malware Domains. That per-list attribution is the main reason for using a custom blocklist instead of chaining through Unbound.


Part 2 β OPNsense firewall (packet layer)
Install the Q-Feeds plugin
System β Firmware β Plugins β search os-q-feeds-connector β install.
Configure the plugin
Navigate to Security β Q-Feeds Connect β Settings:
- Paste your API token
- Enable the service
- Leave “Register domain feeds” OFF β AdGuard handles domains. Loading them into Unbound too would just duplicate work and split the block reporting across two systems.
- Apply
Bump the pf table size
Default pf table size is 200,000 entries. Q-Feeds IP feeds can exceed that and silently truncate.
System β Settings β Tunables β Add:
| Field | Value |
|---|---|
| Tunable | net.pf.request_maxcount |
| Value | 2000000 |
| Description | Increase pf table size for Q-Feeds |
Apply. A reboot is the safest way to pick this up; in practice it usually applies live.
Verify the alias loaded
Firewall β Aliases. Look for __qfeeds_malware_ip. The content count should be in the tens of thousands once a rule references it (pf only loads tables that are referenced by an active rule, so an empty count before step 5 is normal).
Create an Interface Group for all internal VLANs
This is the multi-VLAN part. Rather than copying the same block rule onto every interface tab β which gets tedious and breaks every time you add a VLAN β create one logical group containing every internal interface and write the rule once.
Firewall β Groups β Add:
- Name:
InternalNets - Description: All internal/trusted interfaces
- Members: Ctrl-click to select LAN and every internal VLAN (skip WAN, skip any guest/DMZ that you want to handle separately)
Save and apply. A new tab appears under Firewall β Rules called InternalNets.
Add the block rule on the group
Firewall β Rules β InternalNets β Add:
| Field | Value |
|---|---|
| Action | Block |
| Interface | InternalNets |
| Direction | in |
| TCP/IP Version | IPv4+IPv6 |
| Protocol | any |
| Source | any |
| Destination | Single host or Network β tick “Use alias” β __qfeeds_malware_ip |
| Log | β |
| Description | Block all internal β Q-Feeds malicious IPs |
Save, apply.
That single rule now blocks outbound traffic to known-bad IPs from every interface in the group. Add a new VLAN later β add it to the group β automatically covered.
Group-rule precedence: rules placed on an Interface Group are evaluated before rules on the individual interface tabs. That’s intentional here β you want the threat block to fire before any VLAN-specific allow rule could pass the packet.
Optional: WAN inbound block
Mostly cosmetic if you have no port forwards exposed (pf already drops unsolicited inbound), but it generates clean log entries showing scans from known-bad IPs.
Firewall β Rules β WAN β Add:
| Field | Value |
|---|---|
| Action | Block |
| Interface | WAN |
| Direction | in |
| Source | alias __qfeeds_malware_ip |
| Destination | any |
| Log | β |
| Description | Block Q-Feeds inbound |
Handling false positives
If a legitimate destination ends up on the malicious IP list, don’t rewrite the block rule. Add a small allowlist:
Create alias
qfeeds_allowlist(Type: Host(s) or Network(s)) and add your exceptions.On the same
InternalNetsrules tab, add a Pass rule above the block:- Action: Pass
- Source: any
- Destination: alias
qfeeds_allowlist - Description: Allowlist over Q-Feeds block
OPNsense Quick rules match top-down and stop on first match, so the Pass above lets specific destinations through before the broader Block fires.
Verification across VLANs
Pull an IP from the live alias:
# OPNsense shell
pfctl -t __qfeeds_malware_ip -T show | head -5
From a client in each VLAN, try to reach that IP:
curl -v --connect-timeout 3 http://<that-ip>/
Expected: every connection times out (no SYN-ACK ever returns). In Firewall β Log Files β Live View, filter by destination = that IP. You should see block entries tagged with the source interface (LAN, VLAN10, VLAN20, etc.). That tag is how you confirm the group rule is firing across every member, not just one.
For the DNS side, repeat the nslookup test from a host in each VLAN β every one should get 0.0.0.0 back from AdGuard.
What “working” looks like in practice
On a clean home network, don’t expect a flood of blocks. Typical signal:
- A handful of inbound blocks per hour from scanning bots (if you have port forwards).
- Occasional outbound blocks from telemetry endpoints, ad networks that overlap with malicious infrastructure, or compromised IoT devices.
- More activity on networks with lots of devices, BitTorrent traffic, or sketchy app installs.
If after a week the events are nearly empty even though your verification tests pass β that’s preventative security working as intended. The value is the absence of incidents, not the dashboard counter.



Why this layering matters
A device that’s been compromised has multiple ways to phone home:
- DNS lookup of a known-bad domain β caught by AdGuard via Q-Feeds domain blocklist.
- DNS lookup via DoH/DoT to an external resolver (bypassing AdGuard) β not caught at DNS layer, but the resulting connection to the malicious IP β caught by the firewall via the Q-Feeds IP alias.
- Hardcoded IP, no DNS at all β caught by the firewall.
- New domain not yet on any blocklist, hosted on a known-bad IP β caught by the firewall.
- New domain on a clean IP β not caught by either layer (this is where Suricata/CrowdSec/behavioral defenses earn their keep).
Q-Feeds gives you cases 1β4 with very low CPU cost. The remaining gap (case 5) is what Suricata and CrowdSec are for.
Notes and gotchas
- Token in config: the Q-Feeds API token sits in both AdGuard’s config (the custom blocklist URL) and OPNsense’s plugin config. Both files are readable by anyone with shell access to those hosts. If you back up these configs externally, the token rides along.
- Q-Feeds rate limits: their infrastructure is sized for 20-minute minimum updates per consumer. Don’t set anything more aggressive than that.
- Community tier delay: feeds update every 24 hours upstream on the free tier. Plus and Premium offer 4-hour and 20-minute refresh respectively. The IP feed is also where the paid tiers add commercial-grade enrichment.
- Adding more feed types: the discovery script in Part 1 also reveals which IP feed types are available. You can add additional URL Tables aliases manually for each, or β if you want plugin-managed aliases β check whether the OPNsense plugin auto-creates aliases for additional feed types when they’re enabled in your subscription.
Wrap-up
Two layers, one threat intelligence source, multi-VLAN coverage from a single firewall rule. AdGuard owns the DNS-layer blocks and gives you per-list reporting; the OPNsense plugin owns the IP-layer blocks and the events page. Each layer covers different evasion paths, and adding a new VLAN to the protected set is now a one-click operation (add it to InternalNets).
If something doesn’t behave as expected β empty alias, no log entries, AdGuard rules count of zero β work through the verification steps in order: API curl β AdGuard rules count β pf table count β live log on a known-bad-IP test connection. Most issues surface at one of those checkpoints.
