#!/usr/bin/env bash
# testrad -- automates RADIUS testing between domains
#
# (c) 2014-2016 Mantas Mikulėnas <grawity@gmail.com>
# Released under the MIT License (dist/LICENSE.mit)
#
# Requires 'eapol_test' (from wpa_supplicant) and 'radtest' to be in path.

. lib.bash || exit

lib_config[opt_width]=24

config_file=${XDG_CONFIG_HOME?}/nullroute.lt/testrad.conf.sh

usage() {
	echo "Usage: $progname PROFILE [via SERVER] [options]"
	echo ""
	echo_opt "PROFILE" "Use given profile config block (or 'none')"
	echo ""
	echo "Server:"
	echo_opt "via SERVERPROFILE" "Use given server config block"
	echo_opt "host ADDRESS" "Set RADIUS host name or address"
	echo_opt "port PORT" "Set RADIUS authentication port"
	echo_opt "proto ip4|ip6" "Force IP protocol version"
	echo_opt "secret SECRET" "Set RADIUS secret"
	echo ""
	echo "Mechanism:"
	echo_opt "eap|phase1 MECH" "Tunnel inside an EAP mechanism (peap, tls, ttls)"
	echo_opt "mech|phase2 MECH" "Set the main mechanism (pap, mschap, gtc)"
	echo ""
	echo "Outer identity:"
	echo_opt "anon" "Use '@realm' as outer (anonymous) identity"
	echo_opt "outer[-user] ..." "Set outer (anonymous) identity"
	echo ""
	echo "Inner identity:"
	echo_opt "user ..." "Set main (inner) username"
	echo_opt "pass ..." "Set main (inner) password"
	echo_opt "[tls-]cert PATH" "Set client-auth certificate for EAP-TLS"
	echo_opt "[tls-]key PATH" "Set client-auth private key for EAP-TLS"
	echo ""
	echo "Server verification:"
	echo_opt "[tls-]ca PATH" "Verify server against given CA"
	echo_opt "[tls-]ca system" "Verify server against default system CAs"
	echo_opt "tls-hostname DOMAIN" "Require exact hostname (alias for domain-match)"
	echo_opt "tls-hostname *.DOMAIN" "Require domain suffix (alias for domain-suffix-match)"
	echo ""
	echo_opt "domain-match ..." "Check certificate domain (either CN or SAN)"
	echo_opt "domain-suffix-match ..." "Check certificate domain (either CN or SAN)"
	echo_opt "subject-match ..." "Check certificate Subject [deprecated!]"
	echo_opt "altsubject-match ..." "Check certificate subjectAltName [deprecated!]"
	echo ""
	echo "Phase 1 TLS options:"
	echo_opt "tls v1.3" "Enable TLS v1.3 (disabled by default in wpa_supplicant)"
	echo_opt "tls no-v1.2" "Disable TLS v1.2 (for broken servers)"
	echo_opt "tls no-v1.1" "Disable TLS v1.1 (for really broken servers)"
	echo_opt "tls no-v1.0" "Disable TLS v1.0 (for epicly broken servers)"
	echo_opt "tls ..." "Manually add a phase1=\"...\" option"
	echo ""
	echo "Miscellaneous:"
	echo_opt "show-config" "Dump generated eapol_test config and exit"
	echo_opt "inherit PROFILE" "In testrad.conf.sh, import given profile as base"
	echo_opt "-4|-6" "Force IPv4 or IPv6 (alias for 'proto ip4|ip6')"
	echo_opt "-o ..." "Manually add a config setting for eapol_test"
	echo_opt "-O PATH" "Save phase 1 TLS certificate chain to file"
	echo_opt "-x PATH" "Use a different eapol_test executable"
	echo_opt "--help TOPIC" "Show additional help text"
	echo ""
	echo "Many options translate directly to wpa_supplicant settings."
	echo "Both GNU \"--opt ARG\" and iproute \"opt ARG\" formats are accepted."
	echo ""
	echo "Use \"--help TOPIC\" for additional information. Available topics:"
	echo_opt "config" "Configuration files"
	echo_opt "phase1" "Supported phase-1 EAP mechanisms"
	echo_opt "phase2" "Supported phase-2 mechanisms"
}

