#!/bin/sh
set -u

TAG=ZYRNGIS
SERVICE=zyrngis
UDP_PORTS="6969 17012 4242 14550"
TCP_PORTS="4242 4249"
SKIP_ZONES="block drop trusted"
STATE_DIR=/var/lib/zyrngis
STATE="$STATE_DIR/firewall"

have() { command -v "$1" >/dev/null 2>&1; }
quiet() { "$@" >/dev/null 2>&1; }
say() { [ "${ZYRNGIS_FIREWALL_QUIET:-0}" = 1 ] || printf '%s\n' "$*"; }

recorded() { grep -qsx "$1" "$STATE"; }
record() {
    recorded "$1" && return 0
    mkdir -p "$STATE_DIR" && printf '%s\n' "$1" >> "$STATE"
}

ufw_enabled() { grep -qs '^ENABLED=yes' /etc/ufw/ufw.conf; }

ufw_reload() {
    ufw_enabled || return 0
    if have systemd-run && quiet systemd-run --quiet --no-block --collect "$(command -v ufw)" reload; then
        return 0
    fi
    quiet ufw reload || true
}

ufw_open() {
    for p in $UDP_PORTS; do quiet ufw allow "$p/udp" comment "$TAG" || true; done
    for p in $TCP_PORTS; do quiet ufw allow "$p/tcp" comment "$TAG" || true; done
    ufw_reload
    say "ufw: allowed UDP $UDP_PORTS and TCP $TCP_PORTS"
}

ufw_close() {
    for p in $UDP_PORTS; do quiet ufw delete allow "$p/udp" || true; done
    for p in $TCP_PORTS; do quiet ufw delete allow "$p/tcp" || true; done
    ufw_reload
}

firewalld_running() { have firewall-cmd && [ "$(firewall-cmd --state 2>/dev/null)" = running ]; }

firewalld_zones() {
    for z in $("$@" --get-zones 2>/dev/null); do
        case " $SKIP_ZONES " in
            *" $z "*) ;;
            *) printf '%s\n' "$z" ;;
        esac
    done
}

firewalld_service() {
    quiet "$@" --info-service="$SERVICE" && return 0
    quiet "$@" --new-service="$SERVICE" || return 1
    quiet "$@" --service="$SERVICE" --set-short="ZYRN Workstation"
    for p in $UDP_PORTS; do quiet "$@" --service="$SERVICE" --add-port="$p/udp"; done
    for p in $TCP_PORTS; do quiet "$@" --service="$SERVICE" --add-port="$p/tcp"; done
}

firewalld_open() {
    if firewalld_running; then
        quiet firewall-cmd --reload
        firewalld_service firewall-cmd --permanent || return 1
        quiet firewall-cmd --reload
        for z in $(firewalld_zones firewall-cmd --permanent); do
            quiet firewall-cmd --permanent --zone="$z" --add-service="$SERVICE"
        done
        quiet firewall-cmd --reload
    elif have firewall-offline-cmd; then
        firewalld_service firewall-offline-cmd || return 1
        for z in $(firewalld_zones firewall-offline-cmd); do
            quiet firewall-offline-cmd --zone="$z" --add-service="$SERVICE"
        done
    else
        return 1
    fi
    say "firewalld: added the $SERVICE service to every zone but $SKIP_ZONES"
}

firewalld_close() {
    if firewalld_running; then
        for z in $(firewall-cmd --permanent --get-zones 2>/dev/null); do
            quiet firewall-cmd --permanent --zone="$z" --remove-service="$SERVICE"
        done
        quiet firewall-cmd --reload
    elif have firewall-offline-cmd; then
        for z in $(firewall-offline-cmd --get-zones 2>/dev/null); do
            quiet firewall-offline-cmd --zone="$z" --remove-service-from-zone="$SERVICE"
        done
    fi
}

iptables_restrictive() {
    have iptables || return 1
    iptables -S INPUT 2>/dev/null | awk '
        $1 == "-P" && $2 == "INPUT" && ($3 == "DROP" || $3 == "REJECT") { found = 1 }
        $1 == "-A" && $0 ~ /-j (DROP|REJECT)( |$)/ && $0 !~ /--dport/ && $0 !~ / -s / && $0 !~ /INVALID/ { found = 1 }
        END { exit !found }'
}

ipt() {
    action=$1 proto=$2 port=$3
    shift 3
    iptables "$action" INPUT "$@" -p "$proto" -m "$proto" --dport "$port" -m comment --comment "$TAG" -j ACCEPT
}

iptables_persist() {
    if have netfilter-persistent && quiet netfilter-persistent save; then
        return 0
    fi
    for f in /etc/iptables/rules.v4 /etc/iptables/iptables.rules /etc/sysconfig/iptables; do
        [ -f "$f" ] || continue
        rules=$(iptables-save 2>/dev/null) || continue
        [ -n "$rules" ] && printf '%s\n' "$rules" > "$f"
    done
}

