Architecture¶
The IP Mapper sits at the write end of Platform Filter's subscriber-tracking pipeline. It receives session events, maintains the authoritative in-memory session state, and persists every change to Redis. Downstream consumers do not read from the IP Mapper directly — they receive updates through Redis pubsub, written to a local LMDB by the Settings Syncer.
Role in Platform Filter¶
The pipeline has two distinct halves around Redis:
Write side. Session sources push events into the IP Mapper, which maintains the in-memory session table and writes every change to Redis.
- The RADIUS Listener forwards RADIUS accounting events.
- The Admin Portal can push session changes directly.
- The Organization Manager (optional, 2.11+) manages IP CIDR prefix allocations and
attribute overrides per organization. It computes effective IP ranges using a
most-specific-CIDR-wins algorithm and deploys changes to the IP Mapper via the
set-session-batchandclean-sessionsNATS commands.
Read side. The Settings Syncer subscribes to Redis pubsub and receives session changes as they are written. It writes the received data into a local LMDB database. The DNS filtering consumers — Recursor, DNSdist, and the Simulator — read directly from that LMDB to make per-subscriber filter decisions at query time.
flowchart LR
RL[RADIUS Listener] -- session events --> IM[(IP Mapper)]
AP[Admin Portal] -- session events --> IM
OM["Organization Manager\n(optional, 2.11+)"] -- set-session-batch / clean-sessions --> IM
IM -- persist + publish --> R[(Redis)]
R -. load on startup .-> IM
R -- pubsub --> SS[Settings Syncer]
SS -- write --> DB[(LMDB)]
DB -. read .-> REC[Recursor]
DB -. read .-> DD[DNSdist]
DB -. read .-> SIM[Simulator]
Interfaces¶
The IP Mapper exposes three independently-enabled network interfaces. The NATS and HTTP interfaces are fully equivalent: they expose the same four session operations, accept the same JSON request bodies, and return the same JSON response bodies. Both are backed by a single internal session service, so a caller can move between the two transports without changing any payload.
The RESP interface is more limited. It exposes only the single-session
create/update operation — through the IPMAPPER SESSION command and the legacy
Lua script interface — and does not offer the batch, query, or clean
operations. It also replies differently: a successful IPMAPPER SESSION
returns the Redis simple string OK (and failures a RESP error), not the JSON
response body that NATS and HTTP return.
| Operation | NATS command | RESP | HTTP endpoint (POST) |
|---|---|---|---|
| Create or update one session | set-session |
IPMAPPER SESSION |
/api/v1/sessions |
| Create or update many sessions | set-session-batch |
— | /api/v1/sessions/batch |
| Query sessions by username or IP | get-sessions |
— | /api/v1/sessions/query |
| Remove sessions for an org | clean-sessions |
— | /api/v1/sessions/clean |
RESP can still read raw session data through the Redis query commands
(HGET, HGETALL, SMEMBERS, …), but that is index-level access rather than
the structured get-sessions query. The request and response payloads shared
by NATS and HTTP are documented under HTTP below.
NATS¶
The primary API. The service registers as a NATS micro-service named
ipmapper using the itsy framework.
Subject paths follow the pattern <prefix>.ipmapper.<command>.
| Command | Description |
|---|---|
set-session |
Create or update a single session. |
set-session-batch |
Create or update multiple sessions in one call. |
get-sessions |
Query sessions by username or IP address. |
clean-sessions |
Remove sessions for an org, with optional filters. |
See Configuration → nats for connection details,
and org and revision below for the clean-sessions filter
semantics.
RESP (Redis protocol emulation)¶
A TCP listener that speaks the Redis RESP wire protocol. It serves two audiences:
Legacy Lua script interface. The RADIUS Listener's redis-script mode
invokes setSession, renewSession, and endSession commands — the same
interface originally provided by the pdns-pf-redis-script.lua Lua script.
The IP Mapper implements this interface directly, so the RADIUS Listener needs
no code change to switch from Redis+Lua to the IP Mapper. Do note that the legacy redis-script is deprecated since 2.10 and this mode will be removed in a future version.
IPMAPPER command family. A richer json based interface for direct access:
| Subcommand | Description |
|---|---|
IPMAPPER SESSION |
Create or update a session via JSON payload. |
IPMAPPER STATS |
Return session and index statistics. |
IPMAPPER GC |
Force garbage collection and report memory usage. |
IPMAPPER CHECK |
Verify index consistency (slow, locks state). |
IPMAPPER DEFAULT_REGION |
Set the default region for a connection. |
IPMAPPER LIST_REGIONS |
List known regions. |
The RESP server also supports read-only Redis query commands (HGET,
HGETALL, SMEMBERS, HSCAN, ZSCAN) for reading session data from the
internal indexes.
HTTP¶
A single HTTP server, bound to http.address, serves both operational
endpoints and — when enabled — the session API.
Operational endpoints, always available when the HTTP server is enabled:
/— HTML status page showing active sessions, memory usage, Redis pool stats, and a readiness indicator./healthz— readiness probe. Returns HTTP200once the instance is active and has finished loading state, and HTTP503while it is starting, waiting for the HA lock, or loading from Redis. The response body is always a JSONgo-healthzstatus payload, not a plainOKstring. See High availability → Monitoring availability with/healthz./metrics— Prometheus metrics endpoint./debug/pprof/— Go pprof handlers for CPU, heap, and goroutine profiles.
Session API, enabled with http.api.enabled.
Every endpoint is a POST request that takes and returns JSON. Each endpoint's
request and response bodies are identical to those of its matching NATS command
(see the interface table at the top of this section).
| Endpoint | Request body | Purpose |
|---|---|---|
POST /api/v1/sessions |
a session object | Create or update a single session. |
POST /api/v1/sessions/batch |
an array of session objects | Create or update many sessions in one call. |
POST /api/v1/sessions/query |
a query object (username/ip, or usernames/ips, optional region) |
Return sessions matching a username or IP. |
POST /api/v1/sessions/clean |
a clean request (org, optional region, subnets, revision_lt) |
Remove sessions for an org, with optional filters. |
Every response is a JSON object with a success boolean and, depending on the
operation, data (query results), stats (per-operation counters such as
success/failure for a batch or removed for a clean), and errors (a list
of per-item error strings). The session object fields are described under
Sessions and allocations, Org and revision,
and Regions below.
Authentication and transport security¶
The session API reuses the HTTP listener's TLS settings
(http.tls):
- With a server certificate configured, the listener serves HTTPS; adding a
client CA together with
require_client_certadditionally enables mTLS. - When
http.api.auth_tokenis set, every request must carry that shared secret in theX-API-Keyheader. The token is compared in constant time. Because the token would otherwise travel in clear text, the service refuses to start with a token configured but no TLS. - With no
auth_token, the API is open to anyone who can reach the port — keep it behind TLS/mTLS or a trusted network boundary.
Requests larger than http.api.max_body_size
(default 8 MB) are rejected. The handler maps outcomes to standard status codes:
| Status | Condition |
|---|---|
200 OK |
Request handled — inspect success in the body for per-item outcomes. |
400 Bad Request |
Malformed JSON or a handler-level error. |
401 Unauthorized |
Missing or incorrect X-API-Key. |
404 Not Found |
Unknown path. |
405 Method Not Allowed |
A method other than POST. |
413 Request Entity Too Large |
Body exceeds max_body_size. |
A minimal session create over HTTPS with an API key:
curl https://ipmapper.example.net:5556/api/v1/sessions \
-H "X-API-Key: $PDNS_PF_IPMAPPER_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"action": "start",
"session": "sess-123",
"username": "alice",
"region": "north",
"networks": [{"cidr": "203.0.113.0/24"}],
"ttl": 3600
}'
Session lifecycle¶
A session progresses through these states:
- Start. A new session is created with one or more IP allocations.
- Update. Session metadata or allocations are modified. Fields like
organdrevisionuse merge semantics: an empty or zero incoming value leaves the stored value unchanged. - Renew. The session's expiry is extended without other changes.
- Stop. The session is moved to a muted state for a configurable period (default 30 seconds) before being removed. This absorbs out-of-order RADIUS messages that arrive after a Stop.
- Expiry. Sessions that exceed their TTL are removed by a background cleanup task (default interval: 35 seconds).
Core concepts¶
Sessions and allocations¶
A session represents one active subscriber connection. It has a stable session ID and carries metadata (username, location, org, region) alongside one or more IP allocations (IPv4/IPv6 addresses, CIDRs, or port ranges).
The IP Mapper keeps multiple indexes over sessions so that a lookup can be answered from any of: IP address, session ID, opaque ID, username, org, or region.
Opaque IDs¶
Each session has an opaqueid, which defaults to the session ID when not
set. Opaque IDs support a suffix convention that allows a single logical
subscriber to appear under multiple sessions (e.g. for CG-NAT port
blocks). A lookup by bare opaque ID returns all sessions sharing that base ID.
Stolen allocations¶
When a new session claims an IP address that is already held by an existing session, the allocation is transferred to the new session and recorded as a stolen allocation on the original. The original session is not removed — only its allocation is updated. This handles overlapping RADIUS events without dropping either session.
Regions¶
Sessions carry an optional region field used in CG-NAT deployments where
subscriber IP ranges are partitioned by geographic or network region. The
IPMAPPER DEFAULT_REGION command sets the default region for a RESP
connection, so a client that always works in one region does not need to
specify it per command.
Org and revision¶
Sessions carry two optional fields for org-level lifecycle management:
org (string). An organization identifier. When set, the session is
indexed under that org, enabling clean-sessions to remove all sessions for
an org in one call. An update with a non-empty org replaces the stored
value; an update that omits it leaves it unchanged.
revision (uint64). A caller-supplied version number. Zero means unset.
On an update with revision > 0 the stored revision is replaced; revision = 0
leaves it unchanged. The clean-sessions command accepts an optional
revision_lt filter to remove only sessions older than a given revision.
Values above 53 bits (9007199254740991) may lose precision when carried
through JSON-to-float64 conversions in some clients.
High availability¶
The IP Mapper supports an optional active/passive HA mode for deployments
that do not have a cluster-controller-based failover mechanism. It is enabled
under redis.lock.
When enabled, every IP Mapper instance starts up and immediately tries to
acquire a single named mutex in Redis (key pdns-ipmapper-global-mutex).
Exactly one instance — the active one — succeeds; the rest become
passive and block on the lock without opening their NATS or RESP service
interfaces. The HTTP server is already running at that point so /healthz and
/metrics remain available. The active instance refreshes the lock on a fixed
interval (extend_interval, default 10s) against a longer TTL (expiry,
default 5m).
Failover happens when:
- The active instance exits cleanly. It releases the lock on shutdown and a passive instance acquires it within roughly one second (the passive retry interval).
- The active instance loses Redis connectivity. The lock is not refreshed; once
expiryelapses, a passive instance acquires the freed lock. The previously active instance exits with an error once it observes that extension has been failing long enough for the lock to be considered lost (when the elapsed failure time exceeds two-thirds ofexpiry). - Another process steals the lock. The active instance detects this on its next extension attempt and exits with an error, so the system can converge to a single owner.
This mode is off by default because most deployments run a single IP Mapper instance or use external clustering. Turn it on only when you intend to run more than one process against the same Redis-backed dataset.
Warning
The HA lock guards the IP Mapper process, not the underlying Redis state. All instances must point at the same Redis backend; otherwise each will acquire its own lock independently and run concurrently against separate datasets.
Monitoring availability with /healthz¶
The /healthz HTTP endpoint exposes per-instance readiness and is the
recommended signal for an external load balancer or cluster controller to
discover which instance is currently active. It reflects the internal
ipmapper-ready check:
| Instance state | /healthz |
JSON body signal |
|---|---|---|
| Starting up | 503 |
ok: false, status: "Unavailable", failures["ipmapper-ready"]: "starting" |
| Passive — waiting for the HA lock | 503 |
ok: false, status: "Unavailable", failures["ipmapper-ready"]: "waiting for redis lock" |
| Lock acquired, still initializing | 503 |
ok: false, status: "Unavailable", failures["ipmapper-ready"]: "redis lock acquired" |
| Loading sessions from Redis | 503 |
ok: false, status: "Unavailable", failures["ipmapper-ready"]: "loading from redis" |
| Active — lock held and state loaded | 200 |
ok: true, status: "OK", empty failures map |
Because the HTTP server is started before the lock is acquired, passive
instances still answer /healthz — they just report 503 until they win the
lock. Exactly one instance per Redis backend returns 200 at any time, so a
health-checking proxy converges on the single active replica without needing to
understand the lock protocol itself. If a load balancer validates the response
body, configure it against the JSON payload, for example ok: true or
status: "OK" on a 200 response, rather than a bare OK body.
This makes /healthz the integration point for keeping the RADIUS Listener
(or any other session source) pointed at the live IP Mapper:
- CloudControl (or another cluster controller) can poll
/healthzto drive failover and surface which replica is active. - An HA proxy placed between the RADIUS Listener and the IP Mapper can use
/healthzas its backend health check, routing session traffic only to the instance that returns200. When the active instance exits or loses Redis and a passive instance takes over the lock, its/healthzflips to200and the proxy redirects traffic to it — no client-side reconfiguration needed.
flowchart LR
RL[RADIUS Listener] --> LB{HA proxy}
LB -- 200 OK --> IM1[(IP Mapper · active)]
LB -. 503 .-> IM2[(IP Mapper · passive)]
LB -. health check /healthz .-> IM1
LB -. health check /healthz .-> IM2
IM1 <--> R[(Redis · HA lock)]
IM2 <--> R
Health checks are cheap and have no side effects, so they can be polled frequently (e.g. every one to two seconds) to keep failover detection within the passive retry window described above.
See Metrics → Is this instance the active HA replica? for the gauges that expose lock state, and Operations → Observability for the full list of HTTP surfaces.
Redis persistence¶
Every session change is queued and written to Redis asynchronously. On startup
the service loads all sessions from Redis before opening its interfaces, so the
in-memory state is consistent after a restart. Use --no-redisload to skip the
load (useful for testing or seeding an empty instance) and --no-redis to
disable persistence entirely.