#!/bin/sh
# What this machine answers on, and nothing else.
#
#   nuxpbx-firewall apply    read the machine, put the rules in place
#   nuxpbx-firewall show     what is in place right now
#   nuxpbx-firewall remove   take the rules away again
#
# A phone system reachable from the internet is being guessed at within hours
# of being put there, and a SIP port anybody can reach is how one turns into
# somebody else's long distance bill. So the ports are named here, once, and
# everything else is dropped.
#
# Everything lives in one nftables table called nuxpbx. Nothing of the
# machine's own is touched: no chain of anybody else's is edited, and deleting
# our table is the entire undo. The chain's policy is accept and the drop is
# the last rule in it rather than the policy, so a flushed chain, a half
# written ruleset or a table somebody deleted leaves the machine reachable
# instead of locked. The one rule that must never be missing is ssh, so it is
# written before every drop, checked in the file before it is loaded and read
# back out of the kernel afterwards.
set -eu

conf=/etc/nuxpbx/firewall.conf
env_file=/etc/nuxpbx/nuxpbx.env
table=nuxpbx
jail_d=/etc/fail2ban/jail.d

# The defaults, then whatever the file says, then whatever the environment
# says. A setting rather than silence: a machine that already sits behind
# somebody else's firewall should be able to say so and be left alone.
FIREWALL=on
SSH_PORTS=
EXTRA_TCP=
EXTRA_UDP=
if [ -r "$conf" ]; then
	# shellcheck disable=SC1090
	. "$conf"
fi
FIREWALL=${NUXPBX_FIREWALL:-$FIREWALL}

say() { echo "$*" >&2; }

have_nft() {
	command -v nft >/dev/null 2>&1 || {
		say "nftables is not installed, so there is nothing to write rules with"
		return 1
	}
}

# Where ssh listens. Three sources, because the one that is true is not always
# the one that is readable: what sshd is listening on right now, what sshd
# itself says its configuration means (Include files and all), and the plain
# file. Port 22 rides along regardless, because a rule for a port nothing uses
# costs nothing and a missing one costs the machine.
ssh_ports() {
	{
		ss -H -lntp 2>/dev/null | grep -w sshd | awk '{ print $4 }' | sed 's/.*://'
		if command -v sshd >/dev/null 2>&1; then
			sshd -T 2>/dev/null | awk '$1 == "port" { print $2 }'
		fi
		grep -hiE '^[[:space:]]*Port[[:space:]]+[0-9]+' \
			/etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf 2>/dev/null | awk '{ print $2 }'
	} | grep -E '^[0-9]+$' | sort -un
}

# The range Asterisk hands out for the audio itself. Written nowhere by us, so
# it is read from Asterisk's file and falls back to the range Asterisk falls
# back to. Get this wrong and calls connect and are silent, which is the
# failure everybody spends an afternoon on.
rtp_range() {
	start=$(awk -F= '/^[[:space:]]*rtpstart[[:space:]]*=/ { gsub(/[^0-9]/, "", $2); print $2 }' \
		/etc/asterisk/rtp.conf 2>/dev/null | tail -1)
	end=$(awk -F= '/^[[:space:]]*rtpend[[:space:]]*=/ { gsub(/[^0-9]/, "", $2); print $2 }' \
		/etc/asterisk/rtp.conf 2>/dev/null | tail -1)
	[ -n "${start:-}" ] && [ -n "${end:-}" ] && [ "$start" -le "$end" ] 2>/dev/null \
		|| { start=10000; end=20000; }
	echo "$start-$end"
}

# The port the interface is on. 8080 unless the environment file moved it; 443
# always, because https is switched on from the browser and never writes that
# file, so a rule tied to the file would close the port the moment somebody
# used the feature.
interface_ports() {
	echo 443
	http=8080
	if [ -r "$env_file" ]; then
		for name in NUXPBX_HTTP_ADDR NUXPBX_HTTPS_ADDR; do
			addr=$(awk -F= -v want="$name" '$1 == want { sub(/^[^=]*=/, ""); print }' "$env_file" | tail -1)
			port=$(printf '%s' "${addr:-}" | sed 's/.*://' | grep -E '^[0-9]+$' || true)
			[ -n "$port" ] || continue
			if [ "$name" = NUXPBX_HTTP_ADDR ]; then
				http=$port
			else
				echo "$port"
			fi
		done
	fi
	echo "$http"
}

# A list as nftables wants it inside braces.
as_set() { tr ' \n' ',,' | sed 's/,,*/,/g; s/^,//; s/,$//'; }

# The same list, where fail2ban can read it. Its interface jail carries a port
# list of its own, and it was two numbers written down by hand while
# interface_ports above reads the machine: move the interface with
# NUXPBX_HTTP_ADDR, which the first boot writes and docs/install.md tells
# people to change, and fail2ban went on counting the refusals and reporting
# the address as banned while nftables matched it only on 8080 and 443. One
# place decides the list, and it is this file, because it is the only one that
# reads where the interface actually is.
#
# The name is what makes it stand. fail2ban reads jail.d sorted by file name
# and the last setting of a key wins, and "nuxpbx-ports.local" loses to
# "nuxpbx.local": a hyphen is 0x2D and a dot is 0x2E, so it sorts first and
# its port line is overwritten by the shipped one. Proved against a real
# fail2ban, which went on reporting 8080,443. Hence the prefix, which is what
# every other .d directory uses for the same reason.
#
# Only where there is a fail2ban to read it: Alpine gets the closed ports and
# no bans on purpose. The reload is allowed to fail, and at boot it always
# does, because this runs before anything at all is listening. fail2ban reads
# jail.d itself when it starts, so the file is what matters and the reload is
# only for the machine that is already up.
interface_jail() {
	[ -d "$jail_d" ] || return 0
	cat >"$jail_d/zz-nuxpbx-ports.local" <<JAIL
# Written by nuxpbx-firewall on every apply. Changes here are overwritten.
# The ports the interface really answers on, taken from the same reading of
# this machine that the nftables rules are, so a ban and a rule cannot
# disagree about which ports to shut.
[nuxpbx-interface]
port = $(interface_ports | sort -un | as_set)
JAIL
	fail2ban-client reload nuxpbx-interface >/dev/null 2>&1 || true
}

