diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..473c803
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,58 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: macnet-ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ shellcheck:
+ name: ShellCheck
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+
+ steps:
+ - name: Check out source
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Run ShellCheck
+ uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
+ with:
+ check_together: true
+ scandir: .
+ version: v0.10.0
+
+ macos:
+ name: Bash 3.2 syntax and behavior tests
+ needs: shellcheck
+ runs-on: macos-15
+ timeout-minutes: 10
+
+ steps:
+ - name: Check out source
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Show target Bash
+ run: /bin/bash --version
+
+ - name: Check Bash syntax
+ run: /bin/bash -n netmon.sh tests/netmon_test.sh
+
+ - name: Run deterministic behavior tests
+ run: /bin/bash tests/netmon_test.sh
+
+ - name: Verify the checkout stayed clean
+ run: git diff --exit-code
diff --git a/README.md b/README.md
index d7b09ee..cd8776e 100644
--- a/README.md
+++ b/README.md
@@ -1,38 +1,94 @@
-```
- __ __ _ _ _ ____ _ _ _____
-| \/ | __ _ ___| \ | | ___| |_/ ___|| |_ __ _| |_ ___|_ _|__ _ __ _ __ ___
-| |\/| |/ _` |/ __| \| |/ _ \ __\___ \| __/ _` | __/ __|| |/ _ \ '__| '_ ` _ \
-| | | | (_| | (__| |\ | __/ |_ ___) | || (_| | |_\__ \| | __/ | | | | | | |
-|_| |_|\__,_|\___|_| \_|\___|\__|____/ \__\__,_|\__|___/|_|\___|_| |_| |_| |_|
+# MacNetStatsTerm
+
+A small, interactive macOS terminal dashboard for the active network interface. It reads the system's cumulative byte counters once per second and displays current download and upload throughput without making network requests or leaving output in terminal scrollback.
+
+
+
+## What it does
+
+- Detects the interface used by the local default route.
+- Samples macOS `netstat` receive and transmit byte counters every second.
+- Calculates counter deltas and formats them as KB/s or MB/s.
+- Uses the terminal's alternate screen buffer and restores the cursor on exit.
+- Clamps a counter reset to `0.00 KB/s` instead of displaying negative throughput.
+- Supports an explicit interface override for VPNs, bridges, and multi-interface Macs.
+
+This is an interface-level monitor, not a per-process traffic inspector, packet capture tool, or historical bandwidth recorder.
+
+## Requirements
+
+- macOS with `/bin/bash` 3.2 or newer.
+- An interactive terminal with alternate-screen support.
+- The macOS system utilities `route`, `netstat`, `awk`, `tput`, and `sleep` available on `PATH`.
+
+No package manager or third-party runtime is required. The script is macOS-specific because its `route` and `netstat` parsing follows the macOS command output.
+
+## Install and run
+
+```bash
+git clone https://github.com/okturan/MacNetStatsTerm.git
+cd MacNetStatsTerm
+./netmon.sh
```
-Terminal network monitor for macOS. Real-time interface stats with minimal overhead.
+The repository tracks `netmon.sh` as executable. Press Control+C to stop; the original terminal screen and cursor are restored.
-
+### Select an interface explicitly
-Requirements:
+Automatic detection uses the local default route. To monitor another interface:
-* macOS (uses netstat/route)
-* bash + bc
+```bash
+NETMON_INTERFACE=en1 ./netmon.sh
+```
-Usage:
+Common interface names include `en0`, `en1`, and VPN interfaces such as `utun0`. List interfaces and their counters with:
```bash
-$ ./netmon.sh
+netstat -ib
```
-Auto-detects active interface. Updates every second. Scales KB/s >> MB/s automatically.
-Press ^C to exit cleanly.
+If default-route detection fails, the script warns and falls back to `en0`. Override that fallback without forcing the interface on every healthy run:
+
+```bash
+NETMON_FALLBACK_INTERFACE=en1 ./netmon.sh
+```
-What it does:
+## How throughput is calculated
-> Polls netstat byte counters at 1sec intervals
-> Calculates transfer deltas (rx/tx)
-> Renders to alternate screen buffer (no terminal pollution)
-> Traps signals for proper cleanup
+For each direction, the monitor computes:
-Implementation:
+```text
+(current byte counter - previous byte counter) / 1024 / sample interval
+```
+
+Rates below 1024 KB/s are shown in KB/s; rates at or above that threshold are shown in MB/s. Counters are cumulative for the selected interface, so the display covers all traffic on that interface during the sample window.
+
+## Troubleshooting
+
+### `missing required command(s)`
+
+The startup check lists every unavailable system command. Confirm `/usr/bin`, `/bin`, `/usr/sbin`, and `/sbin` are present on `PATH`; a heavily restricted shell environment can hide tools that macOS normally provides.
+
+### The wrong interface is selected
+
+Run `route -n get default` and inspect the `interface:` row, or use `NETMON_INTERFACE` as shown above. VPN software can intentionally replace the default route.
+
+### `could not read byte counters`
+
+Confirm the interface still exists with `netstat -ib`. Interfaces may disappear when Wi-Fi, Ethernet, a hotspot, or a VPN disconnects. Restart the monitor after choosing an active interface.
+
+### Terminal content or cursor looks wrong after interruption
+
+Normal `INT`, `TERM`, and shell-exit paths restore the cursor and leave the alternate screen. If the process is killed with `SIGKILL`, the shell cannot run cleanup; `reset` restores most terminal state.
+
+## Verification
+
+The deterministic checks source the script without starting its infinite UI loop. They cover rate scaling, counter deltas and resets, interface detection and fallback, dependency failures, and the non-interactive execution guard.
+
+```bash
+bash -n netmon.sh tests/netmon_test.sh
+shellcheck netmon.sh tests/netmon_test.sh
+bash tests/netmon_test.sh
+```
-Pure bash. No external dependencies beyond standard unix tools. Uses `tput` for screen
-control and `bc` for floating point math. Alternate screen buffer keeps your scrollback
-clean.
+GitHub Actions runs ShellCheck on Ubuntu and the Bash 3.2 syntax and behavior-test gates on macOS. The workflow has read-only repository permissions and pins third-party actions to immutable commit SHAs.
diff --git a/netmon.sh b/netmon.sh
index 442073e..162fcfb 100755
--- a/netmon.sh
+++ b/netmon.sh
@@ -1,130 +1,249 @@
#!/bin/bash
-set -euo pipefail # Exit on error, undefined variables, and pipe failures
# Constants
readonly KB_TO_MB_THRESHOLD=1024
readonly REFRESH_INTERVAL=1
-# Global variables for colors (initialized once)
+# Terminal state. These remain empty when the file is sourced for tests.
BOLD=""
GREEN=""
BLUE=""
RED=""
NORMAL=""
+SCREEN_ACTIVE=0
-# Cleanup function to restore terminal state
-cleanup() {
- tput rmcup # Exit alternate screen buffer
- tput cnorm # Show cursor
- tput sgr0 # Reset text attributes
- exit 0
+print_error() {
+ printf 'MacNetStatsTerm: %s\n' "$*" >&2
+}
+
+is_non_negative_integer() {
+ case "${1:-}" in
+ '' | *[!0-9]*) return 1 ;;
+ *) return 0 ;;
+ esac
}
-# Set up signal traps to ensure cleanup on exit
-trap cleanup EXIT INT TERM
+# Accept command names as arguments so dependency failures can be tested
+# without changing PATH or relying on a particular machine image.
+check_dependencies() {
+ local command_name
+ local missing=""
-# Initialize color codes
-init_colors() {
- if [ -t 1 ]; then # Check if stdout is a terminal
- BOLD=$(tput bold)
- GREEN=$(tput setaf 2)
- BLUE=$(tput setaf 4)
- RED=$(tput setaf 1)
- NORMAL=$(tput sgr0)
+ for command_name in "$@"; do
+ if ! command -v "$command_name" >/dev/null 2>&1; then
+ missing="${missing}${missing:+ }${command_name}"
+ fi
+ done
+
+ if [ -n "$missing" ]; then
+ print_error "missing required command(s): $missing"
+ return 1
fi
}
-# Calculate transfer rate with automatic unit scaling
+require_dependencies() {
+ check_dependencies route netstat awk tput sleep
+}
+
+# Calculate a rate from two cumulative byte counters. Counter resets are
+# clamped to zero rather than rendered as negative throughput.
calculate_rate() {
- local prev=$1
- local next=$2
- local interval=${3:-1}
-
- local rate_kb
- rate_kb=$(echo "scale=2; ($next - $prev) / 1024 / $interval" | bc)
-
- if (( $(echo "$rate_kb > $KB_TO_MB_THRESHOLD" | bc -l) )); then
- local rate_mb
- rate_mb=$(echo "scale=2; $rate_kb / 1024" | bc)
- printf "%.2f MB/s" "$rate_mb"
- else
- printf "%.2f KB/s" "$rate_kb"
+ local previous=${1:-}
+ local current=${2:-}
+ local interval=${3:-}
+
+ if ! is_non_negative_integer "$previous" ||
+ ! is_non_negative_integer "$current" ||
+ ! is_non_negative_integer "$interval" ||
+ [ "$interval" -eq 0 ]; then
+ print_error "rate counters and interval must be non-negative integers; interval must be greater than zero"
+ return 1
fi
+
+ awk \
+ -v previous="$previous" \
+ -v current="$current" \
+ -v interval="$interval" \
+ -v threshold="$KB_TO_MB_THRESHOLD" \
+ 'BEGIN {
+ delta = current - previous
+ if (delta < 0) {
+ delta = 0
+ }
+ rate_kb = delta / 1024 / interval
+ if (rate_kb >= threshold) {
+ printf "%.2f MB/s", rate_kb / 1024
+ } else {
+ printf "%.2f KB/s", rate_kb
+ }
+ }'
+}
+
+calculate_transfer_rates() {
+ local rx_previous=${1:-}
+ local tx_previous=${2:-}
+ local rx_current=${3:-}
+ local tx_current=${4:-}
+ local interval=${5:-}
+ local download_rate
+ local upload_rate
+
+ download_rate=$(calculate_rate "$rx_previous" "$rx_current" "$interval") || return 1
+ upload_rate=$(calculate_rate "$tx_previous" "$tx_current" "$interval") || return 1
+ printf '%s|%s\n' "$download_rate" "$upload_rate"
}
-# Detect the active network interface
+# Detect the interface used by the local default route. An explicit override
+# is useful on Macs with VPN, bridge, or multiple active interfaces.
get_active_interface() {
- local interface
- interface=$(route get 8.8.8.8 2>/dev/null | grep interface | awk '{print $2}')
+ local interface=""
+ local fallback=${NETMON_FALLBACK_INTERFACE:-en0}
+
+ if [ -n "${NETMON_INTERFACE:-}" ]; then
+ printf '%s\n' "$NETMON_INTERFACE"
+ return 0
+ fi
+
+ if ! interface=$(route -n get default 2>/dev/null | awk '$1 == "interface:" { print $2; exit }'); then
+ interface=""
+ fi
if [ -z "$interface" ]; then
- echo "en0" # Fallback to default
- return 1
+ print_error "could not detect the default-route interface; using $fallback (override with NETMON_INTERFACE)"
+ printf '%s\n' "$fallback"
+ return 0
fi
- echo "$interface"
+ printf '%s\n' "$interface"
}
-# Get network statistics for a given interface
get_network_bytes() {
- local interface=$1
- netstat -I "$interface" -b 2>/dev/null | awk 'FNR == 2 {print $7, $10}'
+ local interface=${1:-}
+ local bytes
+ local rx
+ local tx
+ local extra
+
+ if [ -z "$interface" ]; then
+ print_error "an interface name is required"
+ return 1
+ fi
+
+ if ! bytes=$(netstat -I "$interface" -b 2>/dev/null | awk '
+ FNR == 2 { print $7, $10; found = 1; exit }
+ END { if (!found) exit 1 }
+ '); then
+ print_error "could not read byte counters for interface $interface"
+ return 1
+ fi
+
+ IFS=' ' read -r rx tx extra <<< "$bytes"
+ if ! is_non_negative_integer "$rx" ||
+ ! is_non_negative_integer "$tx" ||
+ [ -n "${extra:-}" ]; then
+ print_error "netstat returned invalid byte counters for interface $interface"
+ return 1
+ fi
+
+ printf '%s %s\n' "$rx" "$tx"
}
-# Collect network statistics and calculate rates
collect_stats() {
- local interface=$1
- local rx_prev tx_prev rx_next tx_next
+ local interface=${1:-}
+ local previous
+ local current
+ local rx_previous
+ local tx_previous
+ local rx_current
+ local tx_current
+
+ previous=$(get_network_bytes "$interface") || return 1
+ IFS=' ' read -r rx_previous tx_previous <<< "$previous"
- read -r rx_prev tx_prev < <(get_network_bytes "$interface")
sleep "$REFRESH_INTERVAL"
- read -r rx_next tx_next < <(get_network_bytes "$interface")
- local download_rate upload_rate
- download_rate=$(calculate_rate "$rx_prev" "$rx_next")
- upload_rate=$(calculate_rate "$tx_prev" "$tx_next")
+ current=$(get_network_bytes "$interface") || return 1
+ IFS=' ' read -r rx_current tx_current <<< "$current"
+
+ calculate_transfer_rates \
+ "$rx_previous" \
+ "$tx_previous" \
+ "$rx_current" \
+ "$tx_current" \
+ "$REFRESH_INTERVAL"
+}
- printf "%s|%s\n" "$download_rate" "$upload_rate"
+cleanup() {
+ if [ "${SCREEN_ACTIVE:-0}" -eq 1 ]; then
+ tput cnorm 2>/dev/null || true
+ tput sgr0 2>/dev/null || true
+ tput rmcup 2>/dev/null || true
+ SCREEN_ACTIVE=0
+ fi
+}
+
+init_colors() {
+ if [ -t 1 ]; then
+ BOLD=$(tput bold 2>/dev/null || printf '')
+ GREEN=$(tput setaf 2 2>/dev/null || printf '')
+ BLUE=$(tput setaf 4 2>/dev/null || printf '')
+ RED=$(tput setaf 1 2>/dev/null || printf '')
+ NORMAL=$(tput sgr0 2>/dev/null || printf '')
+ fi
}
-# Render the network monitor display
render_display() {
- local interface=$1
- local download_rate=$2
- local upload_rate=$3
-
- echo "${BOLD}${GREEN}NETWORK MONITOR${NORMAL}"
- echo "${BOLD}${BLUE}============================${NORMAL}"
- echo "Interface: ${BOLD}${GREEN}$interface${NORMAL}"
- printf "Download: ${BOLD}${BLUE}%s${NORMAL} | Upload: ${BOLD}${RED}%s${NORMAL}\n" "$download_rate" "$upload_rate"
+ local interface=${1:-}
+ local download_rate=${2:-}
+ local upload_rate=${3:-}
+
+ printf '%s%sNETWORK MONITOR%s\n' "$BOLD" "$GREEN" "$NORMAL"
+ printf '%s%s============================%s\n' "$BOLD" "$BLUE" "$NORMAL"
+ printf 'Interface: %s%s%s\n' "$BOLD" "$GREEN" "$interface$NORMAL"
+ printf 'Download: %s%s%s | Upload: %s%s%s\n' \
+ "$BOLD" "$BLUE" "$download_rate$NORMAL" \
+ "$BOLD" "$RED" "$upload_rate$NORMAL"
}
-# Main function
main() {
- # Initialize colors once
- init_colors
-
- # Detect interface once at startup
local interface
+ local stats
+ local download_rate
+ local upload_rate
+
+ if [ ! -t 1 ]; then
+ print_error "an interactive terminal is required"
+ return 1
+ fi
+
+ require_dependencies
+ init_colors
interface=$(get_active_interface)
- # Enter alternate screen buffer and hide cursor
- tput smcup
- clear
+ if ! tput smcup; then
+ print_error "the current terminal does not support an alternate screen buffer"
+ return 1
+ fi
+
+ SCREEN_ACTIVE=1
+ trap cleanup EXIT
+ trap 'exit 130' INT
+ trap 'exit 143' TERM
+
+ tput clear
tput civis
- # Continuous monitoring loop
while true; do
- # Move cursor to beginning (overwrite existing content)
tput cup 0 0
-
- # Collect statistics and render display
- local stats download_rate upload_rate
stats=$(collect_stats "$interface")
IFS='|' read -r download_rate upload_rate <<< "$stats"
render_display "$interface" "$download_rate" "$upload_rate"
done
}
-# Run the main function
-main
+# Sourcing this file exposes the functions without installing traps or
+# starting the monitor. Direct execution retains strict-mode CLI behavior.
+if [ "${BASH_SOURCE[0]}" = "$0" ]; then
+ set -euo pipefail
+ main "$@"
+fi
diff --git a/tests/netmon_test.sh b/tests/netmon_test.sh
new file mode 100755
index 0000000..aa4401c
--- /dev/null
+++ b/tests/netmon_test.sh
@@ -0,0 +1,185 @@
+#!/bin/bash
+
+set -u
+
+ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+
+# shellcheck source=netmon.sh
+. "$ROOT_DIR/netmon.sh"
+
+TESTS_RUN=0
+TESTS_FAILED=0
+
+fail() {
+ printf ' FAIL: %s\n' "$*" >&2
+ return 1
+}
+
+assert_equal() {
+ local expected=$1
+ local actual=$2
+ local context=${3:-values differ}
+
+ if [ "$expected" != "$actual" ]; then
+ fail "$context (expected '$expected', got '$actual')"
+ fi
+}
+
+assert_contains() {
+ local haystack=$1
+ local needle=$2
+ local context=${3:-text not found}
+
+ case "$haystack" in
+ *"$needle"*) return 0 ;;
+ *) fail "$context (missing '$needle' in '$haystack')" ;;
+ esac
+}
+
+run_test() {
+ local name=$1
+ local test_function=$2
+ TESTS_RUN=$((TESTS_RUN + 1))
+
+ if "$test_function"; then
+ printf 'ok %d - %s\n' "$TESTS_RUN" "$name"
+ else
+ TESTS_FAILED=$((TESTS_FAILED + 1))
+ printf 'not ok %d - %s\n' "$TESTS_RUN" "$name"
+ fi
+}
+
+test_rate_formats_kilobytes() {
+ local actual
+ actual=$(calculate_rate 0 2048 2)
+ assert_equal "1.00 KB/s" "$actual" "two-second KB/s rate"
+}
+
+test_rate_scales_at_one_megabyte() {
+ local actual
+ actual=$(calculate_rate 0 1048576 1)
+ assert_equal "1.00 MB/s" "$actual" "MB/s threshold"
+}
+
+test_transfer_pair_uses_independent_deltas() {
+ local actual
+ actual=$(calculate_transfer_rates 1000 2000 3048 2099152 2)
+ assert_equal "1.00 KB/s|1.00 MB/s" "$actual" "download/upload pair"
+}
+
+test_counter_reset_is_clamped() {
+ local actual
+ actual=$(calculate_rate 4096 1024 1)
+ assert_equal "0.00 KB/s" "$actual" "counter reset"
+}
+
+test_invalid_rate_input_fails() {
+ local output
+ local status
+ output=$(calculate_rate invalid 1024 1 2>&1)
+ status=$?
+
+ assert_equal "1" "$status" "invalid input status" || return 1
+ assert_contains "$output" "must be non-negative integers" "invalid input error"
+}
+
+test_detects_default_route_interface() {
+ local actual
+
+ # ShellCheck cannot see that get_active_interface resolves this test double.
+ # shellcheck disable=SC2329
+ route() {
+ printf ' interface: en7\n'
+ }
+
+ actual=$(get_active_interface 2>/dev/null)
+ unset -f route
+ assert_equal "en7" "$actual" "detected interface"
+}
+
+test_route_failure_returns_successful_fallback() {
+ local actual
+ local status
+
+ # ShellCheck cannot see that get_active_interface resolves this test double.
+ # shellcheck disable=SC2329
+ route() {
+ return 1
+ }
+
+ actual=$(get_active_interface 2>/dev/null)
+ status=$?
+ unset -f route
+
+ assert_equal "0" "$status" "fallback status" || return 1
+ assert_equal "en0" "$actual" "fallback interface"
+}
+
+test_explicit_interface_bypasses_detection() {
+ local actual
+ actual=$(NETMON_INTERFACE=utun9 get_active_interface 2>/dev/null)
+ assert_equal "utun9" "$actual" "explicit interface"
+}
+
+test_dependency_check_accepts_present_commands() {
+ check_dependencies bash awk
+}
+
+test_dependency_check_reports_missing_commands() {
+ local output
+ local status
+ output=$(check_dependencies __macnet_missing_one__ __macnet_missing_two__ 2>&1)
+ status=$?
+
+ assert_equal "1" "$status" "missing dependency status" || return 1
+ assert_contains "$output" "__macnet_missing_one__ __macnet_missing_two__" "missing dependency list"
+}
+
+test_direct_execution_rejects_noninteractive_output() {
+ local output
+ local status
+ output=$(bash "$ROOT_DIR/netmon.sh" 2>&1)
+ status=$?
+
+ assert_equal "1" "$status" "non-interactive status" || return 1
+ assert_contains "$output" "interactive terminal is required" "non-interactive error"
+}
+
+test_cleanup_restores_terminal_state() {
+ local expected="cnorm sgr0 rmcup"
+ TPUT_CALLS=""
+ SCREEN_ACTIVE=1
+
+ # ShellCheck cannot see that cleanup resolves this test double.
+ # shellcheck disable=SC2329
+ tput() {
+ TPUT_CALLS="${TPUT_CALLS}${TPUT_CALLS:+ }$1"
+ }
+
+ cleanup
+ unset -f tput
+
+ assert_equal "$expected" "$TPUT_CALLS" "terminal cleanup order" || return 1
+ assert_equal "0" "$SCREEN_ACTIVE" "terminal cleanup state"
+}
+
+printf '1..12\n'
+run_test "formats KB/s using the sample interval" test_rate_formats_kilobytes
+run_test "scales 1024 KB/s to MB/s" test_rate_scales_at_one_megabyte
+run_test "calculates independent receive/transmit deltas" test_transfer_pair_uses_independent_deltas
+run_test "clamps a reset cumulative counter" test_counter_reset_is_clamped
+run_test "rejects invalid rate input" test_invalid_rate_input_fails
+run_test "parses the default-route interface" test_detects_default_route_interface
+run_test "returns a successful en0 fallback" test_route_failure_returns_successful_fallback
+run_test "honors an explicit interface override" test_explicit_interface_bypasses_detection
+run_test "accepts available dependencies" test_dependency_check_accepts_present_commands
+run_test "reports all missing dependencies" test_dependency_check_reports_missing_commands
+run_test "rejects direct non-interactive execution" test_direct_execution_rejects_noninteractive_output
+run_test "restores terminal state on cleanup" test_cleanup_restores_terminal_state
+
+if [ "$TESTS_FAILED" -ne 0 ]; then
+ printf '%d of %d tests failed\n' "$TESTS_FAILED" "$TESTS_RUN" >&2
+ exit 1
+fi
+
+printf 'All %d tests passed\n' "$TESTS_RUN"