#!/bin/sh
# Copyright (C) 2026 Ycarus (Yannick Chabanois) <ycarus@zugaina.org> for OpenMPTCProuter
# SPDX-License-Identifier: GPL-3.0
#
# Daemon: reads /tmp/metrics/*.json produced by the 040-metrics post-tracking
# hook and POSTs each payload to POST /metrics on every configured VPS server.
# Runs in a tight poll loop; interval is controlled by OMR_METRICS_INTERVAL
# (seconds, default 30) which is set by the init.d script.

. "${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}"
STATE_DIR="${OMR_METRICS_STATE_DIR:-/tmp/omr-metrics-send}"
INTERVAL="${OMR_METRICS_INTERVAL:-30}"
# 0 (which the settings page used to accept) made the loop POST back to back
case "$INTERVAL" in ''|*[!0-9]*) INTERVAL=30 ;; esac
[ "${#INTERVAL}" -gt 9 ] && INTERVAL=30
[ "$INTERVAL" -lt 5 ] && INTERVAL=5

# Login backoff caps and first delays, in seconds: why they are what they are
# is explained in omr-metrics-curl.sh, which uses them. The first retry of a
# path that did not answer follows the poll interval here.
LOGIN_BACKOFF_REJECTED="${OMR_METRICS_LOGIN_BACKOFF_REJECTED:-1800}"
LOGIN_BACKOFF_NO_ANSWER="${OMR_METRICS_LOGIN_BACKOFF_NO_ANSWER:-300}"
LOGIN_FIRST_REJECTED="${OMR_METRICS_LOGIN_FIRST_REJECTED:-300}"
LOGIN_FIRST_NO_ANSWER="${OMR_METRICS_LOGIN_FIRST_NO_ANSWER:-$INTERVAL}"

# Token of the server currently being served, and the credentials it was
# resolved from. Set by _server_creds/_login, consumed by _post_metrics, so
# one cycle logs in at most once per server instead of once per metrics file.
SRV_TOKEN=""
SRV_TOKEN_KEY=""
SRV_USERNAME=""
SRV_PASSWORD=""
# Set by _post_metrics when the address did not answer at all (HTTP 000)
SRV_NO_ANSWER=0

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

# _is_ip6, the login backoff (_login_allowed, _login_failed, _login_ok),
# _token_store, _server_creds and _login come from omr-metrics-curl.sh:
# omr-weight-sync and the metrics rpcd log in to the same API and share the
# backoff.

# POST one JSON payload file to /metrics on a single server, using SRV_TOKEN.
# Retries once with a fresh token on HTTP 401.
# Returns 0 on HTTP 200, 1 on any other outcome.
# The file is read by the HTTP client: passed as -d "<payload>", a WAN
# with a few hundred subflows went over the 128 KiB limit of one argument,
# the exec failed and the WAN was silently never sent.
_post_metrics() {
	local servername="$1" server="$2" serverport="$3" json_file="$4"
	local url http_code

	[ -z "$SRV_TOKEN" ] && return 1

	if _is_ip6 "$server"; then
		url="https://[${server}]:${serverport}/metrics"
	else
		url="https://${server}:${serverport}/metrics"
	fi

	http_code=$(omr_metrics_curl --max-time 10 -s -k \
		-o /dev/null -w "%{http_code}" \
		-H "accept: application/json" \
		-H "Authorization: Bearer ${SRV_TOKEN}" \
		-H "Content-Type: application/json" \
		-X POST --data-binary "@${json_file}" "$url" 2>/dev/null)

	if [ "$http_code" = "401" ]; then
		_token_drop "$SRV_TOKEN_KEY"
		SRV_TOKEN=""
		_login "$servername" "$server" "$serverport" || return 1
		http_code=$(omr_metrics_curl --max-time 10 -s -k \
			-o /dev/null -w "%{http_code}" \
			-H "accept: application/json" \
			-H "Authorization: Bearer ${SRV_TOKEN}" \
			-H "Content-Type: application/json" \
			-X POST --data-binary "@${json_file}" "$url" 2>/dev/null)
	fi

	# No answer (connect timeout, unreachable): the address is dead for the
	# whole batch, not for this one file.
	case "$http_code" in ''|000) SRV_NO_ANSWER=1; return 1 ;; esac

	if [ "$http_code" = "200" ]; then
		# The stored token still works: whatever tripped an earlier
		# backoff is over.
		_login_ok "$(_state_key "$servername" "$server")"
		return 0
	fi

	# 404/501 means the metrics module is not loaded on the VPS.
	# Mark this server as unavailable so we stop trying until restarted.
	if [ "$http_code" = "404" ] || [ "$http_code" = "501" ]; then
		_log "metrics endpoint not available on ${server} (HTTP ${http_code}) — disabling for an hour"
		touch "${STATE_DIR}/${servername}.no_metrics" 2>/dev/null
		return 1
	fi

	#_log "POST ${url}: HTTP ${http_code}"
	return 1
}

# Whether server $1 answered 404/501 to POST /metrics less than an hour ago:
# the module can be added to the VPS later, so the mark does not stay forever.
_no_metrics() {
	local f="${STATE_DIR}/${1}.no_metrics" since
	[ -f "$f" ] || return 1
	since=$(date -r "$f" +%s 2>/dev/null)
	case "$since" in ''|*[!0-9]*) since=0 ;; esac
	[ $(($(date +%s) - since)) -lt 3600 ] && return 0
	rm -f "$f"
	return 1
}

