#!/bin/sh

# SPDX-FileCopyrightText: Copyright 2026, macmpi
# SPDX-License-Identifier: MIT

# BusyBox/POSIX sh mDNS announcement-only script.
#
# Announces:
#   HOSTNAME.local -> all active IPv4 addresses
#   HOSTNAME.local -> all active IPv6 addresses
#
# Optional services:
#   SSH  (_ssh._tcp.local)
#   HTTP (_http._tcp.local)
#
# IPv6 announcements are enabled by default and can be disabled with:
#
#   ADVERTISE_IPV6=0 ./tiny-mdns-ann
#
# This sends unsolicited announcements only. It does not answer queries.
#
# Limitation: nc does not expose IP_MULTICAST_TTL or IPV6_MULTICAST_HOPS.
# Consequently, IPv4 and IPv6 packets may not use the mDNS-required
# TTL/hop limit of 255, which is ok in most cases.

# If socat is available, it will be used instead of nc to send
# announcements with TTL/hop limit of 255.

VERSION="0.5"

SCRIPT="${0##*/}"

error()
{
	echo "$SCRIPT: $*" >&2
}

if [ "$(id -u)" -ne 0 ]; then
	error "Please run with administrator privileges."
	exit 1
fi

alias _logger='logger -st "$SCRIPT"'

# Ignore INT/TERM during execution of the trap to prevent recursion loop
# Kill all background jobs attached to this shell session
# Restore default handlers and exit cleanly
trap 'trap "" INT TERM; kill 0; exit 0' INT TERM

# ----------------------------------------------------------------------
# Base configuration
# ----------------------------------------------------------------------

: "${HOSTNAME:=$(hostname 2>/dev/null || printf '%s' my-host)}"

: "${ADVERTISE_HTTP:=0}"
: "${HTTP_PORT:=80}"
: "${HTTP_PATH:=/}"

: "${ADVERTISE_SSH:=0}"
: "${SSH_PORT:=22}"

: "${INTERVAL:=60}"
: "${ADVERTISE_IPV6:=1}"
: "${DEBUG_LOG:=0}"

MDNS_GROUP4="224.0.0.251"
MDNS_GROUP6="ff02::fb"
MDNS_PORT=5353
DNS_TTL=120

NC_TIMEOUT=0.05
BURST_PAUSE=0.2

# ----------------------------------------------------------------------
# Convert IPv4 address to hexadecimal bytes.
# ----------------------------------------------------------------------

ipv4_to_hex()
{
	local ip_address=$1

	local old_ifs=$IFS
	IFS=.
	# The value is obtained from ip(8).
	# shellcheck disable=SC2086
	set -- $ip_address
	IFS=$old_ifs

	[ "$#" -eq 4 ] || return 1

	printf '%02x%02x%02x%02x' "$1" "$2" "$3" "$4"
}


# ----------------------------------------------------------------------
# Convert an IPv6 address to 32 hexadecimal digits.
#
# The input must not contain a prefix length or zone identifier.
# ----------------------------------------------------------------------