help() {
	if [[ $(type -t "help_$1") == function ]]; then
		"help_$1"
	elif [[ $1 ]]; then
		die "unknown help topic '$1'"
	else
		usage
	fi
}

help_eap() { help_phase1; }
help_mech() { help_phase2; }

help_phase1() {
	echo "Supported phase-1 (outer) EAP mechanisms:"
	echo ""
	echo "  EAP SETTING  SUPPORTED PHASE2 SETTINGS"
	echo "  -----------  ------------------------------------------"
	echo "  NONE         plain pap, chap, mschap"
	echo "  GTC          -"
	echo "  MD5          -"
	echo "  MSCHAPV2     -"
	echo "  OTP          -"
	echo "  PEAP         EAP-based (eap-gtc, eap-md5, eap-mschapv2...)"
	echo "  TLS          -"
	echo "  TTLS         everything"
	echo ""
	echo "Notes:"
	echo ""
	echo "  * 'eap none' uses radtest, so the mechanism support is somewhat limited."
	echo ""
	echo "  * All other 'eap foo' settings map directly to 'eap=FOO' in wpa_supplicant."
	echo "    For PEAP and TTLS, if phase2 is unset then all mechanisms will be accepted."
	echo ""
	echo "  * Pay attention to MSCHAP invocation:"
	echo "    - 'phase1 none     phase2 mschap' will give you MSCHAPv1 via radtest"
	echo "    - 'phase1 mschapv2 phase2 none'   will give you EAP-MSCHAPv2 via eapol_test"
}

help_phase2() {
	echo "Supported phase-2 (inner) mechanisms for PEAP & TTLS:"
	echo ""
	echo "  MECH SETTING  NAME            EAP=PEAP        EAP=TTLS"
	echo "  ------------  --------------  --------------  ----------------"
	echo "  pap           PAP             -               auth=PAP"
	echo "  chap          CHAP            -               auth=CHAP"
	echo "  mschap        MSCHAPv1        -               auth=MSCHAP"
	echo "  mschapv2      MSCHAPv2        -               auth=MSCHAPV2"
	echo "  eap-gtc       GTC             auth=GTC        autheap=GTC"
	echo "  eap-md5       MD5             auth=MD5        autheap=MD5"
	echo "  eap-mschapv2  MSCHAPv2        auth=MSCHAPV2   autheap=MSCHAPV2"
	echo ""
	echo "Notes:"
	echo ""
	echo "  * TTLS supports all mechanisms, while PEAP can only carry EAP-based ones."
	echo ""
	echo "  * PAP is not an EAP mechanism -- its equivalent for PEAP is EAP-GTC."
	echo ""
	echo "  * MSCHAPv2 exists in both raw & EAP versions, resulting in 3 combinations."
}

help_config() {
	echo "Configurations are loaded from ${config_file/#"$HOME"/"~"}:"
	echo ""
	echo "  server_foo=("
	echo "      host radius.example.com"
	echo "      secret quux"
	echo "  )"
	echo "  profile_foo=("
	echo "      # 'via foo' is implied"
	echo "      user test@example.com"
	echo "      pass testing123"
	echo "  )"
	echo "  profile_foo_eap=("
	echo "      inherit foo"
	echo "      eap peap"
	echo "  )"
	echo ""
	echo "In command line, the first parameter is *always* a profile name."
	echo "Use 'testrad none ...' if no profile needs to be loaded."
}

unset ip_version host port secret eap mech phase2
unset identity outer_id password
unset tls_cert tls_key tls_ca save_chain
declare -a tls_opts=() phase1_opts=()
declare -a ecfg_full=() ecfg_extra=()
declare -A ecfg_values=() ecfg_seen=()

eapol_bin="eapol_test"

# parse config

if [[ -e $config_file ]]; then
	debug "loading profiles from '$config_file'"
	. "$config_file" || die "could not load configuration"
fi

# parse arguments

declare -- profile=
declare -i depth=0