# Ship every payload of this cycle to one server address, logging in at most
# once for the whole batch. Returns 0 when the server accepted any of it.
_send_to_server() {
	local servername="$1" server="$2" serverport="$3" files="$4"
	local json_file sent=1

	SRV_NO_ANSWER=0
	_server_creds "$servername"
	[ -z "$SRV_TOKEN" ] && { _login "$servername" "$server" "$serverport" || return 1; }

	for json_file in $files; do
		[ -s "$json_file" ] || continue
		_post_metrics "$servername" "$server" "$serverport" "$json_file" && sent=0
		# A lost token, a server without the metrics module or an address
		# that does not answer applies to the whole batch: stop instead of
		# replaying it file by file. A dead first address cost 10 s per
		# file before the next one was tried, 40 s with 4 WANs, longer
		# than the 30 s interval.
		[ -z "$SRV_TOKEN" ] && return 1
		_no_metrics "$servername" && return 1
		[ "$SRV_NO_ANSWER" = "1" ] && return 1
	done
	return "$sent"
}

# One send cycle: read all JSON files and dispatch to every configured server.
_send_cycle() {
	[ -d "$METRICS_DIR" ] || return

	local custom_server custom_serverport server_names use_custom_server
	use_custom_server=$(uci -q get "omr-metrics.settings.use_custom_server" 2>/dev/null)
	if [ "${use_custom_server:-0}" = "1" ]; then
		custom_server=$(uci -q get "omr-metrics.settings.server" 2>/dev/null)
		custom_serverport=$(uci -q get "omr-metrics.settings.serverport" 2>/dev/null)
		[ -z "$custom_serverport" ] && custom_serverport="65500"
	fi

	# Extract section names of type "server" from UCI without loading config_load
	# (avoids clobbering the global config state if called from within callbacks).
	server_names=$(uci -q show openmptcprouter 2>/dev/null | \
		sed -n 's/^openmptcprouter\.\([^.=][^.=]*\)=server$/\1/p')

	[ -z "$custom_server" ] && [ -z "$server_names" ] && return

	# Collect the payload files once. Every server gets the same batch, and
	# the per-server login has to happen once per cycle, not once per file:
	# a failing login repeated per file is what turns one wrong key into a
	# fail2ban ban of the router's own IP.
	local payload_files="" json_file iface
	for json_file in "${METRICS_DIR}"/*.json; do
		[ -f "$json_file" ] || continue

		# POST /metrics is a per-WAN store on the VPS, and the omrvpn /
		# OWVPN* tunnel is not a WAN: it carries the aggregate of every
		# WAN, so feeding it in adds a phantom interface the decision
		# model then hands a weight to (omr-weight-sync writes those back
		# into UCI and the BPF map). 040-metrics normally doesn't even
		# collect the tunnel, but it stays filtered here too so a build
		# that does collect it -- for the local rpcd/LuCI views, which
		# drop the tunnel on their own -- still never ships it.
		iface="${json_file##*/}"
		iface="${iface%.json}"
		case "$iface" in
			omrvpn|OWVPN*) continue ;;
		esac

		payload_files="${payload_files} ${json_file}"
	done
	[ -z "$payload_files" ] && return

	# If a custom omr-metrics server is configured, use it exclusively.
	if [ -n "$custom_server" ]; then
		_no_metrics omr_metrics_custom && return
		_send_to_server "omr_metrics_custom" "$custom_server" "$custom_serverport" "$payload_files"
		return
	fi

	local servername
	for servername in $server_names; do
		local disabled serverport server_ips server last_ok
		disabled=$(uci -q get "openmptcprouter.${servername}.disabled" 2>/dev/null)
		[ "$disabled" = "1" ] && continue

		# Skip servers where the metrics endpoint was found unavailable.
		_no_metrics "$servername" && continue

		serverport=$(uci -q get "openmptcprouter.${servername}.port" 2>/dev/null)
		[ -z "$serverport" ] && serverport="65500"

		server_ips=$(uci -q get "openmptcprouter.${servername}.ip" 2>/dev/null)
		[ -z "$server_ips" ] && continue

		# The address that took the last batch goes first, so a dead
		# first address does not cost a timeout on every cycle.
		last_ok=$(cat "${STATE_DIR}/${servername}.last_ok" 2>/dev/null)
		case " $server_ips " in
			*" $last_ok "*)
				[ -n "$last_ok" ] && server_ips="$last_ok $(printf '%s\n' $server_ips | grep -vxF -- "$last_ok")"
				;;
		esac

		# One address that took the batch is enough: they are the same VPS,
		# and every other one stored the same samples once more (IPv4 and
		# IPv6 twice). The next address only when this one did not take it.
		for server in $server_ips; do
			[ -n "$server" ] || continue
			if _send_to_server "$servername" "$server" "$serverport" "$payload_files"; then
				[ "$server" = "$last_ok" ] || printf '%s\n' "$server" > "${STATE_DIR}/${servername}.last_ok"
				break
			fi
		done
	done
}

mkdir -p "$STATE_DIR"
# STATE_DIR outlives the daemon: a restart is the documented way to retry a
# server marked as not having the metrics module
rm -f "${STATE_DIR}"/*.no_metrics 2>/dev/null
_log "started (interval=${INTERVAL}s)"
while true; do
	_send_cycle
	sleep "$INTERVAL"
done