iptables_open() {
    iptables_restrictive || return 1
    for proto in udp tcp; do
        if [ "$proto" = udp ]; then ports=$UDP_PORTS; else ports=$TCP_PORTS; fi
        for p in $ports; do
            ipt -C "$proto" "$p" >/dev/null 2>&1 || ipt -I "$proto" "$p" 1 >/dev/null 2>&1
        done
    done
    iptables_persist
    say "iptables: allowed UDP $UDP_PORTS and TCP $TCP_PORTS"
}

iptables_close() {
    have iptables || return 0
    changed=0
    for proto in udp tcp; do
        if [ "$proto" = udp ]; then ports=$UDP_PORTS; else ports=$TCP_PORTS; fi
        for p in $ports; do
            while ipt -D "$proto" "$p" >/dev/null 2>&1; do changed=1; done
        done
    done
    [ "$changed" = 1 ] && iptables_persist
    return 0
}

nft_chains() {
    have nft || return 0
    nft list chains 2>/dev/null | awk '
        $1 == "table" { family = $2; table = $3 }
        $1 == "chain" { chain = $2 }
        /hook input/ && /policy drop/ {
            if (table == "firewalld") next
            if ((family == "ip" || family == "ip6") && table == "filter" && chain == "INPUT") next
            print family, table, chain
        }'
}

nft_set() { printf '{ %s }' "$(printf '%s' "$1" | sed 's/ /, /g')"; }

nft_open() {
    [ -n "$(nft_chains)" ] || return 1
    nft_chains | while read -r family table chain; do
        nft list chain "$family" "$table" "$chain" 2>/dev/null | grep -q "comment \"$TAG\"" && continue
        quiet nft insert rule "$family" "$table" "$chain" tcp dport "$(nft_set "$TCP_PORTS")" accept comment "\"$TAG\""
        quiet nft insert rule "$family" "$table" "$chain" udp dport "$(nft_set "$UDP_PORTS")" accept comment "\"$TAG\""
        say "nftables: allowed UDP $UDP_PORTS and TCP $TCP_PORTS in $family $table $chain"
    done
}

nft_close() {
    have nft || return 0
    nft -a list ruleset 2>/dev/null | awk -v tag="comment \"$TAG\"" '
        $1 == "table" { family = $2; table = $3 }
        $1 == "chain" { chain = $2 }
        index($0, tag) && match($0, /# handle [0-9]+/) { print family, table, chain, substr($0, RSTART + 9, RLENGTH - 9) }' |
        while read -r family table chain handle; do
            quiet nft delete rule "$family" "$table" "$chain" handle "$handle"
        done
}

managed() { ufw_enabled || firewalld_running; }

open_all() {
    if have ufw; then ufw_open; record ufw; fi
    if firewalld_open; then record firewalld; fi
    if ! managed; then
        iptables_open || true
        nft_open || true
    fi
}

boot_all() {
    if have ufw && ! recorded ufw; then ufw_open; record ufw; fi
    if ! recorded firewalld && firewalld_open; then record firewalld; fi
    if ! managed; then
        iptables_open || true
        nft_open || true
    fi
}

close_all() {
    if have ufw; then ufw_close; fi
    firewalld_close
    iptables_close
    nft_close
    rm -f "$STATE"
    rmdir "$STATE_DIR" 2>/dev/null || true
}

isolated_from_host_network() {
    [ "${ZYRNGIS_FIREWALL_IN_HOST:-0}" = 1 ] && return 1
    [ -d /run/systemd/system ] && have systemd-run || return 1
    own_netns=$(readlink /proc/self/ns/net 2>/dev/null) || return 1
    host_netns=$(readlink /proc/1/ns/net 2>/dev/null) || return 1
    [ "$own_netns" != "$host_netns" ]
}

if [ "$(id -u)" != 0 ]; then
    echo "zyrngis-firewall needs root: sudo $0 ${1:-open}" >&2
    exit 1
fi

if isolated_from_host_network; then
    systemd-run --quiet --wait --pipe --collect \
        --setenv=ZYRNGIS_FIREWALL_IN_HOST=1 \
        --setenv=ZYRNGIS_FIREWALL_QUIET="${ZYRNGIS_FIREWALL_QUIET:-0}" \
        "$(readlink -f "$0")" "${1:-open}" </dev/null && exit 0
fi

case "${1:-open}" in
    open) open_all ;;
    boot) boot_all ;;
    close) close_all ;;
    *) echo "usage: $0 open|boot|close" >&2; exit 2 ;;
esac
exit 0
