#!/usr/bin/bash
# Apply or remove per-interface qdiscs (cake, with ifb for inbound shaping).
#
# Configuration is read, later files overriding earlier ones, from:
#   /usr/lib/qos-profiles/role-*.conf    defaults of the installed role package
#   /etc/qos-profiles/defaults.conf      site-wide defaults
#   /etc/qos-profiles/<iface>.conf       the interface
# See /usr/share/doc/qos-profiles/README.md for the variables.

set -euo pipefail
shopt -s nullglob

KEYS=(LINK_RATE EGRESS_RATE INGRESS_RATE
	SHAPE_PERCENT EGRESS_SHAPE_PERCENT INGRESS_SHAPE_PERCENT
	DIFFSERV INGRESS_DIFFSERV INGRESS_WASH NAT
	CAKE_ARGS EGRESS_CAKE_ARGS INGRESS_CAKE_ARGS IFB EGRESS INGRESS)
CONF_DIR=/etc/qos-profiles
ROLE_DIR=/usr/lib/qos-profiles
STATE_DIR=/run/qos-profiles

die() {
	echo "${0##*/}: $*" >&2
	exit 1
}

# Parse KEY=value lines without executing them. Values may be "quoted",
# 'quoted' or bare; a trailing # comment is allowed.
load() {
	local file=$1 line key val n=0

	while IFS= read -r line || [[ -n $line ]]; do
		n=$((n + 1))
		[[ $line =~ ^[[:space:]]*(#|$) ]] && continue
		[[ $line =~ ^[[:space:]]*([A-Z_]+)=(.*)$ ]] || die "$file:$n: invalid line"
		key=${BASH_REMATCH[1]}
		val=${BASH_REMATCH[2]}
		[[ " ${KEYS[*]} " == *" $key "* ]] || die "$file:$n: unknown variable $key"
		if [[ $val =~ ^\"([^\"]*)\"[[:space:]]*(#.*)?$ || $val =~ ^\'([^\']*)\'[[:space:]]*(#.*)?$ ]]; then
			val=${BASH_REMATCH[1]}
		elif [[ $val =~ ^([^[:space:]#\"\']*)[[:space:]]*(#.*)?$ ]]; then
			val=${BASH_REMATCH[1]}
		else
			die "$file:$n: invalid value for $key"
		fi
		printf -v "$key" '%s' "$val"
	done <"$file"
}

# Usage: yesno <variable> <keyword if yes> <keyword if no>
yesno() {
	case ${!1,,} in
		yes) echo "$2" ;;
		no) echo "$3" ;;
		*) die "$1 must be yes or no" ;;
	esac
}

# Usage: percent <variable>
percent() {
	[[ ${!1} =~ ^[0-9]+$ ]] && ((${!1} >= 1 && ${!1} <= 100)) || die "$1 must be 1-100"
	echo "${!1}"
}

# Usage: shaped_rate <link rate> <percent>
# Link rate (e.g. 1gbit, 2.5Gbit, 500mbit) -> percent of it in kbit.
shaped_rate() {
	awk -v r="${1,,}" -v p="$2" 'BEGIN {
		n = r; sub(/[a-z]+$/, "", n); u = substr(r, length(n) + 1)
		m["bit"] = 1; m[""] = 1; m["kbit"] = 1e3; m["mbit"] = 1e6; m["gbit"] = 1e9; m["tbit"] = 1e12
		if (n !~ /^[0-9]+(\.[0-9]+)?$/ || !(u in m) || n == 0) exit 1
		printf "%dkbit\n", n * m[u] * p / 100 / 1000
	}' || die "invalid rate: $1"
}

# Build the qdisc for one direction into the named array.
# Usage: build <array> <raw spec> <link rate> <percent> <cake args> <diffserv> <isolation> [extra...]
build() {
	local -n spec=$1
	local raw=$2 rate=$3 pct=$4 args=$5 diffserv=$6 isolation=$7 bw extra
	shift 7

	spec=()
	if [[ -n $raw ]]; then
		read -ra spec <<<"$raw"
		return
	fi
	[[ -n $rate && $rate != none ]] || return 0
	spec=(cake "$diffserv" "$isolation" "$@")
	if [[ $rate == unlimited ]]; then
		spec+=(unlimited)
	else
		bw=$(shaped_rate "$rate" "$pct")
		spec+=(bandwidth "$bw")
	fi
	read -ra extra <<<"$CAKE_ARGS $args"
	spec+=("${extra[@]}")
}

# Undo whatever the last start applied, as recorded in the state file.
cleanup() {
	local state=$STATE_DIR/$dev key val

	[[ -f $state ]] || return 0
	while IFS='=' read -r key val; do
		case $key in
			egress) tc qdisc del dev "$dev" root 2>/dev/null || true ;;
			ifb)
				tc qdisc del dev "$dev" clsact 2>/dev/null || true
				ip link del dev "$val" 2>/dev/null || true
				;;
		esac
	done <"$state"
	rm -f "$state"
}

start() {
	local -a egress ingress
	local state=$STATE_DIR/$dev ifb egress_pct ingress_pct wash nat

	[[ -d /sys/class/net/$dev ]] || die "no such interface: $dev"
	: "${EGRESS_SHAPE_PERCENT:=$SHAPE_PERCENT}" "${INGRESS_SHAPE_PERCENT:=$SHAPE_PERCENT}"
	egress_pct=$(percent EGRESS_SHAPE_PERCENT)
	ingress_pct=$(percent INGRESS_SHAPE_PERCENT)
	wash=$(yesno INGRESS_WASH wash nowash)
	nat=$(yesno NAT nat nonat)

	# Egress without any rate runs cake unlimited (the NIC is the bottleneck);
	# ingress without a rate is not shaped, cake needs one there.
	build egress "$EGRESS" "${EGRESS_RATE:-${LINK_RATE:-unlimited}}" \
		"$egress_pct" "$EGRESS_CAKE_ARGS" "$DIFFSERV" dual-srchost "$nat"
	build ingress "$INGRESS" "${INGRESS_RATE:-$LINK_RATE}" \
		"$ingress_pct" "$INGRESS_CAKE_ARGS" "$INGRESS_DIFFSERV" dual-dsthost "$nat" \
		ingress "$wash"
	ifb=${IFB:-ifb-$dev}
	((${#ingress[@]} == 0 || ${#ifb} <= 15)) ||
		die "ifb name '$ifb' too long, set IFB= in $CONF_DIR/$dev.conf"

	cleanup
	if ((${#egress[@]})); then
		tc qdisc replace dev "$dev" root "${egress[@]}"
		echo egress=1 >>"$state"
	fi
	if ((${#ingress[@]})); then
		ip link add name "$ifb" type ifb
		echo "ifb=$ifb" >>"$state"
		ip link set dev "$ifb" up
		tc qdisc replace dev "$ifb" root "${ingress[@]}"
		tc qdisc replace dev "$dev" clsact
		tc filter add dev "$dev" ingress protocol all matchall \
			action mirred egress redirect dev "$ifb"
	fi
}

[[ $# -eq 2 ]] || die "usage: ${0##*/} start|stop <interface>"
action=$1
dev=$2
[[ $dev =~ ^[^/[:space:]]{1,15}$ && $dev != . && $dev != .. ]] || die "invalid interface name: $dev"

LINK_RATE= EGRESS_RATE= INGRESS_RATE=
SHAPE_PERCENT=95 EGRESS_SHAPE_PERCENT= INGRESS_SHAPE_PERCENT=
DIFFSERV=diffserv4 INGRESS_DIFFSERV=besteffort INGRESS_WASH=yes NAT=no
CAKE_ARGS= EGRESS_CAKE_ARGS= INGRESS_CAKE_ARGS= IFB= EGRESS= INGRESS=
for f in "$ROLE_DIR"/role-*.conf "$CONF_DIR/defaults.conf" "$CONF_DIR/$dev.conf"; do
	[[ -f $f ]] && load "$f"
done

# Serialise the systemd unit and the NetworkManager dispatcher.
mkdir -p "$STATE_DIR"
exec 9>"$STATE_DIR/$dev.lock"
flock 9

case $action in
	start) start ;;
	stop) cleanup ;;
	*) die "unknown action: $action" ;;
esac
