Skip to content

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/.

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/ Reconciler

Uses cobra for commands and viper for config/env binding.

FileDescription
main.goRoot command, viper init, config file discovery
run.gopipam run — daemon mode (single-node or raft)
output.gopipam output — one-shot collection and pretty/JSON output
reconcile.gopipam reconcile — one-shot full reconcile
clean.gopipam clean — remove all managed DNS/IPAM entries
health.gopipam health — check provider connectivity
providers.goFactory functions for DNS and IPAM providers from config

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 on Applied() 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).

FileDescription
types.goConfig struct hierarchy with mapstructure tags
parse.goViper-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.
  • stringToIPNetHookFunc converts CIDR strings to *net.IPNet during unmarshalling.
  • MappingConfig keys are domain names (e.g. "services.homelab.muehlena.de"). Each contains a list of networks with CIDRs and optional service records.

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.

FileDescription
client.goHTTP client, authentication, JSON response parsing
nodes.goNode listing and node-level network interfaces
vm.goVM listing, config, guest agent (hostname, interfaces)
lxc.goLXC listing, config, interfaces
version.goProxmox version check (used for health)

All API calls use requestor[T] — a generic helper that deserializes the Proxmox {"data": T} envelope.

  • 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 (/confighostname field), interfaces from /interfaces endpoint. IPv6 addresses are in the ip-addresses array (the inet6 field only has link-local). Prefix is returned as a string.

Authenticator interface with ProxmoxAPITokenAuthenticator implementation. Sets the Authorization: PVEAPIToken=user@realm!tokenid=uuid header.

FileDescription
types.goEntry, Interface, CIDR, Snapshot types
pipamcfg.goPIPAMConfig type and description parser
collector.goBuilds a Snapshot from Proxmox API data
diff.goDiff(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.Collect(ctx) iterates all Proxmox nodes, fetches running VMs and LXCs, resolves hostnames, builds interfaces with IP addresses, and applies filtering:

  1. Skip non-running VMs/LXCs
  2. Skip loopback, link-local, multicast IPs
  3. Skip IPs in skip_cidrs
  4. Parse PIPAMConfig from description field
  5. Apply ip_map remapping
  6. 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.

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.

FileDescription
raft.goRaftNode wrapping go.etcd.io/raft/v3
fsm.goFinite state machine (current + reconciled state)
wal.goWAL and snapshot persistence to disk
transport.goHTTP transport for raft messages between nodes
  • Fresh start: raft.StartNode() with all peers
  • Restart (WAL exists): apply persisted raft snapshot (if any) → replay remaining entries into MemoryStorageraft.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: PreVote and CheckQuorum are enabled, and ElectionTick is 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 (keeping snapTrailing (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 state
  • ProposeReconciled(ctx, snapshot) — proposes reconciled state
  • Applied() <-chan struct{} — notifies when new state is committed
  • IsLeader() 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 via ConsumeSnapshotApplied())
  • 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).
  • Load heals 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.

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.

DNSProvider interface:

type DNSProvider interface {
EnsureRecord(ctx, fqdn, address, rrtype string) error
DeleteRecord(ctx, fqdn, address, rrtype string) error
Health(ctx) error
}

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).

IPAMProvider interface:

type IPAMProvider interface {
EnsureVM(ctx, entry state.Entry) error
DeleteVM(ctx, entry state.Entry) error
Health(ctx) error
}

NetBox implementation. EnsureVM performs a cascade of idempotent operations:

  1. Find or create VM (/api/virtualization/virtual-machines/)
  2. Set VM metadata: vcpus, memory, disk, tags, comments, cluster
  3. For each interface: find/create interface, assign MAC address via dcim, assign VLAN
  4. For each IP: resolve prefix from NetBox prefixes, find/create IP address, assign to interface
  5. Remove stale IPs and interfaces not in the current entry
  6. Set primary IPv4/IPv6 (single-interface heuristic or primary_mac from 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.

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.

The reconciler checks PIPAMConfig.Mapping (per-network) before falling back to the global mapping config. This allows per-VM domain overrides.

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.

  1. Create dns/<name>/client.go implementing dns.DNSProvider
  2. Add config type to config/types.go under DNSConfig
  3. Add case to makeDNSProvider in cmd/pipam/providers.go
  1. Create ipam/<name>/client.go implementing ipam.IPAMProvider
  2. Add config type to config/types.go under IPAMConfig
  3. Add case to makeIPAMProvider in cmd/pipam/providers.go