apply() {
	have_nft || return 1

	ssh=$(ssh_ports)
	if [ -z "$ssh" ] && [ -z "$SSH_PORTS" ]; then
		say "refusing to write rules: nothing on this machine says where ssh listens."
		say "Neither ss nor sshd nor /etc/ssh/sshd_config answered, so a rule set written"
		say "now could lock this session out. Put SSH_PORTS=\"22\" in $conf and run this again."
		return 1
	fi
	ssh=$(printf '22\n%s\n%s\n' "$ssh" "$SSH_PORTS" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -un)

	rules=$(mktemp)
	# shellcheck disable=SC2064
	trap "rm -f '$rules'" EXIT INT TERM

	{
		# Declaring the table before deleting it is what makes this safe to
		# run on a machine that has never run it: the declaration creates an
		# empty one if it is missing, and nft applies the whole file at once,
		# so there is no moment in between with no rules.
		echo "table inet $table"
		echo "delete table inet $table"
		echo
		echo "table inet $table {"
		echo "	chain input {"
		echo "		type filter hook input priority 0; policy accept;"
		echo "		ct state established,related accept"
		echo "		iif lo accept"
		echo "		tcp dport { $(printf '%s' "$ssh" | as_set) } accept comment \"ssh, and it comes first\""
		echo "		ct state invalid drop"
		# By number rather than by name: nft looks a protocol name up in
		# /etc/protocols, which comes from netbase, and a machine without
		# it has the whole file refused rather than one rule dropped. That
		# leaves every port open, which is the one way this can fail that
		# nobody notices. 1 is icmp and 58 is ipv6-icmp.
		echo "		meta l4proto { 1, 58 } accept comment \"icmp, and neighbours finding each other\""
		# A lease that cannot be renewed is a machine that loses its address
		# a few hours after this was switched on, which reads as the firewall
		# having killed the network. Conntrack does not always keep the offer.
		echo "		udp dport { 68, 546 } accept comment \"a lease being renewed\""
		echo "		tcp dport { $(interface_ports | sort -un | as_set) } accept comment \"the interface\""
		echo "		udp dport 5060 accept comment \"sip\""
		echo "		tcp dport 5060 accept comment \"sip\""
		echo "		udp dport $(rtp_range) accept comment \"the audio itself\""
		if [ -n "$EXTRA_TCP" ]; then
			echo "		tcp dport { $(printf '%s' "$EXTRA_TCP" | as_set) } accept comment \"asked for in $conf\""
		fi
		if [ -n "$EXTRA_UDP" ]; then
			echo "		udp dport { $(printf '%s' "$EXTRA_UDP" | as_set) } accept comment \"asked for in $conf\""
		fi
		echo "		drop comment \"the rest\""
		echo "	}"
		echo "}"
	} >"$rules"

	# Before it is loaded: ssh is in the file, and it is above every drop in
	# it. A rule set that would cut the session that is applying it is one
	# nobody is there to undo.
	ssh_at=$(grep -n 'comment "ssh' "$rules" | head -1 | cut -d: -f1)
	# The word, not the letters: a comment mentioning dropping is not a rule
	# that drops, and reading it as one is how this refused itself.
	drop_at=$(grep -nw drop "$rules" | head -1 | cut -d: -f1)
	if [ -z "$ssh_at" ] || [ -z "$drop_at" ] || [ "$ssh_at" -ge "$drop_at" ]; then
		say "refusing to load a rule set that drops before it lets ssh through"
		return 1
	fi

	nft -f "$rules" || { say "nftables refused the rule set; nothing was changed"; return 1; }

	# And afterwards, out of the kernel rather than out of the file: what was
	# written and what is in effect are two different claims.
	live=$(nft list table inet "$table" 2>/dev/null | awk '/comment "ssh/ { print; exit }')
	for port in $ssh; do
		if ! printf '%s' "$live" | tr -c '0-9' ' ' | grep -qw "$port"; then
			nft delete table inet "$table" >/dev/null 2>&1 || true
			say "ssh on $port is not in the rules that were loaded; they were taken away again"
			return 1
		fi
	done

	interface_jail

	echo "open: ssh $(printf '%s' "$ssh" | as_set), the interface on $(interface_ports | sort -un | as_set), sip 5060, audio $(rtp_range)"
}

case "${1:-apply}" in
	apply)
		if [ "$FIREWALL" != on ]; then
			echo "the firewall is off in $conf; this machine's own rules are left alone"
			exit 0
		fi
		apply
		;;
	show)
		have_nft || exit 1
		nft list table inet "$table" 2>/dev/null || echo "no rules of ours are in place"
		;;
	remove)
		have_nft || exit 1
		if nft list table inet "$table" >/dev/null 2>&1; then
			nft delete table inet "$table"
			echo "the rules are gone; this machine answers on every port again"
		else
			echo "no rules of ours were in place"
		fi
		;;
	*)
		say "usage: nuxpbx-firewall [apply|show|remove]"
		exit 1
		;;
esac
