ioc2rpz Protocol Support

June 26, 2026 · View on GitHub

DNS Query Handling

ioc2rpz listens on multiple transport protocols for DNS queries. All transports share the same query processing pipeline: rate limiting, TSIG validation, zone lookup, and response generation.

UDP (Port 53)

UDP is used for lightweight DNS queries, primarily SOA lookups. The ioc2rpz_udp module opens a UDP socket with {active, true} and spawns a new process for each incoming packet.

  • SOA queries are the primary use case over UDP
  • Responses exceeding 512 bytes are truncated by the server, which sets the TC (truncation) bit in the response header per RFC 1035 §4.2.1, prompting the client to retry over TCP (see send_dns_udp/5)
  • Management commands are not supported over UDP
# SOA query over UDP
dig @127.0.0.1 zone.ioc2rpz SOA -y hmac-sha256:keyname:base64key

TCP (Port 53)

TCP handles zone transfers (AXFR/IXFR), SOA queries, and management commands. A pool of 5 pre-spawned accept workers handles incoming connections via simple_one_for_one supervisor strategy.

  • Zone transfers (AXFR/IXFR) require TCP
  • Management commands require TCP (or DoT)
  • Default TCP timeout: 3000ms (?TCPTimeout)
  • Accept calls use a 30-second timeout; on timeout the worker re-enters the accept loop instead of blocking indefinitely (the timeout applies only to waiting for a new connection, not to an in-progress transfer)
# AXFR zone transfer over TCP
dig @127.0.0.1 zone.ioc2rpz AXFR +tcp -y hmac-sha256:keyname:base64key

# IXFR incremental transfer
dig @127.0.0.1 zone.ioc2rpz IXFR=12345 +tcp -y hmac-sha256:keyname:base64key

DNS over TLS / DoT (Port 853)

DoT encrypts DNS traffic using TLS. The TLS listener starts automatically when a cert record is present in the configuration. A pool of 5 TLS accept workers handles connections.

  • Supported TLS versions: 1.2 and 1.3 (?TLSVersion = 'tlsv1.2-1.3')
  • Supports AXFR, IXFR, SOA queries, and management commands
  • TLS PIN is not supported
  • DNS NOTIFY messages are sent unencrypted (plain TCP)
  • Accept calls use a 30-second timeout; on timeout the worker re-enters the accept loop instead of blocking indefinitely (the timeout applies only to the accept phase, not to the TLS handshake or an in-progress transfer)
  • Certificates auto-refresh when files are replaced on disk (up to ~2 minute delay due to Erlang SSL caching)
# SOA query over DoT
kdig @127.0.0.1 -p 853 zone.ioc2rpz SOA +tls -y hmac-sha256:keyname:base64key

# AXFR over DoT
dig @127.0.0.1 -p 853 zone.ioc2rpz AXFR +tls +tcp -y hmac-sha256:keyname:base64key

Certificate configuration:

{cert, {"cfg/cert.pem", "cfg/key.pem", "cfg/cacerts.pem"}}.

DNS over HTTPS / DoH (Port 443)

DoH provides DNS resolution over HTTPS, handled by the ioc2rpz_doh module using Cowboy. The endpoint is /dns-query.

Supported methods:

  • GET with base64url-encoded DNS message in ?dns= query parameter
  • POST with Content-Type: application/dns-message body

Responses use Content-Type: application/dns-message. A POST request with an empty body (or any request without a decodable DNS message) returns HTTP 400 Bad Request rather than a 500 / internal error.

POST request bodies are limited to 4096 bytes; a request whose body exceeds this limit receives HTTP 413 Payload Too Large and is not processed. This cap applies to POST bodies only — GET requests carry the query in the dns query-string parameter, which is bounded separately by Cowboy's request-line and header size limits. 4096 bytes is well above the size of any DNS query (including EDNS0 and TSIG).

# DoH GET request (base64url-encoded DNS query)
curl -H "Accept: application/dns-message" \
  "https://127.0.0.1:443/dns-query?dns=AAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB" -k

# DoH POST request
curl -X POST -H "Content-Type: application/dns-message" \
  --data-binary @dns_query.bin \
  "https://127.0.0.1:443/dns-query" -k

Zone Transfer Support

AXFR (Full Zone Transfer)

AXFR serves the complete RPZ zone. Pre-built zone packets are stored compressed in the rpz_axfr_table ETS table. On request, packets are decompressed and wrapped with SOA/NS/TSIG records.

  • Requires TCP or DoT transport
  • TSIG authentication required (unless zone has empty key list)
  • Zone update interval configured per-RPZ via AXFR_Time (seconds)
  • Maximum DNS packet size: 16383 bytes (?DNSPktMax)
  • Compression level: 6 (?Compression, zlib 0-9)

