Skip to content

Static IP Assignment

A static IP in ProxPanel is a Framed-IP-Address row in the radreply table for one specific subscriber. When that subscriber authenticates, the RADIUS server pulls the row, sets it in the Access-Accept, and the BNG hands them that exact IP — every time, on every reconnect.

This page covers how to assign a static IP, the validation that prevents bad data, the duplicate-IP check at write time, and the runtime conflict auto-resolution that picks a new IP after three failed retries.

Pool allocation is the default and the right answer for ~95% of subscribers. Use a static IP when:

  • Port-forwarding — the customer hosts a service on their LAN.
  • Site-to-site / VPN — the firewall on the other side filters by source IP.
  • VoIP regulatory compliance — some jurisdictions require a stable identifier per line.
  • Public IP rentals — see Public IPs (the user-rented public IP feature is built on top of this same radreply mechanism).

If none of the above apply, leave the subscriber on pool allocation. A static IP that doesn’t need to be static is a future support ticket — the IP gets stuck if you delete the subscriber, the customer moves, or the pool is renumbered.

  1. Open the subscriber in Subscribers → Edit.
  2. Tick Static IP. A text field appears.
  3. Enter an IPv4 address (e.g. <subscriber-ip>). Validation runs on save:
    • Must be a valid IPv4 dotted-quad — IPv6 is rejected (added in v1.0.551).
    • Must not already exist in radreply for a different username — see duplicate-IP enforcement.
    • Should fall within an IP-pool subnet for the subscriber’s service (a warning, not a hard reject — out-of-pool IPs work as long as the BNG routes them).
  4. Save. The handler:
    • Upserts radreply (username, attribute='Framed-IP-Address', op=':=', value='<subscriber-ip>').
    • Sets subscribers.static_ip for display.
    • Marks the IP in ip_pool_assignments as in_use so pool allocation skips it.
  5. If the subscriber is currently online, the panel sends a CoA Disconnect (see CoA & Disconnect). The next reconnect picks up the new static IP. Without the disconnect, the live session keeps its old pool-allocated IP until next reconnect anyway.

For 100+ subscribers, use Subscribers → Bulk Actions → Set Static IP with a CSV of username,ip. The bulk handler:

  • Validates each row.
  • Skips rows where the IP is already in radreply for a different username (logged with WARN: Duplicate IP <ip> already in use by <other>).
  • Inserts the rest in a single transaction.

The duplicate-skip behavior was added in v1.0.226 after a real-world incident where the bulk action created 5 duplicates that took hours to track down.

The system blocks an IP from being assigned to a second subscriber at four different code paths, defense-in-depth:

PathWhereBehavior
1. Bulk set-static-IPinternal/handlers/subscriber.goSkips and logs. Added v1.0.226.
2. ippool.AllocateIP (pool path)internal/ippool/pool.goNOT EXISTS query against radreply excludes any IP already statically assigned. Added v1.0.179.
3. RADIUS server findAvailableIPinternal/radius/server.goChecks radreply alongside subscribers.ip_address and subscribers.static_ip. Added v1.0.227.
4. Update handler (single subscriber edit)internal/handlers/subscriber.goPre-flight query rejects the save with a 400 if the IP is taken.

The first three were each added after a duplicate was observed in production — they patch every code path that could ever create one.

Even with all the write-time checks, a runtime conflict can still happen — usually when a MikroTik’s PPP profile is misconfigured and the router assigns from its own pool, ignoring the Framed-IP-Address the panel sent. Two subscribers end up live with the same IP.

The RADIUS server detects this at auth time. When a subscriber tries to authenticate and the IP they would receive is already held by another online subscriber, the server:

  1. Sends a CoA Disconnect to the conflicting session (the other user with that IP).
  2. Lets the new auth proceed.
  3. Increments a counter in staticIPConflicts (a sync.Map) keyed by username:conflicting_ip.

The kick is single-shot. But what if the same IP keeps conflicting because the BNG keeps reassigning it? An infinite kick loop is the failure mode.

After 3 kicks, ProxPanel breaks the cycle by calling findAvailableIP(conflictIP). This:

  1. Computes the /24 subnet from the conflicting IP (e.g. 10.180.96).
  2. Lists every IP currently in use by online subscribers in that subnet.
  3. Lists every static IP in that subnet from subscribers.static_ip.
  4. Lists every IP already in radreply Framed-IP-Address for that subnet.
  5. Iterates <subscriber-ip> through <subscriber-ip> and returns the first IP that’s in none of the three lists.

The new IP is written to radreply for the affected subscriber, and the auth proceeds. The next time they connect, they get the fresh IP without conflict.

If findAvailableIP returns an empty string (the entire /24 is allocated), the conflict counter is kept (not deleted) so the next auth attempt retries instead of silently allowing a duplicate. This was a v1.0.388 fix — previously the counter was cleared on full-pool, opening a small race.

findAvailableIP starts the search at .10 (avoiding .1 gateway, .2.9 traditionally-reserved) and stops at .249. The first free IP wins.

Subnet positionReason
.1Conventional gateway address.
.2.9Reserved for infrastructure (NAS, network management, BGP peers).
.10.249Allocation range.
.250.254Reserved for late-arrival use (broadcast offset, special-purpose).
.255Broadcast.

This is a heuristic, not a configurable. If your network layout uses different conventions, the function still works — it’ll just skip the first 8 addresses regardless of whether you actually use them.

Before v1.0.551, the validation regex on the static IP field allowed IPv6 strings to slip through. The RADIUS server then tried to put an IPv6 address into a Framed-IP-Address (RFC-2865 attribute 8), which expects 4 bytes. The auth silently failed because the attribute encoder dropped malformed values.

The fix:

ip := net.ParseIP(input).To4()
if ip == nil {
return errors.New("must be a valid IPv4 address")
}

For IPv6 static assignment, use the Framed-IPv6-Address attribute (RFC-3162 attribute 168). ProxPanel supports IPv6 assignment but it’s a separate field on the subscriber model — see IPv6 rollout.

The staticIPConflicts sync.Map is in-memory. It resets when the RADIUS container restarts. This is intentional — after a restart, network state is unknown and old conflict counts are meaningless. The first auth after restart gets a fresh kick budget.

If you suspect a runaway conflict cycle and want to clear state immediately, docker restart proxpanel-radius wipes the map.

”I set a static IP but the subscriber gets a different one”

Section titled “”I set a static IP but the subscriber gets a different one””

Almost always a MikroTik PPP profile issue. See MikroTik Integration → PPP profileremote-address must be none. If it’s set to a pool name, the static IP is ignored.

They’re in the auto-resolution loop. Watch the RADIUS log:

docker logs proxpanel-radius 2>&1 | grep -i conflict

If you see Conflict for username=<x>: IP <y> in use, kicking session, look at who else has that IP. If the same culprit keeps grabbing it, fix the culprit’s configuration (likely a static IP set on their CPE that overrides what the router assigns).

”Auto-resolution picked an out-of-pool IP”

Section titled “”Auto-resolution picked an out-of-pool IP””

The function scans the /24 subnet of the conflicting IP, not the subscriber’s service pool. If your network has subnets that span multiple pools, this picks the first free /24 slot — which might not match the service plan. The fix is to manually set the static IP to a desired address; the auto-resolver only runs when nothing was set explicitly.