load_conf() {
	local type=$1 name=${2//-/_}
	local var="${type}_${name}[@]"

	set -- "${!var}"

	if [[ $name == none ]]; then
		debug "accepting '$name' as dummy $type"
		return
	elif (( ! $# )); then
		err "$type '$name' not found"
		return
	elif (( depth > 5 )); then
		die "profile recursion limit exceeded"
	else
		(( ++depth ))
		debug "($depth) parsing $var"
		parse_args "$@"
		debug "($depth) finished $var"
		(( depth-- ))
	fi
}

vargs() {
	local argc=$1 min=$2
	if (( argc < min )); then
		die "not enough arguments"
	fi
}

parse_args() {
	debug "current load type '$type', name '$name'"
	while (( $# )); do
		if [[ $1 == --help && ! $type ]]; then
			help "$2"; ((!errors)); exit
		elif [[ ! $profile && ! $type ]]; then
			debug "<$type/$name:$#> accepting first arg '$1' as profile name"
			profile=$1
			debug "<$type/$name:$#> loading profile config for '$profile'"
			load_conf profile "$profile"
		else
			debug "<$type/$name:$#> parsing arg '$1', next '$2'"
			case ${1#--} in
			# runtime & misc eapol_test options
			-4|-6)
				ip_version=${1#-};;
			-o)
				vargs $# 2; ecfg_extra+=("$2"); shift;;
			-O)
				vargs $# 2; save_chain=$2; shift;;
			-x)
				vargs $# 2; eapol_bin=$2; shift;;
			help)
				help "$2"; ((!errors)); exit;;
			show-config)
				dump_config=1;;
			# RADIUS server
			inherit)
				if [[ $type == server ]]; then
					die "cannot nest profile in server configuration"
				fi
				vargs $# 2; load_conf profile "$2"; shift;;
			via)
				vargs $# 2; load_conf server "$2"; shift;;
			host)
				vargs $# 2; host=$2; shift;;
			port)
				vargs $# 2; port=$2; shift;;
			proto)
				vargs $# 2; case $2 in
				    any) ip_version="";;
				   [46]) ip_version=$2;;
				 ip[46]) ip_version=${2#ip};;
				ipv[46]) ip_version=${2#ipv};;
				*) die "invalid IP protocol version '$2'";;
				esac; shift;;
			secret)
				vargs $# 2; secret=$2; shift;;
			# mechanism choice
			eap|inside|phase1)
				vargs $# 2; eap=$2; shift;;
			mech|using|phase2)
				vargs $# 2; mech=$2; shift;;
			# main (inner) identity
			user|username|identity|login|inner-user|inner-identity|inner-login)
				vargs $# 2; identity=$2; : ${password:="-"}; shift;;
			pass|password)
				vargs $# 2; password=$2; shift;;
			cert|tls-cert)
				vargs $# 2; tls_cert=$2; shift;;
			key|tls-key)
				vargs $# 2; tls_key=$2; shift;;
			ca|tls-ca|ca-cert)
				vargs $# 2; tls_ca=$2; shift;;
			tls)
				vargs $# 2; tls_opts+=(${2//,/ }); shift;;
			# anonymous (outer) identity
			anon)
				outer_id="@";;
			outer|outer-user|outer-identity|outer-login)
				vargs $# 2; outer_id=$2; shift;;
			# misc eapol options
			subject-match)
				vargs $# 2; ecfg_extra+=("subject_match=\"$2\""); shift;;
			altsubject-match)
				vargs $# 2; ecfg_extra+=("altsubject_match=\"$2\""); shift;;
			domain-match|tls-hostname)
				vargs $# 2; ecfg_extra+=("domain_match=\"$2\""); shift;;
			domain-suffix-match|tls-domain)
				vargs $# 2; ecfg_extra+=("domain_suffix_match=\"$2\""); shift;;
			# etc.
			*)
				err "bad arg: \"$1\"";;
			esac
		fi
		shift
	done
}

parse_args "$@"

if [[ $profile && ! $host ]]; then
	debug "server not set, trying to load '$profile' based on profile"
	if [[ -v "server_$profile" ]]; then
		load_conf server "$profile"
	else
		err "server for profile '$profile' not configured (use 'via …')"
	fi
