#! /bin/bash
#
#	Copyright (c) 2017-2023 Apple Inc. All rights reserved.
#
#	This script is currently for Apple Internal use only.
#

declare -r version=1.10
declare -r script=${BASH_SOURCE[0]}
declare -r dnssdutil=${dnssdutil:-dnssdutil}

# The serviceTypesOfInterest array is initialized with commonly-debugged service types or service types whose records can
# provide useful debugging information, e.g., _airport._tcp in case an AirPort base station is a WiFi network's access
# point. Note: Additional service types can be added with the '-s' option.

serviceTypesOfInterest=(
	_airplay._tcp			# AirPlay
	_airport._tcp			# AirPort Base Station
	_companion-link._tcp	# Companion Link
	_hap._tcp				# HomeKit Accessory Protocol (TCP)
	_hap._udp				# HomeKit Accessory Protocol (UDP)
	_homekit._tcp			# HomeKit
	_raop._tcp				# Remote Audio Output Protocol
)

#============================================================================================================================
#	PrintUsage
#============================================================================================================================

PrintUsage()
{
	echo ""
	echo "Usage: $( basename "${script}" ) [options]"
	echo ""
	echo "Options:"
	echo "    -s    Specifies a service type of interest, e.g., _airplay._tcp, _raop._tcp, etc. Can be used more than once."
	echo "    -V    Display version of this script and exit."
	echo ""
}

#============================================================================================================================
#	LogOut
#============================================================================================================================

LogOut()
{
	echo "$( date '+%Y-%m-%d %H:%M:%S%z' ): $*"
}

#============================================================================================================================
#	LogMsg
#============================================================================================================================

LogMsg()
{
	echo "$*"
	if [ -d "${workPath}" ]; then
		LogOut "$*" >> "${workPath}/log.txt"
	fi
}

#============================================================================================================================
#	ErrQuit
#============================================================================================================================

ErrQuit()
{
	echo "error: $*"
	exit 1
}

#============================================================================================================================
#	SignalHandler
#============================================================================================================================

SignalHandler()
{
	LogMsg "Exiting due to signal."
	trap '' SIGINT SIGTERM
	pkill -TERM -P $$
	wait
	exit 2
}

#============================================================================================================================
#	ExitHandler
#============================================================================================================================

ExitHandler()
{
	if [ -d "${tempPath}" ]; then
		rm -fr "${tempPath}"
	fi
}

#============================================================================================================================
#	GetStateDump
#============================================================================================================================

GetStateDump()
{
	local suffix=''
	if [ -n "${1}" ]; then
		suffix="-${1//[^A-Za-z0-9._-]/_}"
	fi
	LogMsg "Getting mDNSResponder state dump."
	dns-sd -O -stdout &> "${workPath}/state-dump${suffix}.txt"
}

#============================================================================================================================
#	RunNetStat
#============================================================================================================================

RunNetStat()
{
	LogMsg "Running netstat -g -n -s"
	netstat -g -n -s &> "${workPath}/netstat-g-n-s.txt"
}

#============================================================================================================================
#	StartPacketCapture
#============================================================================================================================

StartPacketCapture()
{
	LogMsg "Starting tcpdump."
	
	# The first tcpdump doesn't filter for any particular protocols because we want to see what else might be going on
	# on the network. To avoid packet captures that are larger than necessary, a snapshot length of 9256 B is used,
	# which is 9156 B plus 100 B as a fudge factor. This should be large enough to accommodate the largest mDNS message
	# sent by mDNSResponder, since mDNS traffic is what we primarily care about.
	#
	# The 9156 B value is the sum of the following:
	#
	#     8940 B is the maximum mDNS message size used by mDNSResponder not including the mDNS header.
	#       12 B is the size of an mDNS header.
	#        8 B is the size of a UDP header.
	#       40 B is the size of an IPv6 header (IPv6 headers are larger than IPv4 headers).
	#      156 B is the amount of additional per-frame overhead used by tcpdump on macOS 12.0.
	
	tcpdump -n -s 9256 -w "${workPath}/tcpdump.pcapng" &> "${workPath}/tcpdump.txt" &
	tcpdumpPID=$!
	tcpdump -i lo0 -n -w "${workPath}/tcpdump-loopback.pcapng" &> "${workPath}/tcpdump-loopback.txt" 'udp port 5353' &
	tcpdumpLoopbackPID=$!
}

