Files
.dotfile/script/scan-ip.sh
T

537 lines
16 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 "扫描完成"