#!/usr/bin/env bash
set -Eeuo pipefail

#
# Suricata AF_PACKET / high-speed NIC tuning
# Oracle Linux 9 / RHEL 9
#
# Defaults for your host:
#   IFNAME=ens2f1np1
#   PCIE=0000:05:00.1
#   QUEUES=10
#
# WARNING:
#   - briefly takes the capture interface DOWN
#   - stops irqbalance
#

IFNAME="${IFNAME:-ens2f1np1}"
PCIE="${PCIE:-0000:05:00.1}"
QUEUES="${QUEUES:-10}"

# Starting point for interrupt moderation.
# You can later sweep 0 / 25 / 50 / 125 for the burst test.
RX_USECS="${RX_USECS:-125}"

STOP_IRQBALANCE="${STOP_IRQBALANCE:-1}"

#
# Optional manual CPU assignment:
#
#   IRQ_CPUS=1-10 \
#   WORKER_CPUS=11-20 \
#   ./suricata-afp-tune.sh
#
# Otherwise CPU sets are selected automatically from the NIC-local NUMA node.
#
IRQ_CPUS="${IRQ_CPUS:-}"
WORKER_CPUS="${WORKER_CPUS:-}"


log()
{
    printf '\n==> %s\n' "$*"
}

warn()
{
    printf 'WARN: %s\n' "$*" >&2
}

die()
{
    printf 'ERROR: %s\n' "$*" >&2
    exit 1
}

need()
{
    command -v "$1" >/dev/null 2>&1 ||
        die "missing command: $1"
}


for c in \
    ip \
    ethtool \
    awk \
    grep \
    sort \
    sed \
    systemctl \
    sysctl \
    readlink \
    basename \
    cat
do
    need "$c"
done


[[ $EUID -eq 0 ]] ||
    die "run as root"


[[ -d "/sys/class/net/$IFNAME" ]] ||
    die "interface $IFNAME does not exist"


[[ -e "/sys/bus/pci/devices/$PCIE" ]] ||
    die "PCI device $PCIE does not exist"


###############################################################################
# Verify IFNAME <-> PCI mapping
###############################################################################

ACTUAL_PCIE="$(
    basename "$(
        readlink -f "/sys/class/net/$IFNAME/device"
    )"
)"

[[ "$ACTUAL_PCIE" == "$PCIE" ]] ||
    die "$IFNAME maps to $ACTUAL_PCIE, not $PCIE"


[[ "$QUEUES" =~ ^[0-9]+$ && "$QUEUES" -gt 0 ]] ||
    die "QUEUES must be a positive integer"


###############################################################################
# Helpers
###############################################################################

try()
{
    printf '+ '
    printf '%q ' "$@"
    printf '\n'

    "$@" ||
    {
        warn "command failed/unsupported: $*"
        return 0
    }
}


expand_cpulist()
{
    local spec="$1"
    local part a b i
    local -a parts

    IFS=',' read -ra parts <<< "$spec"

    for part in "${parts[@]}"
    do

        if [[ "$part" =~ ^([0-9]+)-([0-9]+)$ ]]
        then

            a="${BASH_REMATCH[1]}"
            b="${BASH_REMATCH[2]}"

            (( a <= b )) ||
                die "bad CPU range: $part"

            for ((i=a; i<=b; i++))
            do
                printf '%s\n' "$i"
            done

        elif [[ "$part" =~ ^[0-9]+$ ]]
        then

            printf '%s\n' "$part"

        else

            die "bad CPU-list token: $part"

        fi

    done
}


join_by_comma()
{
    local IFS=,
    echo "$*"
}


###############################################################################
# NIC identification
###############################################################################

log "NIC identity"

ethtool -i "$IFNAME"


if command -v lspci >/dev/null 2>&1
then
    lspci -s "$PCIE" -nnk || true
fi


NODE="$(
    cat "/sys/bus/pci/devices/$PCIE/numa_node"
)"