#============================================================================================================================
#	SaveExistingPacketCaptures
#============================================================================================================================

SaveExistingPacketCaptures()
{
	LogMsg "Saving existing mDNS packet captures."
	mkdir "${workPath}/pcaps"
	for file in /tmp/mdns-tcpdump.pcapng*; do
		[ -e "${file}" ] || continue
		baseName=$( basename "${file}" | sed -E 's/^mdns-tcpdump.pcapng([0-9]+)$/mdns-tcpdump-\1.pcapng/' )
		gzip < "${file}" > "${workPath}/pcaps/${baseName}.gz"
	done
}

#============================================================================================================================
#	StopPacketCapture
#============================================================================================================================

StopPacketCapture()
{
	LogMsg "Stopping tcpdump."
	kill -TERM "${tcpdumpPID}"
	kill -TERM "${tcpdumpLoopbackPID}"
}

#============================================================================================================================
#	RunInterfaceMulticastTests
#============================================================================================================================

RunInterfaceMulticastTests()
{
	local -r ifname=${1}
	local -r allHostsV4=224.0.0.1
	local -r allHostsV6=ff02::1
	local -r mDNSV4=224.0.0.251
	local -r mDNSV6=ff02::fb
	local -r log="${workPath}/mcast-test-log-${ifname}.txt"
	local serviceList=( $( "${dnssdutil}" queryrecord -i "${ifname}" -A -t ptr -n _services._dns-sd._udp.local -l 6 | sed -E -n 's/.*(_.*_(tcp|udp)\.local\.)$/\1/p' ) )
	serviceList+=( "${serviceTypesOfInterest[@]/%/.local.}" )
	serviceList=( $( IFS=$'\n' sort -f -u <<< "${serviceList[*]}" ) )
	
	LogOut "List of services: ${serviceList[*]}" >> "${log}"
	
	# Ping IPv4 broadcast address.
	
	local broadcastAddr=$( ifconfig "${ifname}" inet | awk '$5 == "broadcast" {print $6}' )
	if [ -n "${broadcastAddr}" ]; then
		LogOut "Pinging ${broadcastAddr} on interface ${ifname}." >> "${log}"
		ping -t 5 -b "${ifname}" "${broadcastAddr}" &> "${workPath}/ping-broadcast-${ifname}.txt"
	else
		LogOut "No IPv4 broadcast address for ${ifname}." >> "${log}"
	fi
	
	# Ping All Hosts IPv4 multicast address.
	
	local routeOutput=$( route -n get -ifscope "${ifname}" "${allHostsV4}" 2> /dev/null )
	if [ -n "${routeOutput}" ]; then
		LogOut "Pinging ${allHostsV4} on interface ${ifname}." >> "${log}"
		ping -t 5 -b "${ifname}" "${allHostsV4}" &> "${workPath}/ping-all-hosts-${ifname}.txt"
	else
		LogOut "No route to ${allHostsV4} on interface ${ifname}." >> "${log}"
	fi
	
	# Ping mDNS IPv4 multicast address.
	
	routeOutput=$( route -n get -ifscope "${ifname}" "${mDNSV4}" 2> /dev/null )
	if [ -n "${routeOutput}" ]; then
		LogOut "Pinging ${mDNSV4} on interface ${ifname}." >> "${log}"
		ping -t 5 -b "${ifname}" "${mDNSV4}" &> "${workPath}/ping-mDNS-${ifname}.txt"
	else
		LogOut "No route to ${mDNSV4} on interface ${ifname}." >> "${log}"
	fi
	
	# Ping All Hosts IPv6 multicast address.
	
	routeOutput=$( route -n get -ifscope "${ifname}" -inet6 "${allHostsV6}" 2> /dev/null )
	if [ -n "${routeOutput}" ]; then
		LogOut "Pinging ${allHostsV6} on interface ${ifname}." >> "${log}"
		ping6 -c 6 -I "${ifname}" "${allHostsV6}" &> "${workPath}/ping6-all-hosts-${ifname}.txt"
	else
		LogOut "No route to ${allHostsV6} on interface ${ifname}." >> "${log}"
	fi
	
	# Ping mDNS IPv6 multicast address.
	
	routeOutput=$( route -n get -ifscope "${ifname}" -inet6 "${mDNSV6}" 2> /dev/null )
	if [ -n "${routeOutput}" ]; then
		LogOut "Pinging ${mDNSV6} on interface ${ifname}." >> "${log}"
		ping6 -c 6 -I "${ifname}" "${mDNSV6}" &> "${workPath}/ping6-mDNS-${ifname}.txt"
	else
		LogOut "No route to ${mDNSV6} on interface ${ifname}." >> "${log}"
	fi
	
	# Send mDNS queries for services.
	
	for service in "${serviceList[@]}"; do
		LogOut "Sending mDNS queries for ${service} on interface ${ifname}." >> "${log}"
		for(( i = 1; i <= 3; ++i )); do
			printf "\n"
			"${dnssdutil}" mdnsquery -i "${ifname}" -n "${service}" -t ptr -r 2
			printf "\n"
			"${dnssdutil}" mdnsquery -i "${ifname}" -n "${service}" -t ptr -r 1 --QU -p 5353
			printf "\n"
		done >> "${workPath}/mdnsquery-${ifname}.txt" 2>&1
	done
}

