#!/bin/ksh93

#
# MIT License
#
# Copyright (c) 2023-2025 Roland Mainz <roland.mainz@nrubsig.org>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#

#
# mount_sshnfs - mount NFSv4 filesystem through ssh tunnel
#

#
# Example usage:
#
# 1. UNIX/Linux: Mount&&unmount /export/home/rmainz on NFS server "/export/home/rmainz":
# $ mkdir -p /tmp/foobarmnt
# $ ksh mount_sshnfs.ksh mount ssh+nfs://rmainz@derfwpc5131//export/home/rmainz /tmp/foobarmnt
# $ mount_sshnfs.ksh umount /tmp/foobarmnt
#
#
# 2. UNIX/Linux: Mount&&unmount /export/home/rmainz on NFS server "/export/home/rmainz" via SSH jumphost rmainz@10.49.20.131:
# $ mkdir -p /tmp/foobarmnt
# $ ksh mount_sshnfs.ksh mount -o ro,mount_sshnfs_jumphost=rmainz@10.49.20.131 ssh+nfs://rmainz@derfwpc5131//export/home/rmainz /tmp/foobarmnt
# $ mount_sshnfs.ksh umount /tmp/foobarmnt
#

#
# For more examples see help and subcommand help:
# $ mount_sshnfs --man
# $ mount_sshnfs mount --man
# $ mount_sshnfs umount --man
#

#
# Written by Roland Mainz <roland.mainz@nrubsig.org>
#

function usage
{
	(( OPTIND=0 ))
	getopts -a "${1}" "${2}" OPT '-?'
	return 2
}


#
# simple netstat -n parser
#
function netstat_list_connections
{
	set -o nounset
	nameref data=$1

	compound out=( typeset stdout stderr ; integer res )

	out.stderr="${ { out.stdout="${ LC_ALL='POSIX' PATH='/usr/bin:/bin' netstat -a -n ; (( out.res=$? )) ; }" ; } 2>&1 ; }"
	if (( out.res != 0 )) ; then
		print -u2 -f $"%s: netstat returned %d exit code.\n" \
			"$0" out.res
		return 1
	fi
	if [[ "${out.stderr}" != '' ]] ; then
		#
		# Handle known Linux netstat warnings
		#
		if [[ "${out.stderr}" != $'warning, got bogus unix line.' ]] ; then
			print -u2 -f $"%s: netstat returned unknown error message %q.\n" \
				"$0" "${out.stderr}"
			return 1
		fi
	fi

	typeset -a data.connections
	typeset l
	typeset leftover
	integer dci=0 # data.connections array index

	while read l ; do
		leftover="${l/~(Elrx)
		(?: # non-capturing group
			#
			# regex group for tcp,udp
			#
			(tcp|tcp6|udp|udp6|raw|raw6|sctp|sctp6)	# Proto
			[[:space:]]+
			([[:digit:]]+)			# Recv-Q
			[[:space:]]+
			([[:digit:]]+)			# Send-Q
			[[:space:]]+
			([^[:space:]]+)			# Local Address
			[[:space:]]+
			([^[:space:]]+)			# Foreign Address
			(?:
				|
				[[:space:]]+
				([^[:space:]]*?) 	# State (Optional)
			)
		|
			#
			# regex for unix
			#
			(unix)				# Proto
			[[:space:]]+
			([[:digit:]]+)			# RefCnt
			[[:space:]]+
			(\[.+?\])			# Flags
			[[:space:]]+
			([^[:space:]]+) 		# Type
			[[:space:]]+
			([^[:space:]]*?)		# State (optional)
			[[:space:]]+
			([[:digit:]]+)			# I-Node
			(?:
				|
				[[:space:]]+
				([^[:space:]]+)		# Path (optional)
			)
		)
			/X}"

		# If the regex above did not match then .sh.match
		# remains untouched, so we might see data from the
		# previous round.
		# So we check the "leftover" var whether it just
		# contains the dummy value of "X" to indicate a
		# successful regex match
		if [[ "$leftover" == 'X' ]] ; then
			#print -v .sh.match

			if [[ "${.sh.match[1]-}" != '' ]] ; then
				nameref dcn=data.connections[$dci]

				typeset dcn.proto="${.sh.match[1]}"
				typeset dcn.recv_q="${.sh.match[2]}"
				typeset dcn.send_q="${.sh.match[3]}"
				typeset dcn.local_address="${.sh.match[4]}"
				typeset dcn.foreign_address="${.sh.match[5]}"
				typeset dcn.state="${.sh.match[6]}"
				((dci++))
			elif [[ "${.sh.match[7]-}" != '' ]] ; then
				nameref dcn=data.connections[$dci]

				typeset dcn.proto="${.sh.match[7]}"
				typeset dcn.refcnt="${.sh.match[8]}"
				typeset dcn.flags="${.sh.match[9]}"
				typeset dcn.type="${.sh.match[10]}"
				[[ "${.sh.match[11]}" != '' ]] && typeset dcn.state="${.sh.match[11]}"
				typeset dcn.inode="${.sh.match[12]}"
				[[ "${.sh.match[13]}" != '' ]] && typeset dcn.path="${.sh.match[13]}"
				((dci++))
			fi
		else
			true
			#printf $"leftover=%q\n" "${leftover}"
		fi
	done <<<"${out.stdout}"

	return 0
}