printf \
    'interface=%s pci=%s numa_node=%s queues=%s\n' \
    "$IFNAME" \
    "$PCIE" \
    "$NODE" \
    "$QUEUES"


###############################################################################
# CPU selection
#
# Use sysfs topology, not lscpu --parse.
#
# One logical CPU is selected from each physical core.
###############################################################################

log "Selecting CPUs"


if (( NODE >= 0 )) &&
   [[ -r "/sys/devices/system/node/node${NODE}/cpulist" ]]
then

    CPU_SPEC="$(
        cat "/sys/devices/system/node/node${NODE}/cpulist"
    )"

else

    warn "NIC has no usable NUMA node; using all online CPUs"

    CPU_SPEC="$(
        cat /sys/devices/system/cpu/online
    )"

fi


mapfile -t CANDIDATE_CPUS < <(
    expand_cpulist "$CPU_SPEC"
)


declare -A SEEN_CORE=()

LOCAL_PHYS=()


for cpu in "${CANDIDATE_CPUS[@]}"
do

    #
    # Leave CPU0 for housekeeping.
    #
    (( cpu == 0 )) &&
        continue


    TOPO="/sys/devices/system/cpu/cpu${cpu}/topology"


    [[ -r "$TOPO/core_id" ]] ||
        continue

    [[ -r "$TOPO/physical_package_id" ]] ||
        continue


    CORE_ID="$(
        cat "$TOPO/core_id"
    )"

    PACKAGE_ID="$(
        cat "$TOPO/physical_package_id"
    )"


    KEY="${PACKAGE_ID}:${CORE_ID}"


    if [[ -z "${SEEN_CORE[$KEY]+x}" ]]
    then

        SEEN_CORE["$KEY"]=1

        LOCAL_PHYS+=(
            "$cpu"
        )

    fi

done


printf \
    'NIC NUMA CPU list: %s\n' \
    "$CPU_SPEC"


printf \
    'Usable physical-core CPUs: %s\n' \
    "$(join_by_comma "${LOCAL_PHYS[@]}")"


