scripts/common.sh
#!/bin/sh
# Made by Jack'lul <jacklul.github.io>
#
# This file contains common stuff used by the scripts
#

#jas-update=common.sh
#shellcheck shell=ash
#shellcheck disable=SC2155

# Make sure this file is not included multiple times
[ -n "$JAS_COMMON" ] && return
readonly JAS_COMMON=1

# Prevent direct execution
[ "$(basename "$0")" = "common.sh" ] && { echo "This file is not meant to be executed directly" >&2; exit 1; }

# Force a safer umask
umask 022

# Static shared variables
if [ -z "$script_path" ]; then # sourced from a script
    readonly script_path="$(readlink -f "$0")"
    readonly script_dir="$(dirname "$script_path")"
    readonly script_name="$(basename "$script_path" .sh)"
    #shellcheck disable=SC2034
    readonly script_config="$script_dir/$script_name.conf"
    readonly common_config="$script_dir/common.conf"
    readonly SCRIPTS_DIR="$script_dir"
elif [ -n "$SCRIPTS_DIR" ]; then # inherited from jas.sh
    readonly common_config="$SCRIPTS_DIR/common.conf"
else
    echo "Cannot locate scripts directory!" >&2
    exit 1
fi

####################

# Shared configuration variables
TMP_DIR=/tmp/jas # used by the scripts to store temporary data
NO_COLORS=false # set to true to disable ANSI colors
NO_LOGGER=false # disable messages sent via logger command
CAPTURE_STDOUT=false # set to true to enable logging of stdout when not running interactively
CAPTURE_STDERR=false # set to true to enable logging of stderr when not running interactively
REMOVE_OPT_FROM_PATH=false # set to true to remove /opt paths from PATH temporarily while running
RENAMED_SCRIPTS_SUPPORT=false # set to true to enable support for renamed scripts

# Migrate old config.conf to new name if it exists
if [ -f "$SCRIPTS_DIR/config.conf" ] && [ ! -f "$common_config" ]; then
    mv "$SCRIPTS_DIR/config.conf" "$common_config"
fi

#shellcheck disable=SC1090
[ -n "$common_config" ] && [ -f "$common_config" ] && . "$common_config"

# Mark these as immutable
readonly TMP_DIR NO_COLORS NO_LOGGER CAPTURE_STDOUT CAPTURE_STDERR REMOVE_OPT_FROM_PATH RENAMED_SCRIPTS_SUPPORT

####################

[ -t 0 ] && readonly IS_INTERACTIVE=true

#shellcheck disable=SC2174
[ ! -d "$TMP_DIR" ] && mkdir -pm 755 "$TMP_DIR"

if [ -z "$IS_INTERACTIVE" ]; then
    if [ "$CAPTURE_STDOUT" = true ] && [ "$CAPTURE_STDERR" = true ]; then
        common_log_file="$TMP_DIR/$script_name-out.log"
        exec 1>> "$common_log_file" 2>&1
    elif [ "$CAPTURE_STDOUT" = true ]; then
        common_log_file="$TMP_DIR/$script_name-stdout.log"
        exec 1>> "$common_log_file"
    elif [ "$CAPTURE_STDERR" = true ]; then
        common_log_file="$TMP_DIR/$script_name-stderr.log"
        exec 2>> "$common_log_file"
    fi

    [ -n "$common_log_file" ] && common_log_lines="$(wc -l < "$common_log_file" 2> /dev/null || echo 0)"
fi

if [ "$REMOVE_OPT_FROM_PATH" = true ]; then
    export PATH="$(echo "$PATH" | sed 's|:/opt[^:]*||g; s|^/opt[^:]*:||; s|^/opt[^:]*$||')"
fi

trap trapexit EXIT HUP INT QUIT TERM

####################

# ANSI colors
#shellcheck disable=SC2034
if [ "$NO_COLORS" != true ]; then
    fbk="[1;30m"
    frd="[1;31m"
    fgn="[1;32m"
    fyw="[1;33m"
    fbe="[1;34m"
    fpe="[1;35m"
    fcn="[1;36m"
    fwe="[1;37m"
    frt="[0m"
