#!/bin/sh
# /usr/libexec/rpcd/metrics
# rpcd plugin exposing omr-tracker metrics via ubus
#
# Usage:
#   ubus call metrics get_status '{"interface":"wan1"}'
#   ubus call metrics get_all
#   ubus call metrics get_interfaces
#   ubus call metrics get_user_info
#   ubus call metrics get_forecast
#   ubus call metrics get_settings
#   ubus call metrics set_settings '{"interval":"30","password":"..."}'

. "${OMR_LIB_DIR:-/usr/share/omr/lib}/omr-vps-curl.sh"
# omr_metrics_curl: the same, and the custom metrics server pinned too
. "${OMR_METRICS_LIB_DIR:-/usr/share/omr/lib}/omr-metrics-curl.sh"
. "${OMR_LIB_DIR:-/usr/share/omr/lib}/omr-state.sh"
METRICS_DIR="${OMR_METRICS_DIR:-/tmp/metrics}"

# _is_ip6, _server_creds, _login (and the login backoff it shares with
# omr-metrics-send and omr-weight-sync), _token_store and _token_drop come
# from omr-metrics-curl.sh. The Metrics page makes three calls here every
# 5 s: logging in on each of them, with no backoff, a wrong key reached the
# API (and fail2ban) that often.
SRV_TOKEN=""
SRV_TOKEN_KEY=""
SRV_USERNAME=""
SRV_PASSWORD=""

_log() {
	logger -t "omr-metrics" "$@"
}

# The VPS the router is on: the current server, else the master, else the
# first one, never a disabled one. "head -1" of the server list could be a
# disabled server, which still got the key.
_vps_server() {
	local name master="" first=""
	for name in $(uci -q show openmptcprouter 2>/dev/null | \
		sed -n 's/^openmptcprouter\.\([^.=][^.=]*\)=server$/\1/p'); do
		[ "$(uci -q get "openmptcprouter.${name}.disabled")" = "1" ] && continue
		[ -n "$(uci -q get "openmptcprouter.${name}.ip")" ] || continue
		if [ "$(omr_state_get "openmptcprouter.${name}.current")" = "1" ]; then
			echo "$name"
			return
		fi
		[ -z "$master" ] && [ "$(uci -q get "openmptcprouter.${name}.master")" = "1" ] && master="$name"
		[ -z "$first" ] && first="$name"
	done
	echo "${master:-$first}"
}

# GET a VPS metrics endpoint and print the JSON response (or {} on failure).
# $1 = endpoint path (e.g. /metrics/user)
# $2 = optional query string (e.g. "horizon=300&since=1h")
_vps_get() {
	local endpoint="$1" extra="${2:-}"
	local servername server="" serverport url http_code tmpfile

	# The server omr-metrics-send sends to: the custom one when it is on and
	# set, else the VPS. Going by a username set in omr-metrics instead
	# asked a custom server that was turned off, the settings page keeping
	# its credentials.
	[ "$(uci -q get omr-metrics.settings.use_custom_server)" = "1" ] && \
		server=$(uci -q get "omr-metrics.settings.server" 2>/dev/null)
	if [ -n "$server" ]; then
		servername="omr_metrics_custom"
		serverport=$(uci -q get "omr-metrics.settings.serverport" 2>/dev/null)
	else
		servername=$(_vps_server)
		if [ -z "$servername" ]; then
			echo '{}'
			return 1
		fi
		serverport=$(uci -q get "openmptcprouter.${servername}.port" 2>/dev/null)
		server=$(uci -q get "openmptcprouter.${servername}.ip" 2>/dev/null | awk '{print $1}')
	fi
	[ -z "$serverport" ] && serverport="65500"

	if [ -z "$server" ]; then
		echo '{}'
		return 1
	fi

	_server_creds "$servername"
	if [ -z "$SRV_TOKEN" ] && ! _login "$servername" "$server" "$serverport"; then
		echo '{}'
		return 1
	fi

	local qs
	[ -n "$extra" ] && qs="?${extra}" || qs=""
	if _is_ip6 "$server"; then
		url="https://[${server}]:${serverport}${endpoint}${qs}"
	else
		url="https://${server}:${serverport}${endpoint}${qs}"
	fi

	tmpfile=$(mktemp /tmp/omr-metrics-rpcd.XXXXXX 2>/dev/null) || { echo '{}'; return 1; }
	http_code=$(omr_metrics_curl --max-time 10 -s -k \
		-o "$tmpfile" -w "%{http_code}" \
		-H "accept: application/json" \
		-H "Authorization: Bearer ${SRV_TOKEN}" \
		"$url" 2>/dev/null)

	if [ "$http_code" = "401" ]; then
		# Refused: not sent again on every call while the login backs off
		_token_drop "$SRV_TOKEN_KEY"
		SRV_TOKEN=""
		_login "$servername" "$server" "$serverport" && http_code=$(omr_metrics_curl --max-time 10 -s -k \
			-o "$tmpfile" -w "%{http_code}" \
			-H "accept: application/json" \
			-H "Authorization: Bearer ${SRV_TOKEN}" \
			"$url" 2>/dev/null)
	fi

	if [ "$http_code" = "200" ]; then
		cat "$tmpfile"
		# The stored token works: whatever armed a backoff is over
		_login_ok "$(_state_key "$servername" "$server")"
	else
		echo '{}'
	fi
	rm -f "$tmpfile"
}