IXFR (Incremental Zone Transfer)

IXFR serves only the changes since a given serial. Individual IOC records are stored in rpz_ixfr_table with serial numbers and expiration timestamps.

  • Requires TCP or DoT transport
  • TSIG authentication required
  • Incremental update interval configured per-RPZ via IXFR_Time (seconds, 0 = disabled)
  • Falls back to AXFR if requested serial is older than serial_ixfr
  • IXFR cache is flushed after each full AXFR update

TSIG Authentication

Zone transfers are authenticated using TSIG (RFC 2845). Supported algorithms:

AlgorithmConfig Value
HMAC-MD5"md5"
HMAC-SHA256"sha256"
HMAC-SHA512"sha512"

Keys are configured as Erlang terms:

{key, {"keyname", "sha256", "base64encodedkey=="}}.
{key, {"keyname", "sha256", "base64encodedkey==", ["group1", "group2"]}}.

RPZ zones reference keys by name or group:

%% Direct key references
["dnsproxykey_1", "dnsproxykey_2"]

%% With key groups
["dnsproxykey_1", {groups, ["customers", "public"]}]

Generate TSIG keys with dnssec-keygen:

dnssec-keygen -a HMAC-SHA256 -b 256 -n USER my-tsig-key

DNS Management Commands

Management is supported over TCP and DoT using DNS queries with class CHAOS and type TXT. A designated management TSIG key is required.

CommandRR NameAction
Server statusioc2rpz-statusReturns current server status
Reload configioc2rpz-reload-cfgReloads configuration file
Update TSIG keysioc2rpz-update-tkeysReloads TSIG keys only
Update all zonesioc2rpz-update-all-rpzForces full refresh of all RPZ zones
Update one zone<zone_name>Forces full refresh of a specific zone
Shutdownioc2rpz-terminateGraceful server shutdown
Sample zonesample-zone.ioc2rpz (class IN, type AXFR)Returns a sample RPZ zone
Sample zone SOAsample-zone.ioc2rpz (class IN, type SOA)Returns the sample zone's SOA (no longer NOTAUTH)
# Check server status
dig +tcp -y hmac-sha256:mgmtkey:base64key== @127.0.0.1 ioc2rpz-status TXT -c CHAOS

# Reload configuration
dig +tcp -y hmac-sha256:mgmtkey:base64key== @127.0.0.1 ioc2rpz-reload-cfg TXT -c CHAOS

# Force update a specific zone
dig +tcp -y hmac-sha256:mgmtkey:base64key== @127.0.0.1 dga.ioc2rpz TXT -c CHAOS

Management over DNS can be disabled by setting MGMToDNS to false in include/ioc2rpz.hrl.

REST API

The REST API runs on port 8443 over HTTPS (requires cert configuration). It uses Cowboy's cowboy_rest behavior.

Authentication

  • HTTP Basic authentication using TSIG key name as username and base64-encoded key as password
  • IP-based ACL restricts access to configured addresses
  • Both conditions must be satisfied
  • All endpoints accept both GET and POST methods
curl -u "keyname:base64key==" --insecure https://127.0.0.1:8443/api/v1/stats/serv

Response Formats

Set via Accept header:

  • application/json (default)
  • text/plain
# JSON response (default)
curl -u "keyname:key==" -k https://127.0.0.1:8443/api/v1/stats/serv

# Plain text response
curl -u "keyname:key==" -k -H "Accept: text/plain" https://127.0.0.1:8443/api/v1/stats/serv

Endpoints

The API version segment is optional (e.g., /api/v1/... or /api/v1.0/...).

Statistics

GET|POST /api/v1/stats/serv — Server statistics (node name, total rules, memory usage)

{
  "node_name": "ioc2rpz@hostname",
  "srv_total_rules": 15000,
  "hot_cache_mem": "12.5 Mb",
  "axfr_table_mem": "45.2 Mb",
  "ixfr_table_mem": "8.1 Mb"
}

GET|POST /api/v1/stats/rpz — RPZ zone statistics

{
  "rpz": [
    {
      "name": "malware.ioc2rpz",
      "status": "ready",
      "rule_count": 5000,
      "ioc_count": 4500,
      "serial": 1709000000,
      "serial_ixfr": 1708990000,
      "update_time": 1709000000,
      "ixfr_update_time": 1708995000,
      "ixfr_nz_update_time": 1708995000
    }
  ]
}

