Developer Guide — Packages
This page documents the internal package structure of pipam for developers
working on the codebase. The source lives in code/proxmox-ipam/.
Package map
Section titled “Package map”code/proxmox-ipam/ cmd/pipam/ CLI entrypoint config/ Configuration types and loading proxmox/ Proxmox API client proxmox/auth/ Authentication strategies state/ State model, collection, diffing cluster/ Raft consensus and persistence dns/ DNS provider interface dns/powerdns/ PowerDNS implementation ipam/ IPAM provider interface ipam/netbox/ NetBox implementation sync/ Reconcilercmd/pipam/ — CLI entrypoint
Section titled “cmd/pipam/ — CLI entrypoint”Uses cobra for commands and viper for config/env binding.
| File | Description |
|---|---|
main.go | Root command, viper init, config file discovery |
run.go | pipam run — daemon mode (single-node or raft) |
output.go | pipam output — one-shot collection and pretty/JSON output |
reconcile.go | pipam reconcile — one-shot full reconcile |
clean.go | pipam clean — remove all managed DNS/IPAM entries |
health.go | pipam health — check provider connectivity |
providers.go | Factory functions for DNS and IPAM providers from config |
Daemon loop (run.go)
Section titled “Daemon loop (run.go)”Two modes based on raft.node_id:
- Single-node (
runSingleNodeLoop): timer-based collect → diff → reconcile. State is kept in memory (lastReconciled *state.Snapshot). - Cluster (
runMainLoop): leader collects and proposes snapshots via raft. Reconciliation triggers onApplied()channel when raft commits a new snapshot (tracked by the FSM’s pending-snapshot flag, so commits that land in the same batch can’t be lost). Reconciled state is proposed back through raft for replication. The leader skips the proposal entirely when the collected snapshot matches both the committed current state and the reconciled state — the raft log stays quiet while nothing changes.
Both loops support full_reconcile_interval — periodically applies
state.FullDiff(reconciled, current): all current entries are re-ensured and
entries that only exist in the reconciled baseline are still removed (so
pending removals aren’t orphaned).
config/ — Configuration
Section titled “config/ — Configuration”| File | Description |
|---|---|
types.go | Config struct hierarchy with mapstructure tags |
parse.go | Viper-based loading, secret resolution, custom decode hooks |
Key design decisions:
- Viper key delimiter is
::(not.) to avoid conflicts with TOML dotted keys in mapping sections. - Secret resolution order: env var → file → inline config value.
stringToIPNetHookFuncconverts CIDR strings to*net.IPNetduring unmarshalling.MappingConfigkeys are domain names (e.g."services.homelab.muehlena.de"). Each contains a list of networks with CIDRs and optional service records.
Resolution functions
Section titled “Resolution functions”config.ResolveFQDN(mapping, hostname, ip) — longest-prefix-match IP against
all mapping network CIDRs, returns hostname.domain. Returns hostname as-is if
it already contains a dot (FQDN).
config.DomainForIP(mapping, ip) — returns just the matching domain (no
hostname prefix).
config.ResolveServiceRecords(mapping, ip) — returns shared FQDNs for networks
that have service_record configured.
proxmox/ — Proxmox API client
Section titled “proxmox/ — Proxmox API client”| File | Description |
|---|---|
client.go | HTTP client, authentication, JSON response parsing |
nodes.go | Node listing and node-level network interfaces |
vm.go | VM listing, config, guest agent (hostname, interfaces) |
lxc.go | LXC listing, config, interfaces |
version.go | Proxmox version check (used for health) |
Generic request pattern
Section titled “Generic request pattern”All API calls use requestor[T] — a generic helper that deserializes the
Proxmox {"data": T} envelope.
VM vs LXC differences
Section titled “VM vs LXC differences”- VMs: hostname from guest agent (
/agent/get-host-name), interfaces from guest agent (/agent/network-get-interfaces), config for MAC/bridge matching. Guest agent may be unavailable — falls back to VM name. - LXCs: hostname from config (
/config→hostnamefield), interfaces from/interfacesendpoint. IPv6 addresses are in theip-addressesarray (theinet6field only has link-local). Prefix is returned as a string.
proxmox/auth/
Section titled “proxmox/auth/”Authenticator interface with ProxmoxAPITokenAuthenticator implementation.
Sets the Authorization: PVEAPIToken=user@realm!tokenid=uuid header.
state/ — State model
Section titled “state/ — State model”| File | Description |
|---|---|
types.go | Entry, Interface, CIDR, Snapshot types |
pipamcfg.go | PIPAMConfig type and description parser |
collector.go | Builds a Snapshot from Proxmox API data |
diff.go | Diff(old, new) computes added/removed/changed |
Central data type representing a VM or LXC:
type Entry struct { ID string // "{node}/{type}/{vmid}" Node string Type string // "qemu" or "lxc" VMID int Name string // Proxmox display name Hostname string // resolved hostname (agent or config) VCPUs int MemoryMB int DiskMB int Tags []string // Proxmox tags (semicolon-separated, parsed) PIPAMCfg *PIPAMConfig // per-VM config from description Interfaces []Interface}Collector
Section titled “Collector”Collector.Collect(ctx) iterates all Proxmox nodes, fetches running VMs and
LXCs, resolves hostnames, builds interfaces with IP addresses, and applies
filtering:
- Skip non-running VMs/LXCs
- Skip loopback, link-local, multicast IPs
- Skip IPs in
skip_cidrs - Parse
PIPAMConfigfrom description field - Apply
ip_mapremapping - Include entry only if hostname is FQDN, has a mapping override, or at least one IP matches a global mapping
Failure semantics: offline nodes are skipped (their guests aren’t running, so absence is real); any fetch failure on an online node aborts the whole sweep with an error. A partial snapshot would make every guest on the failing node look deleted and, after the grace period, remove them from DNS/IPAM.
VLAN resolution
Section titled “VLAN resolution”resolveVLAN walks the bridge → bridge_ports → VLAN chain on the Proxmox node
to find the backing VLAN ID for a bridge interface.
Diff(old, new *Snapshot) *DiffResult — compares by entry ID. Uses JSON
serialization for equality. Returns Added, Removed, Changed lists.
A nil old snapshot means everything is added (used by the one-shot
pipam reconcile). A nil new snapshot means everything is removed (used for
clean).
FullDiff(old, new) — used for periodic full reconciles: every entry in new
is Added (re-ensured) while entries only present in old are still Removed.
EntriesEqual(a, b) — snapshot-content equality, used by the leader to skip
proposing unchanged snapshots.
cluster/ — Raft consensus
Section titled “cluster/ — Raft consensus”| File | Description |
|---|---|
raft.go | RaftNode wrapping go.etcd.io/raft/v3 |
fsm.go | Finite state machine (current + reconciled state) |
wal.go | WAL and snapshot persistence to disk |
transport.go | HTTP transport for raft messages between nodes |
RaftNode
Section titled “RaftNode”- Fresh start:
raft.StartNode()with all peers - Restart (WAL exists): apply persisted raft snapshot (if any) → replay
remaining entries into
MemoryStorage→raft.RestartNode(). The FSM tracks the raft index of its last applied entry, so replayed entries are skipped instead of being re-applied on top of the restored state (which would double-count missing-sweep counters). - Event loop: tick → Ready → persist WAL (only when hard state or entries changed) → send messages → apply to FSM → maybe compact → Advance
- Elections:
PreVoteandCheckQuorumare enabled, andElectionTickis 30 (3 s at the default 100 ms tick) so brief blips don’t depose a healthy leader. - Log compaction: after
snapCount(64) applied entries, the node creates a raft snapshot from the serialized FSM state, persists it, and compacts the in-memory log and WAL (keepingsnapTrailing(16) entries for lagging followers). Followers that fall behind further receive the snapshot over the wire and restore their FSM from it. Without this, every Ready rewrote an ever-growing WAL of full-cluster-state proposals, eventually stalling the tick loop and causing spurious elections. ProposeSnapshot(ctx, snapshot)— proposes collected stateProposeReconciled(ctx, snapshot)— proposes reconciled stateApplied() <-chan struct{}— notifies when new state is committedIsLeader() bool
Holds current, previous, and reconciled snapshots, the per-entry
missing-sweep counters, and the applied raft index. Two proposal types:
ProposalSnapshot— updates current/previous and missing-sweep counters, sets the pending-snapshot flag (consumed by the reconcile loop viaConsumeSnapshotApplied())ProposalReconciled— updates reconciled state (does not trigger reconciliation)
This distinction prevents tight retry loops: reconciled-state commits don’t re-trigger the reconciler. The pending flag (rather than a “last apply type” check) guarantees a snapshot commit is never missed when several entries commit in one batch.
File-based persistence in raft.data_dir: raft hard state + log entries
(wal.json), raft snapshot (snapshot.json), and FSM state
(fsm-state.json). Saving a snapshot compacts wal.json down to the entries
above the snapshot index. Enables recovery after full cluster restart.
Correctness guarantees (all raft-safety relevant):
- Every write is atomic (temp file + rename) and fsynced — file before the rename, directory after — so persisted state survives power loss, not just process crashes.
- An empty HardState in a Ready means “unchanged” and never overwrites the persisted term/vote/commit — forgetting a vote would allow double-voting in the same term.
- Conflicting entries from a new leader truncate the stored suffix instead of being appended (the log stays monotonic).
Loadheals crash-window inconsistencies: entries already covered by the snapshot are dropped, gaps truncate the log, and the commit index is clamped to what the log actually holds (commit is re-learned from the leader; term and vote are never touched). Legacy WALs with duplicate suffixes are sanitized on open.
Transport
Section titled “Transport”HTTP-based: POST /raft/message sends protobuf raftpb.Message between nodes.
Peers are discovered from the raft.peers config map. Requests time out after
10 s — raft tolerates dropped messages, hung connections would leak
goroutines.
dns/ — DNS provider
Section titled “dns/ — DNS provider”DNSProvider interface:
type DNSProvider interface { EnsureRecord(ctx, fqdn, address, rrtype string) error DeleteRecord(ctx, fqdn, address, rrtype string) error Health(ctx) error}dns/powerdns/
Section titled “dns/powerdns/”PowerDNS implementation via REST API. Records are managed per-zone using
PATCH /api/v1/servers/localhost/zones/{zone} with rrsets changetype
REPLACE or DELETE. Zone is resolved from the FQDN by matching against
configured zones.
EnsureRecord merges the new address into existing records for the same
FQDN/rrtype (fetches current records first, appends, PATCHes).
ipam/ — IPAM provider
Section titled “ipam/ — IPAM provider”IPAMProvider interface:
type IPAMProvider interface { EnsureVM(ctx, entry state.Entry) error DeleteVM(ctx, entry state.Entry) error Health(ctx) error}ipam/netbox/
Section titled “ipam/netbox/”NetBox implementation. EnsureVM performs a cascade of idempotent operations:
- Find or create VM (
/api/virtualization/virtual-machines/) - Set VM metadata: vcpus, memory, disk, tags, comments, cluster
- For each interface: find/create interface, assign MAC address via dcim, assign VLAN
- For each IP: resolve prefix from NetBox prefixes, find/create IP address, assign to interface
- Remove stale IPs and interfaces not in the current entry
- Set primary IPv4/IPv6 (single-interface heuristic or
primary_macfrom PIPAM config)
Prefix resolution queries /api/ipam/prefixes/?contains=<ip> and uses the
longest-match prefix, overriding the prefix reported by Proxmox.
Tags: qemu and lxc type tags are created automatically. All other tags
(Proxmox tags, PIPAM config tags) are only included if they already exist in
NetBox.
sync/ — Reconciler
Section titled “sync/ — Reconciler”Reconciler.Reconcile(ctx, diff) applies a DiffResult to DNS and IPAM, in
this order:
- Removed entries first: delete DNS records + IPAM VM
- Added entries: ensure DNS records + IPAM VM
- Changed entries: ensure (idempotent update)
Removals run first so stale records are gone before additions re-ensure them. When a removed entry’s name is also being ensured in the same diff (a VM migrated to another node keeps its name but changes its entry ID), the IPAM delete is skipped: NetBox VMs are looked up by name, so deleting the old identity would delete the record the addition just wrote.
Returns ReconcileResult with FailedIDs — entries that failed are excluded
from the reconciled state so they are retried on the next cycle.
FQDN resolution with overrides
Section titled “FQDN resolution with overrides”The reconciler checks PIPAMConfig.Mapping (per-network) before falling back to
the global mapping config. This allows per-VM domain overrides.
IPAM filtering
Section titled “IPAM filtering”filterEntryForIPAM creates a copy of the entry containing only IPs that
resolve to a domain (global mapping or PIPAM override). FQDN hostnames include
all IPs. IPv6 inheritance applies when the interface has matched IPv4 and
ipv6_inherit_ipv4_fqdn is enabled.
Reconciliation and retry flow
Section titled “Reconciliation and retry flow”Adding a new provider
Section titled “Adding a new provider”- Create
dns/<name>/client.goimplementingdns.DNSProvider - Add config type to
config/types.gounderDNSConfig - Add case to
makeDNSProviderincmd/pipam/providers.go
- Create
ipam/<name>/client.goimplementingipam.IPAMProvider - Add config type to
config/types.gounderIPAMConfig - Add case to
makeIPAMProviderincmd/pipam/providers.go