## -*- mode: shell-script; -*-
##
## Double '##' comments are removed when configlet is processed.
## Single '#' comments stay.
##
## Some shells (not bash) do not like empty functions. Placing a comment
## inside the function does not help. Using dummy ":" as a placeholder.
##
{{$top_comment}}

{{$errors_and_warnings}}

{{$shell_debug}}
FWBDEBUG=""
RETVAL=0

{{$path}}

{{$constants}}

{{$tools}}

{{$shell_functions}}

{{$fwf_chain_functions}}

{{$run_time_address_tables}}

load_modules() {
    :
    {{$load_modules}}
}

{{if using_time_match}}
## A rule with a time window needs the kernel's time match (xt_time), and
## not every kernel has it: RHEL 8, 9 and 10 are built without it
## ("# CONFIG_NETFILTER_XT_MATCH_TIME is not set").  iptables then refuses
## every such rule, after reset_all has already dropped the old ones.  So
## the match is tried on a chain of its own before anything is touched -
## that is what the rules will ask of the kernel - once more after
## modprobe, and the activation stops if the kernel still refuses it.
time_match_works() {
    "$1" -F fwf_time_probe >/dev/null 2>&1
    "$1" -X fwf_time_probe >/dev/null 2>&1
    "$1" -N fwf_time_probe >/dev/null 2>&1 || return 1
    "$1" -A fwf_time_probe -m time --timestart 00:00 --timestop 23:59 -j RETURN >/dev/null 2>&1
    rc=$?
    "$1" -F fwf_time_probe >/dev/null 2>&1
    "$1" -X fwf_time_probe >/dev/null 2>&1
    return $rc
}

check_time_match() {
    for tool in "$@"; do
        time_match_works "$tool" && continue
        "$MODPROBE" xt_time >/dev/null 2>&1
        time_match_works "$tool" && continue
        log "Error: rules of this firewall match on the time of day, and the kernel has no time match (xt_time); RHEL kernels are built without it. Nothing was changed."
        exit 1
    done
}
{{endif}}

{{if nat_helper_modules}}
# A connection tracking helper that has to rewrite the addresses in the
# payload of a translated connection - the PORT command of an FTP client
# behind masquerading, say - needs its NAT module, which the kernel does
# not load on its own (it loads the conntrack module when a rule names the
# helper, net/netfilter/nf_conntrack_helper.c).  firewalld loads both the
# same way.  A module that cannot be loaded costs the translated case, not
# the activation.
fwf_load_nat_helpers() {
    for fwf_module in "$@"; do
        "$MODPROBE" -q "$fwf_module" 2>/dev/null || log "Warning: the kernel module $fwf_module could not be loaded; connection tracking helpers cannot follow translated connections."
    done
}
{{endif}}

verify_interfaces() {
    :
    {{$verify_interfaces}}
}

prolog_commands() {
    echo "Running prolog script"
    {{$prolog_script}}
}

epilog_commands() {
    echo "Running epilog script"
    {{$epilog_script}}
}

run_epilog_and_exit() {
    epilog_commands
    exit "$1"
}

# The activation empties the ruleset before it installs the new one, so a
# rule iptables refuses halfway through would leave the machine with DROP
# policies and part of the rules - on a remote machine usually without
# the rule that lets the administrator back in.  The running ruleset is
# therefore saved before anything is changed and put back when the
# activation fails, the way Shorewall restores its saved state.
##
## A table the saved ruleset does not name did not exist before (every
## fresh machine on the nftables backend of iptables has no table at all),
## and restoring the saved ruleset alone would leave it as the failed run
## left it.  Such a table is restored empty, and filter with its built-in
## chains at ACCEPT, which is what a machine without the table does.
## Verified in a private network namespace with iptables 1.8.11
## (nf_tables) on Fedora 44 and 1.8.4 (nf_tables) on Rocky 8.3, for the
## shell and the iptables-restore form, with an activation failing in the
## IPv6 part after the IPv4 rules were installed: every chain has the
## policy and the rules it had before, also after "nft flush ruleset".
## A table that did not exist before is gone again on 1.8.11 and left
## empty with ACCEPT policies on 1.8.4, which filters nothing either.
##
## Kernel variables, interfaces and ipsets are not rolled back, and
## neither are nftables tables of other tools that "nft flush ruleset"
## removed.
fwf_save_ruleset() {
    :
    {{if save_v4}}fwf_saved_v4=$($IPTABLES_SAVE) || { log "Error: \"$IPTABLES_SAVE\" cannot read the running ruleset. Nothing was changed."; exit 1; }{{endif}}
    {{if save_v6}}fwf_saved_v6=$($IP6TABLES_SAVE) || { log "Error: \"$IP6TABLES_SAVE\" cannot read the running ruleset. Nothing was changed."; exit 1; }{{endif}}
}