fi

# check for necessary tools

have "$eapol_bin" ||
	err "missing '$eapol_bin' binary"

have 'name2addr' ||
have 'getent' ||
	err "missing 'getent' or 'name2addr' binary"

(( !errors )) || exit

# check server parameters

if [[ ! $host && ! $profile ]]; then
	die "missing profile name or RADIUS host address"
elif [[ ! $host ]]; then
	die "missing RADIUS host address in profile"
elif [[ $host == *.*:* || $host == \[*\]:* ]]; then
	die "invalid server name (use the 'port' option)"
elif [[ $host == *%* ]]; then
	die "invalid server name (eapol_test does not support '%scope' yet)"
else
	if have 'name2addr'; then
		if [[ $ip_version ]]; then
			arg="-$ip_version"
		else
			arg=""
		fi
		server_ip=$(name2addr $arg "$host")
	elif have 'getent'; then
		if [[ $ip_version ]]; then
			arg="ahostsv$ip_version"
		else
			arg="ahosts"
		fi
		server_ip=$(getent $arg "$host" | awk '{print $1}' | head -1)
	else
		die "either 'name2addr' or 'getent' are required"
	fi
	if [[ $server_ip ]]; then
		debug "resolved '$host' to '$server_ip'"
		host=$server_ip
	else
		die "could not resolve RADIUS server '$host'"
	fi
fi

[[ $port ]] || port=1812

[[ $secret ]] || err "missing RADIUS secret"

(( !errors )) || exit

# check client parameters

eap=${eap^^}

if [[ $eap == MSCHAP ]]; then
	die "EAP-$eap does not exist; did you mean EAP-MSCHAPv2?"
fi

case $mech in
	# only set a default for radtest, let eapol_test autoguess
	'')		mech='pap';;
	# direct translation to eapol_test phase2
	'='*)		phase2=${mech#=};;
	'pap')		phase2='PAP';;
	'chap')		phase2='CHAP';;
	'mschap')	phase2='MSCHAP';;
	'mschapv2')	phase2='MSCHAPV2';;
	'eap-gtc')	phase2='EAP-GTC';;
	'eap-md5')	phase2='EAP-MD5';;
	'eap-mschapv2')	phase2='EAP-MSCHAPV2';;
	# in case I ever try to make this nonsense work
	'eap-pap')	die "PAP is not an EAP mechanism; did you mean EAP-GTC?";;
	'eap-chap')	die "CHAP is not an EAP mechanism";;
	'eap-mschap')	die "EAP-MSCHAP does not exist; did you mean EAP-MSCHAPV2?";;
	*)		err "unknown mechanism '$mech'";;
esac

if [[ ${eap:-NONE} != @(NONE|TLS|PEAP|TTLS) && $phase2 ]]; then
	die "EAP-$eap does not support inner mechanisms"
fi

if [[ $eap == PEAP && $phase2 && $phase2 != EAP-* ]]; then
	case $phase2 in
		PAP|GTC)	info "you probably want 'phase2 EAP-GTC'";;
		MSCHAP*)	info "you probably want 'phase2 EAP-MSCHAPV2'";;
	esac
	die "EAP-$eap can only transport EAP mechanisms"
fi

if [[ ${eap:-NONE} == NONE && $mech != @(pap|chap|mschap|eap-md5) ]]; then
	die "radtest does not support the '$mech' mechanism"
fi

if [[ ${eap:-NONE} != @(NONE|MD5|MSCHAPV2|OTP|GTC|TLS|PEAP|TTLS) ]]; then
	die "eapol_test does not support the '$eap' EAP mechanism"
fi

if [[ ${eap:-NONE} == TLS ]]; then
	if [[ $identity && $outer_id && "$outer_id" != "$identity" ]]; then
		# XXX: is this true?
		die "EAP-$eap does not support an inner identity"
	fi
else
	if [[ ! $identity ]]; then
		die "missing username"
	fi
fi

if [[ ! $outer_id ]]; then
	outer_id=$identity
fi

