#!/bin/sh
# Copyright (C) 2023 Ycarus (Yannick Chabanois) <ycarus@zugaina.org> for OpenMPTCProuter
# Released under GPL 3. See LICENSE for the full terms.

set -e

[ ! -f /etc/openmptcprouter-vps-admin/omr-bypass.json ] && exit 0

# Configuration
INTERFACE="$(jq -M -r .bypass_intf /etc/openmptcprouter-vps-admin/omr-admin-config.json | tr -d '\n')"
[ "$INTERFACE" = "null" ] && INTERFACE="vpn1"
GATEWAY="$(ip -4 r show dev ${INTERFACE} 2> /dev/null | awk '/via/ {print $3; exit}')"
GATEWAY6="$(ip -6 r show dev ${INTERFACE} 2> /dev/null | awk '/via/ {print $3; exit}')"
TABLE="991337"
# nftables/omr.nft forwards and masquerades this mark: change both together.
MARK="0x539"

# The marked traffic is routed by table ${TABLE}. omr-service (_vpn1) moves
# vpn1's default route there by itself and removes it from the main table, so
# an empty GATEWAY doesn't mean there is no route.
routed() {
	ip rule show | grep -q "fwmark ${MARK} lookup ${TABLE}" &&
		[ -n "$(ip -4 route show table ${TABLE} default 2> /dev/null)$(ip -6 route show table ${TABLE} default 2> /dev/null)" ]
}

CHECKSUM="$(md5sum /etc/openmptcprouter-vps-admin/omr-bypass.json | awk '{print $1}' | tr -d '\n')"
PREVIOUS_CHECKSUM="$(jq -M -r .bypass_checksum /etc/openmptcprouter-vps-admin/omr-admin-config.json | tr -d '\n')"
# The same list as last time, and still in place. A reboot, or a start or
# reload of nftables.service (nftables.conf begins with "flush ruleset"),
# removes the table and keeps the checksum, and the route goes away with the
# interface: check that the table, the rule and the route are there, or the
# bypass would stay off until the list changes. (A chain of the table, not
# the table itself: listing that would print every address of the sets.)
if [ "$CHECKSUM" = "$PREVIOUS_CHECKSUM" ] && nft list chain inet omr_bypass output > /dev/null 2>&1 && routed; then
	exit 0
fi

# Action
# Remove what the iptables/ipset version of this script left behind: on
# iptables-legacy it survives nftables reloads and would keep marking the
# previous list.
for ipt in iptables ip6tables; do
	command -v $ipt > /dev/null 2>&1 || continue
	$ipt -w -t mangle -n -L omr-bypass > /dev/null 2>&1 || $ipt -w -t mangle -n -L omr-bypass-local > /dev/null 2>&1 || continue
	while $ipt -w -t mangle -D PREROUTING -j omr-bypass > /dev/null 2>&1; do :; done
	while $ipt -w -t mangle -D OUTPUT -m addrtype ! --dst-type LOCAL -j omr-bypass-local > /dev/null 2>&1; do :; done
	for chain in omr-bypass omr-bypass-local; do
		$ipt -w -t mangle -F $chain > /dev/null 2>&1 || true
		$ipt -w -t mangle -X $chain > /dev/null 2>&1 || true
	done
done
if command -v ipset > /dev/null 2>&1; then
	for set in $(ipset -n list 2> /dev/null | grep -E '^omr6?_dst_bypass_srv_' || true); do
		ipset -q destroy $set || true
	done
fi

# The words of each list that can be an address, a prefix or a range: nft
# would read anything else as part of the command.
ipv4=$(jq -r --arg intf "$INTERFACE" '[.[$intf].ipv4[]? | strings | splits("\\s+") | select(test("^[0-9A-Fa-f:./-]+$"))] | join(",")' /etc/openmptcprouter-vps-admin/omr-bypass.json)
ipv6=$(jq -r --arg intf "$INTERFACE" '[.[$intf].ipv6[]? | strings | splits("\\s+") | select(test("^[0-9A-Fa-f:./-]+$"))] | join(",")' /etc/openmptcprouter-vps-admin/omr-bypass.json)