fwf_restore_family() {
    fwf_save_tool=$1
    fwf_saved=$2
    shift 2
    fwf_now=$("$fwf_save_tool") || return 1
    {
        printf '%s\n' "$fwf_saved"
        for fwf_table in $(printf '%s\n' "$fwf_now" | sed -n 's/^\*//p'); do
            printf '%s\n' "$fwf_saved" | grep -qx "\*$fwf_table" && continue
            echo "*$fwf_table"
            test "$fwf_table" = filter && printf ':INPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n'
            echo COMMIT
        done
    } | "$@"
}

fwf_rollback_and_exit() {
    fwf_rollback_status=0
    {{if save_v4}}fwf_restore_family "$IPTABLES_SAVE" "$fwf_saved_v4" "$IPTABLES_RESTORE" {{$restore_wait}} || fwf_rollback_status=1{{endif}}
    {{if save_v6}}fwf_restore_family "$IP6TABLES_SAVE" "$fwf_saved_v6" "$IP6TABLES_RESTORE" {{$restore_wait}} || fwf_rollback_status=1{{endif}}
    if test "$fwf_rollback_status" -eq 0; then
        log "Error: the activation failed. The ruleset that was running before it is back in place."
    else
        log "Error: the activation failed, and the ruleset that was running before it could not be put back. The firewall is left with the part of the new rules it could install."
    fi
    run_epilog_and_exit "$1"
}

## The two halves the rollback timer needs from this packet filter (see the
## rollback_timer configlet).  The saved ruleset is handed to
## iptables-restore --test first, so a "try" whose timer could not put it
## back is refused before anything is changed.
fwf_timer_save() {
    check_rollback_tools
    fwf_save_ruleset
    {{if save_v4}}printf '%s\n' "$fwf_saved_v4" >"$1/v4" || return 1{{endif}}
    {{if save_v4}}printf '%s\n' "$fwf_saved_v4" | "$IPTABLES_RESTORE" --test || return 1{{endif}}
    {{if save_v6}}printf '%s\n' "$fwf_saved_v6" >"$1/v6" || return 1{{endif}}
    {{if save_v6}}printf '%s\n' "$fwf_saved_v6" | "$IP6TABLES_RESTORE" --test || return 1{{endif}}
}

fwf_timer_restore() {
    fwf_restore_status=0
    {{if save_v4}}fwf_restore_family "$IPTABLES_SAVE" "$(cat "$1/v4")" "$IPTABLES_RESTORE" {{$restore_wait}} || fwf_restore_status=1{{endif}}
    {{if save_v6}}fwf_restore_family "$IP6TABLES_SAVE" "$(cat "$1/v6")" "$IP6TABLES_RESTORE" {{$restore_wait}} || fwf_restore_status=1{{endif}}
    return "$fwf_restore_status"
}

configure_interfaces() {
    :
    {{$configure_interfaces}}
}

