#!/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/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") 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: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:$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 Tracking Stats - by day' --vertical-label 'millisecondes,ppm' --alt-autoscale \ --units-exponent 0 \ DEF:stratum='$RRD_FILE':stratum:AVERAGE \ DEF:offset='$RRD_FILE':offset:AVERAGE \ DEF:freq='$RRD_FILE':frequency:AVERAGE \ DEF:resid_freq='$RRD_FILE':resid_freq:AVERAGE \ DEF:skew='$RRD_FILE':skew:AVERAGE \ DEF:delay='$RRD_FILE':delay:AVERAGE \ DEF:dispersion='$RRD_FILE':dispersion:AVERAGE \ CDEF:offset_scaled=offset,1000,* \ CDEF:resfreq_scaled=resid_freq,100,* \ CDEF:skew_scaled=skew,100,* \ CDEF:delay_scaled=delay,1000,* \ CDEF:disp_scaled=dispersion,1000,* \ 'COMMENT: \l' \ 'LINE1:stratum#00E000: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:offset_scaled#0000FF:System Time (x1000) ' \ 'GPRINT:offset_scaled:LAST: Cur\: %5.2lf%s' \ 'GPRINT:offset_scaled:MIN:Min\: %5.2lf%s' \ 'GPRINT:offset_scaled:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:offset_scaled:MAX:Max\: %5.2lf%s\l' \ 'LINE1:freq#FFC300:Frequency (ppm) ' \ 'GPRINT:freq:LAST: Cur\: %5.2lf%s' \ 'GPRINT:freq:MIN:Min\: %5.2lf%s' \ 'GPRINT:freq:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:freq:MAX:Max\: %5.2lf%s\l' \ 'LINE1:resfreq_scaled#FF69B4:Residual Freq (ppm, x100) ' \ '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' \ 'LINE1:skew_scaled#9400D3:Skew (ppm, x100) ' \ 'GPRINT:skew_scaled:LAST: Cur\: %5.2lf%s' \ 'GPRINT:skew_scaled:MIN:Min\: %5.2lf%s' \ 'GPRINT:skew_scaled:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:skew_scaled:MAX:Max\: %5.2lf%s\l' \ 'LINE1:delay_scaled#00BFFF:Root delay (ms, x1000) ' \ 'GPRINT:delay_scaled:LAST: Cur\: %5.2lf%s' \ 'GPRINT:delay_scaled:MIN:Min\: %5.2lf%s' \ 'GPRINT:delay_scaled:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:delay_scaled:MAX:Max\: %5.2lf%s\l' \ 'LINE1:disp_scaled#D8D800:Root dispersion (ms, x1000)' \ '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 'millisecondes' \ DEF:offset='$RRD_FILE':offset:AVERAGE \ CDEF:offset_ms=offset,1000,* \ LINE2:offset_ms#00ff00:'System time offset to NTP time' \ GPRINT:offset_ms:LAST:'Cur\: %5.2lf%sms\n'" ["chrony_delay"]="--title 'Chrony Network Delay - by day' --vertical-label 'millisecondes' --units-exponent 0 \ DEF:delay='$RRD_FILE':delay:AVERAGE \ CDEF:delay_ms=delay,1000,* \ LINE2:delay_ms#00ff00:'Network path delay' \ GPRINT:delay_ms:LAST:'Cur\: %5.2lf%sms\n'" ["chrony_frequency"]="--title 'Chrony Clock Frequency Error - by day' --vertical-label 'ppm' \ DEF:freq='$RRD_FILE':frequency:AVERAGE \ LINE2:freq#00ff00:'Local clock frequency error' \ GPRINT:freq:LAST:'Cur\: %5.2lf%sppm\n'" ["chrony_drift"]="--title 'Chrony Drift - by day' --vertical-label 'ppm' \ --units-exponent 0 \ DEF:freq='$RRD_FILE':frequency:AVERAGE \ DEF:skew='$RRD_FILE':skew:AVERAGE \ 'COMMENT: \l' \ 'LINE1:freq#32CD32:System Clock Gain/Loss Rate' \ 'GPRINT:freq:LAST:Cur\: %5.2lf%s' \ 'GPRINT:freq:MIN:Min\: %5.2lf%s' \ 'GPRINT:freq:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:freq:MAX:Max\: %5.2lf%s\l' \ 'LINE1:skew#4169E1:Estimate of Error Bound ' \ 'GPRINT:skew:LAST:Cur\: %5.2lf%s' \ 'GPRINT:skew:MIN:Min\: %5.2lf%s' \ 'GPRINT:skew:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:skew:MAX:Max\: %5.2lf%s\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

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