fi

####################

# Do not define any variables before this definition without whitespace
# before it, otherwise 'jas.sh config common' will pick it up
load_script_config() {
    #shellcheck disable=SC1090
    [ -f "$script_config" ] && . "$script_config"
}

trapexit() {
    local _code=$?
    type script_trapexit > /dev/null 2>&1 && script_trapexit
    [ -n "$lockfd" ] && lockfile unlock

    if [ -n "$common_log_file" ] && [ -f "$common_log_file" ]; then
        local _new_lines="$(wc -l < "$common_log_file" 2> /dev/null || echo 0)"
        [ "$_new_lines" != "$common_log_lines" ] && echo "----- Output closed at $(date) -----" >> "$common_log_file"
    fi

    exit $_code
}

logecho() {
    [ -z "$1" ] && return 1

    local _logecho_alert _logecho_error

    # send to logger if not running interactively
    [ -z "$IS_INTERACTIVE" ] && _logecho_alert=true

    for arg in "$@"; do
        case "$arg" in
            "alert") # allow forcing sending to logger
                _logecho_alert=true
            ;;
            "error") # allow to switch to stderr from stdout
                _logecho_error=true
            ;;
        esac
    done

    [ "$NO_LOGGER" = true ] && _logecho_alert= # disable output to logger if configured

    [ -n "$_logecho_alert" ] && logger -t "$script_name" "$1"
    [ -z "$_logecho_error" ] && echo "$1" || echo "$1" >&2
}

is_merlin_firmware() {
    [ -z "$merlin_uname_check" ] && merlin_uname_check="$(uname -o)" # cache in case of multiple calls

    if [ "$merlin_uname_check" = "ASUSWRT-Merlin" ]; then
        return 0
    fi

    return 1
}

is_started_by_system() {
    [ -n "$JAS_BOOT" ] && return 0
    local _ppid=$PPID
    [ "$_ppid" -eq 1 ] && return 0

    while true; do
        [ -z "$_ppid" ] && break
        [ "$_ppid" -le 1 ] && break
        [ ! -d "/proc/$_ppid" ] && break
        grep -Fq "cron" "/proc/$_ppid/comm" && return 0
        grep -Fq "hotplug" "/proc/$_ppid/comm" && return 0
        _ppid=$(< "/proc/$_ppid/stat" awk '{print $4}')
    done

    return 1
}