# Nothing to bypass (an empty list, or none for ${INTERFACE}): take away the
# table and the rule a previous list left, and don't wait for a route nothing
# would be sent to. This failed on every run of the timer where the router
# sends an empty list and ${INTERFACE} doesn't exist. The route in table
# ${TABLE} is left alone: omr-service puts it there.
if [ -z "$ipv4" ] && [ -z "$ipv6" ]; then
	nft delete table inet omr_bypass > /dev/null 2>&1 || true
	ip rule del prio 1 fwmark ${MARK} lookup ${TABLE} > /dev/null 2>&1 || true
	ip -6 rule del prio 1 fwmark ${MARK} lookup ${TABLE} > /dev/null 2>&1 || true
	exit 0
fi

# Mark what goes to the bypassed addresses, forwarded (prerouting) or sent by
# the VPS itself (output: a route chain, so a changed mark is routed again),
# for the rule below. This used ipset and iptables, but the installer only
# installs iptables on Debian 10, and the tables iptables-nft makes went away
# with every nftables reload. The table is replaced in one transaction.
nft -f - <<-EOF
table inet omr_bypass
delete table inet omr_bypass
table inet omr_bypass {
	set dst4 {
		type ipv4_addr
		flags interval
		auto-merge
	}
	set dst6 {
		type ipv6_addr
		flags interval
		auto-merge
	}
	chain prerouting {
		type filter hook prerouting priority mangle; policy accept;
		ip daddr @dst4 meta mark set ${MARK}
		ip6 daddr @dst6 meta mark set ${MARK}
	}
	chain output {
		type route hook output priority mangle; policy accept;
		fib daddr type local return
		ip daddr @dst4 meta mark set ${MARK}
		ip6 daddr @dst6 meta mark set ${MARK}
	}
}
EOF
# Add the comma-separated addresses $2 to the set $1 in one go (on stdin: a
# long list would be too long for one argument), or one at a time when nft
# refuses the lot, so that only the ones it can't take are left out, as
# "ipset -q add" left them out.
add_elements() {
	[ -n "$2" ] || return 0
	printf 'add element inet omr_bypass %s { %s }\n' "$1" "$2" | nft -f - > /dev/null 2>&1 && return 0
	for ip in $(echo "$2" | tr ',' ' '); do
		nft add element inet omr_bypass "$1" "{ $ip }" > /dev/null 2>&1 || true
	done
}
add_elements dst4 "$ipv4"
add_elements dst6 "$ipv6"

ip rule del prio 1 fwmark ${MARK} lookup ${TABLE} > /dev/null 2>&1 || true
ip rule add prio 1 fwmark ${MARK} lookup ${TABLE}
if [ -n "$GATEWAY" ]; then
	ip route replace default via ${GATEWAY} dev ${INTERFACE} table ${TABLE}
fi
if [ -n "$GATEWAY6" ]; then
	ip -6 rule del prio 1 fwmark ${MARK} lookup ${TABLE} > /dev/null 2>&1 || true
	ip -6 rule add prio 1 fwmark ${MARK} lookup ${TABLE}
	ip -6 route replace default via ${GATEWAY6} dev ${INTERFACE} table ${TABLE}
fi
# No route yet (${INTERFACE} down, or omr-service hasn't copied its route):
# the marked traffic falls through to the main table. Fail without saving the
# checksum, so the timer tries again.
if ! routed; then
	echo "omr-bypass: no default route via ${INTERFACE} in table ${TABLE} yet" >&2
	exit 1
fi

# Commit the checksum only after every firewall and routing operation above
# succeeded. A failed run will therefore be retried by the timer.
#
# omr-admin.py reads this file, changes it (users, keys, VPN IPs) and writes
# it back under an flock on .omr-admin-config.lock. Take the same lock for
# this read-modify-write, or a concurrent omr-admin change could be lost.
# The file holds every user's password and the VPN keys: write the temp copy
# 0600 before the rename, or it would be left with the temp file's default
# mode (the installer hardens it to 0600, debian9-x86_64.sh).
CONFIG=/etc/openmptcprouter-vps-admin/omr-admin-config.json
LOCK=/etc/openmptcprouter-vps-admin/.omr-admin-config.lock
save_checksum() {
	jq -M --arg c "$CHECKSUM" '.bypass_checksum = $c' "$CONFIG" > "$CONFIG.tmp" || { rm -f "$CONFIG.tmp"; return 1; }
	chmod 600 "$CONFIG.tmp" 2> /dev/null || true
	mv "$CONFIG.tmp" "$CONFIG"
}
if command -v flock > /dev/null 2>&1; then
	( flock 9; save_checksum ) 9>> "$LOCK"
else
	save_checksum
fi