The status field reports the zone's current state: ready (counts/serial are current), updating (a full/incremental update is in progress), forceAXFR (a config reload changed the zone's sources/whitelist and a full rebuild is pending), or notready (no data loaded yet). When status is updating or forceAXFR, the reported counts and serial reflect the last completed update until the in-progress update finishes.

Counts, serial, and update timestamps are preserved across a configuration reload for zones that existed before the reload (including non-cached/online zones), so stats do not reset to zero while the zone is re-validated.

GET|POST /api/v1/stats/source — Source statistics

{
  "sources": [
    {"name": "sample_fqdn", "ioc_count": 150}
  ]
}

Zone Management

GET|POST /api/v1/update/all_rpz — Force full refresh of all RPZ zones

{"status": "ok", "msg": "All RPZ zones will be updated"}

GET|POST /api/v1/update/:rpz_name — Force full refresh of a specific zone

{"status": "ok", "msg": "RPZ malware.ioc2rpz will be updated"}

Error (HTTP 520): {"status": "error", "msg": "RPZ nonexistent.zone not found"}

Server Management

GET|POST /api/v1/mgmt/reload_cfg — Reload configuration file

{"status": "ok", "msg": "Configuration reloaded"}

Error (HTTP 520): {"status": "error", "msg": "Configuration reload error"}

GET|POST /api/v1/mgmt/update_tkeys — Reload TSIG keys from configuration

{"status": "ok", "msg": "TSIG keys were updated"}

Error (HTTP 520): {"status": "error", "msg": "TSIG keys update error"}

GET|POST /api/v1/mgmt/terminate — Graceful server shutdown

{"status": "ok", "msg": "Terminating"}

Cache Management

GET|POST /api/v1/cache/sources/clear/all — Remove all sources from hot cache

{"status": "ok", "msg": "All sources were removed from the hotcache"}

GET|POST /api/v1/cache/sources/clear/:source_name — Remove a specific source from hot cache

{"status": "ok", "msg": "sample_fqdn source was removed from the hot cache"}

GET|POST /api/v1/cache/sources/load/all — Reload all sources into hot cache

{"status": "ok", "msg": "All sources will loaded to the hot cache"}

Feed & IOC Queries

GET|POST /api/v1/feed/:rpz_name — Get indicators from an RPZ feed

Optional query parameter: ?type=fqdn|ip|both (default: both)

{
  "status": "ok",
  "rpz": "malware.ioc2rpz",
  "iocs": ["baddomain.com", "evil.example.org"]
}

Error (HTTP 520): {"status": "error", "msg": "RPZ malware.ioc2rpz not found"}

GET|POST /api/v1/ioc/:ioc — Check if an indicator is blocked by any RPZ feed

Optional query parameter: ?tkey=keyname to limit search to zones accessible by that key.

{
  "ioc": "baddomain.com",
  "tkey": "",
  "data": [
    {
      "ioc": "baddomain.com",
      "feeds": [
        {
          "feed": "malware.ioc2rpz",
          "wildcard": "true",
          "type": "fqdn",
          "rpz_serial": 1709000000,
          "ioc_expiration": 0
        }
      ]
    }
  ]
}

Error: {"status": "error", "ioc": "nonexistent.com"}

Error Responses

Authentication failure (HTTP 401):

{"status": "error", "msg": "Authentication failed"}

Zone/resource not found (HTTP 520):

{"status": "error", "msg": "RPZ nonexistent.zone not found"}

Unsupported endpoint (HTTP 200, should be 501):

{"status": "error", "msg": "Unsupported request"}

DNS NOTIFY

After a zone update (AXFR or IXFR), ioc2rpz sends DNS NOTIFY messages (RFC 1996) to configured secondary servers. This prompts secondaries to check the zone SOA and initiate a transfer if the serial has changed.

  • NOTIFY is sent over UDP (unencrypted, even when DoT is enabled)
  • Target IPs are configured per-RPZ in the NotifyList field
{rpz, {"zone.ioc2rpz", 7200, 3600, 2592000, 7200, "true", "true", "nxdomain",
       ["dnsproxykey_1"], "mixed", 86400, 3600,
       ["source1"],
       ["10.0.0.1", "10.0.0.2"],  %% NOTIFY targets
       []}}.

Rate Limiting

DNS queries are rate-limited using an intelligent (hybrid) key stored in an ETS table (rate_limits). The key is chosen per request by ioc2rpz:rl_key/5 so that legitimate multi-zone traffic and abusive query-name-variation traffic are treated differently:

Request classConditionRate-limit keyThreshold macro
Provisioned zone, supported QTYPEclass IN + SOA/AXFR/IXFR for a zone in cfg_table (incl. virtual sample-zone.ioc2rpz){client_IP, query_name, query_type} (granular)?MAX_REQUESTS_PER_WINDOW
Recognized management requestclass CHAOS + TXT with a known management command (ioc2rpz-status, ioc2rpz-reload-cfg, ioc2rpz-update-tkeys, ioc2rpz-terminate, ioc2rpz-update-all-rpz) or a provisioned zone name (force-AXFR){client_IP, query_name, query_type} (granular)?MAX_REQUESTS_PER_WINDOW
Everything elsenon-existent/unprovisioned zone, unsupported QTYPE, wrong class, or unrecognized CHAOS/TXT name{client_IP} (aggregate per-IP)?MAX_UNKNOWN_REQUESTS_PER_WINDOW

The granular bucket means a legitimate secondary polling/transferring several zones (e.g. rpz1, rpz2, rpz3) plus management from one IP is counted independently per zone+type and is not starved. The aggregate bucket means an attacker cannot multiply their effective limit by varying the query name (random subdomains, junk CHAOS/TXT names) — all such traffic shares a single per-IP counter.

ParameterValueMacro
Window10 seconds?RATE_LIMIT_WINDOW (10000 ms)
Max requests per window (granular: known zone + type / management)1?MAX_REQUESTS_PER_WINDOW
Max requests per window (aggregate: unknown zone / unsupported type)1?MAX_UNKNOWN_REQUESTS_PER_WINDOW

The threshold is selected by key shape in ioc2rpz_fun:check_rate_limit/1 (a 1-tuple {IP} uses the aggregate limit; a 3-tuple {IP, QName, QType} uses the granular limit). When the rate limit is exceeded, the server returns a DNS REFUSED response and logs a CEF event (code 429).

Rate limiting applies to all DNS query transports (UDP, TCP, TLS, DoH).

Supported DNS Record Types

Query Types

TypeDescriptionUsage
SOAStart of AuthorityZone serial checks, SOA queries
AXFRFull zone transferComplete RPZ download
IXFRIncremental zone transferDelta RPZ updates
TXTText recordManagement commands (class CHAOS)
AIPv4 addressRPZ redirect responses
AAAAIPv6 addressRPZ redirect responses
CNAMECanonical nameRPZ redirect responses

RPZ Actions

RPZ actions define how matching DNS queries are handled by the consuming DNS resolver. Actions are configured per-zone.

ActionConfig ValueRPZ CNAME TargetDescription
NXDOMAIN"nxdomain"*.Returns NXDOMAIN (domain does not exist)
NODATA"nodata"*. (with NODATA encoding)Returns empty answer (domain exists, no records)
Passthru"passthru"rpz-passthru.Allows the query (exemption rule)
Drop"drop"rpz-drop.Silently drops the query
TCP-Only"tcp-only"rpz-tcp-only.Forces client to retry over TCP
Block NS"blockns"Nameserver blockingBlocks the authoritative nameserver
Redirect (domain){"redirect_domain", "example.com"}Target FQDNRedirects to a specified domain (alias for local_cname)
Redirect (IP){"redirect_ip", "1.2.3.4"}Target IPRedirects to a specified IP (alias for local_a/local_aaaa)

Local Record Actions

Multiple local records can be combined in a single RPZ zone:

[
  {"local_a", "127.0.0.1"},
  {"local_a", "127.0.0.2"},
  {"local_aaaa", "fe80::1"},
  {"local_cname", "www.example.com"},
  {"local_txt", "Blocked by policy"}
]
Local ActionRecord TypeDescription
local_aAReturns an IPv4 address
local_aaaaAAAAReturns an IPv6 address
local_cnameCNAMEReturns a CNAME redirect
local_txtTXTReturns a text record

IOC Types

Each RPZ zone is configured with an IOC type that determines what kind of indicators it contains:

TypeConfig ValueDescription
FQDN"fqdn"Domain name indicators only
IP"ip"IPv4/IPv6 address indicators only
Mixed"mixed"Both domain and IP indicators

When wildcards is set to "true", wildcard RPZ rules (e.g., *.malware.com) are automatically generated for FQDN indicators.

Port Summary

PortProtocolServiceCondition
53UDPDNS queries (SOA)Always
53TCPDNS queries, zone transfers, managementAlways
853TCP+TLSDoT (same as TCP but encrypted)Requires cert config
443TCP+TLSDoH (/dns-query endpoint)Requires cert config
8443TCP+TLSREST APIRequires cert config

References