lockfile() {
    [ -z "$script_name" ] && script_name="$(basename "$0" .sh)"
    local _lockfile="/var/lock/jas-$script_name.lock"
    [ -n "$2" ] && _lockfile="/var/lock/jas-$script_name-$2.lock"
    [ -f "$_lockfile" ] && lockpid="$(cat "$_lockfile")"

    local _min_fd=100
    local _fd

    case "$1" in
        "lockwait"|"lockfail"|"lockexit")
            if [ -n "$lockfd" ]; then
                logecho "Lockfile is already locked by this process ($_lockfile)" error
                exit 1
            fi

            for _fd in "/proc/$$/fd"/*; do
                _fd="$(basename "$_fd")"
                [ "$_fd" -lt "$_min_fd" ] && continue

                if [ "$(readlink -f "/proc/$$/fd/$_fd")" = "$_lockfile" ]; then
                    logecho "File descriptor ($(basename "$_fd")) is already open for the same lockfile ($_lockfile)" error
                    exit 1
                fi
            done

            # Remove stale lockfile if the process does not exist anymore
            if [ -n "$lockpid" ] && [ ! -f "/proc/$lockpid/stat" ]; then
                rm -f "$_lockfile"
                lockpid=
            fi

            _fd=$(lockfile_fd "$_min_fd")
            eval exec "$_fd>$_lockfile"

            case "$1" in
                "lockwait")
                    local _lockwait=60
                    while ! flock -nx "$_fd"; do
                        eval exec "$_fd>&-"
                        _lockwait=$((_lockwait-1))

                        if [ "$_lockwait" -lt 0 ]; then
                            logecho "Failed to acquire a lock after 60 seconds ($_lockfile)" error
                            exit 1
                        fi

                        sleep 1
                        _fd=$(lockfile_fd "$_min_fd")
                        eval exec "$_fd>$_lockfile"
                    done
                ;;
                "lockfail")
                    flock -nx "$_fd" || return 1
                ;;
                "lockexit")
                    flock -nx "$_fd" || exit 1
                ;;
            esac

            echo $$ > "$_lockfile"
            chmod 644 "$_lockfile"
            lockfd="$_fd"
            #lockfile="$_lockfile"
        ;;
        "unlock")
            [ -z "$lockfd" ] && return 1
            _lockfile="$(readlink -f "/proc/$$/fd/$lockfd")"
            flock -u "$lockfd"
            eval exec "$lockfd>&-"
            lockfd=
            rm -f "$_lockfile"
            return 0
        ;;
        "check")
            [ -n "$lockpid" ] && [ -f "/proc/$lockpid/stat" ] && return 0
            return 1
        ;;
        "kill")
            if [ -n "$lockpid" ] && [ -f "/proc/$lockpid/stat" ]; then
                kill -9 "$lockpid"
                rm -f "$_lockfile"
                return 0
            fi

            return 1
        ;;
    esac
}

lockfile_fd() {
    local _min="$1"
    [ -z "$_min" ] && _min=100
    local _max=$((_min+100))

    while [ -f "/proc/$$/fd/$_min" ]; do
        _min=$((_min+1))
        [ "$_min" -gt "$_max" ] && { logecho "Error: No free file descriptors available" error; exit 1; }
    done

    echo "$_min"
}

crontab_entry() {
    local _action _name _data _no_cron_queue _cron_queue

    _action="$1"
    _name="$2"

    if [ "$_action" = "add" ]; then
        _data="$2"
        _name="$3"
        _no_cron_queue="$4"
    fi

    [ -z "$_name" ] && _name="$script_name"
    _name="jas-$_name"

    _cron_queue="$(resolve_script_basename "cron-queue.sh")"
    [ ! -x "$_cron_queue" ] && _cron_queue=""

    case "$_action" in
        "add")
            if [ -n "$_cron_queue" ] && echo "$_data" | grep -Fq "*/1 * * * *"; then
                sh "$_cron_queue" add "$_name" "$(echo "$_data" | sed 's#\*/1 \* \* \* \* ##')"
                return 0
            fi

            cru a "$_name" "$_data"
        ;;
        "delete")
            [ -n "$_cron_queue" ] && sh "$_cron_queue" delete "$_name"
            cru d "$_name"
        ;;
        "check")
            if [ -n "$_cron_queue" ]; then
                if sh "$_cron_queue" check "$_name"; then
                    return 0
                fi
            fi

            if cru l | grep -Fq "#$_name#"; then
                return 0
            fi

            return 1
        ;;
        "list")
            if [ -n "$_cron_queue" ]; then
                sh "$_cron_queue" list | awk 'NF {print "*/1 * * * * " $0}'
            fi

            cru l
        ;;
        *)
            echo "Invalid action: $_action" >&2
            return 1
        ;;
    esac

    return 0
}

# these two sed_* functions are based on https://github.com/RMerl/asuswrt-merlin.ng/blob/master/release/src/router/others/helper.sh
sed_quote() {
    printf "%s\n" "$1" | sed 's/[]\/$*.^&[]/\\&/g'
}

sed_helper() {
    local _pattern=$(sed_quote "$2")
    local _content=$(sed_quote "$3")
    local _file="$4"

    case "$1" in
        "replace")
            sed "s/$_pattern/$_content/" -i "$_file"
        ;;
        "prepend")
            sed "/$_pattern/i$_content" -i "$_file"
        ;;
        "append")
            sed "/$_pattern/a$_content" -i "$_file"
        ;;
        "delete")
            if [ -z "$_file" ] && [ -n "$3" ]; then
                _file="$3"
            fi

            sed "/$_pattern/d" -i "$_file"
        ;;
        *)
            echo "Invalid mode: $1" >&2
            return 1
        ;;
    esac
}

get_curl_binary() {
    local _curl_binary

    if [ -f /opt/bin/curl ]; then
        _curl_binary="/opt/bin/curl" # prefer Entware's curl
    elif [ -f /usr/bin/curl ]; then
        _curl_binary="/usr/bin/curl"
    elif type curl > /dev/null 2>&1; then
        _curl_binary="curl"
    else
        return 1
    fi

    echo "$_curl_binary"
}

#shellcheck disable=SC2086
fetch() {
    [ -z "$1" ] && { echo "No URL specified"; return 1; }
    local _url _output _timeout

    _url="$1"
    _output="$2"
    _timeout="$3"

    if [ -z "$curl_binary" ] && type get_curl_binary > /dev/null 2>&1; then
        curl_binary="$(get_curl_binary)"
    fi

    if [ -n "$curl_binary" ]; then
        [ -n "$_timeout" ] && _timeout="-m $_timeout"

        if [ -n "$_output" ]; then
            $curl_binary -fsSL "$_url" -o "$_output" $_timeout
            return $?
        else
            $curl_binary -sfL --head "$_url" $_timeout > /dev/null
            return $?
        fi
    elif type wget > /dev/null 2>&1; then
        [ -n "$_timeout" ] && _timeout="--timeout=$_timeout"

        if [ -n "$_output" ]; then
            wget -q "$_url" -O "$_output" $_timeout
            return $?
        else
            wget -q --spider "$_url" -O /dev/null $_timeout
            return $?
        fi
    else
        echo "curl or wget not found" >&2
        return 1
    fi
}

get_script_basename() {
    { [ -z "$1" ] || [ ! -f "$1" ] ; } && return 1

    local _basename="$(basename "$1")"
    local _new_basename="$(grep -E '^#(\s+)?jas-update=' "$1" | sed 's/.*jas-update=//' | sed 's/[[:space:]]*$//')"

    if [ -n "$_new_basename" ]; then
        echo "$_new_basename"
    else
        echo "$_basename"
    fi
}

resolve_script_basename() {
    local _name="$(basename "$1")"
    [ -z "$_name" ] && return 1

    if [ -f "$SCRIPTS_DIR/$_name" ]; then
        echo "$SCRIPTS_DIR/$_name"
        return
    fi

    if [ "$RENAMED_SCRIPTS_SUPPORT" != true ]; then
        return 1
    fi

    local _entry

    # Read from cache if available
    if [ -n "$script_basename_cache" ] && echo "$script_basename_cache" | grep -Fq " $_name="; then
        local _path

        for _entry in $script_basename_cache; do
            _path="$(echo "$_entry" | cut -d '=' -f 2)"
            _entry="$(echo "$_entry" | cut -d '=' -f 1)"

            if [ "$_entry" = "$_name" ]; then
                echo "$_path"
                return
            fi
        done
    fi

    if [ -z "$script_basename_cache" ]; then # generate cache only once
        local _entry_name _found_entry

        # In case user renamed the script, we need to rescan all scripts and read jas-update tags
        for _entry in "$SCRIPTS_DIR"/*.sh; do
            _entry="$(readlink -f "$_entry")"
            ! grep -Fq "jas-update=" "$_entry" && continue # skip scripts without the tag

            _entry_name="$(get_script_basename "$_entry")"

            script_basename_cache="$(echo "$script_basename_cache" | sed "s/ $_entry_name=[^ ]*//g")"
            script_basename_cache="$script_basename_cache $_entry_name=$_entry"

            if [ "$_entry_name" = "$_name" ]; then
                _found_entry="$_entry"
            fi
        done

        [ -n "$_found_entry" ] && { echo "$_found_entry"; return; }
    fi

    return 1
}

