Skip to content

Commit 771bfe2

Browse files
authored
Add SNMP script for XCP-ng VM information (#609)
This script exposes XCP-ng virtual machine information via SNMP using the net-snmp 'pass_persist' directive. It collects VM data and handles SNMP requests for various VM attributes.
1 parent c709c7d commit 771bfe2

1 file changed

Lines changed: 374 additions & 0 deletions

File tree

snmp/xcp-ng-vminfo

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
#!/bin/bash
2+
# (c) 2026, LibreNMS
3+
# This program is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
#
8+
# This program is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU General Public License
14+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
15+
#
16+
# net-snmp pass_persist script for XCP-NG-VMINFO-MIB
17+
#
18+
# Exposes XCP-ng virtual machine information via SNMP using the
19+
# net-snmp "pass_persist" directive. The script stays running and
20+
# handles multiple requests without restarting.
21+
#
22+
# snmpd.conf example:
23+
# pass_persist .1.3.6.1.4.1.60652.100 /etc/snmp/xcp-ng-vminfo
24+
#
25+
# OID structure:
26+
# .1.3.6.1.4.1.60652.100.1.1.<column>.<row>
27+
#
28+
# Columns:
29+
# 2 xcpNgVmDisplayName (string) - VM name-label
30+
# 3 xcpNgVmConfigFile (string) - empty for now
31+
# 4 xcpNgVmGuestOS (string) - os-version
32+
# 5 xcpNgVmMemSize (integer) - memory in MB
33+
# 6 xcpNgVmState (integer) - power-state: running(1) halted(2) paused(3) suspended(4) crashed(5)
34+
# 7 xcpNgVmVMID (string) - uuid
35+
# 8 xcpNgVmGuestState (string) - guest tools state (derived from guest-metrics-last-updated)
36+
# 9 xcpNgVmIpAddress (string) - empty for now
37+
# 10 xcpNgVmCpus (integer) - VCPUs-number
38+
# 11 xcpNgVmUUID (string) - uuid
39+
40+
BASE_OID=".1.3.6.1.4.1.60652.100"
41+
ENTRY_OID="${BASE_OID}.1.1"
42+
43+
# First accessible column and last column
44+
FIRST_COL=2
45+
LAST_COL=11
46+
47+
# How often to refresh VM data (seconds)
48+
CACHE_TTL=60
49+
50+
# ---------------------------------------------------------------
51+
# Map power-state string to integer enum
52+
# ---------------------------------------------------------------
53+
map_power_state() {
54+
case "$1" in
55+
running) echo 1 ;;
56+
halted) echo 2 ;;
57+
paused) echo 3 ;;
58+
suspended) echo 4 ;;
59+
dying|crashed) echo 5 ;;
60+
*) echo 5 ;;
61+
esac
62+
}
63+
64+
# ---------------------------------------------------------------
65+
# Collect VM data from xe vm-list
66+
# ---------------------------------------------------------------
67+
declare -a VM_UUID VM_NAME VM_OS VM_MEM VM_STATE VM_CPUS VM_GUESTSTATE
68+
NUM_VMS=0
69+
LAST_REFRESH=0
70+
71+
collect_vm_data() {
72+
local now
73+
now=$(date +%s)
74+
75+
# Only refresh if cache has expired
76+
if [[ $((now - LAST_REFRESH)) -lt $CACHE_TTL && $NUM_VMS -gt 0 ]]; then
77+
return
78+
fi
79+
80+
# Clear old data
81+
VM_UUID=()
82+
VM_NAME=()
83+
VM_OS=()
84+
VM_MEM=()
85+
VM_STATE=()
86+
VM_CPUS=()
87+
VM_GUESTSTATE=()
88+
89+
local idx=0
90+
local line
91+
local cur_uuid="" cur_name="" cur_os="" cur_mem="" cur_state="" cur_cpus="" cur_gueststate=""
92+
local in_record=0
93+
94+
while IFS= read -r line; do
95+
# Blank line (or whitespace-only) signals end of a VM record
96+
if [[ "$line" =~ ^[[:space:]]*$ ]]; then
97+
if [[ $in_record -eq 1 && -n "$cur_uuid" ]]; then
98+
idx=$((idx + 1))
99+
VM_UUID[$idx]="$cur_uuid"
100+
VM_NAME[$idx]="$cur_name"
101+
VM_OS[$idx]="$cur_os"
102+
VM_STATE[$idx]=$(map_power_state "$cur_state")
103+
VM_CPUS[$idx]="$cur_cpus"
104+
VM_GUESTSTATE[$idx]="$cur_gueststate"
105+
106+
# Convert memory from bytes to MB
107+
if [[ -n "$cur_mem" && "$cur_mem" != "<"* ]]; then
108+
VM_MEM[$idx]=$(( (cur_mem + 524288) / 1048576 ))
109+
else
110+
VM_MEM[$idx]=0
111+
fi
112+
113+
# Reset for next record
114+
cur_uuid="" cur_name="" cur_os="" cur_mem="" cur_state="" cur_cpus="" cur_gueststate=""
115+
in_record=0
116+
fi
117+
continue
118+
fi
119+
120+
in_record=1
121+
122+
# Extract field name and value from xe output
123+
# Lines look like: " name-label ( RW): some value"
124+
# or: "uuid ( RO) : some-uuid"
125+
local key value trimmed
126+
127+
# Trim leading whitespace
128+
trimmed="${line#"${line%%[![:space:]]*}"}"
129+
# Extract key: first word (everything before the first space)
130+
key="${trimmed%% *}"
131+
132+
# Extract value: everything after the first ": "
133+
value="${line#*: }"
134+
135+
case "$key" in
136+
uuid)
137+
cur_uuid="$value"
138+
;;
139+
name-label)
140+
cur_name="$value"
141+
;;
142+
os-version)
143+
if [[ "$value" == "<"* ]]; then
144+
cur_os=""
145+
else
146+
cur_os="$value"
147+
fi
148+
;;
149+
memory-actual)
150+
if [[ "$value" == "<"* ]]; then
151+
cur_mem=""
152+
else
153+
cur_mem="$value"
154+
fi
155+
;;
156+
power-state)
157+
cur_state="$value"
158+
;;
159+
VCPUs-number)
160+
cur_cpus="$value"
161+
;;
162+
guest-metrics-last-updated)
163+
if [[ "$value" == "<"* ]]; then
164+
cur_gueststate="not installed"
165+
else
166+
cur_gueststate="running"
167+
fi
168+
;;
169+
esac
170+
done < <(xe vm-list params=uuid,name-label,os-version,memory-actual,power-state,VCPUs-number,guest-metrics-last-updated 2>/dev/null; echo "")
171+
172+
# Handle last record if output didn't end with blank line
173+
if [[ $in_record -eq 1 && -n "$cur_uuid" ]]; then
174+
idx=$((idx + 1))
175+
VM_UUID[$idx]="$cur_uuid"
176+
VM_NAME[$idx]="$cur_name"
177+
VM_OS[$idx]="$cur_os"
178+
VM_STATE[$idx]=$(map_power_state "$cur_state")
179+
VM_CPUS[$idx]="$cur_cpus"
180+
VM_GUESTSTATE[$idx]="$cur_gueststate"
181+
182+
if [[ -n "$cur_mem" && "$cur_mem" != "<"* ]]; then
183+
VM_MEM[$idx]=$(( (cur_mem + 524288) / 1048576 ))
184+
else
185+
VM_MEM[$idx]=0
186+
fi
187+
fi
188+
189+
NUM_VMS=$idx
190+
LAST_REFRESH=$now
191+
}
192+
193+
# ---------------------------------------------------------------
194+
# Get value and type for a given column and row
195+
# ---------------------------------------------------------------
196+
get_value() {
197+
local col=$1
198+
local row=$2
199+
200+
case $col in
201+
2) echo "string"; echo "${VM_NAME[$row]}" ;;
202+
3) echo "string"; echo "" ;;
203+
4) echo "string"; echo "${VM_OS[$row]}" ;;
204+
5) echo "integer"; echo "${VM_MEM[$row]}" ;;
205+
6) echo "integer"; echo "${VM_STATE[$row]}" ;;
206+
7) echo "string"; echo "${VM_UUID[$row]}" ;;
207+
8) echo "string"; echo "${VM_GUESTSTATE[$row]}" ;;
208+
9) echo "string"; echo "" ;;
209+
10) echo "integer"; echo "${VM_CPUS[$row]}" ;;
210+
11) echo "string"; echo "${VM_UUID[$row]}" ;;
211+
*) return 1 ;;
212+
esac
213+
return 0
214+
}
215+
216+
# ---------------------------------------------------------------
217+
# Respond to a GET or GETNEXT request
218+
# ---------------------------------------------------------------
219+
respond() {
220+
local oid="$1"
221+
local col=$2
222+
local row=$3
223+
224+
echo "$oid"
225+
get_value "$col" "$row"
226+
}
227+
228+
# ---------------------------------------------------------------
229+
# Handle a GET request
230+
# ---------------------------------------------------------------
231+
handle_get() {
232+
local GET_OID="$1"
233+
234+
# Strip the entry OID prefix to get column.row
235+
local local_oid="${GET_OID#"${ENTRY_OID}".}"
236+
237+
# Must have column.row format
238+
if [[ "$local_oid" == "$GET_OID" || "$local_oid" == "${ENTRY_OID}" ]]; then
239+
echo "NONE"
240+
return
241+
fi
242+
243+
local col="${local_oid%%.*}"
244+
local row="${local_oid#*.}"
245+
246+
# Validate
247+
if [[ -z "$col" || -z "$row" ]]; then
248+
echo "NONE"
249+
return
250+
fi
251+
if [[ $col -lt $FIRST_COL || $col -gt $LAST_COL ]]; then
252+
echo "NONE"
253+
return
254+
fi
255+
if [[ $row -lt 1 || $row -gt $NUM_VMS ]]; then
256+
echo "NONE"
257+
return
258+
fi
259+
260+
respond "${GET_OID}" "$col" "$row"
261+
}
262+
263+
# ---------------------------------------------------------------
264+
# Handle a GETNEXT request
265+
# ---------------------------------------------------------------
266+
handle_getnext() {
267+
local GET_OID="$1"
268+
269+
# Check if the requested OID is at or above our table
270+
if [[ "$GET_OID" == "$BASE_OID" || \
271+
"$GET_OID" == "${BASE_OID}.1" || \
272+
"$GET_OID" == "${BASE_OID}.1.1" ]]; then
273+
respond "${ENTRY_OID}.${FIRST_COL}.1" "$FIRST_COL" 1
274+
return
275+
fi
276+
277+
# Strip entry OID prefix
278+
local local_oid="${GET_OID#"${ENTRY_OID}".}"
279+
280+
if [[ "$local_oid" == "$GET_OID" ]]; then
281+
# OID is not under our tree
282+
echo "NONE"
283+
return
284+
fi
285+
286+
# Parse column and optional row
287+
local col="${local_oid%%.*}"
288+
local remainder="${local_oid#*.}"
289+
290+
if [[ "$remainder" == "$local_oid" ]]; then
291+
# Only column, no row (e.g., .1.3.6.1.4.1.60652.100.1.1.2)
292+
if [[ $col -lt $FIRST_COL ]]; then
293+
col=$FIRST_COL
294+
fi
295+
if [[ $col -gt $LAST_COL ]]; then
296+
echo "NONE"
297+
return
298+
fi
299+
respond "${ENTRY_OID}.${col}.1" "$col" 1
300+
return
301+
fi
302+
303+
local row="$remainder"
304+
305+
# Calculate next position
306+
local next_row=$((row + 1))
307+
local next_col=$col
308+
309+
if [[ $next_row -gt $NUM_VMS ]]; then
310+
# Move to next column, first row
311+
next_col=$((col + 1))
312+
next_row=1
313+
fi
314+
315+
# Skip column 1 (not-accessible index)
316+
if [[ $next_col -lt $FIRST_COL ]]; then
317+
next_col=$FIRST_COL
318+
next_row=1
319+
fi
320+
321+
if [[ $next_col -gt $LAST_COL ]]; then
322+
# Past end of table
323+
echo "NONE"
324+
return
325+
fi
326+
327+
respond "${ENTRY_OID}.${next_col}.${next_row}" "$next_col" "$next_row"
328+
}
329+
330+
# ---------------------------------------------------------------
331+
# Main loop — pass_persist protocol
332+
#
333+
# Reads commands from stdin:
334+
# PING → respond with PONG
335+
# get\n<OID> → respond with OID/type/value or NONE
336+
# getnext\n<OID> → respond with next OID/type/value or NONE
337+
# ---------------------------------------------------------------
338+
339+
# Collect initial VM data
340+
collect_vm_data
341+
342+
while read -r cmd; do
343+
# Trim carriage return if present
344+
cmd="${cmd%%$'\r'}"
345+
346+
case "$cmd" in
347+
PING)
348+
echo "PONG"
349+
;;
350+
get)
351+
read -r oid
352+
oid="${oid%%$'\r'}"
353+
collect_vm_data
354+
if [[ $NUM_VMS -eq 0 ]]; then
355+
echo "NONE"
356+
else
357+
handle_get "$oid"
358+
fi
359+
;;
360+
getnext)
361+
read -r oid
362+
oid="${oid%%$'\r'}"
363+
collect_vm_data
364+
if [[ $NUM_VMS -eq 0 ]]; then
365+
echo "NONE"
366+
else
367+
handle_getnext "$oid"
368+
fi
369+
;;
370+
*)
371+
echo "NONE"
372+
;;
373+
esac
374+
done

0 commit comments

Comments
 (0)