#!/bin/bash set -e #### Configuration #### INTERFACE="eth0" PAGE_TITLE="Network Traffic and Chrony Statistics for ${INTERFACE}" OUTPUT_DIR="/var/www/chrony-network-stats" HTML_FILENAME="index.html" ENABLE_LOGGING="yes" LOG_FILE="/var/log/chrony-network-stats.log" RRD_DIR="/var/lib/chrony-rrd" RRD_FILE="$RRD_DIR/chrony.rrd" WIDTH=800 HEIGHT=300 TIMEOUT_SECONDS=5 ####################### log_message() { local level="$1" local message="$2" if [[ "$ENABLE_LOGGING" == "yes" ]]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $message" >> "$LOG_FILE" fi echo "[$level] $message" } validate_numeric() { local value="$1" local name="$2" if ! [[ "$value" =~ ^[0-9]+$ ]]; then log_message "ERROR" "Invalid $name: $value. Must be numeric." exit 1 fi } check_commands() { local commands=("vnstati" "rrdtool" "chronyc" "sudo" "timeout") for cmd in "${commands[@]}"; do if ! command -v "$cmd" &>/dev/null; then log_message "ERROR" "Command '$cmd' not found in PATH." exit 1 fi done } setup_directories() { log_message "INFO" "Checking and preparing directories..." for dir in "$OUTPUT_DIR" "$RRD_DIR"; do mkdir -p "$dir" || { log_message "ERROR" "Failed to create directory: $dir" exit 1 } if [ ! -w "$dir" ]; then log_message "ERROR" "Directory '$dir' is not writable." exit 1 fi done } generate_vnstat_images() { log_message "INFO" "Generating vnStat images for interface '$INTERFACE'..." local modes=("s" "d" "t" "h" "m" "y") for mode in "${modes[@]}"; do vnstati -"$mode" -i "$INTERFACE" -o "$OUTPUT_DIR/img/vnstat_${mode}.png" || { log_message "ERROR" "Failed to generate vnstat image for mode $mode" exit 1 } done } collect_chrony_data() { log_message "INFO" "Collecting Chrony data..." get_html() { timeout "$TIMEOUT_SECONDS"s sudo chronyc "$1" -v 2>&1 | sed 's/&/\&/g;s//\>/g;s/$/
/' || { log_message "ERROR" "Failed to collect chronyc $1 data" return 1 } } RAW_TRACKING=$(timeout "$TIMEOUT_SECONDS"s sudo chronyc tracking) || { log_message "ERROR" "Failed to collect chronyc tracking data" exit 1 } CHRONYC_TRACKING_HTML=$(echo "$RAW_TRACKING" | sed 's/&/\&/g;s//\>/g;s/$/
/') CHRONYC_SOURCES=$(get_html sources) || exit 1 CHRONYC_SOURCESTATS=$(get_html sourcestats) || exit 1 CHRONYC_SELECTDATA=$(get_html selectdata) || exit 1 } extract_chronyc_values() { extract_val() { echo "$RAW_TRACKING" | awk "/$1/ {print \$($2)}" | grep -E '^[-+]?[0-9.]+$' || echo "U" } OFFSET=$(extract_val "Last offset" "NF-1") local systime_line systime_line=$(echo "$RAW_TRACKING" | grep "System time") if [[ -n "$systime_line" ]]; then local value value=$(echo "$systime_line" | awk '{print $4}') if [[ "$systime_line" == *"slow"* ]]; then SYSTIME="-$value" else SYSTIME="$value" fi else SYSTIME="U" fi FREQ=$(extract_val "Frequency" "NF-2") RESID_FREQ=$(extract_val "Residual freq" "NF-1") SKEW=$(extract_val "Skew" "NF-1") DELAY=$(extract_val "Root delay" "NF-1") DISPERSION=$(extract_val "Root dispersion" "NF-1") STRATUM=$(extract_val "Stratum" "3") RAW_STATS=$(LC_ALL=C sudo chronyc serverstats) || { log_message "ERROR" "Failed to collect chronyc serverstats" exit 1 } get_stat() { echo "$RAW_STATS" | awk -F'[[:space:]]*:[[:space:]]*' "/$1/ {print \$2}" | grep -E '^[0-9]+$' || echo "U" } PKTS_RECV=$(get_stat "NTP packets received") PKTS_DROP=$(get_stat "NTP packets dropped") CMD_RECV=$(get_stat "Command packets received") CMD_DROP=$(get_stat "Command packets dropped") LOG_DROP=$(get_stat "Client log records dropped") NTS_KE_ACC=$(get_stat "NTS-KE connections accepted") NTS_KE_DROP=$(get_stat "NTS-KE connections dropped") AUTH_PKTS=$(get_stat "Authenticated NTP packets") INTERLEAVED=$(get_stat "Interleaved NTP packets") TS_HELD=$(get_stat "NTP timestamps held") } create_rrd_database() { if [ ! -f "$RRD_FILE" ]; then log_message "INFO" "Creating new RRD file: $RRD_FILE" LC_ALL=C rrdtool create "$RRD_FILE" --step 300 \ DS:offset:GAUGE:600:U:U DS:frequency:GAUGE:600:U:U DS:resid_freq:GAUGE:600:U:U DS:skew:GAUGE:600:U:U \ DS:delay:GAUGE:600:U:U DS:dispersion:GAUGE:600:U:U DS:stratum:GAUGE:600:0:16 \ DS:systime:GAUGE:600:U:U \ DS:pkts_recv:COUNTER:600:0:U DS:pkts_drop:COUNTER:600:0:U DS:cmd_recv:COUNTER:600:0:U \ DS:cmd_drop:COUNTER:600:0:U DS:log_drop:COUNTER:600:0:U DS:nts_ke_acc:COUNTER:600:0:U \ DS:nts_ke_drop:COUNTER:600:0:U DS:auth_pkts:COUNTER:600:0:U DS:interleaved:COUNTER:600:0:U \ DS:ts_held:GAUGE:600:0:U \ RRA:AVERAGE:0.5:1:576 RRA:AVERAGE:0.5:6:672 RRA:AVERAGE:0.5:24:732 RRA:AVERAGE:0.5:288:730 \ RRA:MAX:0.5:1:576 RRA:MAX:0.5:6:672 RRA:MAX:0.5:24:732 RRA:MAX:0.5:288:730 \ RRA:MIN:0.5:1:576 RRA:MIN:0.5:6:672 RRA:MIN:0.5:24:732 RRA:MIN:0.5:288:730 || { log_message "ERROR" "Failed to create RRD database" exit 1 } fi } update_rrd_database() { log_message "INFO" "Updating RRD database..." UPDATE_STRING="N:$OFFSET:$FREQ:$RESID_FREQ:$SKEW:$DELAY:$DISPERSION:$STRATUM:$SYSTIME:$PKTS_RECV:$PKTS_DROP:$CMD_RECV:$CMD_DROP:$LOG_DROP:$NTS_KE_ACC:$NTS_KE_DROP:$AUTH_PKTS:$INTERLEAVED:$TS_HELD" LC_ALL=C rrdtool update "$RRD_FILE" "$UPDATE_STRING" || { log_message "ERROR" "Failed to update RRD database" exit 1 } } generate_graphs() { log_message "INFO" "Generating graphs..." local END_TIME=$(date +%s) local START_TIME=$((END_TIME - 86400)) declare -A graphs=( ["chrony_serverstats"]="--title 'Chrony Server Statistics - by day' --vertical-label 'Packets/second' \ --lower-limit 0 --units-exponent 0 \ DEF:pkts_recv='$RRD_FILE':pkts_recv:AVERAGE \ DEF:pkts_drop='$RRD_FILE':pkts_drop:AVERAGE \ DEF:cmd_recv='$RRD_FILE':cmd_recv:AVERAGE \ DEF:cmd_drop='$RRD_FILE':cmd_drop:AVERAGE \ DEF:log_drop='$RRD_FILE':log_drop:AVERAGE \ DEF:nts_ke_acc='$RRD_FILE':nts_ke_acc:AVERAGE \ DEF:nts_ke_drop='$RRD_FILE':nts_ke_drop:AVERAGE \ DEF:auth_pkts='$RRD_FILE':auth_pkts:AVERAGE \ 'COMMENT: \l' \ 'AREA:pkts_recv#C4FFC4:Packets received ' \ 'LINE1:pkts_recv#00E000:' \ 'GPRINT:pkts_recv:LAST:Cur\: %5.2lf%s' \ 'GPRINT:pkts_recv:MIN:Min\: %5.2lf%s' \ 'GPRINT:pkts_recv:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:pkts_recv:MAX:Max\: %5.2lf%s\l' \ 'LINE1:pkts_drop#FF8C00:Packets dropped ' \ 'GPRINT:pkts_drop:LAST:Cur\: %5.2lf%s' \ 'GPRINT:pkts_drop:MIN:Min\: %5.2lf%s' \ 'GPRINT:pkts_drop:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:pkts_drop:MAX:Max\: %5.2lf%s\l' \ 'LINE1:cmd_recv#4169E1:Command packets received ' \ 'GPRINT:cmd_recv:LAST:Cur\: %5.2lf%s' \ 'GPRINT:cmd_recv:MIN:Min\: %5.2lf%s' \ 'GPRINT:cmd_recv:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:cmd_recv:MAX:Max\: %5.2lf%s\l' \ 'LINE1:cmd_drop#FFD700:Command packets dropped ' \ 'GPRINT:cmd_drop:LAST:Cur\: %5.2lf%s' \ 'GPRINT:cmd_drop:MIN:Min\: %5.2lf%s' \ 'GPRINT:cmd_drop:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:cmd_drop:MAX:Max\: %5.2lf%s\l' \ 'LINE1:log_drop#9400D3:Client log records dropped ' \ 'GPRINT:log_drop:LAST:Cur\: %5.2lf%s' \ 'GPRINT:log_drop:MIN:Min\: %5.2lf%s' \ 'GPRINT:log_drop:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:log_drop:MAX:Max\: %5.2lf%s\l' \ 'LINE1:nts_ke_acc#8A2BE2:NTS-KE connections accepted ' \ 'GPRINT:nts_ke_acc:LAST:Cur\: %5.2lf%s' \ 'GPRINT:nts_ke_acc:MIN:Min\: %5.2lf%s' \ 'GPRINT:nts_ke_acc:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:nts_ke_acc:MAX:Max\: %5.2lf%s\l' \ 'LINE1:nts_ke_drop#9370DB:NTS-KE connections dropped ' \ 'GPRINT:nts_ke_drop:LAST:Cur\: %5.2lf%s' \ 'GPRINT:nts_ke_drop:MIN:Min\: %5.2lf%s' \ 'GPRINT:nts_ke_drop:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:nts_ke_drop:MAX:Max\: %5.2lf%s\l' \ 'LINE1:auth_pkts#FF0000:Authenticated NTP packets ' \ 'GPRINT:auth_pkts:LAST:Cur\: %5.2lf%s' \ 'GPRINT:auth_pkts:MIN:Min\: %5.2lf%s' \ 'GPRINT:auth_pkts:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:auth_pkts:MAX:Max\: %5.2lf%s\l'" ["chrony_tracking"]="--title 'Chrony Dispersion + Stratum - by day' --vertical-label 'milliseconds' --alt-autoscale \ --units-exponent 0 \ DEF:stratum='$RRD_FILE':stratum:AVERAGE \ DEF:freq='$RRD_FILE':frequency:AVERAGE \ DEF:skew='$RRD_FILE':skew:AVERAGE \ DEF:delay='$RRD_FILE':delay:AVERAGE \ DEF:dispersion='$RRD_FILE':dispersion:AVERAGE \ CDEF:skew_scaled=skew,100,* \ CDEF:delay_scaled=delay,1000,* \ CDEF:disp_scaled=dispersion,1000,* \ 'COMMENT: \l' \ 'LINE1:stratum#00ff00:Stratum ' \ 'GPRINT:stratum:LAST: Cur\: %5.2lf%s' \ 'GPRINT:stratum:MIN:Min\: %5.2lf%s' \ 'GPRINT:stratum:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:stratum:MAX:Max\: %5.2lf%s\l' \ 'LINE1:disp_scaled#9400D3:Root dispersion [Root dispersion] ' \ 'GPRINT:disp_scaled:LAST: Cur\: %5.2lf%s' \ 'GPRINT:disp_scaled:MIN:Min\: %5.2lf%s' \ 'GPRINT:disp_scaled:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:disp_scaled:MAX:Max\: %5.2lf%s\l'" ["chrony_offset"]="--title 'Chrony System Time Offset - by day' --vertical-label 'milliseconds' \ DEF:offset='$RRD_FILE':offset:AVERAGE \ DEF:systime='$RRD_FILE':systime:AVERAGE \ CDEF:systime_scaled=systime,1000,* \ CDEF:offset_ms=offset,1000,* \ 'LINE2:offset_ms#00ff00:Actual Offset from NTP Source [Last Offset] ' \ 'GPRINT:offset_ms:LAST: Cur\: %5.2lf%s' \ 'GPRINT:offset_ms:MIN:Min\: %5.2lf%s' \ 'GPRINT:offset_ms:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:offset_ms:MAX:Max\: %5.2lf%s\l' \ 'LINE1:systime_scaled#4169E1:System Clock Adjustment [System Time] ' \ 'GPRINT:systime_scaled:LAST: Cur\: %5.2lf%s' \ 'GPRINT:systime_scaled:MIN:Min\: %5.2lf%s' \ 'GPRINT:systime_scaled:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:systime_scaled:MAX:Max\: %5.2lf%s\l'" ["chrony_delay"]="--title 'Chrony Root Delay - by day' --vertical-label 'milliseconds' --units-exponent 0 \ DEF:delay='$RRD_FILE':delay:AVERAGE \ CDEF:delay_ms=delay,1000,* \ LINE2:delay_ms#00ff00:'Network Delay to Root Source [Root Delay] ' \ 'GPRINT:delay_ms:LAST:Cur\: %5.2lf%s' \ 'GPRINT:delay_ms:MIN:Min\: %5.2lf%s' \ 'GPRINT:delay_ms:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:delay_ms:MAX:Max\: %5.2lf%s\l'" ["chrony_frequency"]="--title 'Chrony Clock Frequency Error - by day' --vertical-label 'ppm'\ DEF:freq='$RRD_FILE':frequency:AVERAGE \ DEF:resid_freq='$RRD_FILE':resid_freq:AVERAGE \ CDEF:resfreq_scaled=resid_freq,100,* \ CDEF:freq_scaled=freq,1,* \ 'LINE2:freq_scaled#00ff00:Natural Clock Drift [Frequency] ' \ 'GPRINT:freq_scaled:LAST:Cur\: %5.2lf%s' \ 'GPRINT:freq_scaled:MIN:Min\: %5.2lf%s' \ 'GPRINT:freq_scaled:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:freq_scaled:MAX:Max\: %5.2lf%s\n' \ 'LINE1:resfreq_scaled#4169E1:Residual Drift (x100) [Residual freq] ' \ 'GPRINT:resfreq_scaled:LAST:Cur\: %5.2lf%s' \ 'GPRINT:resfreq_scaled:MIN:Min\: %5.2lf%s' \ 'GPRINT:resfreq_scaled:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:resfreq_scaled:MAX:Max\: %5.2lf%s\l'" ["chrony_drift"]="--title 'Chrony Drift Margin Error - by day' --vertical-label 'ppm' \ --units-exponent 0 \ DEF:resid_freq='$RRD_FILE':resid_freq:AVERAGE \ DEF:skew_raw='$RRD_FILE':skew:AVERAGE \ CDEF:resfreq_scaled=resid_freq,100,* \ CDEF:skew_scaled=skew_raw,100,* \ 'COMMENT: \l' \ 'LINE1:skew_scaled#00ff00:Estimate Drift Error Margin (x100) [Skew] ' \ 'GPRINT:skew_scaled:LAST:Cur\: %5.2lf' \ 'GPRINT:skew_scaled:MIN:Min\: %5.2lf' \ 'GPRINT:skew_scaled:AVERAGE:Avg\: %5.2lf' \ 'GPRINT:skew_scaled:MAX:Max\: %5.2lf\l'" ) for graph in "${!graphs[@]}"; do local cmd="LC_ALL=C rrdtool graph '$OUTPUT_DIR/$graph.png' --width '$WIDTH' --height '$HEIGHT' --start end-1d --end now-180s ${graphs[$graph]}" eval "$cmd" || { log_message "ERROR" "Failed to generate graph: $graph" exit 1 } done } generate_html() { log_message "INFO" "Generating HTML report..." local GENERATED_TIMESTAMP=$(date) cat >"$OUTPUT_DIR/$HTML_FILENAME" < ${PAGE_TITLE} - Server Status

Chrony Graphs [Data Legend]

Chrony server statistics graph
Chrony system clock offset graph
Chrony system clock tracking graph
Chrony sync delay graph
Chrony clock frequency graph
Chrony clock frequency drift graph

vnStati Graphs

vnStat summary
vnStat daily
vnStat top 10
vnStat hourly
vnStat monthly
vnStat yearly

Chrony - NTP Statistics

Command: chronyc sources -v

${CHRONYC_SOURCES}

Command: chronyc selectdata -v

${CHRONYC_SELECTDATA}

Command: chronyc sourcestats -v

${CHRONYC_SOURCESTATS}

Command: chronyc tracking

${CHRONYC_TRACKING_HTML}
EOF } main() { log_message "INFO" "Starting vnstati script..." validate_numeric "$WIDTH" "WIDTH" validate_numeric "$HEIGHT" "HEIGHT" validate_numeric "$TIMEOUT_SECONDS" "TIMEOUT_SECONDS" check_commands setup_directories generate_vnstat_images collect_chrony_data extract_chronyc_values create_rrd_database update_rrd_database generate_graphs generate_html log_message "INFO" "HTML page and graphs generated in: $OUTPUT_DIR/$HTML_FILENAME" echo "✅ Successfully generated report" } main