Skip to content

Instantly share code, notes, and snippets.

@supersonictw
Last active July 25, 2025 08:31
Show Gist options
  • Save supersonictw/52205f0bd001c28f47240cbb9bf7421b to your computer and use it in GitHub Desktop.
Save supersonictw/52205f0bd001c28f47240cbb9bf7421b to your computer and use it in GitHub Desktop.
Proxmox VE (VM & CT) to phpIPAM Synchronizer
#!/bin/bash
# ipam-pve.sh
# SPDX-License-Identifier: MIT (https://ncurl.xyz/s/Kkn2DQsNR)
#================================================================
# Proxmox VE (VM & CT) to phpIPAM Synchronizer
#
# Features:
# - Periodically scans all running VMs and Containers (CT) on Proxmox VE.
# - Retrieves IP, hostname, and MAC address.
# - Updates the 'lastSeen' timestamp for all active IPs.
# - (Optional) Cleans up stale IP records for stopped/deleted nodes.
# - Synchronizes this information to specified phpIPAM subnets.
# - Supports syncing multiple subnets in a single run.
#
# Usage:
# 1. In phpIPAM, create a new Tag (e.g., "server-sync") under Administration > IP address tags. Note its ID.
# 2. Save this script on the hosts (e.g., /usr/local/bin/ipam-pve.sh).
# 3. Configure the variables in the Configuration Section below.
# 4. Make it executable: chmod +x /usr/local/bin/ipam-pve.sh
# 5. Set up a cron job to run it periodically, e.g., every 15 minutes:
# */15 * * * * /usr/local/bin/ipam-pve.sh >> /var/log/ipam-pve.log 2>&1
#
# Dependencies: curl, jq (Please run 'apt update && apt install -y curl jq' first)
#================================================================
# --- Configuration Section: Please modify the variables below according to your environment ---
# phpIPAM API URL, without the trailing /api/
PHPIPAM_URL="http://your_phpipam_server"
# The App ID you created in phpIPAM
APP_ID="server-sync"
# The App API Key you obtained from phpIPAM
API_KEY="YOUR_PHPIPAM_API_SECRET_KEY"
# The target subnets in phpIPAM to sync to (in CIDR format, separated by spaces).
SUBNET_CIDRS="192.168.0.0/24 192.168.1.0/24" # Example for multiple subnets
# phpIPAM Tag ID for IPs managed by this script.
# Go to Administration > IP address tags, create a tag (e.g., PVE-Synced), and put its ID here.
MANAGED_TAG_ID="2" # Example ID, please change
# --- Feature Flags ---
ENABLE_STALE_CLEANUP="true" # Set to "true" to clean up IPs of stopped/deleted VMs/CTs.
STALE_ACTION="update" # Action for stale entries: "update" (marks as offline, recommended) or "delete".
# List of network interfaces to exclude, separated by spaces. 'lo' should usually be excluded.
EXCLUDE_INTERFACES="lo tun0"
# SSL certificate validation. Set to "--insecure" if phpIPAM uses a self-signed certificate.
CURL_OPTS=""
# Example: CURL_OPTS="--insecure"
# --- System Settings: Usually no changes are needed below ---
LOCK_FILE="/tmp/pve-sync-phpipam.lock"
# --- Function Definitions ---
# Logging function
log() {
local type="$1"
local message="$2"
echo "$(date '+%Y-%m-%d %H:%M:%S') [${type}] ${message}"
}
# Cleanup function to be executed on script exit
cleanup() {
rm -f "${LOCK_FILE}"
log "INFO" "Script execution finished, lock file removed."
}
# Function to check if dependencies are installed
check_dependencies() {
for cmd in curl jq; do
if ! command -v $cmd &> /dev/null;
then
log "ERROR" "Dependency '${cmd}' is not installed. Please run 'apt install -y ${cmd}'."
exit 1
fi
done
}
# --- Script Body ---
# Set trap to execute cleanup function on exit
trap cleanup EXIT
# Set command instances
QM=/usr/sbin/qm
PCT=/usr/sbin/pct
# Check for lock file to prevent concurrent execution
if [ -e "${LOCK_FILE}" ]; then
log "WARN" "Another sync script is already running (lock file ${LOCK_FILE} exists). Skipping this run."
exit 1
else
touch "${LOCK_FILE}"
fi
log "INFO" "==================== Starting PVE (VM & CT) to phpIPAM Sync ===================="
check_dependencies
# Construct the full API base URL
API_BASE_URL="${PHPIPAM_URL}/api/${APP_ID}"
# Create request headers including the API token
HEADERS=(-H "token: ${API_KEY}" -H "Content-Type: application/json")
# Associative array to map an IP to its VM/CT info ("NAME|MAC_ADDRESS")
declare -A IP_INFO_MAP
# Simple array to hold all unique active IPs from both VMs and CTs
declare -a ACTIVE_IPS_LIST
log "INFO" "--- Step 1: Collecting data from all running PVE nodes (VMs & CTs) ---"
# VM Data Collection
log "INFO" "Scanning for running VMs..."
for VMID in $($QM list | awk 'NR>1 {print $1}'); do
VM_STATUS=$($QM status $VMID 2>/dev/null | awk '{print $2}')
if [ "$VM_STATUS" != "running" ]; then
continue
fi
VM_NAME=$(timeout 5 $QM agent ${VMID} get-osinfo 2>/dev/null | jq -r '.hostname // empty')
if [[ -z "$VM_NAME" ]]; then
VM_NAME=$($QM config $VMID | grep -w 'name:' | awk '{print $2}')
fi
MAC_ADDRESS=$($QM config $VMID | grep -E "^net[0-9]+" | head -n 1 | grep -oE '([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}')
AGENT_RESPONSE=$(timeout 5 $QM agent ${VMID} network-get-interfaces 2>/dev/null)
if [ $? -ne 0 ]; then
log "WARN" " - Failed to get response from Guest Agent for VM ${VMID} (${VM_NAME}). Skipping."
continue
fi
IP_ADDRESSES=$(echo "${AGENT_RESPONSE}" | jq -r \
--arg ex_ifaces "$EXCLUDE_INTERFACES" \
'.[] | select(.name as $iface | ($ex_ifaces | split(" ") | index($iface) | not)) | .["ip-addresses"][]? | select(.["ip-address-type"] == "ipv4") | .["ip-address"]')
if [[ -z "$IP_ADDRESSES" ]]; then
continue
fi
for IP in $IP_ADDRESSES; do
log "INFO" " - Found active IP: ${IP} on VM ${VMID} (${VM_NAME})"
IP_INFO_MAP["$IP"]="${VM_NAME}|${MAC_ADDRESS}"
if ! [[ " ${ACTIVE_IPS_LIST[@]} " =~ " ${IP} " ]]; then
ACTIVE_IPS_LIST+=("$IP")
fi
done
done
# Container (CT) Data Collection
log "INFO" "Scanning for running Containers (CTs)..."
for CTID in $($PCT list | awk 'NR>1 {print $1}'); do
CT_STATUS=$($PCT status $CTID 2>/dev/null | grep 'status:' | awk '{print $2}')
if [ "$CT_STATUS" != "running" ]; then
continue
fi
CT_NAME=$($PCT config $CTID | grep -w 'hostname:' | awk '{print $2}')
MAC_ADDRESS=$($PCT config $CTID | grep -E "^net[0-9]+" | grep -i 'hwaddr' | head -n 1 | sed -n 's/.*,hwaddr=\([^,]*\).*/\1/p')
# Get IP by executing command inside the container
IP_ADDRESSES=$(timeout 5 $PCT exec $CTID -- ip -4 addr | grep -oP 'inet \K[\d.]+' | grep -v '127.0.0.1')
if [ $? -ne 0 ]; then
log "WARN" " - Failed to execute command in CT ${CTID} (${CT_NAME}). Skipping."
continue
fi
if [[ -z "$IP_ADDRESSES" ]]; then
continue
fi
for IP in $IP_ADDRESSES; do
log "INFO" " - Found active IP: ${IP} on CT ${CTID} (${CT_NAME})"
IP_INFO_MAP["$IP"]="${CT_NAME}|${MAC_ADDRESS}"
if ! [[ " ${ACTIVE_IPS_LIST[@]} " =~ " ${IP} " ]]; then
ACTIVE_IPS_LIST+=("$IP")
fi
done
done
# Step 2: Iterate through each configured subnet to sync data
log "INFO" "--- Step 2: Processing each configured subnet with combined VM & CT data ---"
for SUBNET_CIDR in $SUBNET_CIDRS; do
log "INFO" "==================== Processing Subnet: ${SUBNET_CIDR} ===================="
log "INFO" "Fetching Subnet ID for '${SUBNET_CIDR}'..."
SUBNET_ID=$(curl ${CURL_OPTS} -s -X GET "${HEADERS[@]}" "${API_BASE_URL}/subnets/cidr/${SUBNET_CIDR}/" | jq -r '.data[0].id')
if [[ -z "$SUBNET_ID" || "$SUBNET_ID" == "null" ]]; then
log "WARN" "Could not find subnet '${SUBNET_CIDR}' or failed to get its ID. Skipping this subnet."
continue
fi
log "INFO" "Successfully retrieved Subnet ID: ${SUBNET_ID} for ${SUBNET_CIDR}"
for IP in "${ACTIVE_IPS_LIST[@]}"; do
IFS='|' read -r NODE_NAME MAC_ADDRESS <<< "${IP_INFO_MAP[$IP]}"
ADDRESS_INFO=$(curl ${CURL_OPTS} -s -X GET "${HEADERS[@]}" "${API_BASE_URL}/addresses/search/${IP}/")
ADDRESS_ID=$(echo "${ADDRESS_INFO}" | jq -r '.data[0].id')
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
if [[ -z "$ADDRESS_ID" || "$ADDRESS_ID" == "null" ]]; then
log "ACTION" " -> IP ${IP} not found, attempting to create in ${SUBNET_CIDR}..."
CREATE_PAYLOAD=$(jq -n \
--arg ip "$IP" \
--arg hostname "$NODE_NAME" \
--arg mac "$MAC_ADDRESS" \
--arg desc "Synced from PVE IPAM Script" \
--argjson tagId "$MANAGED_TAG_ID" \
--arg lastSeen "$timestamp" \
--arg subnetId "$SUBNET_ID" \
'{ip: $ip, hostname: $hostname, mac: $mac, description: $desc, tag: $tagId, lastSeen: $lastSeen, subnetId: $subnetId}')
CREATE_RESPONSE=$(curl ${CURL_OPTS} -s -X POST "${HEADERS[@]}" --data "$CREATE_PAYLOAD" "${API_BASE_URL}/addresses/")
if [[ $(echo "$CREATE_RESPONSE" | jq -r '.success') == "true" ]]; then
log "SUCCESS" " -> SUCCESS: Created IP ${IP} for ${NODE_NAME} in subnet ${SUBNET_CIDR}."
fi
else
ADDRESS_SUBNET_ID=$(echo "${ADDRESS_INFO}" | jq -r '.data[0].subnetId')
if [[ "$ADDRESS_SUBNET_ID" == "$SUBNET_ID" ]]; then
UPDATE_PAYLOAD=$(jq -n \
--arg hostname "$NODE_NAME" \
--arg mac "$MAC_ADDRESS" \
--arg lastSeen "$timestamp" \
'{hostname: $hostname, mac: $mac, lastSeen: $lastSeen}')
log "ACTION" " -> IP ${IP} exists. Updating lastSeen and info."
UPDATE_RESPONSE=$(curl ${CURL_OPTS} -s -X PATCH "${HEADERS[@]}" --data "$UPDATE_PAYLOAD" "${API_BASE_URL}/addresses/${ADDRESS_ID}/")
if [[ $(echo "$UPDATE_RESPONSE" | jq -r '.success') == "true" ]]; then
log "SUCCESS" " -> SUCCESS: Updated info for IP ${IP}."
else
log "ERROR" " -> ERROR: Failed to update IP ${IP}. Response: $(echo "$UPDATE_RESPONSE" | jq '.message')" >&2
fi
fi
fi
done
# Clean up stale entries if enabled
if [[ "$ENABLE_STALE_CLEANUP" == "true" ]]; then
log "INFO" "--- Cleaning Up Stale Entries for ${SUBNET_CIDR} ---"
MANAGED_ADDRESSES=$(curl ${CURL_OPTS} -s -X GET "${HEADERS[@]}" "${API_BASE_URL}/tags/${MANAGED_TAG_ID}/addresses/" | jq -c '.data[] | select(.subnetId == "'$SUBNET_ID'")')
if [[ -z "$MANAGED_ADDRESSES" ]]; then
log "INFO" "No managed IPs found in this subnet. Nothing to clean."
else
while IFS= read -r addr_json; do
STALE_IP=$(echo "$addr_json" | jq -r '.ip')
STALE_ID=$(echo "$addr_json" | jq -r '.id')
is_active=false
for active_ip in "${ACTIVE_IPS_LIST[@]}"; do
if [[ "$active_ip" == "$STALE_IP" ]]; then
is_active=true
break
fi
done
if ! $is_active; then
log "ACTION" " - Found stale IP: ${STALE_IP} (ID: ${STALE_ID})."
if [[ "$STALE_ACTION" == "delete" ]]; then
log "ACTION" " -> Deleting stale IP record..."
DELETE_RESPONSE=$(curl ${CURL_OPTS} -s -X DELETE "${HEADERS[@]}" "${API_BASE_URL}/addresses/${STALE_ID}/")
if [[ $(echo "$DELETE_RESPONSE" | jq -r '.success') == "true" ]]; then
log "SUCCESS" " -> SUCCESS: Deleted stale IP ${STALE_IP}."
else
log "ERROR" " -> ERROR: Failed to delete stale IP ${STALE_IP}. Response: $(echo "$DELETE_RESPONSE" | jq '.message')" >&2
fi
else
log "ACTION" " -> Marking stale IP as offline..."
original_desc=$(echo "$addr_json" | jq -r '.description')
if ! [[ "$original_desc" =~ ^\[OFFLINE ]]; then
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
new_desc="[OFFLINE at ${timestamp}] ${original_desc}"
OFFLINE_PAYLOAD=$(jq -n --arg desc "$new_desc" '{description: $desc}')
UPDATE_RESPONSE=$(curl ${CURL_OPTS} -s -X PATCH "${HEADERS[@]}" --data "$OFFLINE_PAYLOAD" "${API_BASE_URL}/addresses/${STALE_ID}/")
if [[ $(echo "$UPDATE_RESPONSE" | jq -r '.success') == "true" ]]; then
log "SUCCESS" " -> SUCCESS: Marked stale IP ${STALE_IP} as offline."
else
log "ERROR" " -> ERROR: Failed to update stale IP ${STALE_IP}. Response: $(echo "$UPDATE_RESPONSE" | jq '.message')" >&2
fi
else
log "INFO" " -> IP ${STALE_IP} is already marked as offline. Skipping."
fi
fi
fi
done <<< "$MANAGED_ADDRESSES"
fi
fi
done
log "INFO" "==================== Sync Process Finished ===================="
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment