lease2db follows the lease file of an ISC DHCP server
(dhcpd.leases) and keeps the history of who held which address, and
when in a PostgreSQL database.
ISC dhcpd only knows the present: its lease file says who has an address
now, and forgets everybody who had it before. lease2db turns that file
into a history you can ask questions of:
- Who had 192.0.2.57 last Tuesday at 21:40?
- Which addresses has this MAC address had, and on which switch port?
- How full is every network right now?
It is one static binary. It does not talk to dhcpd, does not change its configuration and does not need to run as root: it only reads two files.
dhcpd appends a lease <ip> { ... } block to its lease file every time
something happens to a lease, and now and then rewrites the whole file
with one block per lease. lease2db reads the blocks as they appear and
keeps one row for every time a client held an address - from the
moment it got it until it gave it up, lost it, or somebody else got it:
- a renewal extends the row; it does not make a new one
- a release, an expiry or another client taking the address ends the row
- the same client coming back later starts a new row
Because the lease file repeats itself (after every rewrite, and whenever
lease2db starts), every block is compared with what is already stored.
Reading the same file any number of times gives the same rows, so the
program can be stopped, restarted or moved to another machine at any
time. If the database is away, nothing is lost: the file is still there,
and lease2db catches up when the database is back.
Download the archive for your machine from the
releases page and check
it against SHA256SUMS:
tar xzf lease2db_<version>_linux_amd64.tar.gz
install -m 755 lease2db_<version>_linux_amd64/lease2db /usr/local/bin/
lease2db -versionThe released binaries are built with the latest stable Go. Or build it yourself (Go 1.25 or newer):
go build ./cmd/lease2dblease2db creates and updates its own tables. All it needs is a
PostgreSQL database and a role that may create tables in it:
CREATE ROLE lease2db LOGIN PASSWORD '...';
CREATE DATABASE dhcp OWNER lease2db;The tests run against PostgreSQL 17.
lease2db -dsn 'host=db.example.net dbname=dhcp user=lease2db password=... sslmode=verify-full' \
-leases /var/lib/dhcp/dhcpd.leases \
-scopes /etc/dhcp/dhcpd.conf| Flag | Default | |
|---|---|---|
-dsn |
$LEASE2DB_DSN |
PostgreSQL connection string or postgres:// URL |
-leases |
/var/lib/dhcp/dhcpd.leases |
dhcpd's lease file |
-scopes |
(none) | dhcpd configuration file with the subnet declarations |
-interval |
1s |
how often to look for new leases |
-metrics |
(none) | write Prometheus metrics to this file |
-version |
print the version and exit |
Run it on the machine where dhcpd runs, as any user that can read the
two files. An example systemd unit is in contrib/.
Prefer LEASE2DB_DSN in an environment file over -dsn when the
connection string holds a password: command lines are visible to every
user of the machine.
The connection string takes the usual sslmode, sslrootcert, sslcert
and sslkey settings, so lease2db can log in with a client certificate
instead of a password:
host=db.example.net dbname=dhcp user=lease2db sslmode=verify-full
sslrootcert=/etc/ssl/ca.pem sslcert=/etc/ssl/dhcp1.pem sslkey=/etc/ssl/private/dhcp1.key
with a line like this in the server's pg_hba.conf (and ssl_ca_file
set):
hostssl dhcp lease2db 192.0.2.10/32 cert map=lease2db
With -scopes, the networks dhcpd hands addresses out in - every
subnet declaration with its ranges and option routers - are kept in
the table scopes, and reread whenever the file changes. That is what
makes "how full is this network" answerable. include statements are not
followed: point -scopes at the file that holds the subnets.
A network gets a name and a VLAN number when the line before its declaration says so, in a comment of this form:
#@ NET OFFICE 192.0.2.0/24 - VLAN 10
subnet 192.0.2.0 netmask 255.255.255.0 {
range 192.0.2.20 192.0.2.250;
option routers 192.0.2.1;
}
Without -scopes the lease history works just the same; only the
per-network views stay empty.
leases - one row for every time a client held an address.
| Column | |
|---|---|
ip, mac |
the address and the client (mac is NULL for an abandoned address) |
first_seen |
when this client got the address |
starts |
its latest renewal |
ends |
when the lease runs out, or ended; NULL = never |
state |
dhcpd's binding state: active, free, expired, released, abandoned, ... |
current |
true for the latest row of its address |
hostname |
the name the client sent |
circuit_id, remote_id |
relay agent information (option 82): where the client is plugged in. bytea - the bytes the relay sent |
vendor_class |
the client's vendor class, when dhcpd is set up to record it |
updated |
when the row last changed |
scopes - the networks: network, name, vlan, gateway,
addresses (in the dynamic ranges), active (in the configuration now).
Applications should read the views rather than the tables:
| View | |
|---|---|
leases_history |
every row of leases with its network (network, scope, vlan), a boolean active (leased right now), and the relay information made readable: circuit_id_text, remote_id_text |
leases_active |
the rows of leases_history that are leased right now |
scopes_usage |
for every active network: name, vlan, gateway, addresses, leases (leased right now) and percent_used |
leases_totals |
one row: active_leases, active_clients (distinct MAC addresses), clients_ever |
circuit_id and remote_id tell which switch and port a client sits
behind, in whatever format the relay's vendor chose - text on some
(ge-0/0/7.0:guests), packed bytes on others. dhcpd writes them into
its lease file as quoted text when every byte happens to be printable
and as hex otherwise; lease2db stores the bytes themselves, so
text that merely looks like hex is never mistaken for it.
circuit_id_text and remote_id_text (the function
lease2db_show(bytea)) give them for reading: as text when all bytes
are printable ASCII, else as hex (00:04:00:64:01:0c). Decoding a
vendor's packed format - VLAN, module and port on Cisco, for example -
is left to the application:
SELECT get_byte(circuit_id, 2) * 256 + get_byte(circuit_id, 3) AS vlan,
get_byte(circuit_id, 4) AS module, get_byte(circuit_id, 5) AS port
FROM leases_active WHERE ip = '192.0.2.57' AND length(circuit_id) = 6;Where is this address plugged in, right now?
SELECT mac, hostname, remote_id_text AS switch, circuit_id_text AS port, scope, vlan
FROM leases_active WHERE ip = '192.0.2.57';Who had this address at that time?
SELECT mac, hostname, circuit_id_text, first_seen, ends
FROM leases_history
WHERE ip = '192.0.2.57'
AND first_seen <= '2026-01-13 21:40+01'
AND (ends IS NULL OR ends >= '2026-01-13 21:40+01');Where has this client been? (Search by hostname works the same way.)
SELECT ip, scope, first_seen, ends, state, circuit_id_text
FROM leases_history WHERE mac = '00:00:5e:00:53:01' ORDER BY first_seen;How full is every network?
SELECT name, vlan, network, gateway, leases, addresses, percent_used
FROM scopes_usage ORDER BY percent_used DESC NULLS LAST;Who is in this network right now?
SELECT ip, mac, hostname, ends FROM leases_active WHERE vlan = 10 ORDER BY ip;How many leases and clients are there?
SELECT * FROM leases_totals;The views work directly as data sources for Grafana's PostgreSQL plugin. Give an application that only reads a role of its own:
CREATE ROLE dhcp_read LOGIN PASSWORD '...';
GRANT CONNECT ON DATABASE dhcp TO dhcp_read;
GRANT USAGE ON SCHEMA public TO dhcp_read;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO dhcp_read;
ALTER DEFAULT PRIVILEGES FOR ROLE lease2db IN SCHEMA public GRANT SELECT ON TABLES TO dhcp_read;- Times in the lease file are UTC; the columns are
timestamptz. - A lease handed out and gone again while
lease2dbwas not running is only in the history if dhcpd had not yet rewritten its file - dhcpd keeps the last word about every address, not everything in between. - Addresses given by
fixed-addressare not in the lease file at all, so they are not in the history either - and have no relay information. - Old rows are never deleted. Delete what you no longer want yourself,
for example
DELETE FROM leases WHERE NOT current AND ends < now() - interval '1 year'. - DHCPv6 (
dhcpd6.leases) is not read.
With -metrics /var/lib/prometheus/node-exporter/lease2db.prom the
program writes its own state for the node exporter's textfile collector
every 15 seconds:
| Metric | |
|---|---|
lease2db_connected |
1 = connected to the database |
lease2db_last_success_timestamp_seconds |
when the file was last read and the database written |
lease2db_blocks_total |
lease blocks read |
lease2db_changes_total |
blocks that changed a row |
lease2db_blocks_refused_total |
blocks that could not be understood (they are logged and skipped) |
lease2db_errors_total |
times the database connection was lost |
lease2db_file_restarts_total |
times the file was read from the top |
lease2db_addresses |
addresses with a row in the history |
lease2db_leases_active |
addresses leased right now |
lease2db_clients_active |
clients (MAC addresses) holding a lease right now |
lease2db_scope_addresses{network,name,vlan} |
addresses in the network's dynamic ranges (with -scopes) |
lease2db_scope_leases_active{network,name,vlan} |
addresses of the network leased right now (with -scopes) |
A sensible alert: time() - lease2db_last_success_timestamp_seconds > 300.
And a graph of how full the networks are, over time, without asking the
database: lease2db_scope_leases_active / lease2db_scope_addresses.
go test ./...The tests of the database layer need a real PostgreSQL and are skipped
without one. To run them, point LEASE2DB_TEST_DSN at a database the
tests may fill and empty:
LEASE2DB_TEST_DSN='postgres://postgres:test@localhost/lease2db_test?sslmode=disable' go test -p 1 ./...A release is a tag: pushing vX.Y.Z builds the archives and publishes
them.
GPL-3.0-or-later. See LICENSE.