IK
← Incident Log
RESOLVED MEDIUM Home Lab Β· incident Β· 08-15-26 Β· networking, bottlenecked speeds, adapters

Node 2 Network Speeds Throttled and Reset on Reboot

What happened

In my home lab my node 2's integrated motherboard has a 1gb Ethernet port, but since I need faster speeds between nodes and my home network I added a USB 2.0 adapter because my PCI slots were already full. The issue is that every time I reboot my node, my USB 3.0 adapter defaults to 1gb speeds and I have to run a script to force it to 2.5gb optimal speeds.

Root cause

Hardware Reboot or Unexpected Shutdowns

The fix

Create a script that runs on boot to force my USB adapter to 2.5gb speeds if it's detected.

I already implemented this fix in my network speeds incident report, but that was a temporary fix and I didn't release it; it reset the speeds back to default on reboots, and since I was having overheating issues on node 2, I noticed the change after those issues.

Unraid has a feature where I can set up bash scripts to run on a schedule. I plan to implement a bash script that checks which eth port my USB device is on, determines the device's max capable speeds, and sets it at the start of the array (on boot).
image

Since I'm very novice at creating bash scripts, I used an LLM to help create a script that parses all from eth0 to eth4, then looks at their max rated speeds and sets them accordingly. It also has side features to force the Ethernet ports to run at the fastest speeds and a safety guard to revert to the original settings if the same client doesn't have a connection within 25 seconds.

BashnetworkSpeedInit.sh
#!/bin/bash

MAX_IF=4
APPLY=1         
ASSUME_YES=1    
FORCE=1          
DETACH=1        
LINK_TIMEOUT=25 
LOGFILE=/var/log/nic-speed.log

# ------------------------------------------------------------------------

while [ $# -gt 0 ]; do
    case "$1" in
        "")        ;;   # User Scripts passes an empty arg; ignore it
        --apply)   APPLY=1 ;;
        -y|--yes)  ASSUME_YES=1 ;;
        --force)   FORCE=1 ;;
        --detach)  DETACH=1 ;;
        --max)     MAX_IF="$2"; shift ;;
        --timeout) LINK_TIMEOUT="$2"; shift ;;
        -h|--help) sed -n '2,20p' "$0"; exit 0 ;;
        *) echo "unknown option: $1" >&2; exit 1 ;;
    esac
    shift
done

[ -t 0 ] || ASSUME_YES=1

ETHTOOL=$(command -v ethtool || echo /usr/sbin/ethtool)
[ -x "$ETHTOOL" ] || { echo "ethtool not found" >&2; exit 1; }

# ------------------------------------------------------------------ log ----

log() {
    local msg="$*"
    printf '%s  %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$msg"
    command -v logger >/dev/null 2>&1 && logger -t nic-speed "$msg"
}

echo "=============================================="
echo " Started Network Max Speed Set"
echo " $(date '+%Y-%m-%d %H:%M:%S')  host: $(hostname)"
echo " mode: $( [ "$APPLY" -eq 1 ] && echo APPLY || echo REPORT-ONLY )  force: $FORCE  scan: eth0-eth$MAX_IF"
echo "=============================================="
echo

# --------------------------------------------------------------- detach ----
# Re-exec detached from the controlling terminal / User Scripts window, with
# all output appended to LOGFILE. Losing the link then cannot abort the run.

if [ "$DETACH" -eq 1 ] && [ -z "$NICSPEED_CHILD" ]; then
    args=()
    [ "$APPLY"  -eq 1 ] && args+=(--apply)
    [ "$FORCE"  -eq 1 ] && args+=(--force)
    args+=(-y --max "$MAX_IF" --timeout "$LINK_TIMEOUT")
    echo "Detaching. Output goes to $LOGFILE"
    echo "Follow it with:  tail -f $LOGFILE"
    NICSPEED_CHILD=1 setsid nohup "$0" "${args[@]}" >> "$LOGFILE" 2>&1 < /dev/null &
    exit 0
fi

# ---------------------------------------------------------------- parsing ---

list_modes() {
    printf '%s\n' "$1" | awk -v want="$2:" '
        index($0, want) { insec = 1 }
        insec {
            if (!index($0, want) && index($0, ":")) { insec = 0; next }
            n = split($0, tok, /[ \t]+/)
            for (i = 1; i <= n; i++) if (tok[i] ~ /^[0-9]+base/) print tok[i]
        }'
}

