Skip to content

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-batch and clean-sessions NATS 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 HTTP 200 once the instance is active and has finished loading state, and HTTP 503 while it is starting, waiting for the HA lock, or loading from Redis. The response body is always a JSON go-healthz status payload, not a plain OK string. 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_cert additionally enables mTLS.
  • When http.api.auth_token is set, every request must carry that shared secret in the X-API-Key header. 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:

  1. Start. A new session is created with one or more IP allocations.
  2. Update. Session metadata or allocations are modified. Fields like org and revision use merge semantics: an empty or zero incoming value leaves the stored value unchanged.
  3. Renew. The session's expiry is extended without other changes.
  4. 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.
  5. 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 expiry elapses, 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 of expiry).
  • 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 /healthz to drive failover and surface which replica is active.
  • An HA proxy placed between the RADIUS Listener and the IP Mapper can use /healthz as its backend health check, routing session traffic only to the instance that returns 200. When the active instance exits or loses Redis and a passive instance takes over the lock, its /healthz flips to 200 and 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.