#============================================================================================================================
#	RunMulticastTests
#============================================================================================================================

RunMulticastTests()
{
	local -r interfaces=( $( ifconfig -l -u ) )
	local -r skipPrefixes=( awdl bridge ipsec llw nan p2p pdp_ip pktap UDC utun )
	local -a pids
	local ifname
	local skip
	local pid
	
	LogMsg "List of interfaces: ${interfaces[*]}"
	for ifname in "${interfaces[@]}"; do
		skip=false
		for prefix in "${skipPrefixes[@]}"; do
			if [[ ${ifname} =~ ^${prefix}[0-9]*$ ]]; then
				skip=true
				break
			fi
		done
		
		if ! "${skip}"; then
			ifconfig ${ifname} | egrep -q '\binet6?\b'
			if [ $? -ne 0 ]; then
				skip=true
			fi
		fi
		
		if "${skip}"; then
			continue
		fi
		
		LogMsg "Starting interface multicast tests for ${ifname}."
		RunInterfaceMulticastTests "${ifname}" & pids+=( $! )
	done
	
	LogMsg "Waiting for interface multicast tests to complete..."
	for pid in "${pids[@]}"; do
		wait "${pid}"
	done
	LogMsg "All interface multicast tests completed."
}

#============================================================================================================================
#	RunBrowseTest
#============================================================================================================================

RunBrowseTest()
{
	local -a typeArgs
	
	if [ "${#serviceTypesOfInterest[@]}" -gt 0 ]; then
		for serviceType in "${serviceTypesOfInterest[@]}"; do
			typeArgs+=( "-t" "${serviceType}" )
		done
		
		LogMsg "Running dnssdutil browseAll command for service types of interest."
		"${dnssdutil}" browseAll -A -d local -b 10 -c 10 "${typeArgs[@]}" &> "${workPath}/browseAll-STOI.txt"
	fi
	
	LogMsg "Running general dnssdutil browseAll command."
	"${dnssdutil}" browseAll -A -d local -b 10 -c 10 &> "${workPath}/browseAll.txt"
}

#============================================================================================================================
#	ArchiveLogs
#============================================================================================================================