max_of() { awk '{ s = $0; sub(/base.*/, "", s); if (s + 0 > m) m = s + 0 } END { print m + 0 }'; }

fmt() {
    awk -v m="$1" 'BEGIN {
        if (m + 0 <= 0) { print "-"; exit }
        if (m + 0 < 1000) { printf "%dM\n", m; exit }
        g = m / 1000
        if (g == int(g)) printf "%dG\n", g; else printf "%.1fG\n", g
    }'
}

# Bit positions from the kernel ETHTOOL_LINK_MODE_* enum.
mode_bit() {
    case "$1" in
        10baseT/Half) echo 0 ;;      10baseT/Full) echo 1 ;;
        100baseT/Half) echo 2 ;;     100baseT/Full) echo 3 ;;
        1000baseT/Half) echo 4 ;;    1000baseT/Full) echo 5 ;;
        10000baseT/Full) echo 12 ;;  2500baseX/Full) echo 15 ;;
        1000baseKX/Full) echo 17 ;;  10000baseKX4/Full) echo 18 ;;
        10000baseKR/Full) echo 19 ;; 25000baseCR/Full) echo 31 ;;
        25000baseKR/Full) echo 32 ;; 25000baseSR/Full) echo 33 ;;
        1000baseX/Full) echo 41 ;;   10000baseCR/Full) echo 42 ;;
        10000baseSR/Full) echo 43 ;; 10000baseLR/Full) echo 44 ;;
        10000baseLRM/Full) echo 45 ;; 10000baseER/Full) echo 46 ;;
        2500baseT/Full) echo 47 ;;   5000baseT/Full) echo 48 ;;
        *) echo -1 ;;
    esac
}

mask_from() {  # $1 = ethtool output, $2 = label -> hex mask on stdout
    local mask=0 m b
    while read -r m; do
        [ -z "$m" ] && continue
        b=$(mode_bit "$m")
        [ "$b" -ge 0 ] && [ "$b" -lt 63 ] && mask=$(( mask | (1 << b) ))
    done < <(list_modes "$1" "$2")
    printf '0x%x' "$mask"
}

# ------------------------------------------------------------ environment ---

SESSION_IF=""
if [ -n "$SSH_CONNECTION" ]; then
    peer=$(echo "$SSH_CONNECTION" | awk '{print $1}')
    SESSION_IF=$(ip route get "$peer" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')
fi
DEFAULT_IF=$(ip route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')

resolve_master() {
    local d="$1" m
    while [ -L "/sys/class/net/$d/master" ]; do
        m=$(basename "$(readlink -f "/sys/class/net/$d/master")")
        [ "$m" = "$d" ] && break
        d="$m"
    done
    echo "$d"
}

is_critical() {
    local up; up=$(resolve_master "$1")
    if [ -n "$SESSION_IF" ] && { [ "$1" = "$SESSION_IF" ] || [ "$up" = "$SESSION_IF" ]; }; then return 0; fi
    if [ -n "$DEFAULT_IF" ] && { [ "$1" = "$DEFAULT_IF" ] || [ "$up" = "$DEFAULT_IF" ]; }; then return 0; fi
    return 1
}

wait_link() {  # $1 = dev, $2 = seconds -> echoes negotiated Mb/s, "" if down
    local dev="$1" secs="$2" i st spd
    for (( i = 0; i < secs; i++ )); do
        sleep 1
        st=$("$ETHTOOL" "$dev" 2>/dev/null)
        if printf '%s\n' "$st" | grep -q "Link detected: yes"; then
            spd=$(printf '%s\n' "$st" | awk -F': ' '/[[:space:]]Speed:/ {print $2; exit}')
            spd=${spd%Mb/s}
            case "$spd" in ''|*[!0-9]*) ;; *) echo "$spd"; return 0 ;; esac
        fi
    done
    echo ""
    return 1
}

# ---------------------------------------------------------------- report ----

TODO_IF=(); TODO_MASK=(); TODO_OLD=(); TODO_FROM=(); TODO_TO=(); TODO_CRIT=(); TODO_ADD=()
CHANGED=(); UNCHANGED=(); FAILED=()

