Module Documentation » IPv4 Pool Module

IPv4 Pool Module

PacketRF needs a single, consistent place to assign IPv4 addresses, because several pieces of the system want to do it: the USB interface needs an address to bring itself up; PPP needs a local/peer pair; the future DHCP server wants a range to hand out; the NPR master wants to allocate addresses to connecting slaves. Without one shared mechanism, each of those would invent its own small allocator with its own quirks. The pool module is that shared mechanism.

The design is intentionally not a generic enterprise IPAM framework. It is a small, deterministic, embedded-friendly allocator focused on PacketRF's actual needs.

What the module does

The pool module covers persistent pool configuration, the runtime view of static and dynamic pools, block allocation and reuse policy, temporary offers and committed allocations, lease expiration and cleanup, source-driven activation of dynamic pools, and stable sharing of one pool by multiple consumers. It is the single authority on "who currently holds which IPs from which range".

What it deliberately stays out of: parsing DHCP packets and running the DHCP lease state machine, deriving NPR network configuration, setting up interface-specific transport for USB or PPP, defining the control-interface commands that read and edit pools, and the internals of the persistent storage backend. Those live elsewhere — DHCP packet handling in the future DHCP service, NPR configuration in src/npr/runtime, USB and PPP wiring in src/net/network_config_provider.*, control endpoints under src/control and the owning modules, persistent storage in src/config.

Why pools exist (the practical case)

Some examples that drive the design:

USB and PPP can both draw from the same subnet without anyone hardcoding an address; the pool manager hands out non-overlapping addresses. Multiple DHCP server instances can share one allocation authority instead of fighting over the same range. An NPR slave publishes its learned address range exactly once, into one named dynamic pool, and the rest of the system consumes it without needing to know any NPR internals. An NPR master reserves a contiguous block for one slave through the same API that PPP uses for its address pair, instead of growing its own little allocator.

The model is inspired by RouterOS-style pools: pools are configured explicitly, services refer to them by name, the same pool can be used by more than one consumer, and the central allocator avoids conflicts.

Static and dynamic pools

A pool is one config section (pool1, pool2, …). It carries a type (static or dynamic), an effective IPv4 snapshot, an allocation table, and a generation counter that increments whenever the effective range changes.

A static pool has its range, mask, and optional gateway/DNS written directly in configuration:

pool1.enabled = true
pool1.type = "static"
pool1.ip_start = 0xC0A80A0Au   # 192.168.10.10
pool1.ip_size = 32
pool1.subnet_mask = 0xFFFFFF00u
pool1.gateway_active = true
pool1.gateway = 0xC0A80A01u
pool1.max_allocations = 16

It is valid as soon as configuration parses cleanly.

A dynamic pool does not carry its range. It declares a source interface and waits for that source to publish a snapshot at runtime:

pool1.enabled = true
pool1.type = "dynamic"
pool1.source_interface = "np2"
pool1.max_allocations = 16

Until the source publishes, the pool is configured but not valid — two states the rest of the system can tell apart cleanly. The classic example is pool1 with source_interface=np2 on a slave: the address range arrives from the master at connect time, and only then does the dynamic pool become usable for downstream consumers like USB or PPP. Until that happens, services that depend on it know to wait, rather than assuming the feature is missing.

Configuration model

Pool sections live in src/net/pool/pool_config.*. The supported keys are:

  • enabled
  • type (static or dynamic)
  • source_interface
  • ip_start, ip_size, subnet_mask
  • gateway_active, gateway
  • dns_active, dns_server
  • max_allocations

Validation rules: a disabled pool is accepted without full address validation; a static pool must have a valid range inside one subnet and must not declare source_interface; a dynamic pool must declare a non-empty source_interface; gateway and DNS, if provided, must lie in the same subnet; max_allocations must fit the compile-time RAM budget.

Runtime data model

There are four runtime structures worth knowing about.

PoolSourceSnapshot is the input from a source owner (typically the NPR runtime). It carries validity, source revision, the address range, the subnet mask, optional gateway/DNS, and an optional source-interface IP for auto-reservation.