((${#LOCAL_PHYS[@]} >= QUEUES)) ||
    die \
        "need $QUEUES physical cores on NUMA node $NODE, only found ${#LOCAL_PHYS[@]}"


###############################################################################
# Decide IRQ / Suricata worker CPUs
###############################################################################

if [[ -n "$IRQ_CPUS" || -n "$WORKER_CPUS" ]]
then

    [[ -n "$IRQ_CPUS" && -n "$WORKER_CPUS" ]] ||
        die "set both IRQ_CPUS and WORKER_CPUS, or neither"


    mapfile -t IRQ_ARR < <(
        expand_cpulist "$IRQ_CPUS"
    )


    mapfile -t WORKER_ARR < <(
        expand_cpulist "$WORKER_CPUS"
    )


    ((${#IRQ_ARR[@]} >= QUEUES)) ||
        die "IRQ_CPUS provides fewer than $QUEUES CPUs"


    ((${#WORKER_ARR[@]} >= QUEUES)) ||
        die "WORKER_CPUS provides fewer than $QUEUES CPUs"


    IRQ_ARR=(
        "${IRQ_ARR[@]:0:QUEUES}"
    )


    WORKER_ARR=(
        "${WORKER_ARR[@]:0:QUEUES}"
    )


    CPU_MODE="manual"

else

    #
    # Best case:
    # separate physical cores for NIC IRQ/NAPI and Suricata workers.
    #
    if ((${#LOCAL_PHYS[@]} >= 2 * QUEUES))
    then

        IRQ_ARR=(
            "${LOCAL_PHYS[@]:0:QUEUES}"
        )


        WORKER_ARR=(
            "${LOCAL_PHYS[@]:QUEUES:QUEUES}"
        )


        CPU_MODE="dedicated physical IRQ + worker cores"

    else

        #
        # Not enough physical cores for separate sets.
        #
        # Use the same deterministic physical cores for both.
        #
        WORKER_ARR=(
            "${LOCAL_PHYS[@]:0:QUEUES}"
        )


        IRQ_ARR=(
            "${WORKER_ARR[@]}"
        )


        CPU_MODE="shared IRQ/worker physical cores"

    fi

fi


IRQ_CPUS="$(
    join_by_comma "${IRQ_ARR[@]}"
)"


WORKER_CPUS="$(
    join_by_comma "${WORKER_ARR[@]}"
)"


printf \
    'CPU mode: %s\n' \
    "$CPU_MODE"


printf \
    'IRQ CPUs: %s\n' \
    "$IRQ_CPUS"


printf \
    'Suricata worker CPUs: %s\n' \
    "$WORKER_CPUS"


###############################################################################
# irqbalance
###############################################################################

if [[ "$STOP_IRQBALANCE" == 1 ]]
then

    log "Stopping irqbalance"

    systemctl stop irqbalance 2>/dev/null ||
        true

fi


###############################################################################
# Queue capability
###############################################################################

log "Checking queue capability"


CHANNELS="$(
    ethtool -l "$IFNAME"
)"


printf '%s\n' "$CHANNELS"


MAX_COMBINED="$(
    printf '%s\n' "$CHANNELS" |
    awk '
        /Pre-set maximums:/ {
            p=1
            next
        }

        /Current hardware settings:/ {
            p=0
        }

        p && $1=="Combined:" {
            print $2
            exit
        }
    '
)"


[[ "$MAX_COMBINED" =~ ^[0-9]+$ ]] ||
    die "could not determine maximum combined queues"


(( QUEUES <= MAX_COMBINED )) ||
    die "requested $QUEUES queues; NIC maximum is $MAX_COMBINED"


###############################################################################
# Link down while modifying queue topology
###############################################################################

log "Reconfiguring NIC -- link will bounce"


ip link set dev "$IFNAME" down


ethtool -L "$IFNAME" combined "$QUEUES"


###############################################################################
# Offloads
###############################################################################

#
# Preserve checksum offloading.
#
try ethtool -K "$IFNAME" rx on
try ethtool -K "$IFNAME" tx on


#
# Disable aggregation/segmentation features which alter packet semantics.
#
try ethtool -K "$IFNAME" gro off
try ethtool -K "$IFNAME" lro off
try ethtool -K "$IFNAME" gso off
try ethtool -K "$IFNAME" tso off
try ethtool -K "$IFNAME" ufo off
try ethtool -K "$IFNAME" sg off


#
# Preserve VLAN tags for Suricata.
#
try ethtool -K "$IFNAME" rxvlan off
try ethtool -K "$IFNAME" txvlan off


#
# Hardware GRO / UDP GRO if supported.
#
try ethtool -K "$IFNAME" rx-gro-hw off
try ethtool -K "$IFNAME" rx-udp-gro-forwarding off


#
# RSS / classification support.
#
try ethtool -K "$IFNAME" rxhash on
try ethtool -K "$IFNAME" ntuple on


###############################################################################
# Pause frames / EEE
###############################################################################

#
# Passive IDS should not change sender behavior through PAUSE frames.
#
try ethtool -A "$IFNAME" rx off tx off


#
# Avoid Energy Efficient Ethernet power transitions.
#
try ethtool --set-eee "$IFNAME" eee off


###############################################################################
# Maximum hardware RX descriptor ring
###############################################################################

RING="$(
    ethtool -g "$IFNAME" 2>/dev/null ||
    true
)"


MAX_RX="$(
    printf '%s\n' "$RING" |
    awk '
        /Pre-set maximums:/ {
            p=1
            next
        }

        /Current hardware settings:/ {
            p=0
        }

        p && $1=="RX:" {
            print $2
            exit
        }
    '
)"


if [[ "$MAX_RX" =~ ^[0-9]+$ ]]
then

    log \
        "Setting RX descriptor ring to NIC maximum: $MAX_RX"


    try ethtool -G "$IFNAME" \
        rx "$MAX_RX"

else

    warn \
        "could not determine maximum RX ring; leaving unchanged"

fi


###############################################################################
# Bring interface back
###############################################################################

ip link set dev "$IFNAME" up

ip link set dev "$IFNAME" promisc on


###############################################################################
# Verify queue count
###############################################################################

CUR_COMBINED="$(
    ethtool -l "$IFNAME" |
    awk '
        /Current hardware settings:/ {
            p=1
            next
        }

        p && $1=="Combined:" {
            print $2
            exit
        }
    '
)"


[[ "$CUR_COMBINED" == "$QUEUES" ]] ||
    die \
        "combined queues are $CUR_COMBINED; expected $QUEUES"


###############################################################################
# Interrupt moderation
###############################################################################

log "Interrupt moderation"


try ethtool -C "$IFNAME" \
    adaptive-rx off \
    adaptive-tx off \
    rx-usecs "$RX_USECS"


###############################################################################
# RSS
###############################################################################

log "RSS Toeplitz + equal indirection"


try ethtool -X "$IFNAME" \
    hfunc toeplitz


#
# Detect RSS key size exposed by the driver.
#
RSS_HEX="$(
    ethtool -x "$IFNAME" 2>/dev/null |
    awk '
        /RSS hash key:/ {
            p=1
            next
        }

        /RSS hash function:/ {
            p=0
        }

        p {
            gsub(/[^0-9A-Fa-f]/, "")
            printf "%s", $0
        }
    '
)"


if [[ -n "$RSS_HEX" &&
      $(( ${#RSS_HEX} % 2 )) -eq 0 ]]
then

    KEY_BYTES=$(( ${#RSS_HEX} / 2 ))

    SYM_KEY=""


    for ((i=0; i<KEY_BYTES; i++))
    do

        if (( i % 2 == 0 ))
        then
            B="6D"
        else
            B="5A"
        fi


        [[ -n "$SYM_KEY" ]] &&
            SYM_KEY+=":"


        SYM_KEY+="$B"

    done


    printf \
        'RSS key length: %d bytes\n' \
        "$KEY_BYTES"


    try ethtool -X "$IFNAME" \
        hkey "$SYM_KEY" \
        equal "$QUEUES"

else

    warn \
        "could not determine RSS key length"


    try ethtool -X "$IFNAME" \
        equal "$QUEUES"

fi


###############################################################################
# RSS hash fields
###############################################################################

for proto in tcp4 udp4 tcp6 udp6
do

    if ethtool -N "$IFNAME" \
        rx-flow-hash "$proto" sdfn \
        >/dev/null 2>&1
    then

        printf \
            '+ ethtool -N %s rx-flow-hash %s sdfn\n' \
            "$IFNAME" \
            "$proto"

    else

        warn \
            "$proto sdfn unsupported; trying sd"


        try ethtool -N "$IFNAME" \
            rx-flow-hash "$proto" sd

    fi

done


###############################################################################
# Disable software RPS/RFS
###############################################################################

log "Disabling software RPS/RFS"


sysctl -w \
    net.core.rps_sock_flow_entries=0 \
    >/dev/null


for q in /sys/class/net/"$IFNAME"/queues/rx-*
do

    if [[ -e "$q/rps_cpus" ]]
    then
        echo 0 > "$q/rps_cpus"
    fi


    if [[ -e "$q/rps_flow_cnt" ]]
    then
        echo 0 > "$q/rps_flow_cnt"
    fi

done


###############################################################################
# OL9/RHEL9 NAPI / softirq headroom
###############################################################################

log "Increasing OL9 networking softirq headroom"


sysctl -w \
    net.core.netdev_budget=600 \
    >/dev/null


sysctl -w \
    net.core.netdev_budget_usecs=4000 \
    >/dev/null


sysctl -w \
    net.core.netdev_max_backlog=8192 \
    >/dev/null


###############################################################################
# IRQ affinity
###############################################################################

log "Pinning NIC queue IRQs"


mapfile -t IRQS < <(

    grep -F "$IFNAME" /proc/interrupts |

    grep -Ei \
        'TxRx|rx[-_ ]|queue' |

    awk '
        {
            gsub(":", "", $1)

            if ($1 ~ /^[0-9]+$/)
                print $1
        }
    ' |

    sort -n -u

)


#
# Driver naming varies.
#
# If the specific queue matching didn't work,
# fall back to every IRQ containing IFNAME.
#
if ((${#IRQS[@]} == 0))
then

    warn \
        "could not identify queue IRQ names; falling back to all IRQs mentioning $IFNAME"


    mapfile -t IRQS < <(

        grep -F "$IFNAME" /proc/interrupts |

        awk '
            {
                gsub(":", "", $1)

                if ($1 ~ /^[0-9]+$/)
                    print $1
            }
        ' |

        sort -n -u

    )

fi


if ((${#IRQS[@]} == 0))
then

    warn \
        "no IRQs found for $IFNAME; inspect /proc/interrupts manually"

else

    for i in "${!IRQS[@]}"
    do

        irq="${IRQS[$i]}"

        idx=$(( i % ${#IRQ_ARR[@]} ))

        cpu="${IRQ_ARR[$idx]}"


        if [[ -w "/proc/irq/$irq/smp_affinity_list" ]]
        then

            echo "$cpu" \
                > "/proc/irq/$irq/smp_affinity_list" ||

            warn \
                "could not pin IRQ $irq to CPU $cpu"

        else

            warn \
                "IRQ $irq affinity not writable; possibly a managed IRQ"

        fi

    done

fi


###############################################################################
# Final state
###############################################################################

log "Final NIC state"


printf '\n-- channels --\n'

ethtool -l "$IFNAME"


printf '\n-- RX/TX rings --\n'

ethtool -g "$IFNAME" 2>/dev/null ||
    true


printf '\n-- interrupt coalescing --\n'

ethtool -c "$IFNAME" 2>/dev/null ||
    true


printf '\n-- RSS --\n'

ethtool -x "$IFNAME" 2>/dev/null ||
    true


printf '\n-- important offloads --\n'

ethtool -k "$IFNAME" |
grep -E \
    '(^rx-checksumming:|^tx-checksumming:|scatter-gather:|tcp-segmentation|udp-fragmentation|generic-segmentation|generic-receive|large-receive|rx-vlan-offload|tx-vlan-offload|receive-hashing|ntuple)' ||
true


printf '\n-- IRQ affinity --\n'


for irq in "${IRQS[@]:-}"
do

    if [[ -r "/proc/irq/$irq/effective_affinity_list" ]]
    then

        printf \
            'IRQ %-5s effective=%s\n' \
            "$irq" \
            "$(
                cat \
                    "/proc/irq/$irq/effective_affinity_list"
            )"

    fi

done


printf '\n-- drop/error counters --\n'


ethtool -S "$IFNAME" 2>/dev/null |
grep -Ei \
    'rx.*(drop|disc|miss|err|no.?buf)|drop|discard|missed|error|no.?buf' ||
true


###############################################################################
# Recommended Suricata configuration summary
###############################################################################

cat <<EOF

============================================================
Recommended matching Suricata configuration
============================================================

interface:              $IFNAME
AF_PACKET threads:      $QUEUES
cluster-type:           cluster_qm

IRQ CPUs:
  $IRQ_CPUS

Suricata worker CPUs:
  $WORKER_CPUS

AF_PACKET:
  tpacket-v3:           yes
  ring-size:            100000
  block-size:           1048576

Before and after every benchmark:

  ethtool -S $IFNAME | egrep -i 'drop|disc|miss|error|no.?buf'

  cat /proc/net/softnet_stat

If /proc/net/softnet_stat column 3 increases during the test,
increase net.core.netdev_budget and
net.core.netdev_budget_usecs further.

============================================================

EOF
