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. Reconciled state is proposed back through raft for replication.

Both loops support full_reconcile_interval — periodically diffs against nil so all current entries are re-ensured.

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

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 for full reconcile). A nil new snapshot means everything is removed (used for clean).

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): replay into MemoryStorageraft.RestartNode()
  • Event loop: tick → Ready → persist WAL → send messages → apply to FSM → Advance
  • 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. Two proposal types:

  • ProposalSnapshot — updates current/previous (triggers reconciliation)
  • ProposalReconciled — updates reconciled state (does not trigger reconciliation)

This distinction prevents tight retry loops: reconciled-state commits don’t re-trigger the reconciler.

File-based persistence in raft.data_dir. Stores raft hard state, log entries, and FSM state. Enables recovery after full cluster restart.

HTTP-based: POST /raft/message sends protobuf raftpb.Message between nodes. Peers are discovered from the raft.peers config map.

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:

  • Added entries: ensure DNS records + IPAM VM
  • Changed entries: ensure (idempotent update)
  • Removed entries: delete DNS records + IPAM VM

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.

flowchart LR
Collect --> Propose --> Commit
Commit --> Diff
subgraph Reconcile
Diff --> Added
Diff --> Changed
Diff --> Removed
Added -->|success| RS[Reconciled State]
Added -->|failure| Excluded[Excluded from RS]
Changed -->|success| RS
Changed -->|failure| Reverted[Reverted in RS]
Removed -->|success| RS
Removed -->|failure| Kept[Kept in RS]
end
RS --> ProposeRS[Propose reconciled]
ProposeRS --> CommitRS[Commit via raft]
Excluded -.->|next cycle| Diff
Reverted -.->|next cycle| Diff
Kept -.->|next cycle| Diff
  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