PoolSnapshot is the effective view that consumers see. It carries the configured/valid flags, the pool generation, the address range, the mask, and the optional gateway/DNS.

PoolClientIdentity identifies one logical consumer. The primary identity key is client_id, which is intentionally generic — it does not force Ethernet-style MAC ownership. DHCP may identify by client-id, USB and PPP identify by interface section name, and a future NPR master can identify by slave callsign. A mac field is present as optional metadata for future use, but equality is based on client_id.

PoolAllocation is one allocated block. It carries valid/generation flags, ip_start and ip_count, the state (Offered or Committed), and the expiration timestamp in milliseconds. The sentinel UINT64_MAX is treated as infinite lifetime.

Allocation semantics

The allocator hands out contiguous blocks, not single addresses. That sounds excessive for an embedded allocator until you notice where it is needed: a PPP local/peer pair, future grouped DHCP reservations, a future NPR master assigning a contiguous block per slave. Single-address requests are just blocks of size 1.

Internally the algorithm is a sequential wraparound scan: the manager remembers next_candidate_offset, the next search starts there, scanning wraps within the pool, the first free contiguous block of the requested size wins. It is simple, deterministic and cheap. It is not optimized for fragmentation-heavy workloads; the expected pool sizes and turnover do not need that.

Allocation API

The runtime API is intentionally small. Its members:

  • snapshot_pool(name) — read the current effective snapshot when a consumer needs routing metadata but no allocation.
  • acquire_committed(client, count) — return an existing committed allocation for that client if compatible (same generation, same size); otherwise create a new committed allocation. A different size for the same client is rejected, as is a stale generation.
  • acquire_committed_with_snapshot(client, count) — the preferred API for consumers that need both the block and the snapshot metadata under the same lock, so they never see a mixed result from two different generations. USB and PPP both use this.
  • offer_temporary(client, count) — create or refresh a temporary allocation. This is the future DHCP offer path.
  • commit_offer(client) — turn an existing offer into a committed allocation.
  • renew_allocation(client) — extend the expiration of a committed allocation.
  • release_allocation(client) — release immediately.
  • list_allocations(name) — for diagnostics or future control-interface export.
  • service_timeouts() — drop expired entries; called periodically from the network task.

Dynamic pool source integration

Dynamic pools are updated through IPoolSourceSink, which currently exposes:

  • publish_source_snapshot(source_interface, snapshot)
  • clear_source_snapshot(source_interface)

The pool manager does not know any NPR internals. It only knows that some subsystem owns a named source interface and can publish or clear the current L3 snapshot for it. Today the main producer is the NPR runtime, which does the following on a slave:

  1. tracks the effective np2 network configuration,
  2. decides through its own synchronization logic whether np2 is publishable,
  3. publishes a PoolSourceSnapshot to the pool manager,
  4. lets dynamic pools bound to source_interface=np2 become valid,
  5. and the rest of the system — USB, PPP, DHCP — allocates from them.

When NPR loses the source range — radio link drops, runtime invalidation — the runtime clears the snapshot, the dynamic pool becomes invalid, the pool generation changes, and previous allocations are dropped. That is fail-closed by design: an allocation against a generation that no longer exists is not secretly kept around in the hope it might still apply.

Auto-reservation of the source-interface IP

A dynamic pool may receive interface_ip in its source snapshot. When it does and the address falls inside the pool range, the manager auto-reserves it for the source interface itself. In practice this means NPR publishes the np2 local address as interface_ip, the pool reserves that IP under client identity equal to the source interface name (np2), and downstream consumers cannot accidentally reassign it.

This is a small but important safety rule: the publishing interface typically lives in the same subnet it is exporting, and "give it back to itself by name" beats "remember not to allocate this one particular address" everywhere else.

Generations

Every effective snapshot carries a generation that increments when the effective range changes — first activation of a dynamic pool, source range change, source invalidation, static pool config change that alters the snapshot. Generations matter because allocations are only meaningful within one effective range. A fresh generation invalidates old allocations automatically, and consumers can detect cleanly whether their cached result still matches the pool's current state.

RAM model