ipv6_to_hex()
{
	local ip_address=$1

	case "$ip_address" in
		*/*|*%*)
			return 1
			;;
	esac

	awk '
	function valid_hextet(value) {
		return value != "" &&
			length(value) <= 4 &&
			value ~ /^[[:xdigit:]]+$/
	}

	function count_part(part,    count, values, i) {
		if (part == "")
			return 0

		count = split(part, values, ":")

		for (i = 1; i <= count; i++) {
			if (!valid_hextet(values[i]))
				exit 1
		}

		return count
	}

	function emit_part(part,    count, values, i, value) {
		if (part == "")
			return

		count = split(part, values, ":")

		for (i = 1; i <= count; i++) {
			value = values[i]
			printf "%s", substr("0000" value, length("0000" value) - 3)
		}
	}

	{
		address = $0
		double_colon = index(address, "::")

		if (double_colon) {
			left_part = substr(address, 1, double_colon - 1)
			right_part = substr(address, double_colon + 2)

			if (index(right_part, "::"))
				exit 1

			left_count = count_part(left_part)
			right_count = count_part(right_part)

			if (left_count + right_count >= 8)
				exit 1

			emit_part(left_part)

			for (i = left_count + right_count; i < 8; i++)
				printf "0000"

			emit_part(right_part)
		} else {
			count = count_part(address)

			if (count != 8)
				exit 1

			emit_part(address)
		}

		printf "\n"
	}
	' <<EOF
$ip_address
EOF
}


# ----------------------------------------------------------------------
# Encode a dotted DNS name as hexadecimal DNS labels.
#
# Example:
#   mybox.local
# becomes:
#   056d79626f78056c6f63616c00
# ----------------------------------------------------------------------

dns_name_hex()
{
	local remaining=$1
	local encoded_name=""
	local label label_length label_hex

	while [ -n "$remaining" ]; do
		case "$remaining" in
			*.*)
				label=${remaining%%.*}
				remaining=${remaining#*.}
				;;
			*)
				label=$remaining
				remaining=""
				;;
		esac

		[ -n "$label" ] || return 1
		[ "${#label}" -le 63 ] || return 1

		label_length=$(printf '%02x' "${#label}") || return 1
		label_hex=$(printf '%s' "$label" |
			od -An -tx1 | tr -d ' \n') || return 1

		encoded_name=${encoded_name}${label_length}${label_hex}
	done

	printf '%s00' "$encoded_name"
}


# ----------------------------------------------------------------------
# Convert hexadecimal packet data to printf %b escape text.
#
# Example:
#   aabb -> \xaa\xbb
# ----------------------------------------------------------------------

packet_from_hex()
{
	local hex_packet="$1"
	local byte payload=""
	local byte

	while [ -n "$hex_packet" ]; do
	# Grab only the first two characters
	byte="${hex_packet%"${hex_packet#??}"}"

	# Destructively strip those two characters off the front for the next run
	hex_packet="${hex_packet#??}"

	# Concatenate the string byte accumulation buffer natively
	payload="${payload}\\x${byte}"
	done

	printf '%s' "$payload"
}


# ----------------------------------------------------------------------
# Build a single packet containing PTR records for all active services
# ----------------------------------------------------------------------
make_combined_ptr_payload()
{
	local total_records=0
	local combined_hex=""
	local service_hex target_hex target_length target_length_hex
	local dns_count_hex dns_header

	if [ "$ADVERTISE_SSH" -eq 1 ]; then
		service_hex=$(dns_name_hex "_services._dns-sd._udp.local") || return 1
		target_hex=$(dns_name_hex "_ssh._tcp.local") || return 1
		target_length=$(( ${#target_hex} / 2 ))
		target_length_hex=$(printf '%04x' "$target_length") || return 1

		combined_hex="${combined_hex}${service_hex}000c0001${TTL_HEX}${target_length_hex}${target_hex}"
		total_records=$((total_records + 1))
	fi

	if [ "$ADVERTISE_HTTP" -eq 1 ]; then
		service_hex=$(dns_name_hex "_services._dns-sd._udp.local") || return 1
		target_hex=$(dns_name_hex "_http._tcp.local") || return 1
		target_length=$(( ${#target_hex} / 2 ))
		target_length_hex=$(printf '%04x' "$target_length") || return 1

		combined_hex="${combined_hex}${service_hex}000c0001${TTL_HEX}${target_length_hex}${target_hex}"
		total_records=$((total_records + 1))
	fi

	[ "$total_records" -gt 0 ] || return 1

	# Assemble DNS Header with the dynamic Answer Count
	# 00008400 (Standard Response) -> 0000 (Questions) -> [total_records] (Answers)
	dns_count_hex=$(printf '%04x' "$total_records")
	dns_header="000084000000${dns_count_hex}00000000"

	packet_from_hex "${dns_header}${combined_hex}"
}


# ----------------------------------------------------------------------
# Build a single unified packet for ALL services (SSH, HTTP, etc.)
# It combines services announcement with PTR, SRV, and TXT within same  packet.
# ----------------------------------------------------------------------

make_all_services_announcement()
{
	local instance="$1"
	local total_records=0
	local combined_hex=""
	local srv_type port txt_val
	local svc_hex inst_hex port_hex rec_len_hex
	local txt_bytes txt_len txt_hex txt_rec_len_hex
	local dns_count_hex dns_header

	# Loop through active services to build records linearly
	for srv_type in ssh http; do
		if [ "$srv_type" = "ssh" ] && [ "$ADVERTISE_SSH" -eq 1 ]; then
			port="$SSH_PORT"
			txt_val=""
		elif [ "$srv_type" = "http" ] && [ "$ADVERTISE_HTTP" -eq 1 ]; then
			port="$HTTP_PORT"
			txt_val="path=${HTTP_PATH}"
		else
			continue
		fi

		# Compute DNS Labels
		svc_hex=$(dns_name_hex "_${srv_type}._tcp.local") || return 1
		inst_hex=$(dns_name_hex "${instance}._${srv_type}._tcp.local") || return 1
		port_hex=$(printf '%04x' "$port") || return 1

		# PTR Record (Service Name -> Instance Name)
		rec_len_hex=$(printf '%04x' $(( ${#inst_hex} / 2 )))
		combined_hex="${combined_hex}${svc_hex}000c0001${TTL_HEX}${rec_len_hex}${inst_hex}"

		# SRV Record (Instance Name -> Target Host + Port)
		# 6 fixed bytes for Priority(2) + Weight(2) + Port(2)
		rec_len_hex=$(printf '%04x' $(( 6 + (${#HOST_HEX} / 2) )))
		combined_hex="${combined_hex}${inst_hex}00210001${TTL_HEX}${rec_len_hex}00000000${port_hex}${HOST_HEX}"

		# TXT Record (Instance Name -> Text Attributes)
		if [ -n "$txt_val" ]; then
			txt_bytes=$(printf '%s' "$txt_val" | od -An -tx1 | tr -d ' \n') || return 1
			txt_len=$(printf '%02x' "${#txt_val}") || return 1
			txt_hex="${txt_len}${txt_bytes}"
		else
			txt_hex="00"
		fi
		txt_rec_len_hex=$(printf '%04x' $(( ${#txt_hex} / 2 )))
		combined_hex="${combined_hex}${inst_hex}00108001${TTL_HEX}${txt_rec_len_hex}${txt_hex}"

		total_records=$((total_records + 3))
	done

	[ "$total_records" -gt 0 ] || return 1

	# Assemble complete DNS Response packet (12-byte header + records)
	dns_count_hex=$(printf '%04x' "$total_records")
	dns_header="000084000000${dns_count_hex}00000000"

	packet_from_hex "${dns_header}${combined_hex}"
}


# ----------------------------------------------------------------------
# Send an escaped packet through nc, or socat if available.
# ----------------------------------------------------------------------

send_payload()
{
	local payload=$1
	local source_address=$2
	local destination_address=$3
	local family=$4

	local  socat_target

	if [ "$SEND_TOOL" = socat ]; then
		case "$family" in
			4) socat_target="UDP4-DATAGRAM:${destination_address}:${MDNS_PORT},bind=${source_address}:${MDNS_PORT},multicastttl=255"
				;;
			6) socat_target="UDP6-DATAGRAM:[${destination_address}]:${MDNS_PORT},bind=[${source_address}]:${MDNS_PORT},${SOCAT_IPV6_HOPS}"
				;;
			*)
				return 1
				;;
		esac

		# socat -T0 quits rapidly after EOF
		printf '%b' "$payload" |
			socat -u -T0 - "$socat_target,reuseaddr" \
				>/dev/null 2>&1
		return $?
	fi

	# nc path: use source binding only if supported
	# nc without -N option have a 2 sec built-in timeout to disconnect, so we add external timeout
	# braces to silence Terminated message if timeout kicks-in
	{
		if [ "$NC_SUPPORTS_SOURCE_BINDING" -eq 1 ]; then
			printf '%b' "$payload" | timeout "$NC_TIMEOUT" nc -u -p "$MDNS_PORT" \
				-s "$source_address" \
				-w 1 \
				"$destination_address" "$MDNS_PORT"
		else
			# this degraded mode may loose IPv4 services announcements
			printf '%b' "$payload" | timeout "$NC_TIMEOUT" nc -u -p "$MDNS_PORT" \
				-w 1 \
				"$destination_address" "$MDNS_PORT"
		fi
	} >/dev/null 2>&1
	# 0: closed cleanly
	# 124: successfully cut off by timeout after sending
	# 128+15=143: successfully cut off by timeout after sending
	case "$?" in
		0|124|143)
			return 0
			;;
		*)
			return 1
			;;
	esac
}


# ----------------------------------------------------------------------
# Announce one IPv4 or IPv6 address.
# ----------------------------------------------------------------------

announce_address()
{
	local family=$1
	local iface=$2
	local ip_address=$3

	local source_address destination_address payload

	case "$family" in
		4)
			source_address=$ip_address
			destination_address=$MDNS_GROUP4

			ip_hex=$(ipv4_to_hex "$ip_address") || {
				_logger "ERROR: ipv4_to_hex failed for $ip_address"
				return 1
			}
			raw_packet="000084000000000100000000${A_FRAGMENT}${ip_hex}"
			;;
		6)
			source_address=$ip_address
			destination_address=${MDNS_GROUP6}%${iface}

			case "$ip_address" in
				fe80:*)
					source_address=${ip_address}%${iface}
					;;
			esac

			ip_hex=$(ipv6_to_hex "$ip_address") || {
				_logger "ERROR: ipv6_to_hex failed for $ip_address"
				return 1
			}
			raw_packet="000084000000000100000000${AAAA_FRAGMENT}${ip_hex}"
			;;
		*)
			return 1
			;;
	esac

	if [ "$DEBUG_LOG" -eq 1 ]; then
		_logger "Announcing records for ${HOSTNAME}.local -> ${ip_address} via ${iface} (IPv${family})"
	fi

	# Hostname announcement.
	send_payload  "$(packet_from_hex "$raw_packet")" "$source_address" \
		"$destination_address" "$family" || {
			_logger "ERROR: hostname announcement"
			return 1
		}

	if [ -n "$COMBINED_PTR_PACKET" ]; then
	# Service-type enumeration records.
		send_payload "$COMBINED_PTR_PACKET" "$source_address" \
			"$destination_address" "$family" || {
				_logger " ERROR: unified ptr services announcement"
				return 1
			}
	# Broadcast ALL service instances simultaneously in one packet.
		send_payload "$ALL_INSTANCES_FULL_PACKET" "$source_address" \
			"$destination_address" "$family" || {
				_logger "ERROR: services announcement"
				return 1
			}
	fi
}


# ----------------------------------------------------------------------
# Enumerate active IPv4 addresses.
# ----------------------------------------------------------------------

send_ipv4_announcements()
{
	ip -o -4 addr show up 2>/dev/null |
		while read -r _ iface _ address _; do
			ip_address=${address%/*}

			case "$ip_address" in
				127.*|0.*|169.254.*)
					continue
					;;
			esac

			announce_address 4 "$iface" "$ip_address" || {
				error "Failed to announce IPv4 $ip_address on $iface"
			}
		done
}


# ----------------------------------------------------------------------
# Enumerate active IPv6 addresses.
# ----------------------------------------------------------------------

send_ipv6_announcements()
{
	[ "$ADVERTISE_IPV6" -eq 1 ] || return 0

	ip -o -6 addr show up 2>/dev/null |
		while read -r _ iface _ address _ scope _; do
			ip_address=${address%/*}

			case "$ip_address" in
				::|::1|ff*|0:*)
					continue
					;;
			esac

			case "$scope" in
				global|link)
					;;
				*)
					continue
					;;
			esac

			announce_address 6 "$iface" "$ip_address" || {
				error "Failed to announce IPv6 $ip_address on $iface"
			}
		done
}


# ----------------------------------------------------------------------
# Enumerate and announce all configured addresses.
# ----------------------------------------------------------------------

send_all_announcements()
{
	send_ipv4_announcements
	send_ipv6_announcements
}


# ----------------------------------------------------------------------
# Usage.
# ----------------------------------------------------------------------

usage()
{
	if [ "$2" ]; then
		error "$2"
	fi

	if [ "$1" -eq 0 ]; then
		output_fd=1
	else
		output_fd=2
	fi

	cat >&"$output_fd" <<EOF

usage: $SCRIPT [OPTIONS]

Announce HOSTNAME.local over mDNS on active interfaces.

Options:
  --help                Show this help.
  --host NAME           Host name to announce in the .local domain.
  --http[=PORT[,PATH]]  Advertise HTTP, default port 80, path /.
  --ssh[=PORT]          Advertise SSH, default port 22.

Environment:
  INTERVAL              Announcement interval in seconds, default 60.
  ADVERTISE_IPV6        Enable IPv6 announcements, default 1.
  DEBUG_LOG             Log each announcement, default 0.

EOF

	exit "$1"
}


# ----------------------------------------------------------------------
# Main.
# ----------------------------------------------------------------------

if command -v socat >/dev/null 2>&1; then
	SEND_TOOL=socat
	# SOCAT_IPV6_HOPS parameter is os dependant
	case "$(uname -s)" in
		Linux) OPT=18 ;;
    	*) OPT=10 ;; # default value for Darwin,*BSD,CYGWIN*,MSYS*
	esac
	SOCAT_IPV6_HOPS="setsockopt=41:$OPT:255"
elif command -v nc >/dev/null 2>&1; then
	SEND_TOOL=nc
else
	usage 1 "This tool requires netcat, or optionally socat."
fi

# Detect nc capabilities
NC_SUPPORTS_SOURCE_BINDING=0

if [ "$SEND_TOOL" = nc ]; then
	# Test if nc supports -p and -s flags for source binding
	if printf '%b' "test" | nc -u -p 5353 -s 127.0.0.1 -w 1 127.0.0.1 5353 \
		>/dev/null 2>&1; then
		NC_SUPPORTS_SOURCE_BINDING=1
	else
		_logger "nc does not support -s/-p features: other nc version or socat may improve announcements."
	fi
fi

command -v ip >/dev/null 2>&1 ||
	usage 1 "This tool requires ip."

command -v getopt >/dev/null 2>&1 ||
	usage 1 "This tool requires getopt."

OPTS=$(getopt -o "" \
	-l help,host:,http::,ssh:: \
	-n "$SCRIPT" -- "$@") || usage 1

# getopt output is shell syntax by design.
# shellcheck disable=SC2086
eval set -- "$OPTS"

while [ "$1" != "--" ]; do
	case "$1" in
		--help)
			echo "$SCRIPT $VERSION"
			usage 0
			;;
		--host)
			shift
			HOSTNAME=$1
			;;
		--http)
			ADVERTISE_HTTP=1
			if [ -n "$2" ]; then
				HTTP_PORT="${2%%,*}"
				: "${HTTP_PORT:=80}"
				HTTP_PATH="${2#*,}"
				[ "$HTTP_PATH" = "$2" ] && HTTP_PATH=""
				: "${HTTP_PATH:=/}"
				shift
			fi
			;;
		--ssh)
			ADVERTISE_SSH=1
			if [ -n "$2" ]; then
				SSH_PORT=$2
				shift
			fi
			;;
	esac

	shift
done


# ----------------------------------------------------------------------
# Parameter validation.
# ----------------------------------------------------------------------

case "$HOSTNAME" in
	""|*[!A-Za-z0-9-]*)
		usage 1 "Invalid HOSTNAME: use only letters, digits, and '-'."
		;;
esac

if [ "${#HOSTNAME}" -gt 63 ]; then
	usage 1 "HOSTNAME is longer than 63 characters."
fi

case "$INTERVAL:$MDNS_PORT:$DNS_TTL:$SSH_PORT:$HTTP_PORT" in
	*[!0-9:]*)
		usage 1 "INTERVAL, ports, and DNS_TTL must be numeric."
		;;
esac

case "$ADVERTISE_IPV6:$ADVERTISE_SSH:$ADVERTISE_HTTP:$DEBUG_LOG" in
	*[!01:]*)
		usage 1 "Feature flags must be 0 or 1."
		;;
esac


_logger "Starting Tiny mDNS announcer v$VERSION by macmpi for ${HOSTNAME}.local"
if [ "$DEBUG_LOG" -eq 1 ]; then
	_logger "IPv6 announcements: ${ADVERTISE_IPV6}"
	_logger "UDP sender: ${SEND_TOOL}"
fi


# ----------------------------------------------------------------------
# Packets pre-computation
# ----------------------------------------------------------------------

TTL_HEX=$(printf '%08x' "$DNS_TTL")

# Partial Hostname Headers
HOST_HEX=$(dns_name_hex "$HOSTNAME.local")
A_FRAGMENT="${HOST_HEX}00018001${TTL_HEX}0004"
AAAA_FRAGMENT="${HOST_HEX}001c8001${TTL_HEX}0010"

# Separate Service Type Discoveries
COMBINED_PTR_PACKET=""
ALL_INSTANCES_FULL_PACKET=""
if [ "$ADVERTISE_SSH" -eq 1 ] || [ "$ADVERTISE_HTTP" -eq 1 ]; then
	COMBINED_PTR_PACKET=$(make_combined_ptr_payload)
	ALL_INSTANCES_FULL_PACKET=$(make_all_services_announcement "${HOSTNAME}")
fi


# ----------------------------------------------------------------------
# Announcements
# ----------------------------------------------------------------------

# Initial announcement burst.
# shellcheck disable=SC2034  # i unused on purpose
for i in 1 2 3 4; do
	send_all_announcements
	sleep "$BURST_PAUSE"
done
sleep 1 & # keep responsiveness to SIGINT
wait $!
send_all_announcements
sleep 2 &
wait $!
send_all_announcements

# Periodic announcements.
while :; do
	sleep "$INTERVAL" &
	wait $!
	send_all_announcements
done