# The settings page goes through get_settings/set_settings instead of
# reading and writing the omr-metrics UCI config: an ACL granting that
# config also handed out the custom server's password and Bearer token.
# get_settings returns every option but those two, set_settings takes a
# new password without ever giving one back, and the token stays the
# daemons' business.
SETTINGS_OPTS="send_to_vps interval enable_weight_sync enable_decision_weights decision_predict decision_horizon use_custom_server server serverport username custom_server_pin"

_json_str() {
	printf '"%s"' "$(printf '%s' "$1" | tr -d '\000-\037' | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')"
}

_get_settings() {
	local opt val sep=""
	printf '{'
	for opt in $SETTINGS_OPTS; do
		val=$(uci -q get "omr-metrics.settings.${opt}") || continue
		printf '%s"%s":%s' "$sep" "$opt" "$(_json_str "$val")"
		sep=","
	done
	[ -n "$(uci -q get omr-metrics.settings.password)" ] && val=true || val=false
	printf '%s"password_set":%s}\n' "$sep" "$val"
}

# Whether $1 is a non-negative integer fit for a query string
_qs_uint() {
	case "$1" in ''|*[!0-9]*) return 1 ;; esac
	[ "${#1}" -le 9 ]
}

# Whether set_settings may store value $2 for option $1 (empty values,
# which remove the option, are not checked)
_setting_valid() {
	case "$1" in
		send_to_vps|enable_weight_sync|enable_decision_weights|decision_predict|use_custom_server)
			case "$2" in 0|1) ;; *) return 1 ;; esac
			;;
		interval)
			# 0 made omr-metrics-send POST in a tight loop
			case "$2" in *[!0-9]*) return 1 ;; esac
			[ "${#2}" -le 9 ] && [ "$2" -ge 5 ]
			;;
		decision_horizon)
			case "$2" in *[!0-9]*) return 1 ;; esac
			[ "${#2}" -le 5 ] && [ "$2" -ge 1 ] && [ "$2" -le 86400 ]
			;;
		serverport)
			case "$2" in *[!0-9]*) return 1 ;; esac
			[ "${#2}" -le 5 ] && [ "$2" -ge 1 ] && [ "$2" -le 65535 ]
			;;
		server)
			# Goes into https://<server>:<port>/ URLs
			case "$2" in *[!]A-Za-z0-9.:%_[-]*) return 1 ;; esac
			;;
		username|password)
			case "$2" in *[[:cntrl:]]*) return 1 ;; esac
			;;
		custom_server_pin)
			_omr_vps_pin_valid "${2#sha256//}"
			;;
		*)
			return 1
			;;
	esac
}

# Options missing from the request are left alone, an empty one is
# removed. An empty or missing password keeps the stored one.
_set_settings() {
	local input opt val relogin="" changed=""
	input=$(cat)

	# Check everything before changing anything
	for opt in $SETTINGS_OPTS password; do
		val=$(printf '%s' "$input" | jsonfilter -q -e "@.${opt}") || continue
		[ -z "$val" ] || _setting_valid "$opt" "$val" || {
			printf '{"result":false,"error":"invalid %s"}\n' "$opt"
			return
		}
	done

	[ -n "$(uci -q get omr-metrics.settings)" ] || uci -q set omr-metrics.settings=settings
	for opt in $SETTINGS_OPTS; do
		val=$(printf '%s' "$input" | jsonfilter -q -e "@.${opt}") || continue
		[ "$val" = "$(uci -q get "omr-metrics.settings.${opt}")" ] && continue
		if [ -n "$val" ]; then
			uci -q set "omr-metrics.settings.${opt}=${val}"
		else
			uci -q delete "omr-metrics.settings.${opt}"
		fi
		changed=1
		case "$opt" in
			server|serverport)
				relogin=1
				;;
			username)
				relogin=1
				# No username, no custom login: forget its password too
				[ -n "$val" ] || uci -q delete omr-metrics.settings.password
				;;
			custom_server_pin)
				# A pin typed in is the user's, for whatever server is
				# set: forget which server a learned one came from, or
				# omr-metrics-curl.sh would drop it as another server's
				uci -q delete omr-metrics.settings.custom_server_pin_host
				;;
		esac
	done

	val=$(printf '%s' "$input" | jsonfilter -q -e '@.password')
	if [ -n "$val" ] && [ "$val" != "$(uci -q get omr-metrics.settings.password)" ]; then
		uci -q set "omr-metrics.settings.password=${val}"
		changed=1
		relogin=1
	fi

	# The stored token was issued by the old server to the old login: it
	# must not be sent to a new server, nor stand in for new credentials.
	# It lives in /var/state; the config only holds one an older version
	# left there, until the init drops it.
	if [ -n "$relogin" ]; then
		omr_state_del omr-metrics settings token
		uci -q delete omr-metrics.settings.token
	fi

	if [ -n "$changed" ]; then
		uci -q commit omr-metrics
		# What applying the change from LuCI did: procd reloads omr-metrics
		ubus call service event '{"type":"config.change","data":{"package":"omr-metrics"}}' >/dev/null 2>&1
	fi
	echo '{"result":true}'
}

