1#!/bin/sh -eu
2# -*- sh -*-
3
4: << =cut
5
6=head1 NAME
7
8internode_usage - Plugin to monitor quota usage of an Internode service
9
10The ideal usage is also used as an updated warning limit.
11
12=head1 CONFIGURATION
13
14  [internode_usage]
15    env.internode_api_login LOGIN
16    env.internode_api_password PASSWORD
17
18You can display the graph on another host (e.g., the actual router) than the
19one running munin. To do so, first configure the plugin to use a different
20hostname.
21
22    env.host_name router
23
24Then configure munin (in /etc/munin/munin-conf or /etc/munin/munin-conf.d), to
25support a new host.
26
27  [example.net;router]
28    address 127.0.0.1
29    use_node_name no
30
31An optional 'env.internode_api_url' can be used, but should not be needed.  It
32will default to  https://customer-webtools-api.internode.on.net/api/v1.5.
33
34If multiple services are available, the plugin will automatically pick the first
35service from the list. To monitor other services, the plugin can be used
36multiple times, by symlinking it as 'internode_usage_SERVICEID'.
37
38=head1 CACHING
39
40As the API is sometimes flakey, the initial service information is cached
41locally, with a day's lifetime, before hitting the base API again. However,
42if hitting the API to refresh the cache fails, the stale cache is used anyway,
43to have a better chance of getting the data out nonetheless.
44
45=head1 CAVEATS
46
47* The hourly rate are a bit spikey in the -day view, as the API seems to update
48  every 20 to 30 minutes; it is fine in the -month and more aggregated views
49* The daily rate is the _previous_ day, and does always lag by 24h
50* Due to the way the API seems to update the data, values for the daily rate
51  are missing for a short period every day
52
53=head1 AUTHOR
54
55Olivier Mehani
56
57Copyright (C) 2019 Olivier Mehani <shtrom+munin@ssji.net>
58
59=head1 LICENSE
60
61SPDX-License-Identifier: GPL-3.0-or-later
62
63=cut
64
65# shellcheck disable=SC1090
66. "${MUNIN_LIBDIR:-.}/plugins/plugin.sh"
67
68CURL_ARGS='-s'
69if [ "${MUNIN_DEBUG:-0}" = 1 ]; then
70    CURL_ARGS='-v'
71    set -x
72fi
73
74if ! command -v curl >/dev/null; then
75	echo "curl not found" >&2
76	exit 1
77fi
78if ! command -v xpath >/dev/null; then
79	echo "xpath (Perl XML::LibXML) not found" >&2
80	exit 1
81fi
82if ! command -v bc >/dev/null; then
83	echo "bc not found" >&2
84	exit 1
85fi
86
87if [ -z "${internode_api_url:-}" ]; then
88    internode_api_url="https://customer-webtools-api.internode.on.net/api/v1.5"
89fi
90
91xpath_extract() {
92	# shellcheck disable=SC2039
93	local xpath="$1"
94	# shellcheck disable=SC2039
95	local node="$(xpath -q -n -e "${xpath}")" \
96		|| { echo "error extracting ${xpath}" >&2; false; }
97	echo "${node}" | sed 's/<\([^>]*\)>\([^<]*\)<[^>]*>/\2/;s^N/A^U^'
98}
99
100xpath_extract_attribute() {
101	# shellcheck disable=SC2039
102	local xpath="$1"
103	# shellcheck disable=SC2039
104	local node="$(xpath -q -n -e "${xpath}")" \
105		|| { echo "error extracting attribute at ${xpath}" >&2; false; }
106	echo "${node}" | sed 's/.*="\([^"]\+\)".*/\1/'
107}
108
109fetch() {
110	# shellcheck disable=SC2154
111	curl -u "${internode_api_login}:${internode_api_password}" -f ${CURL_ARGS} "$@" \
112		|| { echo "error fetching ${*} for user ${internode_api_login}" >&2; false; }
113}
114
115get_cached_api() {
116	# shellcheck disable=SC2039
117	local url=${1}
118	# shellcheck disable=SC2039
119	local name=${2}
120	# shellcheck disable=SC2039
121	local api_data=''
122	# shellcheck disable=SC2039
123	local cachefile="${MUNIN_PLUGSTATE}/$(basename "${0}").${name}.cache"
124	if [ -n "$(find "${cachefile}" -mmin -1440 2>/dev/null)" ]; then
125		api_data=$(cat "${cachefile}")
126	else
127		api_data="$(fetch "${url}" \
128			|| true)"
129
130		if [ -n "${api_data}" ]; then
131			echo "${api_data}" > ${cachefile}
132		else
133			echo "using ${name} info from stale cache ${cachefile}" >&2
134			api_data=$(cat "${cachefile}")
135		fi
136	fi
137	echo "${api_data}"
138}
139
140get_service_data() {
141	# Determine the service ID from the name of the symlink
142	SERVICE_ID="$(echo "${0}" | sed -n 's/^.*internode_usage_//p')"
143	if [ -z "${SERVICE_ID}" ]; then
144		# Otherwise, get the first service in the list
145		API_XML="$(get_cached_api ${internode_api_url} API_XML)"
146		if [ -z "${API_XML}" ]; then
147			echo "unable to determine service ID for user ${internode_api_login}" >&2
148			exit 1
149		fi
150		SERVICE_ID="$(echo "${API_XML}" | xpath_extract "internode/api/services/service")"
151	fi
152
153
154	CURRENT_TIMESTAMP="$(date +%s)"
155	SERVICE_USERNAME='n/a'
156	SERVICE_QUOTA='n/a'
157	SERVICE_PLAN='n/a'
158	SERVICE_ROLLOVER='n/a'
159	IDEAL_USAGE=''
160	USAGE_CRITICAL=''
161	SERVICE_XML="$(get_cached_api "${internode_api_url}/${SERVICE_ID}/service" SERVICE_XML \
162		|| true)"
163	if [ -n "${SERVICE_XML}" ]; then
164		SERVICE_USERNAME="$(echo "${SERVICE_XML}" | xpath_extract "internode/api/service/username")"
165		SERVICE_QUOTA="$(echo "${SERVICE_XML}" | xpath_extract "internode/api/service/quota")"
166		SERVICE_PLAN="$(echo "${SERVICE_XML}" | xpath_extract "internode/api/service/plan")"
167		SERVICE_ROLLOVER="$(echo "${SERVICE_XML}" | xpath_extract "internode/api/service/rollover")"
168		SERVICE_INTERVAL="$(echo "${SERVICE_XML}" | xpath_extract "internode/api/service/plan-interval" | sed 's/ly$//')"
169
170		FIRST_DAY="$(date +%s --date "${SERVICE_ROLLOVER} -1 ${SERVICE_INTERVAL}")"
171		LAST_DAY="$(date +%s --date "${SERVICE_ROLLOVER}")"
172		BILLING_PERIOD="(${LAST_DAY}-${FIRST_DAY})"
173		IDEAL_USAGE="$(echo "${SERVICE_QUOTA}-(${SERVICE_QUOTA}*(${LAST_DAY}-${CURRENT_TIMESTAMP})/${BILLING_PERIOD})" | bc -ql)"
174
175		USAGE_CRITICAL="${SERVICE_QUOTA}"
176	fi
177
178}
179
180get_data() {
181	DAILY_TIMESTAMP=N
182	DAILY_USAGE=U
183	HISTORY_XML="$(fetch "${internode_api_url}/${SERVICE_ID}/history" \
184		|| true)"
185	if [ -n "${HISTORY_XML}" ]; then
186		DAILY_USAGE="$(echo "${HISTORY_XML}" | xpath_extract "internode/api/usagelist/usage[last()-1]/traffic")"
187		DAILY_DATE="$(echo "${HISTORY_XML}" | xpath_extract_attribute "internode/api/usagelist/usage[last()-1]/@day")"
188		DAILY_TIMESTAMP="$(date -d "${DAILY_DATE} $(date +%H:%M:%S)" +%s \
189			|| echo N)"
190	fi
191
192	SERVICE_USAGE='U'
193	USAGE_XML="$(fetch "${internode_api_url}/${SERVICE_ID}/usage" \
194		|| true)"
195	if [ -n "${USAGE_XML}" ]; then
196		SERVICE_USAGE="$(echo "${USAGE_XML}" | xpath_extract "internode/api/traffic")"
197
198
199	fi
200}
201
202graph_config() {
203	graph=""
204	if [ -n "${1:-}" ]; then
205		graph=".$1"
206	fi
207
208	echo "multigraph internode_usage_${SERVICE_ID}${graph}"
209
210	case "$graph" in
211		.current)
212			echo "graph_title Uplink usage rate (hourly)"
213			echo "graph_info Username: ${SERVICE_USERNAME}; Service ID: ${SERVICE_ID}; Plan: ${SERVICE_PLAN}"
214			echo 'graph_category network'
215			# ${graph_period} is not a shell variable
216			# shellcheck disable=SC2016
217			echo 'graph_vlabel bytes per ${graph_period}'
218			# XXX: this seems to be updated twice per hour;
219			# the data from this graph may be nonsense
220			echo 'graph_period hour'
221
222			echo "hourly_rate.label Hourly usage"
223			echo "hourly_rate.type DERIVE"
224			echo "hourly_rate.min 0"
225
226			;;
227		.daily)
228			echo "graph_title Uplink usage rate (daily)"
229			echo "graph_info Username: ${SERVICE_USERNAME}; Service ID: ${SERVICE_ID}; Plan: ${SERVICE_PLAN}"
230			echo "graph_info Uplink usage rate (daily)"
231			echo 'graph_category network'
232			# ${graph_period} is not a shell variable
233			# shellcheck disable=SC2016
234			echo 'graph_vlabel bytes per ${graph_period}'
235			echo 'graph_period day'
236
237			echo "daily_rate.label Previous-day usage"
238			echo "daily_rate.type GAUGE"
239
240			;;
241		*)
242		#.usage)
243			echo "graph_title Uplink usage"
244			echo "graph_info Username: ${SERVICE_USERNAME}; Service ID: ${SERVICE_ID}; Plan: ${SERVICE_PLAN}"
245			echo 'graph_category network'
246			echo 'graph_vlabel bytes'
247			echo 'graph_period hour'
248
249			echo "usage.label Total usage"
250			echo "usage.draw AREA"
251			echo "ideal.extinfo Quota rollover: ${SERVICE_ROLLOVER}"
252			echo "ideal.label Ideal usage"
253			echo "ideal.draw LINE"
254			echo "ideal.colour FFA500"
255
256			echo "usage.critical ${USAGE_CRITICAL}"
257			echo "usage.warning ${IDEAL_USAGE}"
258			echo "ideal.critical 0:"
259			echo "ideal.warning 0:"
260			;;
261	esac
262	echo
263}
264
265graph_data() {
266	graph=""
267	if [ -n "${1:-}" ]; then
268		graph=".${1}"
269	fi
270
271	echo "multigraph internode_usage_${SERVICE_ID}${graph}"
272	case "${graph}" in
273		.current)
274			echo "hourly_rate.value ${CURRENT_TIMESTAMP}:${SERVICE_USAGE:-U}"
275			;;
276		.daily)
277			echo "daily_rate.value ${DAILY_TIMESTAMP}:${DAILY_USAGE:-U}"
278			;;
279		*)
280			echo "usage.value ${CURRENT_TIMESTAMP}:${SERVICE_USAGE:-U}"
281			echo "ideal.value ${CURRENT_TIMESTAMP}:${IDEAL_USAGE:-U}"
282			;;
283	esac
284	echo
285}
286
287main() {
288	case ${1:-} in
289		config)
290			if [ -n "${host_name:-}" ]; then
291				echo "host_name ${host_name}"
292			fi
293			graph_config
294			graph_config usage
295			graph_config daily
296			graph_config current
297			;;
298		*)
299			get_data
300			graph_data
301			graph_data usage
302			graph_data daily
303			graph_data current
304			;;
305	esac
306}
307
308get_service_data
309
310main "${1:-}"
311