killports•13.9 kB
#!/usr/bin/env bash
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
# Kills processes listening on specified TCP ports or port ranges.
# Works on macOS and Linux.
#
# Usage:
#   killports [<options>] <port_or_range> [<port_or_range>...]
#
# Options:
#   -f, --force: Use SIGKILL (kill -9) instead of the default SIGTERM.
#   -y, --yes:   Automatically answer yes to confirmation prompts.
#
# Arguments:
#   <port_or_range>: A single TCP port number or a range specified as start..end (inclusive).
#
# Examples:
#   killports 8000 8080                # Kills processes on ports 8000 and 8080 with SIGTERM after confirmation
#   killports 9000..9010               # Kills processes on ports 9000 through 9010 with SIGTERM after confirmation
#   killports -y 3100..3102 3400       # Kills processes on 3100-3102 and 3400 with SIGTERM, skipping confirmation
#   killports --force 8000             # Force kills process on port 8000 with SIGKILL, skipping confirmation
#   killports -y --force 8080 9090     # Force kills processes on 8080 and 9090 with SIGKILL, skipping confirmation
#
# To test the script, run the following commands to start test servers:
#   python3 -m http.server 3100 &
#   python3 -m http.server 3101 &
#   python3 -m http.server 3102 &
#   python3 -m http.server 3400 &
#
# Then run the script with the following command to kill the processes:
#   killports 3100..3102 3400
#
# To force kill the processes, run the following command:
#   killports --force 3100..3102 3400
#
# To skip confirmation, run the following command:
#   killports -y 3100..3102 3400
#
# To force kill and skip confirmation, run the following command:
#   killports --force -y 3100..3102 3400
#set -x
set -euo pipefail
DEFAULT_SIGNAL="TERM" # Default signal (TERM allows graceful shutdown)
FORCE_SIGNAL="KILL"   # Force signal (KILL terminates immediately)
# Populated by killports::parse_args
declare -A TARGET_PORTS_MAP=() # Associative array for unique target ports
FORCE_KILL=0                   # 1 if --force is used, 0 otherwise
SKIP_CONFIRMATION=0            # 1 if -y/--yes is used, 0 otherwise
KILL_SIGNAL=$DEFAULT_SIGNAL    # Signal to use (TERM or KILL)
# Populated by killports::kill_processes
KILLED_COUNT=0 # Number of processes successfully signaled
FAILED_COUNT=0 # Number of processes failed to signal
# Print usage instructions and exit.
killports::usage() {
  local script_name
  script_name=$(basename "$0")
  echo "Usage: $script_name [<options>] <port_or_range> [<port_or_range>...]" >&2
  echo "" >&2
  echo "Options:" >&2
  echo "  -f, --force: Use SIGKILL (kill -9) instead of the default SIGTERM." >&2
  echo "  -y, --yes:   Automatically answer yes to confirmation prompts." >&2
  echo "" >&2
  echo "Arguments:" >&2
  echo "  <port_or_range>: A single TCP port number or a range specified as start..end (inclusive)." >&2
  echo "" >&2
  echo "Examples:" >&2
  echo "  $script_name 8000 8080                # Kills processes on ports 8000 and 8080 with SIGTERM after confirmation" >&2
  echo "  $script_name 9000..9010               # Kills processes on ports 9000 through 9010 with SIGTERM after confirmation" >&2
  echo "  $script_name -y 3100..3102 3400       # Kills processes on 3100-3102 and 3400 with SIGTERM, skipping confirmation" >&2
  echo "  $script_name --force 8000             # Force kills process on port 8000 with SIGKILL, skipping confirmation" >&2
  echo "  $script_name -y --force 8080 9090     # Force kills processes on 8080 and 9090 with SIGKILL, skipping confirmation" >&2
  exit 1
}
# Check if a command exists.
killports::command_exists() {
  command -v "$1" >/dev/null 2>&1
}
# Parse command line arguments.
# Populates global variables: TARGET_PORTS_MAP, FORCE_KILL, SKIP_CONFIRMATION, KILL_SIGNAL.
killports::parse_args() {
  local args_remaining=()
  local arg port start_port end_port
  # Separate flags from port/range arguments
  while [[ $# -gt 0 ]]; do
    case "$1" in
    -f | --force)
      FORCE_KILL=1
      KILL_SIGNAL=$FORCE_SIGNAL
      shift
      ;;
    -y | --yes)
      SKIP_CONFIRMATION=1
      shift
      ;;
    --)
      # End of options marker
      shift
      args_remaining+=("$@") # Add all remaining arguments
      break                  # Stop processing options
      ;;
    -*)
      echo "Error: Unknown option: $1" >&2
      killports::usage
      ;;
    *)
      # Not an option, assume it's a port/range
      args_remaining+=("$1")
      shift # consume the argument
      ;;
    esac
  done
  # Validation after Parsing Flags.
  if [[ ${#args_remaining[@]} -eq 0 ]]; then
    echo "Error: No ports or ranges specified." >&2
    killports::usage
  fi
  if [[ $FORCE_KILL -eq 1 ]]; then
    echo "Using force kill (SIGKILL)."
  fi
  if [[ $SKIP_CONFIRMATION -eq 1 && $FORCE_KILL -eq 0 ]]; then
    echo "Skipping confirmation prompt."
  fi
  # Process Port/Range Arguments.
  for arg in "${args_remaining[@]}"; do
    if [[ $arg =~ ^([0-9]+)\.\.([0-9]+)$ ]]; then
      # Range a..b
      start_port="${BASH_REMATCH[1]}"
      end_port="${BASH_REMATCH[2]}"
      if ! [[ $start_port =~ ^[0-9]+$ && $end_port =~ ^[0-9]+$ && $start_port -ge 0 && $start_port -le 65535 && $end_port -ge 0 && $end_port -le 65535 ]]; then
        echo "Error: Invalid port numbers in range '$arg'. Ports must be between 0 and 65535." >&2
        killports::usage
      fi
      if ((start_port > end_port)); then
        echo "Error: Start port ($start_port) cannot be greater than end port ($end_port) in range '$arg'." >&2
        killports::usage
      fi
      echo "Adding ports from range $start_port to $end_port."
      for ((port = start_port; port <= end_port; port++)); do
        TARGET_PORTS_MAP[$port]=1
      done
    elif [[ $arg =~ ^[0-9]+$ ]]; then
      # Single port
      port="$arg"
      if ! [[ $port =~ ^[0-9]+$ && $port -ge 0 && $port -le 65535 ]]; then
        echo "Error: Invalid port number '$port'. Port must be between 0 and 65535." >&2
        killports::usage
      fi
      echo "Adding port $port."
      TARGET_PORTS_MAP[$port]=1
    else
      echo "Error: Invalid argument '$arg'. Must be a port number (0-65535) or a range (e.g., 8000..8010)." >&2
      killports::usage
    fi
  done
  # This check should technically be covered by the args_remaining check above,
  # but keeping it as a safeguard in case of future logic changes.
  if [[ ${#TARGET_PORTS_MAP[@]} -eq 0 ]]; then
    echo "Error: No valid ports or ranges were ultimately processed." >&2
    killports::usage
  fi
}
# Check for required dependencies.
killports::check_dependencies() {
  if ! killports::command_exists lsof; then
    echo "Error: 'lsof' command not found. Please install it." >&2
    echo "  On Debian/Ubuntu: sudo apt-get update && sudo apt-get install lsof" >&2
    echo "  On Fedora/CentOS/RHEL: sudo yum install lsof" >&2
    echo "  On macOS: 'lsof' is usually pre-installed." >&2
    exit 1
  fi
}
# Find processes listening on specified ports.
#
# Args:
#   $1: Comma-separated string of sorted target ports.
# Outputs:
#   Prints process info (PID and Command) to stdout.
killports::find_processes() {
  local awk_ports_list=$1
  local process_info
  # Note: On some systems, lsof might need root privileges to see all processes.
  process_info=$(lsof -i TCP -sTCP:LISTEN -P -n 2>/dev/null | awk -v ports="$awk_ports_list" '
    BEGIN {
        split(ports, port_arr, ",");
        for (i in port_arr) {
            target_ports[port_arr[i]] = 1;
        }
        # Skip header line explicitly if present (safer than tail)
        header_skipped = 0
    }
    # Check if line looks like a header and skip
    NR == 1 && $1 == "COMMAND" && $2 == "PID" { next }
    {
        # Extract port from NAME field (column 9)
        # Matches *:port, 127.0.0.1:port, [::1]:port etc.
        if (match($9, /[:][0-9]+$/)) {
            port = substr($9, RSTART + 1) # Extract port number after ":"
            if (port in target_ports) {
                # Print PID (col 2) and Command (col 1)
                print $2 " " $1;
            }
        }
    }' | sort -k1,1n -u)
  echo "$process_info"
}
# Confirm and kill processes.
#
# Args:
#   $1: Process info string (multiline, PID COMMAND per line).
#   $2: Kill signal (e.g., TERM, KILL).
#   $3: Force kill flag (1 for force, 0 for confirmation).
#   $4: Skip confirmation flag (1 for yes, 0 otherwise).
# Populates global variables: KILLED_COUNT, FAILED_COUNT.
killports::kill_processes() {
  local process_info="$1"
  local signal_to_use="$2"
  local force_flag="$3"
  local skip_confirm_flag="$4"
  local pid cmd_rest confirm
  local line_count
  # Count lines/processes found.
  line_count=$(echo "$process_info" | wc -l)
  if [[ -z $process_info ]]; then
    # Should not happen if called after checking process_info, but defensive check.
    echo "Warning: kill_processes called with no PIDs found." >&2
    return
  fi
  echo ""
  echo "Found the following $line_count process(es)/PID(s) listening on the specified ports:"
  echo "--------------------------------------------------"
  echo "PID     COMMAND"
  echo "$process_info"
  echo "--------------------------------------------------"
  echo ""
  # Ask for confirmation unless force killing or skipping confirmation.
  if [[ $force_flag -eq 0 && $skip_confirm_flag -eq 0 ]]; then
    # Use line_count instead of array length.
    read -p "Terminate these $line_count process(es) with signal $signal_to_use? [y/N]: " confirm
    # Convert confirmation to lowercase.
    confirm=${confirm,,}
    if [[ $confirm != "y" && $confirm != "yes" ]]; then
      echo "Aborted by user."
      exit 0
    fi
  elif [[ $force_flag -eq 1 ]]; then
    echo "Force killing specified. Skipping confirmation."
  # No need for an explicit message if only -y was used, parse_args already printed it.
  fi
  echo "Attempting to terminate PIDs with signal $signal_to_use..."
  KILLED_COUNT=0 # Reset counts for this run
  FAILED_COUNT=0
  # Temporarily disable exit-on-error for the loop, as read can exit non-zero at EOF.
  set +e                                                  # Temporarily disable exit on error for the loop.
  while IFS=' ' read -r pid cmd_rest || [[ -n $pid ]]; do # Handle potential missing newline at EOF.
    if [[ -z $pid ]]; then continue; fi                   # Skip empty lines if any.
    # cmd_rest will contain the command name and any subsequent parts.
    echo -n "Killing PID $pid ($cmd_rest)... "
    # Execute kill command.
    if kill "-${signal_to_use}" "$pid" 2>/dev/null; then
      echo "Success."
      ((KILLED_COUNT++))
    else
      # Check if the process still exists before declaring failure.
      if ps -p "$pid" >/dev/null; then
        echo "Failed. (Process might require higher privileges)."
        ((FAILED_COUNT++))
      else
        echo "Failed. (Process likely already exited)."
      fi
    fi
  done <<<"$process_info"
  set -e # Re-enable exit on error.
  # Print Summary.
  killports::print_summary "$KILL_SIGNAL" "$KILLED_COUNT" "$FAILED_COUNT"
  # Exit Status.
  # Exit with 0 if all targeted processes were signaled successfully or didn't need signaling.
  # Exit with 1 if some processes failed to be signaled (might need sudo).
  if ((FAILED_COUNT > 0)); then
    exit 1
  else
    exit 0
  fi
}
# Print the final summary.
#
# Args:
#   $1: Kill signal used (e.g., TERM, KILL).
#   $2: Number of processes killed.
#   $3: Number of processes failed.
killports::print_summary() {
  local signal_used="$1"
  local killed_num="$2"
  local failed_num="$3"
  echo ""
  echo "Summary:"
  echo "  Successfully sent signal $signal_used to $killed_num process(es)."
  if ((failed_num > 0)); then
    echo "  Failed to send signal $signal_used to $failed_num process(es)." >&2
    echo "  (You might need to run this script with 'sudo' for some processes)." >&2
  fi
}
# Run the main function.
#
# Args:
#   $@: The command line arguments.
main() {
  # Parse Arguments.
  # This populates TARGET_PORTS_MAP, FORCE_KILL, SKIP_CONFIRMATION, KILL_SIGNAL.
  killports::parse_args "$@"
  # Check Dependencies.
  killports::check_dependencies
  # Prepare Port List for Display and Filtering.
  local target_ports_sorted=()
  local ports_str=""
  # Get the unique sorted list of ports as a comma-separated list.
  mapfile -t target_ports_sorted < <(echo "${!TARGET_PORTS_MAP[@]}" | tr ' ' '\n' | sort -n)
  ports_str=$(
    IFS=,
    echo "${target_ports_sorted[*]}"
  )
  echo "Searching for processes listening on TCP ports: $ports_str ..."
  # Find Processes.
  local process_info
  process_info=$(killports::find_processes "$ports_str")
  if [[ -z $process_info ]]; then
    echo "No processes found listening on the specified ports ($ports_str)."
    exit 0
  fi
  # Kill Processes (includes confirmation logic).
  # This populates KILLED_COUNT, FAILED_COUNT.
  killports::kill_processes "$process_info" "$KILL_SIGNAL" "$FORCE_KILL" "$SKIP_CONFIRMATION"
  # Print Summary
  killports::print_summary "$KILL_SIGNAL" "$KILLED_COUNT" "$FAILED_COUNT"
  # Exit Status
  # - Exit with 0 if all targeted processes were signaled successfully or didn't
  #   need signaling.
  # - Exit with 1 if some processes failed to be signaled (might need sudo).
  if ((FAILED_COUNT > 0)); then
    exit 1
  else
    exit 0
  fi
}
# Run the main function
main "$@"