function netstat_list_active_local_tcp_connections
{
	set -o nounset
	nameref ar=$1
	compound c
	integer port
	integer i

	netstat_list_connections c || return 1
	#print -v c

	[[ -v ar ]] || integer -a ar

	for i in "${!c.connections[@]}" ; do
		nameref n=c.connections[$i]

		# look for only for TCP connections which match
		# 127.0.*.* or IPv6 ::1 for localhost
		# 0.0.0.0 or IPv6 :: for all addresses (e.g. servers)
		if [[ "${n.proto}" == ~(El)tcp && \
			"${n.local_address}" == ~(Elr)((127\.0\..+|::1)|(::|0\.0\.0\.0|)):[[:digit:]]+ ]] ; then

			port="${n.local_address##*:}"
			#printf $"port = %d\n" port

			(( ar[port]=1 ))
		fi
	done

	return 0
}

function netstat_find_next_free_local_tcp_port
{
	set -o nounset
	compound c=( integer -a ar )
	nameref ret_free_port=$1
	integer start_port
	integer end_port
	integer i

	netstat_list_active_local_tcp_connections c.ar || return 1

	#print -v c

	(( start_port=$2 ))
	if (( $# > 2 )) ; then
		(( end_port=$3 ))
	else
		(( end_port=65535 ))
	fi

	for ((i=start_port ; i < end_port ; i++ )) ; do
		if [[ ! -v c.ar[i] ]] ; then
			(( ret_free_port=i ))
			return 0
		fi
	done

	return 1
}


function urldecodestr
{
	nameref out=$1
	typeset s="$2"

	#
	# build format string for printf(1) ...
	#

	# quote backslashes
	s="${s//$'\\'/$'\\\\'}"
	# urldecode '+' to ' '
	s="${s//+/ }"
	# urldecode %<hexdigit><hexdigit>
	s="${s//~(E)(?:%([[:xdigit:]][[:xdigit:]]))/\\x[\1]}"
	# quote any remaining "%" to make it safe for printf(1)
	s="${s//%/%%}"

	#
	# ... and then let printf(1) do the formatting
	#
	out="${ printf "$s" ; }"
	return 0
}


#
# parse_rfc1738_url - parse RFC 1838 URLs
#
# Output variables are named after RFC 1838 Section 5 ("BNF for
# specific URL schemes")
#
function parse_rfc1738_url
{
	set -o nounset

	typeset url="$2"
	typeset leftover
	nameref data="$1" # output compound variable
	typeset url_param_str

	# ~(E) is POSIX extended regular expression matching (instead
	# of shell pattern), "x" means "multiline", "l" means "left
	# anchor", "r" means "right anchor"
	leftover="${url/~(Elrx)
		(.+?)				# scheme
		:\/\/				# '://'
		(				# login
			(?:
				(.+?)		# user (optional)
				(?::(.+))?	# password (optional)
				@
			)?
			(			# hostport
				(.+?)		# host
				(?::([[:digit:]]+))? # port (optional)
			)
		)
		(?:\/(.*?))?			# path (optional)
		(?:\?(.*?))?			# URL parameters (optional)
		/X}"

	# All parsed data should be captured via eregex in .sh.match - if
	# there is anything left (except the 'X') then the input string did
	# not properly match the eregex
	[[ "$leftover" == 'X' ]] ||
		{ print -u2 -f $"%s: Parser error, leftover=%q\n" \
			"$0" "$leftover" ; return 1 ; }

	data.url="${.sh.match[0]}"
	data.scheme="${.sh.match[1]}"
	data.login="${.sh.match[2]}"
	# FIXME: This should use [[ ! -v .sh.match[3] ]], but ksh93u has bugs
	[[ "${.sh.match[3]-}" != '' ]] && data.user="${.sh.match[3]}"
	[[ "${.sh.match[4]-}" != '' ]] && data.password="${.sh.match[4]}"
	data.hostport="${.sh.match[5]}"
	data.host="${.sh.match[6]}"
	[[ "${.sh.match[7]-}" != '' ]] && integer data.port="${.sh.match[7]}"
	[[ "${.sh.match[8]-}" != '' ]] && data.uripath="${.sh.match[8]}"

	if [[ "${.sh.match[9]-}" != '' ]] ; then
		compound -a data.parameters

		url_param_str="${.sh.match[9]-}"

		while [[ "$url_param_str" != '' ]] ; do
			leftover="${url_param_str/~(Elrx)(?:(.+?)(?:=(.+?))?)(?:&(.*))?/X}"

			# save matches because urldecodestr uses .sh.match, too
			typeset dp_name="${.sh.match[1]-}"
			typeset dp_value="${.sh.match[2]-}"
			typeset dp_next="${.sh.match[3]-}"

			urldecodestr dp_name "${dp_name}"
			urldecodestr dp_value "${dp_value}"

			data.parameters+=(
				name="${dp_name}"
				value="${dp_value}"
				)

			# next parameter
			url_param_str="${dp_next}"
		done
	fi

	if [[ -v data.uripath ]] ; then
		urldecodestr data.path "${data.uripath}"
	fi

	return 0
}


function parse_sshnfs_url
{
	typeset url="$2"
	nameref data="$1"

	parse_rfc1738_url data "$url" || return 1

	[[ "${data.scheme}" == ~(Elr)(ssh\+nfs|nfs) ]] || \
		{ print -u2 -f $"%s: Not a nfs:// or ssh+nfs:// url\n" "$0" ; return 1 ; }
	[[ "${data.host}" != '' ]] || { print -u2 -f $"%s: NFS hostname missing\n" "$0" ; return 1 ; }
	[[ "${data.uripath}" != '' ]] || { print -u2 -f $"%s: NFS path missing\n" "$0" ; return 1 ; }
	[[ "${data.uripath}" == /* ]] || { print -u2 -f $"%s: NFS path (%q) must be absolute\n" "$0" "${data.uripath}" ; return 1 ; }
	[[ "${data.uripath}" != //* ]] || { print -u2 -f $"%s: NFS path (%q) should not start with '//' \n" "$0" "${data.uripath}" ; return 1 ; }

	return 0
}


function mountpoint2configfilename
{
	nameref configfilename=$1
	typeset mountpoint="$2"

	#
	# FIXME:
	# - We should urlencode more than just '/'
	# - We should strip the leading '/'
	# - We should use realpath(1) for mountpoints here
	#

	# .cpv means ComPound Variable"
	configfilename="/tmp/mount_sshnfs/${mountpoint//\//%2f}.cpv"
	return 0
}


function kernel_supports_nfs42_client
{
	typeset s=''

	if [[ "$(uname -s)" == 'Linux' ]] ; then
		if [[ -r '/proc/config.gz' ]] ; then
			s="$(gunzip -c <'/proc/config.gz' | egrep -v '^[[:space:]]*#')"
		elif [[ -r "/boot/config-$(uname -r)" ]] ; then
			s="$( egrep -v '^[[:space:]]*#' "/boot/config-$(uname -r)")"
		fi

		if [[ "$s" == *CONFIG_NFS_V4_2=y* ]] ; then
			return 0
		fi

		return 1
	else
		# FIXME: Add more OS support
		true
	fi

	return 2
}


function cmd_mount
{
	set -o nounset
	nameref c=$1

	# fixme: Need better text layout for $ mount_sshnfs mount --man #
	typeset -r mount_sshnfs_cmdmount_usage=$'+
	[-?\n@(#)\$Id: mount_sshnfs mount (Roland Mainz) 2025-02-19 \$\n]
	[-author?Roland Mainz <roland.mainz@nrubsig.org>]
	[+NAME?mount_sshnfs mount - mount NFSv4 filesystem through ssh
		tunnel]
	[+DESCRIPTION?\bmount_sshnfs mount\b mounts a NFSv4 filesystem
		through a ssh tunnel.]
	[r:readonly?Mount file system readonly.]
	[w:readwrite?Mount file system read-write.]
	[o:options?Use the specified mount options.
		The opts argument is a comma-separated list.\n
		options starting with mount_sshnfs_jumphost_* will be
		consumed by mount_sshnfs, all other options will be
		passed through to mount.nfs.]:[options]{
		[+?mount_sshnfs options are:]{
			[+?-o mount_sshnfs_jumphost=user@host:port - ssh jumphost]
			[+?-o mount_sshnfs_local_forward_port=port - local TCP port
				for SSH-forwarded NFS connection to server.
				Defaults is to use netstat(1) to find a free TCP port]
			}
		}

	url mountpoint

	[+NOTES?]{
		[+?The original CITI Windows NFSv4 nfs_mount.exe does not
		support the port= option.\nUse -o mount_sshnfs_local_forward_port=2049
		as workaround. Newer versions from https://github.com/kofemann/ms-nfs41-client
		support the -o port=... option.]
		}
	[+SEE ALSO?\bksh93\b(1),\bssh\b(1),\bmount.nfs\b(8),\bnfs\b(5)]
	'
	typeset mydebug=false	# fixme: should be "bool" for ksh93v
	typeset c.url
	typeset c.mountpoint
	typeset config_filename

	typeset -a c.mount_nfs_options
	integer i
	integer saved_optind_m1	# saved OPTIND-1
	typeset s		# generic temporary string variable

	# remove subcmd name (in this case 'mount')
	unset c.args[0]

	#
	# Expand MOUNT_SSHNFS_CMDMOUNT_OPTIONS before arguments given to
	# mount_sshnfs.ksh.
	# By default we use IFS=$' \t\n' for argument splitting
	#
	c.args=( ${MOUNT_SSHNFS_CMDMOUNT_OPTIONS-} "${c.args[@]}" )

	#
	# Argument parsing
	#
	while getopts -a "${progname} mount" "${mount_sshnfs_cmdmount_usage}" OPT "${c.args[@]}" ; do
		case "${OPT}" in
			'r')
				c.mount_nfs_options+=( 'ro' )
				;;
			'w')
				c.mount_nfs_options+=( 'rw' )
				;;
			'o')
				#
				# Split options like "-o foo=bar,baz=BAM"
				# into "-o foo=bar -o baz=BAM" for easier
				# processing below
				IFS=$','
				c.mount_nfs_options=( "${c.mount_nfs_options[@]}" ${OPTARG} )
				IFS=$' \t\n'
				;;
			*)
				usage "${progname} mount" "${mount_sshnfs_cmdmount_usage}"
				return $?
				;;
		esac
	done

	(( saved_optind_m1=OPTIND-1 ))

	# remove options we just parsed from c.args
	for ((i=0 ; i < saved_optind_m1 ; i++)) ; do
		unset c.args[$i]
	done


	#
	# Get remaining arguments
	#
	c.url="${c.args[saved_optind_m1+0]-}"
	c.mountpoint="${c.args[saved_optind_m1+1]-}"


	#
	# Filter out our options, other options are passed to mount.nfs
	#
	for ((i=0 ; i < ${#c.mount_nfs_options[@]} ; i++)) ; do
		s="${c.mount_nfs_options[$i]}"

		#
		# Intercept options starting with eregex mount_sshnfs.+
		#
		if [[ "$s" == ~(Elr)mount_sshnfs.+=.+ ]] ; then
			case "$s" in
				~(Eli)mount_sshnfs_jumphost=)
					[[ ! -v c.ssh_jumphost_args ]] && typeset -a c.ssh_jumphost_args
					c.ssh_jumphost_args+=( "-J" "${c.mount_nfs_options[i]/~(Eli)mount_sshnfs_jumphost=}" )
					;;
				~(Eli)mount_sshnfs_local_forward_port=)
					# command(1) prevents that the shell interpreter
					# exits if typeset produces a syntax error
					command integer c.local_forward_port="${c.mount_nfs_options[i]/~(Eli)mount_sshnfs_local_forward_port=}" || return 1
					;;
				*)
					usage "${progname} mount" "${mount_sshnfs_cmdmount_usage}"
					return $?
					;;
			esac
			unset c.mount_nfs_options[$i]
		fi
	done


	#
	# Parse url
	#
	parse_sshnfs_url c.nfs_server "${c.url}" || return 1

	mountpoint2configfilename config_filename "${c.mountpoint}"

	if [[ -f "${config_filename}" ]] ; then
		print -u2 -f $"%s: Config file %q for mount point %q found.\n" \
			"$0" \
			"$config_filename" \
			"${c.mountpoint}"
		return 1
	fi

	#
	# Prechecks for writing the config file
	#
	mkdir -p '/tmp/mount_sshnfs/'
	if [[ ! -w '/tmp/mount_sshnfs/' ]] ; then
		print -u2 -f $"%s: mount_nfs data directory %q not writeable.\n" \
			"$0" \
			'/tmp/mount_sshnfs/'
		return 1
	fi

	${mydebug} && print -v c

	case "${c.nfs_server.scheme}" in
		'ssh+nfs')
			#
			# Find free local forwarding port
			#

			# Note: Original CITI ms-nfsv41 client
			# nfs_mount.exe
			# does not support -o port=..., so we set a default
			# here if it was not set yet
			if (( c.is_ccygwin == 1 )) && [[ ! -v c.local_forward_port ]] ; then
				integer c.local_forward_port=2049
			fi

			# port on THIS machine
			if [[ ! -v c.local_forward_port ]] ; then
				integer c.local_forward_port

				(( i=34049 ))
				if ! netstat_find_next_free_local_tcp_port c.local_forward_port $i ; then
					print -u2 -f "%s: netstat_find_next_free_local_tcp_port failed.\n" "$0"
					return 1
				fi
			fi


			c.ssh_control_socket_name="/tmp/mount_sshnfs/mount_sshnfs_ssh-control-socket_logname${LOGNAME}_ppid${PPID}_pid$$"

			#
			# Find SSH login user name for NFS server
			#
			if [[ -v c.nfs_server.user ]] ; then
				typeset c.nfsserver_ssh_login_name="${c.nfs_server.user}"
			fi
			# fixme: Implement NFSServerSSHLoginName
			if [[ ! -v c.nfsserver_ssh_login_name ]] ; then
				# default user name if neither URL nor
				# "-o NFSServerSSHLoginName=..." were given
				typeset c.nfsserver_ssh_login_name="$LOGNAME"
			fi

			#
			# Forward NFS port from server to local machine
			#
			# Notes:
			# - We use $ ssh -M ... # here as a way to terminate the port
			# forwarding process later using "-O exit" without the need
			# for a pid
			#
			print -u2 -f $"# Please enter the login data for NFS server (%s):\n" \
				"${c.nfsserver_ssh_login_name}@${c.nfs_server.host}"

			#
			# Notes:
			# - fixme: c.nfs_server.port is fixed
			# for ssh+nfs://-URLs, so for now we
			# have to hardcode TCP/2049 for now
			# - We use aes128-cbc,aes128-ctr ciphers for better
			# throughput (see https://bash-prompt.net/guides/bash-ssh-ciphers/
			# for a benchmark) and lower latency, as NFS is
			# a bit latency-sensitive
			# - We turn compression off, as it incrases latency
			# - ServerAliveInterval+ServerAliveCountMax ==
			#   15min timeout (10sec*6*15)
			#
			ssh \
				-L "${c.local_forward_port}:localhost:2049" \
				-M -S "${c.ssh_control_socket_name}" \
				-N \
				-f -o 'ExitOnForwardFailure=yes' \
				-o 'Compression=no' \
				-o 'Ciphers=aes128-cbc,aes128-ctr' \
				'-o' 'ServerAliveInterval=10' \
				'-o' "ServerAliveCountMax=$((6*15))" \
				"${c.ssh_jumphost_args[@]}" \
				"${c.nfsserver_ssh_login_name}@${c.nfs_server.host}"
			if (( $? != 0 )) ; then
				print -u2 -f $"%s: NFS forwarding ssh failed with error code %d\n" "$0" $?
				return 1
			fi

			# debug
			${mydebug} && \
				ssh \
					-S "${c.ssh_control_socket_name}" \
					-O 'check' \
					"${c.nfsserver_ssh_login_name}@${c.nfs_server.host}"


			if (( c.is_ccygwin == 1 )) ; then
				#
				# Build argument list for nfs_mount.exe ...
				#
				typeset -a mount_args
				for s in "${c.mount_nfs_options[@]}" ; do
					mount_args+=( '-o' "$s" )
				done

				if (( c.local_forward_port != 2049 )) ; then
					#
					# The original CITI NFSv4 nfs_mount.exe does not
					# support the port= option, so only set it
					# if we do not use the default port
					#
					mount_args+=( '-o' "port=${c.local_forward_port}" )
				fi
				# fixme: can we remove -o sec=sys ?
				mount_args+=( '-o' 'sec=sys' )
				# '*' == Let nfs_mount.exe should pick drive letter itself
				mount_args+=( '*' )
				mount_args+=( "localhost:${c.nfs_server.uripath}" )

				#
				# ... and do the mount
				#
				typeset stdout dummy

				# fixme: we should set LC_ALL=C because below we depend on
				# a l10n message
				stdout="${ "${c.msnfsv41_nfsmountcmd}" "${mount_args[@]}" ; (( retval=$? )) ;}"

				cat <<<"$stdout"

				if (( retval == 0 )) ; then
					# Parse stdout for drive letter
					dummy="${stdout/~(E)Successfully mounted (.+) to drive (?:\'|)(.+):(?:\'|)/dummy}"

					# fixme: we should test whether c.windows_drive_letter is empty or not
					typeset c.windows_drive_letter="${.sh.match[2]}"

					print -u2 -f $"%s: NFS filesystem mounted to drive %q.\n" \
						"$0" "${c.windows_drive_letter}"

					# Cygwin mount
					mount -o posix=1,notexec,sparse "${c.windows_drive_letter}:" "${c.mountpoint}"
				fi
			else
				#
				# Build argument list for mount.nfs ...
				#
				typeset -a mount_args
				mount_args+=( '-vvv' )
				mount_args+=( '-t' 'nfs' )
				for s in "${c.mount_nfs_options[@]}" ; do
					mount_args+=( '-o' "$s" )
				done

				if kernel_supports_nfs42_client ; then
					mount_args+=( '-o' 'vers=4.2' )
				else
					#
					# some kernels (like WSL) have a
					# Linux 5.15.x kernel with NFSv4.2
					# client support turned off
					#
					mount_args+=( '-o' 'vers=4.1' )
				fi
				mount_args+=( '-o' "port=${c.local_forward_port}" )
				mount_args+=( "localhost:${c.nfs_server.uripath}" )
				mount_args+=( "${c.mountpoint}" )

				#
				# ... and do the mount
				#
				mount "${mount_args[@]}"
				(( retval=$? ))
			fi

			if (( retval != 0 )) ; then
				#
				# Quit ssh port forwarding process
				#
				ssh \
					-S "${c.ssh_control_socket_name}" \
					-O 'exit' \
					"${c.nfsserver_ssh_login_name}@${c.nfs_server.host}"
				return $retval
			fi


			#
			# Save status data
			#
			compound mnt_config=(
				typeset url="${c.url}"
				typeset mountpoint="${c.mountpoint}"
				typeset ssh_control_socket_name="${c.ssh_control_socket_name}"
				typeset nfsserver_ssh_login_name="${c.nfsserver_ssh_login_name}"
				typeset nfsserver_host="${c.nfs_server.host}"
			)

			if (( c.is_ccygwin == 1 )) ; then
				typeset mnt_config.windows_drive_letter="${c.windows_drive_letter}"
			fi

			print -v mnt_config >"$config_filename"

			return 0
			;;
		# fixme: Implement nfs://-URLs
		*)
			print -u2 -f $"%s: Unknown URL scheme %q\n" "$0" "${c.nfs_server.scheme}"
			return 2
			;;
	esac

	# notreached
}


function cmd_umount
{
	set -o nounset
	nameref c=$1
	integer retval
	integer saved_optind_m1	# saved OPTIND-1

	typeset mydebug=false # fixme: should be "bool" for ksh93v
	# fixme: Need better text layout for $ mount_sshnfs mount --man #
	typeset -r mount_sshnfs_cmdumount_usage=$'+
	[-?\n@(#)\$Id: mount_sshnfs umount (Roland Mainz) 2025-02-19 \$\n]
	[-author?Roland Mainz <roland.mainz@nrubsig.org>]
	[+NAME?mount_sshnfs umount - unmount NFSv4 filesystem mounted
		via mount_sshnfs mount]
	[+DESCRIPTION?\bmount_sshnfs umount\b unmounts a NFSv4
		filesystem previously mounted via mount_sshnfs mount.]

	mountpoint

	[+SEE ALSO?\bksh93\b(1),\bssh\b(1),\bmount.nfs\b(8),\bnfs\b(5)]
	'

	# remove subcmd name (in this case 'umount')
	unset c.args[0]

	#
	# Expand MOUNT_SSHNFS_CMDUMOUNT_OPTIONS before arguments given to
	# mount_sshnfs.ksh.
	# By default we use IFS=$' \t\n' for argument splitting
	#
	c.args=( ${MOUNT_SSHNFS_CMDUMOUNT_OPTIONS-} "${c.args[@]}" )

	#
	# Argument parsing
	#
	while getopts -a "${progname} umount" "${mount_sshnfs_cmdumount_usage}" OPT "${c.args[@]}" ; do
		case "${OPT}" in
			*)
				usage "${progname} umount" "${mount_sshnfs_cmdumount_usage}"
				return $?
				;;
		esac
	done

	(( saved_optind_m1=OPTIND-1 ))

	# remove options we just parsed from c.args
	for ((i=0 ; i < saved_optind_m1 ; i++)) ; do
		unset c.args[$i]
	done


	#
	# Get remaining arguments
	#
	c.mountpoint="${c.args[saved_optind_m1+0]-}"

	#
	# Read configuration file for this mountpoint
	#
	typeset config_filename
	mountpoint2configfilename config_filename "${c.mountpoint}"

	if [[ ! -f "${config_filename}" ]] ; then
		print -u2 -f $"%s: Config file %q for mount point %q not found.\n" \
			"$0" \
			"$config_filename" \
			"${c.mountpoint}"
		return 1
	fi

	compound mnt_config
	read -C mnt_config <"${config_filename}" || return 1

	${mydebug} && print -v mnt_config

	#
	# Do the unmount
	#
	if (( c.is_ccygwin == 1 )) ; then
		# unmount the NFS filesystem
		"${c.msnfsv41_nfsmountcmd}" -d "${mnt_config.windows_drive_letter}"
		(( retval=$? ))

		# remove the Cygwin mount
		(( retval == 0 )) && umount "${c.mountpoint}"
	else
		umount "${c.mountpoint}"
		(( retval=$? ))
	fi

	if (( retval != 0 )) ; then
		return $retval
	fi

	#
	# Quit ssh port forwarding process
	#
	ssh \
		-S "${mnt_config.ssh_control_socket_name}" \
		-O 'exit' \
		"${mnt_config.nfsserver_ssh_login_name}@${mnt_config.nfsserver_host}"

	rm -f "${config_filename}"
	return 0
}


function main
{
	set -o nounset

	# fixme: Need better text layout for $ mount_sshnfs --man #
	typeset -r mount_sshnfs_usage=$'+
	[-?\n@(#)\$Id: mount_sshnfs (Roland Mainz) 2025-02-19 \$\n]
	[-author?Roland Mainz <roland.mainz@nrubsig.org>]
	[+NAME?mount_sshnfs - mount/umount NFSv4 filesystem via ssh
		tunnel]
	[+DESCRIPTION?\bmount_sshnfs\b mounts/unmounts a NFSv4
		filesystem via ssh tunnel.]
	[D:debug?Enable debugging.]

	mount [options]
	umount [options]
	status [options]
	restart_forwarding [options]
	--man

	[+EXAMPLES]{
		[+?Example 1:][+?Mount&&unmount /export/home/rmainz on NFS server "/export/home/rmainz"]{
[+\n# mkdir -p /tmp/foobarmnt
# mount_sshnfs mount ssh+nfs:://rmainz@derfwpc5131//export/home/rmainz /tmp/foobarmnt
# mount_sshnfs umount /tmp/foobarmnt
]
}
		[+?Example 2:][+?Mount&&unmount /export/home/rmainz on NFS server "/export/home/rmainz" via SSH jumphost rmainz@10.49.20.131]{
[+\n# mkdir -p /tmp/foobarmnt
# mount_sshnfs mount -o ro,mount_sshnfs_jumphost=rmainz@10.49.20.131 ssh+nfs:://rmainz@derfwpc5131//export/home/rmainz /tmp/foobarmnt
# mount_sshnfs umount /tmp/foobarmnt
]
		}
	}
	[+SEE ALSO?\bksh93\b(1),\bssh\b(1),\bmount.nfs\b(8),\bnfs\b(5)]
	'

	compound c
	typeset -a c.args
	integer saved_optind_m1	# saved OPTIND-1

	if [[ "${ uname -o ;}" == 'Cygwin' ]] ; then
		integer c.is_ccygwin=1

		# Cygwin has nfs_mount.exe in /sbin
		PATH+=':/sbin:/usr/sbin:/usr/bin:/bin'

		typeset c.msnfsv41_nfsmountcmd="$(which 'nfs_mount.exe')"

		if [[ ! -x "${c.msnfsv41_nfsmountcmd}" ]] ; then
			print -u2 -f $"%s: Cannot find MS-NFSV41 nfs_mount.exe command\n" "$0"
			return 1
		fi
	else
		integer c.is_ccygwin=0
	fi

	# Cygwin does not set logname
	[[ ! -v LOGNAME ]] && export LOGNAME="$(logname)"

	#
	# Expand MOUNT_SSHNFS_OPTIONS before arguments given to
	# mount_sshnfs.ksh.
	# By default we use IFS=$' \t\n' for argument splitting
	#
	c.args=( ${MOUNT_SSHNFS_OPTIONS-} "$@" )

	#
	# Argument parsing
	#
	while getopts -a "${progname}" "${mount_sshnfs_usage}" OPT "${c.args[@]}" ; do
		case "${OPT}" in
			'D')
				# fixme: Implement debugging option
				;;
			*)
				usage "${progname}" "${mount_sshnfs_usage}"
				return $?
				;;
		esac
	done

	(( saved_optind_m1=OPTIND-1 ))

	# remove options we just parsed from c.args
	for ((i=0 ; i < saved_optind_m1 ; i++)) ; do
		unset c.args[$i]
	done

	#
	# c.args mighth be a sparse array (e.g. "([1]=aaa [2]=bbb [4]=ccc)")
	# right now after we removed processed options/arguments.
	# For easier processing below we "reflow" the array back to a
	# normal linear layout (e.g. ([0]=aaa [1]=bbb [2]=ccc)
	#
	c.args=( "${c.args[@]}" )

	#
	# Subcommand dispatcher
	#
	case "${c.args[0]-}" in
		'mount')
			cmd_mount c
			return $?
			;;
		'umount')
			cmd_umount c
			return $?
			;;
		'status' | 'restart_forwarding')
			print -u2 -f $"%s: not implemented yet\n" "$0"
			return 2
			;;
		*)
			print -u2 -f $"%s: Unknown command %q\n" \
				"$0" "${c.args[0]-}"
			usage "${progname}" "${mount_sshnfs_usage}"
			return 1
			;;
	esac

	# notreached
}


#
# main
#
builtin cat
builtin mkdir
builtin basename

typeset progname="${ basename "${0}" ; }"

main "$@"
exit $?

# EOF.