PacketRF runs on hardware where RAM matters. The naive design where "every pool owns a fixed full allocation array" was too expensive in static RAM, so the current implementation keeps one shared allocation arena and gives each pool a slice of it.

Compile-time limits today:

  • maximum pools: kMaxPools = 8
  • total allocations across all pools: kMaxAllocationsTotal = 128

At init() each pool gets a slice sized after its configured max_allocations. The sum across pools must fit the global allocation arena, and init() fails clearly if it does not. That is deliberately fail-closed — silent oversubscription is harder to diagnose than a refusal to start.

Because each pool's slice is sized at init, live changes to max_allocations are not supported afterwards. The manager enforces this:

  1. init() freezes per-pool slice capacity,
  2. a later config refresh reparses live config,
  3. if the new max_allocations does not match the frozen slice capacity, refresh fails,
  4. operations on that pool fail until the manager is reinitialized.

This keeps persistent config and runtime budget in honest agreement.

Integration with service interfaces

Most pool consumption happens through src/net/network_config_provider.*.

For USB (usbX):

  • modes are static, dhcp, or from-pool,
  • under from-pool, the provider uses the section name (us0) as the pool client identity, asks for one committed IP from the configured pool, atomically reads allocation and snapshot, and builds Ipv4NetifConfig from the result.

For PPP (pppX):

  • modes are static or from-pool,
  • under from-pool, the provider uses the section name as the client identity and asks for a contiguous block of two addresses; the first becomes the local IP, the second becomes the peer IP.

The two-address PPP allocation is the original reason block allocation exists.

Future DHCP and NPR master

The current API is shaped to absorb the future DHCP server cleanly: DHCP DISCOVER/REQUEST candidates use offer_temporary, REQUEST acceptance uses commit_offer, lease extension uses renew_allocation, RELEASE uses release_allocation. Multiple DHCP instances can safely share one pool because the pool manager is the single authority. The same shape supports a future NPR master flow: slave reconnect or block request → master uses the slave callsign as client_id → master asks for one committed contiguous block → returned range goes into NPR signaling state → expiration is governed by pool timestamps. That keeps IP assignment policy out of NPR master logic.

Failure semantics

The pool module prefers explicit failure over silent fallback. Examples: invalid pool config makes init() (or refresh-backed operations) fail; a dynamic pool without a valid published source stays unusable; live max_allocations changes fail closed; duplicate dynamic source-interface configuration is rejected; block requests larger than available space are rejected. None of that is hostile — address management bugs are far easier to diagnose when the system fails clearly than when it quietly reuses stale state.

Concurrency

One internal mutex serializes all pool operations. That makes allocation-plus-snapshot atomic reads straightforward and keeps the implementation small and deterministic. It is also enough: pool traffic volume is bounded — periodic timeout cleanup, occasional USB/PPP snapshot rebuilds, infrequent NPR source updates, future DHCP transactions at embedded scale. Lock-free behavior or fine-grained per-pool locking would be more code without solving a real problem.

Tests

Pool tests live in:

They cover static config validation, dynamic source activation, sequential allocation, the offer / commit / renew / release flow, source-interface auto-reservation, generation matching, total allocation budget rejection, and fail-closed behavior on live max_allocations change. Integration paths are covered separately in src/net/tests/test_network_config_provider.cpp and in NPR runtime tests around source publication and invalidation.

Design decisions, summarized

  • One pool == one config section, so configuration and ownership are explicit.
  • Static and dynamic pool split, so source-driven addressing is a first-class concept and does not pollute static config.
  • Generic client identity, so the allocator can serve USB, PPP, DHCP and NPR with the same API.
  • Contiguous block allocation, because PPP and the future NPR master both need it and the cost of supporting it is small.
  • Generation-based invalidation, so dynamic range changes never leak stale allocations.
  • Shared allocation arena with fixed slices, so RAM use is predictable on a constrained MCU.
  • Fail-closed on unsupported live budget changes, so the system never operates on stale agreements.
  • Callback-style source publication, so the pool manager stays independent from any specific radio protocol.

Together those choices make the pool subsystem small enough for RP2350, specific enough for PacketRF, and reusable enough to be the common IPv4 backend for both current networking features and the ones we know are coming.