Get icon
List icon
Commands icon
Docker icon
Script icon
Log icon
If icon
Fail icon
Pause icon
Loop icon
Delete icon
SlackIncomingWebhook icon
Schedule icon

Reclaim stale phpIPAM reservations whose host is gone, behind a two-signal gate

Delete phpIPAM reservations only when unreachable plus a second signal agree, past a grace window, behind a ceiling plus a Pause.

Categories
Infrastructure

Safety

A ping that gets no answer is weak truth. A host can be powered off, firewalled, asleep or mid reboot while still owning its address, so deleting its reservation hands that address to someone else and causes a duplicate-IP outage weeks later. This flow refuses to act on a single failed ping. Every delete needs two independent signals. Five separate brakes stand between a finding and a write:

  1. the address must be unreachable now,
  2. a second signal the operator picks must agree (absent from an owner inventory, a phpIPAM last-seen stamp older than a threshold or an explicit decommissioned tag),
  3. the phpIPAM record must be older than a grace window, so a reservation cut minutes ago for a host still booting is never touched,
  4. the address must sit outside every protected range, which also covers anything phpIPAM marks as a gateway or flags exclude-ping,
  5. the candidate count must stay under a per-run ceiling, because a whole-network outage looks exactly like every host being dead.

Then auto_remediate must be true, dry_run must be false. A human must approve the exact address list at a Pause. An unanswered Pause counts as a refusal.

How it works

subnet.Get reads the subnet mask and address.List reads every record phpIPAM holds for it. A shell task sweeps all of them with ICMP from a container on the subnet's own network. A Python task correlates the two, applies the five brakes above then prints one line per address saying either why it is a candidate or why it was spared.

Nothing is written unless the gate opens. When it does, a Loop with concurrencyLimit: 1 calls address.Delete once per candidate, serially, because concurrent writes to one subnet race inside phpIPAM. The flow then reads the address list and the usage counters again. If a targeted reservation survived or anything the selector spared has vanished, the run fails. A delete task reporting success carries no information on its own, which is why the re-read is the thing that decides.

What you get

  • The recovered address count plus subnet utilisation before and after, computed from the subnet mask phpIPAM reports and the records it still holds.
  • A named reason for every address the flow did not touch, so an operator can audit a decision instead of trusting a total.
  • A re-verify that fails the run when a delete did not land, plus a Slack alert on failure.
  • Read-only by default. The shipped inputs report candidates and change nothing.

Output expressions worth wiring into a downstream flow: {{ outputs.select_candidates.vars.finding_count }}, {{ outputs.select_candidates.vars.candidate_ips }}, {{ outputs.select_candidates.vars.spared_summary }}, {{ outputs.select_candidates.vars.free_percent_before }}, {{ outputs.verify_reclaim.vars.freed_count }}, {{ outputs.verify_reclaim.vars.free_percent_after }}, plus the raw sweep at {{ outputs.probe_reachability.outputFiles['reachability.json'] }}.

Who it's for

Network and platform engineers running phpIPAM as the source of truth for allocations. The failure it addresses is a subnet that reports itself full while a large share of its addresses belong to VMs that were destroyed, containers that were rebuilt or devices that were retired. New allocations then fail even though the network has room, which usually gets "fixed" by requesting a bigger subnet.

Why orchestrate this with Kestra

The hard part is not the delete, it is the evidence that has to exist before one. That means two sources read in order, a probe run from the right network, a grace window measured against the record's own edit date, a human approval over the exact list, serial writes, then a re-read that can fail the run. A script would do the deletes and report success either way. Kestra gives the gate, the Pause, the serial loop, the typed phpIPAM tasks plus a failure path that reaches the on-call.

Tested end to end

Run against phpIPAM 1.7 with MariaDB 11 on Kestra 2.0.5, plugin-phpipam 1.0.8. The lab subnet was a /28 seeded with 13 of 14 addresses used: two hosts answering ICMP, five unreachable and stale, one unreachable with a record edited seconds earlier, one unreachable but still listed in the owner inventory, one flagged as a gateway, one inside a DHCP tag, one carrying exclude-ping.

Every path was run for real. A healthy subnet produced zero candidates. dry_run: true named the five stale reservations without deleting any. auto_remediate: true with dry_run: false and an approved Pause deleted exactly those five. The re-read showed the subnet going from 13 of 14 used to 8 of 14, free space from 7.14% to 42.86%. A declined Pause deleted nothing. The reachable, protected, fresh and still-owned addresses were never selected in any run. A ceiling of 3 against 5 candidates failed the run and deleted nothing. Removing a candidate id from the delete list made the re-verify fail, which is how the re-verify was proven able to fail.

Prerequisites

Secrets

  • PHPIPAM_APP_TOKEN: app code token for a phpIPAM API application with read plus delete permission on the subnet. Create it under Administration, API.
  • SLACK_WEBHOOK_URL: incoming webhook for the failure alert.

Inputs

  • phpipam_base_url, phpipam_app_id, phpipam_app_token, insecure_tls: how to reach the API. Leave phpipam_app_token on its default so the value comes from the secret.
  • subnet_id: the one subnet this run may touch.
  • second_signal: OWNER_INVENTORY, PHPIPAM_LAST_SEEN or DECOMMISSIONED_TAG.
  • owner_inventory_url: HTTP endpoint for the owner source. Any JSON document works, every IPv4 literal in it counts as owned.
  • stale_after_days, decommissioned_tag_id: thresholds for the other two signals.
  • grace_hours: how recent a record change makes an address untouchable.
  • protected_tag_ids, protected_ranges: the allowlist. Gateways plus exclude-ping addresses are protected without being listed.
  • max_deletes_per_run: the ceiling that fails the run instead of mass deleting.
  • ping_count, ping_timeout_seconds, probe_image, probe_network_mode: the ICMP sweep.
  • dry_run, auto_remediate: the write gate. Both ship safe.

Quick start

  1. Create a phpIPAM API application, then store its app code as PHPIPAM_APP_TOKEN.
  2. Set phpipam_base_url, phpipam_app_id plus the subnet_id you want reviewed.
  3. Set probe_network_mode if the Kestra worker cannot reach the subnet directly.
  4. Run it as shipped. It reports candidates and changes nothing.
  5. Read the spared lines. If any reason looks wrong, widen protected_ranges or grace_hours before going further.
  6. To reclaim, run with auto_remediate: true plus dry_run: false, then approve the Pause once you recognise every address on the list.

How to extend

  • Add a fourth second-signal mode for your own source of truth, for example a CMDB query or a hypervisor inventory, by extending the selector's signal branch.
  • Swap the ICMP sweep for a TCP port probe where ICMP is blocked, keeping the same output shape.
  • Replace the delete with address.Update to park a candidate on an Offline tag first, then delete on a second pass a week later.
  • Loop the whole flow over several subnets with a subflow, keeping one subnet per execution so the ceiling stays meaningful.

Links

This blueprint was created by zkasuran.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.