ArchiveLogs()
{
	local parentDir=''
	# First, check for the non-macOS sysdiagnose archive path, then check for the macOS sysdiagnose archive path.
	for dir in '/var/mobile/Library/Logs/CrashReporter' '/var/tmp'; do
		if [ -w "${dir}" ]; then
			parentDir="${dir}"
			break
		fi
	done
	# If a writable path wasn't available, just use /tmp.
	[ -n "${parentDir}" ] || parentDir='/tmp'
	local -r workdir=$( basename "${workPath}" )
	local -r archivePath="${parentDir}/${workdir}.tar.gz"
	LogMsg "Archiving logs."
	echo "---"
	tar -C "${tempPath}" -czf "${archivePath}" "${workdir}"
	if [ -e "${archivePath}" ]; then
		echo "Created log archive at ${archivePath}"
		echo "*** Please run sysdiagnose NOW. ***"
		echo "Attach both the log archive and the sysdiagnose archive to the radar."
		if command -v open 2>&1 > /dev/null; then
			open "${parentDir}"
		fi
	else
		echo "Failed to create archive at ${archivePath}."
	fi
	echo "---"
}

#============================================================================================================================
#	CreateWorkDirName
#============================================================================================================================

CreateWorkDirName()
{
	local suffix=''
	local -r productName=$( sw_vers -productName )
	if [ -n "${productName}" ]; then
		suffix+="_${productName}"
	fi
	
	local model=''
	if command -v gestalt_query 2>&1 > /dev/null; then
		model=$( gestalt_query -undecorated ProductType )
	else
		model=$( sysctl -n hw.model )
	fi
	model=${model//,/-}
	if [ -n "${model}" ]; then
		suffix+="_${model}"
	fi
	
	local -r buildVersion=$( sw_vers -buildVersion )
	if [ -n "${buildVersion}" ]; then
		suffix+="_${buildVersion}"
	fi
	
	suffix=${suffix//[^A-Za-z0-9._-]/_}
	
	printf "bonjour-mcast-diags_$( date '+%Y.%m.%d_%H-%M-%S%z' )${suffix}"
}

#============================================================================================================================
#	main
#============================================================================================================================

main()
{
	while getopts ":s:hV" option; do
		case "${option}" in
			h)
				PrintUsage
				exit 0
				;;
			s)
				serviceType=$( awk '{print tolower($0)}' <<< "${OPTARG}" )
				if [[ ${serviceType} =~ ^_[-a-z0-9]*\._(tcp|udp)$ ]]; then
					serviceTypesOfInterest+=( "${serviceType}" )
				else
					ErrQuit "Service type '${OPTARG}' is malformed."
				fi
				;;
			V)
				echo "$( basename "${script}" ) version ${version}"
				exit 0
				;;
			:)
				ErrQuit "option '${OPTARG}' requires an argument."
				;;
			*)
				ErrQuit "unknown option '${OPTARG}'."
				;;
		esac
	done
	
	[ "${OPTIND}" -gt "$#" ] || ErrQuit "unexpected argument \"${!OPTIND}\"."
	
	if [ "${EUID}" -ne 0 ]; then
		if command -v sudo 2>&1 > /dev/null; then
			echo "Re-launching with sudo"
			exec sudo "${script}" "$@"
		else
			ErrQuit "$( basename "${script}" ) needs to be run as root."
		fi
	fi
	
	tempPath=$( mktemp -d -q ) || ErrQuit "Failed to make temp directory."
	workPath="${tempPath}/$( CreateWorkDirName )"
	mkdir "${workPath}" || ErrQuit "Failed to make work directory."
	
	trap SignalHandler	SIGINT SIGTERM
	trap ExitHandler	EXIT
	
	LogMsg "About: $( basename "${script}" ) version ${version} ($( md5 -q "${script}" ))."
	if [ "${dnssdutil}" != "dnssdutil" ]; then
		if [ -x "$( which "${dnssdutil}" )" ]; then
			LogMsg "Using $( "${dnssdutil}" -V ) at $( which "${dnssdutil}" )."
		else
			LogMsg "WARNING: dnssdutil (${dnssdutil}) isn't an executable."
		fi
	fi
	
	serviceTypesOfInterest=( $( IFS=$'\n' sort -u <<< "${serviceTypesOfInterest[*]}" ) )
	
	GetStateDump 'before'
	RunNetStat
	StartPacketCapture
	SaveExistingPacketCaptures
	RunBrowseTest
	RunMulticastTests
	GetStateDump 'after'
	StopPacketCapture
	ArchiveLogs
}

main "$@"