printf "%-7s %-9s %-6s %-8s %-10s %-9s %-8s %s\n" \
    IFACE DRIVER LINK CURRENT ADVERTISED SUPPORTED PARTNER NOTE
printf -- "--------------------------------------------------------------------------------\n"

best=0
for n in $(seq 0 "$MAX_IF"); do
    dev="eth$n"
    [ -e "/sys/class/net/$dev" ] || continue
    out=$("$ETHTOOL" "$dev" 2>/dev/null) || continue

    sup=$(list_modes "$out" "Supported link modes" | max_of)
    adv=$(list_modes "$out" "Advertised link modes" | max_of)
    par=$(list_modes "$out" "Link partner advertised link modes" | max_of)

    link=$(printf '%s\n' "$out" | awk -F': ' '/Link detected:/ {print $2; exit}')
    autoneg=$(printf '%s\n' "$out" | awk -F': ' '/Supports auto-negotiation:/ {print $2; exit}')
    cur=$(printf '%s\n' "$out" | awk -F': ' '/[[:space:]]Speed:/ {print $2; exit}'); cur=${cur%Mb/s}
    case "$cur" in ''|*[!0-9]*) cur_h="-" ;; *) cur_h=$(fmt "$cur") ;; esac

    drv="?"
    [ -L "/sys/class/net/$dev/device/driver" ] && \
        drv=$(basename "$(readlink -f "/sys/class/net/$dev/device/driver")")

    note=""
    if [ "$sup" -gt "$adv" ]; then
        if [ "$autoneg" != "Yes" ]; then
            note="capped, but no autoneg"
        else
            newmask=$(mask_from "$out" "Supported link modes")
            oldmask=$(mask_from "$out" "Advertised link modes")
            crit=0; is_critical "$dev" && crit=1

            if [ "$newmask" = "0x0" ]; then
                note="capped, no mappable modes"
            elif [ "$crit" -eq 1 ] && [ "$FORCE" -eq 0 ]; then
                note="capped -> SKIPPED (carries session; use --force --detach)"
            else
                # Exactly which link modes this run will add.
                addmodes=$(comm -13 \
                    <(list_modes "$out" "Advertised link modes" | sort -u) \
                    <(list_modes "$out" "Supported link modes"  | sort -u) \
                    | tr '\n' ' ')
                addmodes=${addmodes% }

                note="capped -> will raise to $(fmt "$sup")"
                [ "$crit" -eq 1 ] && note="$note (SESSION NIC, watchdogged)"
                TODO_IF+=("$dev");     TODO_MASK+=("$newmask")
                TODO_OLD+=("$oldmask"); TODO_FROM+=("$adv")
                TODO_TO+=("$sup");     TODO_CRIT+=("$crit")
                TODO_ADD+=("${addmodes:-none}")
            fi
            if [ "$par" -gt 0 ] && [ "$par" -lt "$sup" ]; then
                note="$note [partner tops out at $(fmt "$par")]"
            fi
        fi
    fi

    printf "%-7s %-9s %-6s %-8s %-10s %-9s %-8s %s\n" \
        "$dev" "$drv" "${link:-?}" "$cur_h" "$(fmt "$adv")" "$(fmt "$sup")" "$(fmt "$par")" "$note"

    [ "$sup" -gt "$best" ] && best=$sup
done

echo
echo "Fastest NIC in this box: $(fmt "$best")"

# ----------------------------------------------------------------- apply ----

if [ "$APPLY" -eq 0 ]; then
    if [ "${#TODO_IF[@]}" -gt 0 ]; then
        echo
        echo "Report-only mode - NO CHANGES APPLIED. Would change:"
        for i in "${!TODO_IF[@]}"; do
            echo "  ${TODO_IF[$i]}: ceiling $(fmt "${TODO_FROM[$i]}") -> $(fmt "${TODO_TO[$i]}"), adding ${TODO_ADD[$i]}"
            echo "    ethtool -s ${TODO_IF[$i]} autoneg on advertise ${TODO_MASK[$i]}"
        done
    fi
    exit 0
fi

if [ "${#TODO_IF[@]}" -eq 0 ]; then
    echo; echo "Nothing to raise."; exit 0
fi