# The shell form of the policy is hundreds of separate commands, and the
# status of the function that runs them is the status of the last one - so
# a rule iptables refused in the middle left the ruleset incomplete while
# the script reported a clean activation.  script_body therefore points
# $IPTABLES and $IP6TABLES at these two for as long as it runs, which
# leaves every generated command line exactly as it was, and counts what
# was refused.  "set -e" is not an alternative: the first thing script_body
# does is write kernel variables, which fail in a container and on a
# read-only /proc, and a rule guarded by 'test -n "$i_eth0" &&' is false
# whenever the interface has no address.
fwf_run_tool() {
    fwf_tool=$1
    shift
    "$fwf_tool" "$@" && return 0
    fwf_status=$?
##  A chain that is already there is not a failure: the script asks for
##  every chain on every activation and redirects the answer away for that
##  reason ("-N ... 2>/dev/null").
    case " $* " in
        *" -N "*) return "$fwf_status" ;;
    esac
    fwf_failed_commands=$((fwf_failed_commands + 1))
    echo "Error: \"$fwf_tool $*\" failed" >&2
    return "$fwf_status"
}

fwf_run_iptables() {
    fwf_run_tool "$fwf_tool_v4" "$@"
}

fwf_run_ip6tables() {
    fwf_run_tool "$fwf_tool_v6" "$@"
}

## The colon keeps the function valid when a firewall compiles to no rules
## at all, the same guard the functions above use.  A shell function needs
## at least one command, so an empty body makes the whole script
## unparsable and nothing in it runs.
##
## $IPTABLES and $IP6TABLES name a shell function for as long as the body
## runs, so that a command the tool refuses is counted instead of being
## lost in the status of whichever command happened to run last.  Every
## generated line is unchanged by it, and the two variables hold the tool
## path again afterwards, which is what `check_tools`, `reset_all` and the
## block, stop and status actions read.  See `fwf_run_tool`.
script_body() {
    :
    fwf_failed_commands=0
    fwf_tool_v4="$IPTABLES"
    fwf_tool_v6="$IP6TABLES"
    IPTABLES=fwf_run_iptables
    IP6TABLES=fwf_run_ip6tables
    {{$script_body}}
    IPTABLES="$fwf_tool_v4"
    IP6TABLES="$fwf_tool_v6"
    test "$fwf_failed_commands" -eq 0
}

ip_forward() {
    :
    {{$ip_forward_commands}}
}

reset_all() {
    :
    {{$reset_all}}
}

{{$block_action}}

{{$stop_action}}

{{$status_action}}

# Two runs of this script at the same time - an installer and a timer, or
# an address table reload during an activation - interleave their
# commands, and the second one works on the first one's half-finished
# ruleset.  Every command that changes the firewall therefore takes a
# lock first and waits up to a minute for it.  /run belongs to root, so
# the file cannot be a planted symlink.  flock comes with util-linux;
# where it or the lock file is missing, the script runs without the lock
# rather than not at all.  "reload" runs "$0 start", which finds the lock
# already held (FWF_LOCKED) instead of waiting for itself.
FWF_LOCK_FILE=${FWF_LOCK_FILE:-/run/fwf.lock}

fwf_lock() {
    test -n "$FWF_LOCKED" && return 0
    command -v flock >/dev/null 2>&1 || return 0
    (: >>"$FWF_LOCK_FILE") 2>/dev/null || return 0
    exec 9>>"$FWF_LOCK_FILE"
    if ! flock -w 60 9; then
        log "Error: another run of this script still holds $FWF_LOCK_FILE after a minute. Nothing was changed."
        exit 1
    fi
    FWF_LOCKED=1
    export FWF_LOCKED
}

{{$rollback_timer}}

# See how we were called.
# For backwards compatibility missing argument is equivalent to 'start'

cmd=$1
test -z "$cmd" && {
    cmd="start"
}

case "$cmd" in
    status|test_interfaces|test_address_table) ;;
    *) fwf_lock ;;
esac

case "$cmd" in
    start)
        log "Activating firewall script generated by {{$user}}"
        fwf_timer_cancel
        check_tools
        check_rollback_tools
        {{if prolog_top}} prolog_commands {{endif}}
        check_run_time_address_table_files
        {{if using_ipset}}
        check_module_ipset