# XXX: forgot what this is for
if [[ $outer_id == "@" ]]; then
	outer_id+=${identity#*@}
fi

if [[ $tls_ca == @(default|system) ]]; then
	tls_ca=/etc/ssl/certs/ca-certificates.crt
	[[ -f $tls_ca ]] ||
		tls_ca=/etc/ssl/cert.pem
	[[ -f $tls_ca ]] ||
		die "could not find system TLS CA certificate bundle"
	debug "found '$tls_ca'"
fi

if [[ $tls_ca && ! -f $tls_ca ]]; then
	err "CA file '$tls_ca' does not exist"
fi

if [[ $eap == TLS ]]; then
	unset password

	if [[ $tls_key == same ]]; then
		tls_key=$tls_cert
	fi

	if [[ ! $tls_cert ]]; then
		err "missing certificate for EAP-$eap ('cert' option)"
	elif [[ ! -f $tls_cert ]]; then
		err "certificate '$tls_cert' does not exist"
	fi

	if [[ ! $tls_key ]]; then
		err "missing private key for EAP-$eap ('key' option)"
	elif [[ $tls_key == pkcs11:* ]]; then
		true
	elif [[ ! -f $tls_key ]]; then
		err "key file '$tls_key' does not exist"
	fi
else
	if [[ $password == "-" ]]; then
		read -s -p "password for '$identity': " password; echo
	fi

	if [[ ! $password ]]; then
		err "missing password"
	fi
fi

if [[ $eap == @(PEAP|TLS|TTLS) ]]; then
	for opt in "${tls_opts[@]}"; do
		case $opt in
		# as of hostap_2_6-1911-ge8a7af9a3, v1.3 is enabled manually
		'v1.3')		phase1_opts+=("tls_disable_tlsv1_3=0");;
		'no-v1.0')	phase1_opts+=("tls_disable_tlsv1_0=1");;
		'no-v1.1')	phase1_opts+=("tls_disable_tlsv1_1=1");;
		'no-v1.2')	phase1_opts+=("tls_disable_tlsv1_2=1");;
		'no-v1.3')	phase1_opts+=("tls_disable_tlsv1_3=1");;
		'tls_'*)	phase1_opts+=("$opt");;
		*)		err "unknown TLS option '$opt'";;
		esac
	done
fi

(( !errors )) || exit

# do the test

if (( DEBUG )); then
	xpassword=$password
	xsecret=$secret
else
	xpassword="-hidden-"
	xsecret="-hidden-"
fi

lib:info "Identity: $identity"
lib:info "Password: $xpassword"
lib:info "RADIUS host: $host"
lib:info "RADIUS secret: $xsecret"

