Files
ProxmoxVE/tools/pve/add-iptag.sh
T
CanbiZ (MickLesk) 9fbe2de1cb Refactor: reduce IP-Tag resource usage and clean up ShellCheck findings (#15418)
* Reduce IP-Tag resource usage and clean up ShellCheck findings

Performance / resource fixes in the generated service:
- VM IP detection only queries the QEMU guest agent when it is actually
  enabled in the VM config. Previously every VM without an agent stalled
  the loop for the full `qm guest cmd` timeout on each cycle; the timeout
  is also lowered from 8s to 5s.
- Skip the ARP/ping fallback for VMs entirely when the guest agent already
  returned addresses, avoiding needless ping probes every run.
- Snapshot `ip neighbor show` once per host instead of invoking it per MAC
  in the VM and LXC lookups.
- Lower ping verification to a 1s timeout (`-W 1`).

ShellCheck cleanup in the installer:
- Define color variables directly instead of via $(echo ...) (SC2116/SC2028).
- Use `read -rp` everywhere (SC2162).
- Replace Unicode quotes with ASCII in a status message (SC1111).

* Cut IP-Tag CPU usage by avoiding per-guest pct/qm status calls

The periodic check spawned one `pct status` per container and one
`qm status` per VM each cycle. Both are heavy Perl tools (~hundreds of ms
CPU per invocation), so on hosts with many guests the 5-minute run caused
a noticeable CPU spike.

- Derive LXC status from the single `pct list` call that is already made
  for enumeration.
- Add one `qm list` call to collect all VM statuses at once.
- Store both in a per-cycle STATUS_CACHE and read from it instead of
  calling `pct status` / `qm status` per guest (with a fallback for direct
  calls outside the cycle).
2026-06-26 21:55:53 +02:00

1179 lines
36 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Copyright (c) 2021-2026 community-scripts ORG
# Author: MickLesk (Canbiz) && Desert_Gamer
# License: MIT
function header_info {
clear
cat <<"EOF"
___ ____ _____
|_ _| _ \ _ |_ _|_ _ __ _
| || |_) (_) | |/ _` |/ _` |
| || __/ _ | | (_| | (_| |
|___|_| (_) |_|\__,_|\__, |
|___/
EOF
}
clear
header_info
APP="IP-Tag"
hostname=$(hostname)
# Color variables
YW="\033[33m"
GN="\033[1;92m"
RD="\033[01;31m"
CL="\033[m"
BFR="\\r\\033[K"
HOLD=" "
CM="${GN}${CL} "
CROSS="${RD}${CL} "
# Telemetry
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/api.func) 2>/dev/null || true
declare -f init_tool_telemetry &>/dev/null && init_tool_telemetry "add-iptag" "pve"
# Stop any running spinner
stop_spinner() {
if [ -n "$SPINNER_PID" ] && kill -0 "$SPINNER_PID" 2>/dev/null; then
kill -TERM "$SPINNER_PID" 2>/dev/null
wait "$SPINNER_PID" 2>/dev/null
fi
SPINNER_PID=""
printf "\e[?25h\r"
}
# Error handler for displaying error messages
error_handler() {
stop_spinner
local exit_code="$?"
local line_number="$1"
local command="$2"
local error_message="${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}"
echo -e "\n$error_message\n"
}
# Spinner for progress indication
spinner() {
local msg="$1"
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local spin_i=0
local interval=0.1
trap 'exit 0' TERM INT
printf "\e[?25l" 2>/dev/null
while true; do
printf "\r%s ${YW}%s${CL}" "${frames[spin_i]}" "$msg" 2>/dev/null || exit 0
spin_i=$(((spin_i + 1) % ${#frames[@]}))
sleep "$interval" || exit 0
done
}
# Info message
msg_info() {
local msg="$1"
stop_spinner
spinner "$msg" &
SPINNER_PID=$!
disown $SPINNER_PID 2>/dev/null
}
# Success message
msg_ok() {
stop_spinner
local msg="$1"
echo -e "${BFR}${CM}${GN}${msg}${CL}"
}
# Error message
msg_error() {
stop_spinner
local msg="$1"
echo -e "${BFR}${CROSS}${RD}${msg}${CL}"
}
# Migrate configuration from old path to new
migrate_config() {
local old_config="/opt/lxc-iptag"
local new_config="/opt/iptag/iptag.conf"
if [[ -f "$old_config" ]]; then
msg_info "Migrating configuration from old path"
if cp "$old_config" "$new_config" &>/dev/null; then
rm -rf "$old_config" &>/dev/null
msg_ok "Configuration migrated and old config removed"
else
msg_error "Failed to migrate configuration"
fi
fi
}
# Update existing installation
update_installation() {
msg_info "Updating IP-Tag Scripts"
systemctl stop iptag.service &>/dev/null
msg_ok "Stopped IP-Tag service"
# Create directory if it doesn't exist
if [[ ! -d "/opt/iptag" ]]; then
mkdir -p /opt/iptag
fi
# Create new config file (check if exists and ask user)
if [[ -f "/opt/iptag/iptag.conf" ]]; then
echo -e "\n${YW}Configuration file already exists.${CL}"
echo -e "${YW}Note: No critical changes were made in this version.${CL}"
while true; do
read -rp "Do you want to replace it with defaults? (y/n): " yn
case $yn in
[Yy]*)
interactive_config_setup
msg_info "Replacing configuration file"
generate_config >/opt/iptag/iptag.conf
msg_ok "Configuration file replaced with defaults"
break
;;
[Nn]*)
echo -e "${GN}✓ Keeping existing configuration file${CL}"
break
;;
*)
echo -e "${RD}Please answer yes or no.${CL}"
;;
esac
done
else
interactive_config_setup
msg_info "Creating new configuration file"
generate_config >/opt/iptag/iptag.conf
msg_ok "Created new configuration file at /opt/iptag/iptag.conf"
fi
# Update main script
msg_info "Updating main script"
generate_main_script >/opt/iptag/iptag
chmod +x /opt/iptag/iptag
msg_ok "Updated main script"
# Update service file
msg_info "Updating service file"
generate_service >/lib/systemd/system/iptag.service
msg_ok "Updated service file"
msg_info "Creating manual run command"
cat <<'EOF' >/usr/local/bin/iptag-run
#!/usr/bin/env bash
CONFIG_FILE="/opt/iptag/iptag.conf"
SCRIPT_FILE="/opt/iptag/iptag"
if [[ ! -f "$SCRIPT_FILE" ]]; then
echo "✗ Main script not found: $SCRIPT_FILE"
exit 1
fi
export FORCE_SINGLE_RUN=true
exec "$SCRIPT_FILE"
EOF
chmod +x /usr/local/bin/iptag-run
msg_ok "Created iptag-run executable - You can execute this manually by entering 'iptag-run' in the Proxmox host, so the script is executed by hand."
msg_info "Restarting service"
systemctl daemon-reload &>/dev/null
systemctl enable -q --now iptag.service &>/dev/null
msg_ok "Updated IP-Tag Scripts"
# Show configuration information after update
show_post_install_info
}
# Install only command without service
install_command_only() {
msg_info "Installing IP-Tag Command Only"
# Create directory if it doesn't exist
if [[ ! -d "/opt/iptag" ]]; then
mkdir -p /opt/iptag
fi
# Migrate config if needed
migrate_config
# Interactive configuration setup
if [[ ! -f /opt/iptag/iptag.conf ]]; then
interactive_config_setup_command
msg_info "Setup Configuration"
generate_config >/opt/iptag/iptag.conf
msg_ok "Created configuration file at /opt/iptag/iptag.conf"
else
stop_spinner
echo -e "\n${YW}Configuration file already exists.${CL}"
read -rp "Do you want to reconfigure tag format? (y/n): " reconfigure
case $reconfigure in
[Yy]*)
interactive_config_setup_command
msg_info "Updating Configuration"
generate_config >/opt/iptag/iptag.conf
msg_ok "Updated configuration file"
;;
*)
msg_ok "Keeping existing configuration file"
;;
esac
fi
# Setup main script
msg_info "Setup Main Script"
generate_main_script >/opt/iptag/iptag
chmod +x /opt/iptag/iptag
msg_ok "Created main script"
# Create manual run command
msg_info "Creating iptag-run command"
cat <<'EOF' >/usr/local/bin/iptag-run
#!/usr/bin/env bash
CONFIG_FILE="/opt/iptag/iptag.conf"
SCRIPT_FILE="/opt/iptag/iptag"
if [[ ! -f "$SCRIPT_FILE" ]]; then
echo "✗ Main script not found: $SCRIPT_FILE"
exit 1
fi
export FORCE_SINGLE_RUN=true
exec "$SCRIPT_FILE"
EOF
chmod +x /usr/local/bin/iptag-run
msg_ok "Created iptag-run command"
msg_ok "IP-Tag Command installed successfully! Use 'iptag-run' to run manually."
}
# Show post-installation information
show_post_install_info() {
stop_spinner
echo -e "\n${YW}=== Next Steps ===${CL}"
# Show usage information
if command -v iptag-run >/dev/null 2>&1; then
echo -e "${YW}Run IP tagging manually: ${GN}iptag-run${CL}"
echo -e "${YW}Add to cron for scheduled execution if needed${CL}"
echo -e ""
fi
echo -e "${RD}IMPORTANT: Configure your network subnets!${CL}"
echo -e ""
echo -e "${YW}Configuration file: ${GN}/opt/iptag/iptag.conf${CL}"
echo -e ""
echo -e "${YW}Edit CIDR_LIST with your actual subnets:${CL}"
echo -e "${GN}nano /opt/iptag/iptag.conf${CL} ${YW}or${CL} ${GN}vim /opt/iptag/iptag.conf${CL}"
echo -e ""
echo -e "${YW}Example configuration:${CL}"
echo -e "${GN}CIDR_LIST=(${CL}"
echo -e "${GN} 192.168.1.0/24 # Your actual subnet${CL}"
echo -e "${GN} 10.10.0.0/16 # Another subnet${CL}"
echo -e "${GN})${CL}"
echo -e ""
}
# Interactive configuration setup for command-only (TAG_FORMAT only)
interactive_config_setup_command() {
echo -e "\n${YW}=== Configuration Setup ===${CL}"
# TAG_FORMAT configuration
echo -e "\n${YW}Select tag format:${CL}"
echo -e "${GN}1)${CL} last_two_octets - Show last two octets (e.g., 0.100) [Default]"
echo -e "${GN}2)${CL} last_octet - Show only last octet (e.g., 100)"
echo -e "${GN}3)${CL} full - Show full IP address (e.g., 192.168.0.100)"
while true; do
read -rp "Enter your choice (1-3) [1]: " tag_choice
case ${tag_choice:-1} in
1)
TAG_FORMAT="last_two_octets"
echo -e "${GN}✓ Selected: last_two_octets${CL}"
break
;;
2)
TAG_FORMAT="last_octet"
echo -e "${GN}✓ Selected: last_octet${CL}"
break
;;
3)
TAG_FORMAT="full"
echo -e "${GN}✓ Selected: full${CL}"
break
;;
*)
echo -e "${RD}Please enter 1, 2, or 3.${CL}"
;;
esac
done
# Set default LOOP_INTERVAL for command mode
LOOP_INTERVAL=300
}
# Interactive configuration setup for service (TAG_FORMAT + LOOP_INTERVAL)
interactive_config_setup() {
echo -e "\n${YW}=== Configuration Setup ===${CL}"
# TAG_FORMAT configuration
echo -e "\n${YW}Select tag format:${CL}"
echo -e "${GN}1)${CL} last_two_octets - Show last two octets (e.g., 0.100) [Default]"
echo -e "${GN}2)${CL} last_octet - Show only last octet (e.g., 100)"
echo -e "${GN}3)${CL} full - Show full IP address (e.g., 192.168.0.100)"
while true; do
read -rp "Enter your choice (1-3) [1]: " tag_choice
case ${tag_choice:-1} in
1)
TAG_FORMAT="last_two_octets"
echo -e "${GN}✓ Selected: last_two_octets${CL}"
break
;;
2)
TAG_FORMAT="last_octet"
echo -e "${GN}✓ Selected: last_octet${CL}"
break
;;
3)
TAG_FORMAT="full"
echo -e "${GN}✓ Selected: full${CL}"
break
;;
*)
echo -e "${RD}Please enter 1, 2, or 3.${CL}"
;;
esac
done
# LOOP_INTERVAL configuration
echo -e "\n${YW}Set check interval (in seconds):${CL}"
echo -e "${YW}Default: 300 seconds (5 minutes)${CL}"
echo -e "${YW}Recommended range: 300-3600 seconds${CL}"
while true; do
read -rp "Enter interval in seconds [300]: " interval_input
interval_input=${interval_input:-300}
if [[ $interval_input =~ ^[0-9]+$ ]] && [ $interval_input -ge 300 ] && [ $interval_input -le 7200 ]; then
LOOP_INTERVAL=$interval_input
echo -e "${GN}✓ Selected: ${LOOP_INTERVAL} seconds${CL}"
break
else
echo -e "${RD}Please enter a valid number between 300 and 7200 seconds.${CL}"
fi
done
}
# Generate configuration file content
generate_config() {
cat <<EOF
# Configuration file for IP tagging
# List of allowed CIDRs
CIDR_LIST=(
192.168.0.0/16
10.0.0.0/8
100.64.0.0/10
)
# Tag format options:
# - "full": full IP address (e.g., 192.168.0.100)
# - "last_octet": only the last octet (e.g., 100)
# - "last_two_octets": last two octets (e.g., 0.100)
TAG_FORMAT="${TAG_FORMAT:-last_two_octets}"
# Check interval (in seconds)
LOOP_INTERVAL=${LOOP_INTERVAL:-300}
# Debug settings (set to true to enable debugging)
DEBUG=false
EOF
}
# Generate systemd service file content
generate_service() {
cat <<EOF
[Unit]
Description=IP-Tag service
After=network.target
[Service]
Type=simple
ExecStart=/opt/iptag/iptag
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
}
# Generate main script content
generate_main_script() {
cat <<'EOF'
#!/bin/bash
# =============== CONFIGURATION =============== #
readonly CONFIG_FILE="/opt/iptag/iptag.conf"
readonly DEFAULT_TAG_FORMAT="full"
readonly DEFAULT_CHECK_INTERVAL=60
# Load the configuration file if it exists
if [ -f "$CONFIG_FILE" ]; then
# shellcheck source=./iptag.conf
source "$CONFIG_FILE"
fi
# Set default DEBUG value if not defined
DEBUG=${DEBUG:-false}
# Debug logging function
debug_log() {
if [[ "$DEBUG" == "true" || "$DEBUG" == "1" ]]; then
echo "[DEBUG] $*" >&2
fi
}
# Color constants
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[0;33m'
readonly BLUE='\033[0;34m'
readonly PURPLE='\033[0;35m'
readonly CYAN='\033[0;36m'
readonly WHITE='\033[1;37m'
readonly GRAY='\033[0;37m'
readonly NC='\033[0m' # No Color
# Logging functions with colors
log_success() {
echo -e "${GREEN}✓${NC} $*"
}
log_info() {
echo -e "${BLUE}${NC} $*"
}
log_warning() {
echo -e "${YELLOW}⚠${NC} $*"
}
log_error() {
echo -e "${RED}✗${NC} $*"
}
log_change() {
echo -e "${CYAN}~${NC} $*"
}
log_unchanged() {
echo -e "${GRAY}=${NC} $*"
}
# Check if IP is in CIDR
ip_in_cidr() {
local ip="$1" cidr="$2"
debug_log "ip_in_cidr: checking '$ip' against '$cidr'"
# Manual CIDR check - более надёжный метод
debug_log "ip_in_cidr: using manual check (bypassing ipcalc)"
local network prefix
IFS='/' read -r network prefix <<< "$cidr"
# Convert IP and network to integers for comparison
local ip_int net_int mask
IFS='.' read -r a b c d <<< "$ip"
ip_int=$(( (a << 24) + (b << 16) + (c << 8) + d ))
IFS='.' read -r a b c d <<< "$network"
net_int=$(( (a << 24) + (b << 16) + (c << 8) + d ))
# Create subnet mask
mask=$(( 0xFFFFFFFF << (32 - prefix) ))
# Apply mask and compare
local ip_masked=$((ip_int & mask))
local net_masked=$((net_int & mask))
debug_log "ip_in_cidr: IP=$ip ($ip_int), Network=$network ($net_int), Prefix=$prefix"
debug_log "ip_in_cidr: Mask=$mask (hex: $(printf '0x%08x' $mask))"
debug_log "ip_in_cidr: IP&Mask=$ip_masked ($(printf '%d.%d.%d.%d' $((ip_masked>>24&255)) $((ip_masked>>16&255)) $((ip_masked>>8&255)) $((ip_masked&255))))"
debug_log "ip_in_cidr: Net&Mask=$net_masked ($(printf '%d.%d.%d.%d' $((net_masked>>24&255)) $((net_masked>>16&255)) $((net_masked>>8&255)) $((net_masked&255))))"
if (( ip_masked == net_masked )); then
debug_log "ip_in_cidr: manual check PASSED - IP is in CIDR"
return 0
else
debug_log "ip_in_cidr: manual check FAILED - IP is NOT in CIDR"
return 1
fi
}
# Format IP address according to the configuration
format_ip_tag() {
local ip="$1"
[[ -z "$ip" ]] && return
local format="${TAG_FORMAT:-$DEFAULT_TAG_FORMAT}"
case "$format" in
"last_octet") echo "${ip##*.}" ;;
"last_two_octets") echo "${ip#*.*.}" ;;
*) echo "$ip" ;;
esac
}
# Check if IP is in any CIDRs
ip_in_cidrs() {
local ip="$1" cidrs="$2"
[[ -z "$cidrs" ]] && return 1
local IFS=' '
debug_log "Checking IP '$ip' against CIDRs: '$cidrs'"
for cidr in $cidrs; do
debug_log "Testing IP '$ip' against CIDR '$cidr'"
if ip_in_cidr "$ip" "$cidr"; then
debug_log "IP '$ip' matches CIDR '$cidr' - PASSED"
return 0
else
debug_log "IP '$ip' does not match CIDR '$cidr'"
fi
done
debug_log "IP '$ip' failed all CIDR checks"
return 1
}
# Check if IP is valid
is_valid_ipv4() {
local ip="$1"
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1
local IFS='.' parts
read -ra parts <<< "$ip"
for part in "${parts[@]}"; do
(( part >= 0 && part <= 255 )) || return 1
done
return 0
}
# Get VM IPs using improved methods
get_vm_ips() {
local vmid=$1 ips=""
local vm_config="/etc/pve/qemu-server/${vmid}.conf"
[[ ! -f "$vm_config" ]] && return
debug_log "vm $vmid: starting IP detection"
# Check if VM is running first (status comes from the cached `qm list`,
# falling back to `qm status` only when called outside the normal cycle).
local vm_status="${STATUS_CACHE[vm_${vmid}]:-}"
if [[ -z "$vm_status" ]] && command -v qm >/dev/null 2>&1; then
vm_status=$(qm status "$vmid" 2>/dev/null | awk '{print $2}')
fi
if [[ "$vm_status" != "running" ]]; then
debug_log "vm $vmid: not running (status: $vm_status)"
return
fi
# Get MAC addresses from config
local mac_addresses=$(grep -E "^net[0-9]+:" "$vm_config" | grep -oE "([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}" | head -3)
debug_log "vm $vmid: found MACs: $mac_addresses"
# Method 1: QEMU guest agent (most reliable for current IP). Only query it
# when the agent is actually enabled in the VM config, otherwise the call
# blocks until the timeout on every VM without an agent.
local agent_enabled=0
if [[ "$(grep -E '^agent:' "$vm_config" 2>/dev/null)" =~ (^agent:[[:space:]]*1|enabled=1) ]]; then
agent_enabled=1
fi
if [[ "$agent_enabled" == "1" ]] && command -v qm >/dev/null 2>&1; then
debug_log "vm $vmid: querying guest agent"
local qm_ips=$(timeout 5 qm guest cmd "$vmid" network-get-interfaces 2>/dev/null | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | grep -v "127.0.0.1" | head -3)
for qm_ip in $qm_ips; do
if [[ "$qm_ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
debug_log "vm $vmid: found IP $qm_ip via qm guest cmd"
ips+="$qm_ip "
fi
done
else
debug_log "vm $vmid: guest agent not enabled, skipping qm guest cmd"
fi
# Method 2: ARP table lookup (only if the guest agent gave us nothing).
if [[ -z "$ips" && -n "$mac_addresses" ]]; then
debug_log "vm $vmid: checking ARP table"
# Snapshot the neighbor table once instead of per MAC
local neigh_table
neigh_table=$(ip neighbor show 2>/dev/null)
for mac in $mac_addresses; do
local mac_lower=$(echo "$mac" | tr '[:upper:]' '[:lower:]')
# Check current ARP table
local current_ip=$(echo "$neigh_table" | grep "$mac_lower" | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -1)
# If found in ARP, verify it's still valid by trying to ping
if [[ -n "$current_ip" && "$current_ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
debug_log "vm $vmid: found IP $current_ip in ARP table for MAC $mac_lower, verifying..."
# Quick ping test to verify IP is still active
if timeout 1 ping -c 1 -W 1 "$current_ip" >/dev/null 2>&1; then
debug_log "vm $vmid: verified IP $current_ip is active via ping"
ips+="$current_ip "
else
debug_log "vm $vmid: IP $current_ip failed ping verification, removing from ARP"
# Remove stale ARP entry
ip neighbor del "$current_ip" dev $(ip route get "$current_ip" 2>/dev/null | grep -oE 'dev [^ ]+' | cut -d' ' -f2) 2>/dev/null || true
fi
fi
done
fi
# Method 3: DHCP leases (backup method)
if [[ -z "$ips" ]]; then
debug_log "vm $vmid: checking DHCP leases as fallback"
for mac in $mac_addresses; do
local mac_lower=$(echo "$mac" | tr '[:upper:]' '[:lower:]')
for dhcp_file in "/var/lib/dhcp/dhcpd.leases" "/var/lib/dhcpcd5/dhcpcd.leases"; do
if [[ -f "$dhcp_file" ]]; then
# Look for most recent lease for this MAC
local dhcp_ip=$(tac "$dhcp_file" 2>/dev/null | grep -A 10 "ethernet $mac_lower" | grep "binding state active" -A 5 | grep -oE "([0-9]{1,3}\.){3}[0-9]{1,3}" | head -1)
if [[ -n "$dhcp_ip" && "$dhcp_ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
debug_log "vm $vmid: found IP $dhcp_ip via DHCP leases"
# Verify this IP responds
if timeout 1 ping -c 1 -W 1 "$dhcp_ip" >/dev/null 2>&1; then
debug_log "vm $vmid: verified DHCP IP $dhcp_ip is active"
ips+="$dhcp_ip "
break 2
else
debug_log "vm $vmid: DHCP IP $dhcp_ip failed ping test"
fi
fi
fi
done
done
fi
# Remove duplicates and clean up
local unique_ips=$(echo "$ips" | tr ' ' '\n' | sort -u | tr '\n' ' ')
unique_ips="${unique_ips% }"
debug_log "vm $vmid: final IPs: '$unique_ips'"
echo "$unique_ips"
}
# Cache for configs to avoid repeated reads
declare -A CONFIG_CACHE
declare -A IP_CACHE
# Status cache populated once per check from `pct list` / `qm list` to avoid
# spawning an expensive `pct status` / `qm status` (Perl) per guest each cycle.
declare -A STATUS_CACHE
# Update tags for container or VM
update_tags() {
local type="$1" vmid="$2"
local current_ips_full
local current_tags_raw=""
# Get IPs with caching
local cache_key="${type}_${vmid}"
if [[ -n "${IP_CACHE[$cache_key]:-}" ]]; then
current_ips_full="${IP_CACHE[$cache_key]}"
debug_log "$type $vmid: using cached IPs"
else
if [[ "$type" == "lxc" ]]; then
current_ips_full=$(get_lxc_ips "${vmid}")
else
current_ips_full=$(get_vm_ips "${vmid}")
fi
IP_CACHE[$cache_key]="$current_ips_full"
fi
# Get current tags (optimized file reading)
if [[ "$type" == "lxc" ]]; then
local config_file="/etc/pve/lxc/${vmid}.conf"
if [[ -f "$config_file" ]]; then
current_tags_raw=$(grep "^tags:" "$config_file" 2>/dev/null | cut -d: -f2 | sed 's/^[[:space:]]*//')
fi
else
local vm_config="/etc/pve/qemu-server/${vmid}.conf"
if [[ -f "$vm_config" ]]; then
current_tags_raw=$(grep "^tags:" "$vm_config" 2>/dev/null | cut -d: -f2 | sed 's/^[[:space:]]*//')
fi
fi
local current_tags=() next_tags=() current_ip_tags=()
if [[ -n "$current_tags_raw" ]]; then
mapfile -t current_tags < <(echo "$current_tags_raw" | sed 's/;/\n/g')
fi
# Separate IP/numeric and user tags
for tag in "${current_tags[@]}"; do
if is_valid_ipv4 "${tag}" || [[ "$tag" =~ ^[0-9]+(\.[0-9]+)*$ ]]; then
current_ip_tags+=("${tag}")
else
next_tags+=("${tag}")
fi
done
# Generate new IP tags from current IPs
local formatted_ips=()
debug_log "$type $vmid current_ips_full: '$current_ips_full'"
debug_log "$type $vmid CIDR_LIST: ${CIDR_LIST[*]}"
for ip in $current_ips_full; do
[[ -z "$ip" ]] && continue
debug_log "$type $vmid processing IP: '$ip'"
if is_valid_ipv4 "$ip"; then
debug_log "$type $vmid IP '$ip' is valid"
if ip_in_cidrs "$ip" "${CIDR_LIST[*]}"; then
debug_log "$type $vmid IP '$ip' passed CIDR check"
local formatted_ip=$(format_ip_tag "$ip")
debug_log "$type $vmid formatted '$ip' -> '$formatted_ip'"
[[ -n "$formatted_ip" ]] && formatted_ips+=("$formatted_ip")
else
debug_log "$type $vmid IP '$ip' failed CIDR check"
fi
else
debug_log "$type $vmid IP '$ip' is invalid"
fi
done
debug_log "$type $vmid final formatted_ips: ${formatted_ips[*]}"
# If LXC and no IPs detected, do not touch tags at all
if [[ "$type" == "lxc" && ${#formatted_ips[@]} -eq 0 ]]; then
log_unchanged "LXC ${GRAY}${vmid}${NC}: No IP detected, tags unchanged"
return
fi
# Prepend new IP tags to the beginning of the tag list
local final_tags=()
for new_ip in "${formatted_ips[@]}"; do
final_tags+=("$new_ip")
done
for tag in "${next_tags[@]}"; do
final_tags+=("$tag")
done
next_tags=("${final_tags[@]}")
# Update tags if there are changes
local old_tags_str=$(IFS=';'; echo "${current_tags[*]}")
local new_tags_str=$(IFS=';'; echo "${next_tags[*]}")
debug_log "$type $vmid old_tags: '$old_tags_str'"
debug_log "$type $vmid new_tags: '$new_tags_str'"
debug_log "$type $vmid tags_equal: $([[ "$old_tags_str" == "$new_tags_str" ]] && echo true || echo false)"
if [[ "$old_tags_str" != "$new_tags_str" ]]; then
# Determine what changed
local old_ip_tags_count=${#current_ip_tags[@]}
local new_ip_tags_count=${#formatted_ips[@]}
# Build detailed change message
local change_details=""
if [[ $old_ip_tags_count -eq 0 ]]; then
change_details="added ${new_ip_tags_count} IP tag(s): [${GREEN}${formatted_ips[*]}${NC}]"
else
# Compare old and new IP tags
local added_tags=() removed_tags=() common_tags=()
# Find removed tags
for old_tag in "${current_ip_tags[@]}"; do
local found=false
for new_tag in "${formatted_ips[@]}"; do
if [[ "$old_tag" == "$new_tag" ]]; then
found=true
break
fi
done
if [[ "$found" == false ]]; then
removed_tags+=("$old_tag")
else
common_tags+=("$old_tag")
fi
done
# Find added tags
for new_tag in "${formatted_ips[@]}"; do
local found=false
for old_tag in "${current_ip_tags[@]}"; do
if [[ "$new_tag" == "$old_tag" ]]; then
found=true
break
fi
done
if [[ "$found" == false ]]; then
added_tags+=("$new_tag")
fi
done
# Build change message
local change_parts=()
if [[ ${#added_tags[@]} -gt 0 ]]; then
change_parts+=("added [${GREEN}${added_tags[*]}${NC}]")
fi
if [[ ${#removed_tags[@]} -gt 0 ]]; then
change_parts+=("removed [${YELLOW}${removed_tags[*]}${NC}]")
fi
if [[ ${#common_tags[@]} -gt 0 ]]; then
change_parts+=("kept [${GRAY}${common_tags[*]}${NC}]")
fi
change_details=$(IFS=', '; echo "${change_parts[*]}")
fi
log_change "${type^^} ${CYAN}${vmid}${NC}: ${change_details}"
if [[ "$type" == "lxc" ]]; then
pct set "${vmid}" -tags "$(IFS=';'; echo "${next_tags[*]}")" &>/dev/null
else
qm set "${vmid}" --tags "$(IFS=';'; echo "${next_tags[*]}")" &>/dev/null
fi
else
# Tags unchanged
local ip_count=${#formatted_ips[@]}
local status_msg=""
if [[ $ip_count -eq 0 ]]; then
status_msg="No IPs detected"
elif [[ $ip_count -eq 1 ]]; then
status_msg="IP tag [${GRAY}${formatted_ips[0]}${NC}] unchanged"
else
status_msg="${ip_count} IP tags [${GRAY}${formatted_ips[*]}${NC}] unchanged"
fi
log_unchanged "${type^^} ${GRAY}${vmid}${NC}: ${status_msg}"
fi
}
# Update all instances of specified type
update_all_tags() {
local type="$1" vmids count=0
# Get list of all containers/VMs
if [[ "$type" == "lxc" ]]; then
# A single `pct list` call yields both the VMID list and the running
# status, so we never need a per-container `pct status` afterwards.
local pct_list_output
pct_list_output=$(pct list 2>/dev/null)
vmids=($(echo "$pct_list_output" | awk 'NR>1 {print $1}'))
local _vmid _status _rest
while read -r _vmid _status _rest; do
[[ "$_vmid" == "VMID" || -z "$_vmid" ]] && continue
STATUS_CACHE["lxc_${_vmid}"]="$_status"
done <<<"$pct_list_output"
else
# More efficient: direct file listing instead of ls+sed
vmids=()
for conf in /etc/pve/qemu-server/*.conf; do
[[ -f "$conf" ]] || continue
local basename="${conf##*/}"
vmids+=("${basename%.conf}")
done
# A single `qm list` call yields the status for all VMs, avoiding a
# per-VM `qm status`.
if command -v qm >/dev/null 2>&1; then
local _vmid _name _status _rest
while read -r _vmid _name _status _rest; do
[[ "$_vmid" == "VMID" || -z "$_vmid" ]] && continue
STATUS_CACHE["vm_${_vmid}"]="$_status"
done <<<"$(qm list 2>/dev/null)"
fi
fi
count=${#vmids[@]}
[[ $count -eq 0 ]] && return
# Display processing header with color
if [[ "$type" == "lxc" ]]; then
log_info "Processing ${WHITE}${count}${NC} LXC container(s)"
else
log_info "Processing ${WHITE}${count}${NC} virtual machine(s)"
fi
# Process each VM/LXC container
local processed=0
for vmid in "${vmids[@]}"; do
update_tags "$type" "$vmid"
((processed++))
done
# Add completion message
if [[ "$type" == "lxc" ]]; then
log_success "Completed processing LXC containers"
else
log_success "Completed processing virtual machines"
fi
}
# Main check function
check() {
local start_time=$(date +%s)
log_info "Starting periodic check"
# Clear caches before each run
CONFIG_CACHE=()
IP_CACHE=()
STATUS_CACHE=()
# Update LXC containers
update_all_tags "lxc"
# Update VMs
update_all_tags "vm"
local end_time=$(date +%s)
local duration=$((end_time - start_time))
log_success "Check completed in ${WHITE}${duration}${NC} seconds"
}
# Main loop
main() {
# Display startup message
echo -e "\n${PURPLE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
log_success "IP-Tag service started successfully"
echo -e "${BLUE}${NC} Loop interval: ${WHITE}${LOOP_INTERVAL:-300}${NC} seconds"
echo -e "${BLUE}${NC} Debug mode: ${WHITE}${DEBUG:-false}${NC}"
echo -e "${BLUE}${NC} Tag format: ${WHITE}${TAG_FORMAT:-full}${NC}"
echo -e "${BLUE}${NC} Allowed CIDRs: ${WHITE}${CIDR_LIST[*]}${NC}"
echo -e "${PURPLE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
if [[ "$FORCE_SINGLE_RUN" == "true" ]]; then
check
exit 0
fi
while true; do
check
sleep "${LOOP_INTERVAL:-300}"
done
}
# Simple LXC IP detection
get_lxc_ips() {
local vmid=$1
debug_log "lxc $vmid: starting IP detection"
# Check if LXC is running (status comes from the cached `pct list`,
# falling back to `pct status` only when called outside the normal cycle).
local lxc_status="${STATUS_CACHE[lxc_${vmid}]:-}"
if [[ -z "$lxc_status" ]]; then
lxc_status=$(pct status "${vmid}" 2>/dev/null | awk '{print $2}')
fi
if [[ "$lxc_status" != "running" ]]; then
debug_log "lxc $vmid: not running (status: $lxc_status)"
return
fi
local ips=""
# Method 1: Check Proxmox config for ALL static IPs (multiple interfaces)
local pve_lxc_config="/etc/pve/lxc/${vmid}.conf"
if [[ -f "$pve_lxc_config" ]]; then
local static_ips=$(grep -E "^net[0-9]+:" "$pve_lxc_config" 2>/dev/null | grep -oE 'ip=([0-9]{1,3}\.){3}[0-9]{1,3}' | cut -d'=' -f2)
if [[ -n "$static_ips" ]]; then
while IFS= read -r ip; do
if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
debug_log "lxc $vmid: found static IP $ip in config"
ips="${ips}${ips:+ }${ip}"
fi
done <<< "$static_ips"
fi
fi
# Method 2: ARP table lookup for ALL MAC addresses if no static IPs found
if [[ -z "$ips" && -f "$pve_lxc_config" ]]; then
local mac_addrs=$(grep -Eo 'hwaddr=([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}' "$pve_lxc_config" | cut -d'=' -f2)
if [[ -n "$mac_addrs" ]]; then
# Snapshot the neighbor table once instead of per MAC
local neigh_table
neigh_table=$(ip neighbor show 2>/dev/null)
while IFS= read -r mac_addr; do
[[ -z "$mac_addr" ]] && continue
local arp_ip=$(echo "$neigh_table" | grep -i "$mac_addr" | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -1)
if [[ -n "$arp_ip" && "$arp_ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
debug_log "lxc $vmid: found IP $arp_ip via ARP table for MAC $mac_addr"
ips="${ips}${ips:+ }${arp_ip}"
fi
done <<< "$mac_addrs"
fi
fi
# Method 3: Direct container command to get ALL IPs if previous methods failed
if [[ -z "$ips" ]]; then
local container_ips=$(timeout 5s pct exec "$vmid" -- ip -4 addr show 2>/dev/null | grep -oE 'inet ([0-9]{1,3}\.){3}[0-9]{1,3}' | awk '{print $2}' | grep -v '127.0.0.1')
if [[ -n "$container_ips" ]]; then
while IFS= read -r ip; do
if is_valid_ipv4 "$ip"; then
debug_log "lxc $vmid: found IP $ip via pct exec"
ips="${ips}${ips:+ }${ip}"
fi
done <<< "$container_ips"
fi
fi
# Remove duplicates and clean up
local unique_ips=$(echo "$ips" | tr ' ' '\n' | sort -u | tr '\n' ' ')
unique_ips="${unique_ips% }"
debug_log "lxc $vmid: final IPs: '$unique_ips'"
echo "$unique_ips"
}
main
EOF
}
# Choose installation/update mode
echo -e "\n${YW}Choose action:${CL}"
echo -e "${GN}1)${CL} Install with automatic service (recommended)"
echo -e "${GN}2)${CL} Install command only (manual execution)"
echo -e "${GN}3)${CL} Update existing installation"
echo -e "${RD}4)${CL} Cancel"
while true; do
read -rp "Enter your choice (1-4): " choice
case $choice in
1)
INSTALL_MODE="service"
echo -e "${GN}✓ Selected: Service installation${CL}"
break
;;
2)
INSTALL_MODE="command"
echo -e "${GN}✓ Selected: Command-only installation${CL}"
break
;;
3)
echo -e "${GN}✓ Selected: Update installation${CL}"
update_installation
exit 0
;;
4)
msg_error "Action cancelled."
exit 0
;;
*)
msg_error "Please enter 1, 2, 3, or 4."
;;
esac
done
echo -e "\n${YW}This will install ${APP} on ${hostname} in $INSTALL_MODE mode.${CL}"
while true; do
read -rp "Proceed? (y/n): " yn
case $yn in
[Yy]*)
break
;;
[Nn]*)
msg_error "Installation cancelled."
exit
;;
*)
msg_error "Please answer yes or no."
;;
esac
done
if ! pveversion | grep -Eq "pve-manager/(8\.[0-4]|9\.[0-9]+)(\.[0-9]+)*"; then
msg_error "This version of Proxmox Virtual Environment is not supported"
msg_error "⚠ Requires Proxmox Virtual Environment Version 8.08.4 or 9.x."
msg_error "Exiting..."
sleep 2
exit
fi
msg_info "Installing Dependencies"
apt-get update &>/dev/null
apt-get install -y ipcalc net-tools &>/dev/null
msg_ok "Installed Dependencies"
# Execute installation based on selected mode
if [[ "$INSTALL_MODE" == "service" ]]; then
# Full service installation
msg_info "Setting up IP-Tag Scripts"
mkdir -p /opt/iptag
msg_ok "Setup IP-Tag Scripts"
# Migrate config if needed
migrate_config
# Interactive configuration setup
if [[ ! -f /opt/iptag/iptag.conf ]]; then
interactive_config_setup
msg_info "Setup Default Config"
generate_config >/opt/iptag/iptag.conf
msg_ok "Setup default config"
else
stop_spinner
echo -e "\n${YW}Configuration file already exists.${CL}"
read -rp "Do you want to reconfigure tag format and loop interval? (y/n): " reconfigure
case $reconfigure in
[Yy]*)
interactive_config_setup
msg_info "Updating Configuration"
generate_config >/opt/iptag/iptag.conf
msg_ok "Updated configuration file"
;;
*)
msg_ok "Keeping existing configuration file"
;;
esac
fi
msg_info "Setup Main Function"
generate_main_script >/opt/iptag/iptag
chmod +x /opt/iptag/iptag
msg_ok "Setup Main Function"
msg_info "Creating Service"
generate_service >/lib/systemd/system/iptag.service
msg_ok "Created Service"
msg_info "Starting Service"
systemctl daemon-reload &>/dev/null
systemctl enable -q --now iptag.service &>/dev/null
msg_ok "Started Service"
msg_info "Creating manual run command"
cat <<'EOF' >/usr/local/bin/iptag-run
#!/usr/bin/env bash
CONFIG_FILE="/opt/iptag/iptag.conf"
SCRIPT_FILE="/opt/iptag/iptag"
if [[ ! -f "$SCRIPT_FILE" ]]; then
echo "✗ Main script not found: $SCRIPT_FILE"
exit 1
fi
export FORCE_SINGLE_RUN=true
exec "$SCRIPT_FILE"
EOF
chmod +x /usr/local/bin/iptag-run
msg_ok "Created iptag-run command"
echo -e "\n${GN}${APP} service installation completed successfully! ${CL}"
echo -e "${YW}The service is now running automatically.${CL}"
echo -e "${YW}You can also run it manually with: ${GN}iptag-run${CL}\n"
# Show configuration information
show_post_install_info
elif [[ "$INSTALL_MODE" == "command" ]]; then
# Command-only installation
install_command_only
stop_spinner
echo -e "\n${GN}${APP} command installation completed successfully! ${CL}"
# Show configuration information
show_post_install_info
fi
# Clean up any running spinner and exit
stop_spinner
exit 0