##      A rule naming an address table says "-m set --match-set <name>",
##      and iptables refuses that rule outright when no set of that name
##      is there ("Set <name> doesn't exist"): the rule is simply absent
##      from a ruleset the script then reports as activated.  ipsets do
##      not survive a reboot, so the first activation after one lost
##      every such rule and the second got them back from the sets the
##      first one left behind.  Firewall Builder fills the sets after
##      script_body for the same reason it does most things - nobody
##      noticed - and the restore form makes it worse, because
##      iptables-restore refuses the whole table over the one rule.
        load_run_time_address_table_files
        {{endif}}
        load_modules "{{$load_modules_with_nat}} {{$load_modules_with_ipv6}}"
        {{if nat_helper_modules}}fwf_load_nat_helpers {{$nat_helper_modules}}{{endif}}{{if using_time_match}}
        check_time_match {{$time_match_tools}}{{endif}}
        configure_interfaces
        verify_interfaces
        {{if prolog_after_interfaces}} prolog_commands {{endif}}
        fwf_save_ruleset
##      `iptables-restore` empties the table itself, so `reset_all`
##      only runs where nothing else does: for the shell form, and
##      for the restore form in coexistence mode, where the restore
##      is called with --noflush.
        {{if not_using_iptables_restore}} reset_all {{endif}}
        {{$setup_fwf_jumps_commands}}
        {{if prolog_after_flush}} prolog_commands {{endif}}
        script_body || fwf_rollback_and_exit 1
        ip_forward
        epilog_commands
        ;;

    stop)
        fwf_timer_cancel
        check_tools
        stop_action
        RETVAL=$?
        ;;

    status)
        check_tools
        status_action
        RETVAL=$?
        ;;

    block)
        check_tools
        block_action
        RETVAL=$?
        ;;

##  "reload" is "start", not "stop" and then "start".  The start branch
##  resets everything the stop branch resets - `reset_all` is the same
##  function both go through - and the only thing the stop adds is setting
##  the three built-in policies to ACCEPT.  Between the two commands that
##  is a machine with no rules and no policy, for as long as a second
##  process needs to start, read its address tables, load its modules and
##  configure its interfaces.  Firewall Builder writes both calls here.
    reload)
        $0 start
        RETVAL=$?
        ;;

## Usage:  script.fw try <seconds>, then script.fw confirm
    try)
        fwf_try "$2"
        RETVAL=$?
        ;;

    confirm)
        fwf_confirm
        RETVAL=$?
        ;;

    rollback)
        fwf_rollback "$2"
        RETVAL=$?
        ;;

    interfaces)
        check_tools
        configure_interfaces
        RETVAL=$?
        ;;

    test_interfaces)
        check_tools
        FWBDEBUG="echo"
        configure_interfaces
        RETVAL=$?
        ;;

{{if using_ipset}}
## Usage:  script.fw reload_address_table <address_table_name> <file_name> [-4|-6]
    reload_address_table)
        check_tools
        reload_address_table "$2" "$3" "$4"
        RETVAL=$?
        ;;

## Usage:  script.fw add_to_address_table <address_table_name> <file_name> <address>
    add_to_address_table)
        check_tools
        add_to_address_table "$2" "$3" "$4"
        RETVAL=$?
        ;;

## Usage:  script.fw remove_from_address_table <address_table_name> <file_name> <address>
    remove_from_address_table)
        check_tools
        remove_from_address_table "$2" "$3" "$4"
        RETVAL=$?
        ;;

## Usage:  script.fw test_address_table <address_table_name> <address>
##         Answers with the exit code, so a monitoring check can ask.
    test_address_table)
        check_tools
        test_address_table "$2" "$3"
        RETVAL=$?
        ;;
{{endif}}

    *)
        echo "Usage $0 [start|stop|status|block|reload|try|confirm|rollback|interfaces|test_interfaces{{if using_ipset}}|reload_address_table|add_to_address_table|remove_from_address_table|test_address_table{{endif}}]"
        RETVAL=1
        ;;

esac

exit $RETVAL