case "$1" in
	list)
		echo '{'
		echo '  "get_status":    { "interface": "str" },'
		echo '  "get_all":       {},'
		echo '  "get_interfaces":{},'
		echo '  "get_user_info": {},'
		echo '  "get_forecast":  { "horizon": 0, "since": "str", "limit": 0 },'
		echo '  "get_decision":  { "predict": false, "horizon": 0 },'
		echo '  "get_settings":  {},'
		printf '  "set_settings":  {'
		sep=" "
		for opt in $SETTINGS_OPTS password; do
			printf '%s"%s": "str"' "$sep" "$opt"
			sep=", "
		done
		echo ' }'
		echo '}'
		;;
	call)
		case "$2" in
			get_status)
				input=$(cat)
				iface=$(echo "$input" | jsonfilter -q -e '@.interface')
				if [ -z "$iface" ]; then
					echo '{"error": "missing interface parameter"}'
					exit 1
				fi
				# a file name under METRICS_DIR: "../" read any .json file
				# (the proxy configs and their passwords) with the read ACL
				case "$iface" in
					.*|*[!A-Za-z0-9_.-]*)
						echo '{"error": "invalid interface parameter"}'
						exit 1
						;;
				esac
				metrics_file="${METRICS_DIR}/${iface}.json"
				if [ -f "$metrics_file" ]; then
					cat "$metrics_file"
				else
					echo "{\"interface\": \"${iface}\", \"error\": \"no data available\"}"
				fi
				;;
			get_all)
				echo '{"interfaces": ['
				first=1
				for f in "${METRICS_DIR}"/*.json; do
					[ -f "$f" ] || continue
					[ "$first" = "1" ] && first=0 || echo ','
					cat "$f"
				done
				echo ']}'
				;;
			get_interfaces)
				echo '{"interfaces": ['
				first=1
				for f in "${METRICS_DIR}"/*.json; do
					[ -f "$f" ] || continue
					iface_name=$(basename "$f" .json)
					[ "$first" = "1" ] && first=0 || echo ','
					echo "\"${iface_name}\""
				done
				echo ']}'
				;;
			get_user_info)
				_vps_get "/metrics/user"
				;;
			get_forecast)
				input=$(cat)
				horizon=$(echo "$input" | jsonfilter -q -e '@.horizon' 2>/dev/null)
				since=$(echo "$input"   | jsonfilter -q -e '@.since'   2>/dev/null)
				limit=$(echo "$input"   | jsonfilter -q -e '@.limit'   2>/dev/null)
				# These go into the query string unencoded
				_qs_uint "$horizon" || horizon=""
				_qs_uint "$limit" || limit=""
				case "$since" in *[!A-Za-z0-9._:-]*) since="" ;; esac
				[ "${#since}" -le 32 ] || since=""
				qs=""
				[ -n "$horizon" ] && [ "$horizon" != "0" ] && qs="${qs}horizon=${horizon}&"
				[ -n "$since" ]                             && qs="${qs}since=${since}&"
				[ -n "$limit" ]   && [ "$limit"   != "0" ] && qs="${qs}limit=${limit}&"
				qs="${qs%&}"
				_vps_get "/metrics/quality/forecast" "$qs"
				;;
			get_decision)
				input=$(cat)
				predict=$(echo "$input" | jsonfilter -q -e '@.predict' 2>/dev/null)
				horizon=$(echo "$input" | jsonfilter -q -e '@.horizon' 2>/dev/null)
				_qs_uint "$horizon" || horizon=""
				qs=""
				[ "$predict" = "true" ] && qs="${qs}predict=true&"
				[ -n "$horizon" ] && [ "$horizon" != "0" ] && qs="${qs}horizon=${horizon}&"
				qs="${qs%&}"
				_vps_get "/metrics/decision" "$qs"
				;;
			get_settings)
				_get_settings
				;;
			set_settings)
				_set_settings
				;;
			*)
				echo '{"error": "unknown method"}'
				exit 1
				;;
		esac
		;;
esac