if [[ $eap && $eap != NONE ]]; then
	# base options
	ecfg_full=(
		"eap=$eap"
	)
	if [[ ${phase1_opts[*]} ]]; then
		ecfg_full+=("phase1=\"${phase1_opts[*]}\"")
	fi

	# phase 2
	if [[ $eap == PEAP ]]; then
		if [[ $phase2 == EAP-* ]]; then
			ecfg_full+=("phase2=\"auth=${phase2#EAP-}\"")
		elif [[ $phase2 ]]; then
			err "EAP-$eap can only transport other EAP mechanisms"
		fi
	elif [[ $eap == TTLS ]]; then
		if [[ $phase2 == EAP-* ]]; then
			ecfg_full+=("phase2=\"autheap=${phase2#EAP-}\"")
		elif [[ $phase2 ]]; then
			ecfg_full+=("phase2=\"auth=${phase2}\"")
		fi
	fi

	# credentials
	ecfg_full+=("identity=\"$identity\"")
	if [[ $password ]]; then
		ecfg_full+=("password=\"$password\"")
	fi
	if [[ $outer_id ]]; then
		ecfg_full+=("anonymous_identity=\"$outer_id\"")
	fi
	if [[ $tls_ca ]]; then
		ecfg_full+=("ca_cert=\"$tls_ca\"")
	fi
	if [[ $tls_cert ]]; then
		ecfg_full+=("client_cert=\"$tls_cert\"")
	fi
	if [[ $tls_key ]]; then
		ecfg_full+=("private_key=\"$tls_key\"")
	fi

	# user-supplied settings
	for opt in "${ecfg_extra[@]}"; do
		case $opt in
		# disallowed settings
		'anonymous_identity='*)
			err "'$opt': use 'outer-user ...' to set the anonymous identity";;
		'eap='*)
			err "'$opt': use 'eap ...' to set the outer EAP method";;
		'identity='*)
			err "'$opt': use 'user ...' to set the auth identity";;
		'phase2='*)
			err "'$opt': use 'phase2 ...' to set the inner EAP method";;
		# magic values
		'domain_match="*.'*'"')
			debug "expanding '$opt' to domain_suffix_match"
			opt="${opt#*=}"
			opt="domain_suffix_match=${opt/'*.'/}"
			debug "... converted to '$opt'"
			;;&
		'domain_suffix_match="auto"') ;&
		'domain_suffix_match="@"')
			debug "expanding '$opt'"
			if [[ $identity == *@* ]]; then
				opt="${opt%=*}=\"${identity#*@}\""
				debug "... expanded to '$opt'"
			else
				err "cannot expand '$opt' from identity '$identity'"
			fi
			;;&
		# rest
		?*'='*)
			ecfg_full+=("$opt");;
		*)
			err "supplicant option '$opt' given without value";;
		esac
	done
	(( ! errors )) || exit

	# if option given multiple times, the last value takes priority
	for opt in "${ecfg_full[@]}"; do
		k=${opt%%=*}
		v=${opt#*=}
		ecfg_values["$k"]=$v
	done

	lib:info "Mechanism: EAP-$eap (outer), ${phase2:-default} (inner)"
	if [[ "$outer_id" != "$identity" ]]; then
		lib:info "Anonymous identity: \"$outer_id\""
	fi

	# generate the eapol_test config
	wd=$(mktemp -d /tmp/testrad.XXXXXXXX)
	conf="$wd/eapol_test.conf"
	{
		echo "network={"
		for opt in "${ecfg_full[@]}"; do
			k=${opt%%=*}
			v=${ecfg_values["$k"]}
			if (( ecfg_seen["$k"]++ )); then
				continue
			fi
			if [[ ! $v || $v == \"\" ]]; then
				continue
			fi
			printf "\t%s=%s\n" "$k" "$v"
		done
		echo "}"
	} > "$conf"
	(( ! errors )) || exit

	cmd=($eapol_bin -c "$conf" -a "$host" -p "$port" -s "$secret" -t 5
			-M "22:44:66:42:42:42"
			-C "testrad+eapol_test (${EMAIL:-$USERNAME@$HOSTNAME})")

	if [[ $save_chain ]]; then
		cmd+=(-o "$save_chain")
	fi
else
	lib:info "Mechanism: $phase2 (direct)"
	if [[ "$outer_id" != "$identity" ]]; then
		die "anonymous identity \"$outer_id\" not supported without EAP"
	fi
	if [[ $host == *:* ]]; then
		host="[$host]"
	fi
	cmd=(radtest -t "$mech" "$identity" "$password" "$host:$port" 0 "$secret")
	if (( dump_config )); then
		vmsg "dumping generated config and exiting" >&2
		cmd=(: "${cmd[@]}")
	else
		have "${cmd[0]}" || die "missing '${cmd[0]}' binary"
	fi
fi

(scmd="+"
for carg in "${cmd[@]}"; do
	if ! [[ $carg =~ ^[0-9a-zA-Z_./-]+$ ]]; then
		carg=${carg@Q}
	fi
	scmd+=" $carg"
done
echo "$scmd")

if (( dump_config )); then
	r=0
	if [[ $conf ]]; then
		cat "$conf"
	fi
	vmsg "dumping generated config and exiting" >&2
else
	if "${cmd[@]}"; then
		r=0
		lib:info "test successful"
	else
		r=$?
		err "test failed ($cmd returned $r)"
	fi

	if [[ -s $save_chain ]]; then
		lib:info "server certificate chain saved to '$save_chain'"
	elif [[ $save_chain ]]; then
		err "did not obtain server certificate chain"
	fi
fi

if [[ $wd == /tmp/testrad.* ]]; then
	rm -rf "$wd"
fi

exit $r