execute_script_basename() {
    local _script="$(resolve_script_basename "$1")"

    if [ -n "$_script" ] && [ -x "$_script" ]; then
        [ "$2" = "check" ] && return 0
        shift
        $_script "$@"
        return $?
    fi

    return 1
}

interface_exists() {
    [ -z "$1" ] && return 1
    local _iface="$1"

    if [ "$(printf "%s" "$_iface" | tail -c 1)" = "*" ]; then
        _iface="${_iface%?}"

        if ip link show | grep -Fq ": $_iface"; then
            return 0
        fi
    elif ip link show "$_iface" >/dev/null 2>&1; then
        return 0
    fi

    return 1
}

get_wan_interface() {
    local _id="$1"
    [ -z "$_id" ] && _id=0

    local _interface="$(nvram get "wan${_id}_ifname")"
    local _test="$(nvram get "wan${_id}_gw_ifname")"

    if [ "$_test" != "$_interface" ]; then
        _interface="$_test"
    fi

    echo "$_interface"
}

mask_to_cidr() {
    [ -z "$1" ] && return 1
    local _mask="$1"
    local _cidr=0

    local _oldIFS=$IFS
    IFS='.'
    for _octet in $_mask; do
        case $_octet in
            255) _cidr=$((_cidr + 8)) ;;
            254) _cidr=$((_cidr + 7)) ;;
            252) _cidr=$((_cidr + 6)) ;;
            248) _cidr=$((_cidr + 5)) ;;
            240) _cidr=$((_cidr + 4)) ;;
            224) _cidr=$((_cidr + 3)) ;;
            192) _cidr=$((_cidr + 2)) ;;
            128) _cidr=$((_cidr + 1)) ;;
            0) ;;
            *) echo "Invalid subnet mask octet: $_octet" >&2; return 1 ;;
        esac
    done
    IFS=$_oldIFS

    echo "$_cidr"
}

#shellcheck disable=SC2086
calculate_network() {
    { [ -z "$1" ] || [ -z "$2" ] ; } && return 1
    local _ip="$1"
    local _mask="$2"

    local _oldIFS=$IFS
    IFS='.'
    set -- $_ip
    local _ip1=$1; _ip2=$2; _ip3=$3; _ip4=$4
    set -- $_mask
    local _mask1=$1; _mask2=$2; _mask3=$3; _mask4=$4
    IFS=$_oldIFS

    local _net1=$((_ip1 & _mask1))
    local _net2=$((_ip2 & _mask2))
    local _net3=$((_ip3 & _mask3))
    local _net4=$((_ip4 & _mask4))

    echo "$_net1.$_net2.$_net3.$_net4"
}

get_vpnc_clientlist() {
    [ -z "$vpnc_clientlist" ] && vpnc_clientlist="$(nvram get vpnc_clientlist | tr '<' '\n')"
    echo "$vpnc_clientlist"
}

remove_iptables_rules_by_comment() {
    local _iptables="iptables"
    [ -n "$1" ] && _iptables="$1"
    local _remove_rules_tables="filter nat mangle"
    [ -n "$2" ] && _remove_rules_tables="$2"
    local _remove_rules_comment="jas-$script_name"
    [ -n "$3" ] && _remove_rules_comment="$3"

    local _remove_rules_table _remove_rules _cmd
    local _remove_rules_success=

    # Based on https://github.com/MartineauUK/Unbound-Asuswrt-Merlin/blob/dev/unbound_DNS_via_OVPN.sh
    for _remove_rules_table in $_remove_rules_tables; do
        _remove_rules="$($_iptables -t "$_remove_rules_table" -S | grep "^-A.*$_remove_rules_comment" | sed "s|^-A|$_iptables -t $_remove_rules_table -D|")"

        if [ -n "$_remove_rules" ]; then
            _remove_rules_success=1

            echo "$_remove_rules" | while read -r _cmd; do
                eval "$_cmd"
            done
        fi
    done

    [ -n "$_remove_rules_success" ] && return 0
    return 1
}
