#!/bin/bash # ICMP/TCP 扫描本网段或指定网段的在线主机,并显示主机名、MAC、厂商与开放端口 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/common/help.sh" usage() { cat <<'EOF' 用法: scan-ip [端口或网段参数...] 扫描局域网主机: 无参数 ICMP 扫描本网段 .1-.254,并尽力显示名称、MAC 和厂商 检测到多个 IPv4 网卡时会先让你选择扫描源 --identify 额外通过 mDNS 识别设备(macOS,约增加 3 秒) 10.0.0 扫描指定网段前缀 22 扫描本网段 TCP 22 端口 80,443,8080 扫描多个端口(任一开放即显示) 22 10.0.0 指定网段 + 端口(顺序不限) 选项: -h, --help 显示此帮助 依赖: ping;端口模式需要 nc (netcat)。 名称: macOS 使用系统解析器;Linux 使用 NSS(getent),安装 avahi-utils 时也会查询 mDNS;均会回退反向 DNS。 MAC: 从同一二层网络的 ARP/邻居表读取;nmap 的 OUI 数据库存在时会离线显示厂商。 提示: IP 和 hostname 都可能变化或根本不公开;MAC + 厂商通常更适合辨认设备。 要永久、准确地对应“这台设备是谁”,请在路由器 DHCP 客户端列表中为其设置备注和 DHCP 保留地址。 EOF } TIMEOUT=1 IDENTIFY=0 ARGS=() for arg in "$@"; do case "$arg" in --identify) IDENTIFY=1 ;; *) ARGS+=("$arg") ;; esac done set -- "${ARGS[@]}" dotfile_help_requested "${1:-}" && dotfile_show_help find_mac_prefix_file() { local nmap_bin prefix candidate local candidates=( /usr/local/share/nmap/nmap-mac-prefixes /opt/homebrew/share/nmap/nmap-mac-prefixes /usr/share/nmap/nmap-mac-prefixes /usr/local/share/nmap-mac-prefixes ) if nmap_bin=$(command -v nmap 2>/dev/null); then prefix=$(cd "$(dirname "$nmap_bin")/.." 2>/dev/null && pwd) candidates+=("$prefix/share/nmap/nmap-mac-prefixes") fi for candidate in "${candidates[@]}"; do if [ -r "$candidate" ]; then printf '%s' "$candidate" return fi done } MAC_PREFIX_FILE=$(find_mac_prefix_file) is_port_spec() { [[ "$1" =~ ^[0-9]+$ || "$1" =~ ^[0-9]+-[0-9]+$ || "$1" =~ ^[0-9,]+$ ]] } is_ip_prefix() { [[ "$1" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){1,3}$ ]] } PORT_SPEC="" PREFIX_ARG="" if [ -n "$1" ] && is_ip_prefix "$1"; then PREFIX_ARG="$1" [ -n "$2" ] && is_port_spec "$2" && PORT_SPEC="$2" elif [ -n "$1" ]; then is_port_spec "$1" && PORT_SPEC="$1" [ -n "$2" ] && is_ip_prefix "$2" && PREFIX_ARG="$2" fi if [ -n "$1" ] && [ -z "$PORT_SPEC" ] && [ -z "$PREFIX_ARG" ]; then echo "错误: 无法识别参数: $1" usage exit 1 fi list_ipv4_interfaces() { if [ "$(uname -s)" = "Darwin" ] && command -v ifconfig &>/dev/null; then ifconfig | awk ' /^[^[:space:]][^:]*:/ { iface=$1 sub(/:$/, "", iface) next } /^[[:space:]]+inet / { ip=$2 if (iface !~ /^lo/ && ip != "127.0.0.1" && ip !~ /^169\.254\./) { print iface "\t" ip } } ' elif command -v ip &>/dev/null; then ip -o -4 addr show up scope global 2>/dev/null \ | awk '{split($4, address, "/"); print $2 "\t" address[1]}' elif command -v ifconfig &>/dev/null; then ifconfig | awk ' /^[^[:space:]][^:]*:/ { iface=$1 sub(/:$/, "", iface) next } /^[[:space:]]+inet / { ip=$2 if (iface !~ /^lo/ && ip != "127.0.0.1" && ip !~ /^169\.254\./) { print iface "\t" ip } } ' else return 1 fi } select_ipv4_interface() { local iface ip choice entry default_iface default_label index local candidates=() while IFS=$'\t' read -r iface ip; do [ -n "$iface" ] && [ -n "$ip" ] || continue candidates+=("$iface|$ip") done < <(list_ipv4_interfaces) if [ ${#candidates[@]} -eq 0 ]; then echo "错误: 未能检测到可用的 IPv4 网卡" >&2 return 1 fi default_iface="" if [ "$(uname -s)" = "Darwin" ] && command -v route &>/dev/null; then default_iface=$(route -n get default 2>/dev/null \ | awk '/^[[:space:]]*interface:/{print $2; exit}') elif command -v ip &>/dev/null; then default_iface=$(ip route show default 2>/dev/null \ | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}') fi if [ ${#candidates[@]} -gt 1 ]; then echo "检测到多个可用 IPv4 网卡,请选择扫描源:" >&2 for index in "${!candidates[@]}"; do entry=${candidates[$index]} iface=${entry%%|*} ip=${entry#*|} default_label="" [ "$iface" = "$default_iface" ] && default_label=" (默认路由)" printf ' %d) %s %s%s\n' "$((index + 1))" "$iface" "$ip" "$default_label" >&2 done while :; do printf '请选择 [1-%d]: ' "${#candidates[@]}" >&2 IFS= read -r choice || { echo >&2 echo "错误: 未选择扫描源网卡" >&2 return 1 } if [[ "$choice" =~ ^[0-9]+$ ]] \ && [ "$choice" -ge 1 ] \ && [ "$choice" -le ${#candidates[@]} ]; then entry=${candidates[$((choice - 1))]} break fi echo "错误: 请输入 1-${#candidates[@]} 之间的编号" >&2 done else entry=${candidates[0]} fi LOCAL_IFACE=${entry%%|*} LOCAL_IP=${entry#*|} } select_ipv4_interface || exit 1 if [ -n "$PREFIX_ARG" ]; then PREFIX="$PREFIX_ARG" else PREFIX="${LOCAL_IP%.*}" fi PORTS=() if [ -n "$PORT_SPEC" ]; then if [[ "$PORT_SPEC" =~ ^[0-9]+$ ]]; then PORTS=("$PORT_SPEC") elif [[ "$PORT_SPEC" =~ ^[0-9]+-[0-9]+$ ]]; then START_PORT=${PORT_SPEC%-*} END_PORT=${PORT_SPEC#*-} if [ "$START_PORT" -gt "$END_PORT" ]; then echo "错误: 起始端口 ($START_PORT) 不能大于结束端口 ($END_PORT)" && exit 1 fi for p in $(seq "$START_PORT" "$END_PORT"); do PORTS+=("$p"); done elif [[ "$PORT_SPEC" =~ ^[0-9,]+$ ]]; then IFS=',' read -ra PORTS <<<"$PORT_SPEC" else echo "错误: 无效的端口格式: $PORT_SPEC" exit 1 fi fi echo "扫描源网卡: ${LOCAL_IFACE} (${LOCAL_IP})" echo "本机 IP: ${LOCAL_IP},扫描网段: ${PREFIX}.1-254" if [ ${#PORTS[@]} -gt 0 ]; then echo "扫描端口: ${PORTS[*]} (TCP,超时 ${TIMEOUT}s)" else echo "扫描模式: ICMP (在线检测)" fi echo "------------------------------------------------------" hostname_for_ip() { local ip="$1" local hostname="" # macOS 的系统解析器包含本机 hosts、DNS 和可用的 Bonjour/mDNS 记录。 if [ "$(uname -s)" = "Darwin" ] && command -v dscacheutil &>/dev/null; then hostname=$(dscacheutil -q host -a ip_address "$ip" 2>/dev/null \ | awk -F': ' '/^name: / {print $2; exit}') fi # Linux 的 NSS 配置可包含 /etc/hosts、DNS 与 mDNS(例如 libnss-mdns)。 if [ -z "$hostname" ] && command -v getent &>/dev/null; then hostname=$(getent hosts "$ip" 2>/dev/null | awk 'NR == 1 {print $2}') fi # 未配置 NSS mDNS 时,avahi-utils 仍可直接查询局域网的 .local 名称。 if [ -z "$hostname" ] && command -v avahi-resolve-address &>/dev/null; then if command -v timeout &>/dev/null; then hostname=$(timeout 1 avahi-resolve-address -4 "$ip" 2>/dev/null \ | awk -F '\t' 'NR == 1 {print $2}') else hostname=$(avahi-resolve-address -4 "$ip" 2>/dev/null \ | awk -F '\t' 'NR == 1 {print $2}') fi fi # 没有本地记录时再查询 PTR;限制超时,避免名称解析拖慢扫描。 if [ -z "$hostname" ] && command -v dig &>/dev/null; then # macOS 的 dig 会在 DNS 超时时将 ";; connection timed out" 输出到 stdout。 # PTR 记录只接受由主机名字符组成的一行,避免将诊断信息显示为主机名。 hostname=$(dig +time=1 +tries=1 +short -x "$ip" 2>/dev/null \ | awk '/^[[:alnum:]_][[:alnum:]_.-]*\.?$/ {print; exit}') fi # PTR 记录通常以点结尾,显示时去掉以保持紧凑。 printf '%s' "${hostname%.}" } normalize_mac() { local mac="$1" local octet local octets=() local normalized=() IFS=':' read -r -a octets <<<"$mac" [ "${#octets[@]}" -eq 6 ] || { printf '%s' "$mac" return } for octet in "${octets[@]}"; do [[ "$octet" =~ ^[[:xdigit:]]{1,2}$ ]] || { printf '%s' "$mac" return } printf -v octet '%02x' "0x$octet" normalized+=("$octet") done (IFS=':'; printf '%s' "${normalized[*]}") } mac_for_ip() { local ip="$1" local mac="" # 一次 ICMP/TCP 探测后,位于同一二层网络的设备一般会出现在 ARP/邻居表中。 if [ "$(uname -s)" = "Darwin" ] && command -v arp &>/dev/null; then mac=$(arp -n "$ip" 2>/dev/null \ | awk '/ at ([[:xdigit:]]{1,2}:){5}[[:xdigit:]]{1,2}/ {for (i = 1; i <= NF; i++) if ($i == "at") {print $(i + 1); exit}}') elif command -v ip &>/dev/null; then mac=$(ip neigh show to "$ip" 2>/dev/null \ | awk '/lladdr/ {for (i = 1; i <= NF; i++) if ($i == "lladdr") {print $(i + 1); exit}}') elif command -v arp &>/dev/null; then mac=$(arp -n "$ip" 2>/dev/null \ | awk '/ at ([[:xdigit:]]{1,2}:){5}[[:xdigit:]]{1,2}/ {for (i = 1; i <= NF; i++) if ($i == "at") {print $(i + 1); exit}}') fi normalize_mac "$mac" } mac_vendor_for_mac() { local mac="$1" local first_octet prefix vendor [ -z "$mac" ] && return first_octet=${mac%%:*} # 本地管理位为 1 的 MAC 常见于 iOS/Android/Windows 的 Wi-Fi 随机地址,不能据此推断厂商。 if (( (16#$first_octet) & 2 )); then return fi [ -z "$MAC_PREFIX_FILE" ] && return prefix=${mac//:/} prefix=${prefix:0:6} prefix=$(printf '%s' "$prefix" | tr '[:lower:]' '[:upper:]') vendor=$(awk -v prefix="$prefix" '$1 == prefix {$1 = ""; sub(/^ /, ""); print; exit}' "$MAC_PREFIX_FILE") printf '%s' "$vendor" } format_host() { local ip="$1" local hostname mac vendor hostname=$(hostname_for_ip "$ip") mac=$(mac_for_ip "$ip") vendor=$(mac_vendor_for_mac "$mac") printf '%s' "$ip" if [ "$ip" = "$LOCAL_IP" ]; then printf ' (本机)' fi if [ -n "$hostname" ]; then printf ' name=%s' "$hostname" fi if [ -n "$mac" ]; then printf ' mac=%s' "$mac" fi if [ -n "$vendor" ]; then printf ' vendor=%s' "$vendor" fi } RESULT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/scan-ip.XXXXXX") MDNS_RESULT_DIR="$RESULT_DIR/mdns" mkdir -p "$MDNS_RESULT_DIR" trap 'rm -rf "$RESULT_DIR"' EXIT show_total() { local i total=0 for i in $(seq 1 254); do [ -f "$RESULT_DIR/$i" ] && total=$((total + 1)) done echo "共发现 $total 个 IP" } show_results() { local i file line mdns labels for i in $(seq 1 254); do [ -f "$RESULT_DIR/$i" ] || continue labels=() for file in "$MDNS_RESULT_DIR/$i."*; do [ -f "$file" ] && labels+=("${file##*.}") done if [ "${#labels[@]}" -gt 0 ]; then mdns=$(IFS=,; printf '%s' "${labels[*]}") line=$(cat "$RESULT_DIR/$i") if [[ "$line" == *" 在线" ]]; then printf '%s mdns=%s 在线\n' "${line% 在线}" "$mdns" else printf '%s mdns=%s\n' "$line" "$mdns" fi else cat "$RESULT_DIR/$i" fi done } capture_dns_sd() { local output="$1" shift local pid # dns-sd 持续监听且重定向时会缓冲;script 提供伪终端并立即刷新输出。 script -qF "$output" dns-sd "$@" >/dev/null 2>&1 & pid=$! sleep 2 pkill -TERM -P "$pid" 2>/dev/null || true kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true } resolve_mdns_instance() { local type="$1" local label="$2" local instance="$3" local index="$4" local output="$RESULT_DIR/mdns-resolve-$index" local pid host ip suffix model script -qF "$output" dns-sd -L "$instance" "$type" local. >/dev/null 2>&1 & pid=$! sleep 1 pkill -TERM -P "$pid" 2>/dev/null || true kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true host=$(tr -d '\r\b' <"$output" \ | awk '/can be reached at/ {for (i = 1; i <= NF; i++) if ($i == "at") {print $(i + 1); exit}}') host=${host%:*} host=${host%.} [ -n "$host" ] || return model=$(tr -d '\r\b' <"$output" \ | awk '{for (i = 1; i <= NF; i++) if ($i ~ /^model=/) {sub(/^model=/, "", $i); print $i; exit}}') if [ -n "$model" ]; then model=${model//\//_} label="$label($model)" fi ip=$(dscacheutil -q host -a name "$host" 2>/dev/null \ | awk -F': ' '/^ip_address: / && $2 ~ /^[0-9.]+$/ {print $2; exit}') case "$ip" in "$PREFIX".*) suffix=${ip##*.} : >"$MDNS_RESULT_DIR/$suffix.$label" ;; esac } identify_mdns() { local type label browse_file instance index=0 local types=(_airplay._tcp _device-info._tcp _workstation._tcp) [ "$IDENTIFY" -eq 1 ] || return if [ "$(uname -s)" != "Darwin" ] || ! command -v dns-sd &>/dev/null \ || ! command -v script &>/dev/null; then echo "提示: 当前系统缺少受支持的 mDNS 查询工具,跳过设备识别" >&2 return fi echo "正在通过 mDNS 识别设备..." for type in "${types[@]}"; do capture_dns_sd "$RESULT_DIR/mdns-${type//_tcp/}" -B "$type" local. & done wait for type in "${types[@]}"; do case "$type" in _airplay._tcp) label=airplay ;; _device-info._tcp) label=device-info ;; *) label=workstation ;; esac browse_file="$RESULT_DIR/mdns-${type//_tcp/}" while IFS= read -r instance; do [ -n "$instance" ] || continue index=$((index + 1)) resolve_mdns_instance "$type" "$label" "$instance" "$index" & done < <(tr -d '\r\b' <"$browse_file" \ | awk '$2 == "Add" {for (i = 7; i <= NF; i++) printf "%s%s", (i == 7 ? "" : " "), $i; print ""}') done wait } if [ ${#PORTS[@]} -eq 0 ]; then if [ "$(uname -s)" = "Darwin" ]; then for i in $(seq 1 254); do ( ip="$PREFIX.$i" if ping -c 1 -W 1000 -S "$LOCAL_IP" "$ip" >/dev/null 2>&1; then printf '%s 在线\n' "$(format_host "$ip")" >"$RESULT_DIR/$i" fi ) & done else for i in $(seq 1 254); do ( ip="$PREFIX.$i" if ping -c 1 -W 1 -I "$LOCAL_IFACE" "$ip" >/dev/null 2>&1; then printf '%s 在线\n' "$(format_host "$ip")" >"$RESULT_DIR/$i" fi ) & done fi wait identify_mdns show_results echo "------------------------------------------------------" show_total echo "扫描完成" exit 0 fi if ! command -v nc &>/dev/null; then echo "错误: 找不到 nc (netcat)" exit 1 fi NC_OPTS=(-z -w "$TIMEOUT" -s "$LOCAL_IP") if [ "$(uname -s)" = "Darwin" ]; then NC_OPTS+=(-G "$TIMEOUT") fi check_host() { local ip="$1" local hit=() for port in "${PORTS[@]}"; do if nc "${NC_OPTS[@]}" "$ip" "$port" >/dev/null 2>&1; then hit+=("$port") fi done if [ ${#hit[@]} -gt 0 ]; then printf '%s 开放: %s\n' "$(format_host "$ip")" "${hit[*]}" >"$RESULT_DIR/${ip##*.}" fi } for i in $(seq 1 254); do check_host "$PREFIX.$i" & done wait identify_mdns show_results echo "------------------------------------------------------" show_total echo "扫描完成"