echo
echo "About to re-advertise (each link bounces for a few seconds):"
for i in "${!TODO_IF[@]}"; do
    printf "  %s: ceiling %s -> %s%s\n" \
        "${TODO_IF[$i]}" "$(fmt "${TODO_FROM[$i]}")" "$(fmt "${TODO_TO[$i]}")" \
        "$([ "${TODO_CRIT[$i]}" -eq 1 ] && echo '   <-- carries this session')"
    printf "      adding link modes : %s\n" "${TODO_ADD[$i]}"
    printf "      advertise mask    : %s  (rollback to %s)\n" \
        "${TODO_MASK[$i]}" "${TODO_OLD[$i]}"
done

if [ "$ASSUME_YES" -eq 0 ]; then
    printf "Proceed? [y/N] "
    read -r ans
    case "$ans" in y|Y|yes|YES) ;; *) echo "Aborted."; exit 1 ;; esac
fi

for i in "${!TODO_IF[@]}"; do
    dev="${TODO_IF[$i]}"; new="${TODO_MASK[$i]}"; old="${TODO_OLD[$i]}"
    top="${TODO_TO[$i]}"; was="${TODO_FROM[$i]}"

    log "$dev: CHANGE - adding link modes: ${TODO_ADD[$i]}"
    log "$dev: CHANGE - advertise mask $old -> $new (ceiling $(fmt "$was") -> $(fmt "$top"))"

    if ! "$ETHTOOL" -s "$dev" autoneg on advertise "$new" 2>&1; then
        log "$dev: FAILED - driver rejected mask $new, NO CHANGE APPLIED (still $(fmt "$was"))"
        FAILED+=("$dev: rejected mask, unchanged at $(fmt "$was")")
        continue
    fi

    spd=$(wait_link "$dev" "$LINK_TIMEOUT")
    if [ -n "$spd" ]; then
        if [ "$spd" -gt "$was" ]; then
            log "$dev: APPLIED - link speed $(fmt "$was") -> $(fmt "$spd") (hardware max $(fmt "$top"))"
            CHANGED+=("$dev: $(fmt "$was") -> $(fmt "$spd")  [added ${TODO_ADD[$i]}]")
        else
            log "$dev: APPLIED mask, but link renegotiated at $(fmt "$spd") - speed unchanged from $(fmt "$was")"
            UNCHANGED+=("$dev: still $(fmt "$spd") despite advertising up to $(fmt "$top")")
        fi
    else
        log "$dev: NO LINK after ${LINK_TIMEOUT}s - ROLLING BACK mask $new -> $old"
        "$ETHTOOL" -s "$dev" autoneg on advertise "$old" 2>&1
        spd=$(wait_link "$dev" "$LINK_TIMEOUT")
        if [ -n "$spd" ]; then
            log "$dev: ROLLED BACK - restored to $(fmt "$spd"), no net change"
            FAILED+=("$dev: would not link at $(fmt "$top"), rolled back to $(fmt "$spd")")
        else
            log "$dev: STILL DOWN after rollback - needs manual attention: ethtool -s $dev autoneg on"
            FAILED+=("$dev: DOWN after rollback - manual fix needed")
        fi
    fi
done

echo
echo "=============================================="
echo " Network Max Speed Set - summary"
echo "=============================================="
if [ "${#CHANGED[@]}" -gt 0 ]; then
    echo " Changes applied:"
    for e in "${CHANGED[@]}"; do echo "   $e"; done
else
    echo " Changes applied: none"
fi
[ "${#UNCHANGED[@]}" -gt 0 ] && { echo " No speed gain:"; for e in "${UNCHANGED[@]}"; do echo "   $e"; done; }
[ "${#FAILED[@]}" -gt 0 ]    && { echo " Problems:";      for e in "${FAILED[@]}";    do echo "   $e"; done; }

echo
echo "Unraid runs from RAM; this does not survive a reboot."
echo "To persist, add the ethtool lines above to /boot/config/go."
exit 0

After running this script standalone it was a success

Before

image

Run networkSpeedInit.sh

image

After

image

Results

Success. Now all I need to do is set the script to run every time the array starts and test it on a reboot.

With Unraid this is easy: I go to User Scripts and set it to run at the start of the array
image

I would run it only at the first start, but it doesn't harm anything if it runs anytime I start my array since all it does is set the device to its max rated speeds.

Overall this was a success, and I gained experience with startup scripts and bash scripting.

// related project

Home Lab β†’

// referenced in

↩ Linux Container failing after Network Drive SMB unmount