Last active
July 25, 2025 08:31
-
-
Save supersonictw/52205f0bd001c28f47240cbb9bf7421b to your computer and use it in GitHub Desktop.
Proxmox VE (VM & CT) to phpIPAM Synchronizer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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