#!/usr/bin/env bash # # ehough/docker-nfs-server: A lightweight, robust, flexible, and containerized NFS server. # # https://hub.docker.com/r/erichough/nfs-server # https://github.com/ehough/docker-nfs-server # # Copyright (C) 2017-2019 Eric D. Hough # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . ###################################################################################### ### constants ###################################################################################### readonly ENV_VAR_NFS_DISABLE_VERSION_3='NFS_DISABLE_VERSION_3' readonly ENV_VAR_NFS_SERVER_THREAD_COUNT='NFS_SERVER_THREAD_COUNT' readonly ENV_VAR_NFS_ENABLE_KERBEROS='NFS_ENABLE_KERBEROS' readonly ENV_VAR_NFS_PORT_MOUNTD='NFS_PORT_MOUNTD' readonly ENV_VAR_NFS_PORT='NFS_PORT' readonly ENV_VAR_NFS_PORT_STATD_IN='NFS_PORT_STATD_IN' readonly ENV_VAR_NFS_PORT_STATD_OUT='NFS_PORT_STATD_OUT' readonly ENV_VAR_NFS_VERSION='NFS_VERSION' readonly ENV_VAR_NFS_LOG_LEVEL='NFS_LOG_LEVEL' readonly DEFAULT_NFS_PORT=2049 readonly DEFAULT_NFS_PORT_MOUNTD=32767 readonly DEFAULT_NFS_PORT_STATD_IN=32765 readonly DEFAULT_NFS_PORT_STATD_OUT=32766 readonly DEFAULT_NFS_VERSION='4.2' readonly PATH_BIN_EXPORTFS='/usr/sbin/exportfs' readonly PATH_BIN_IDMAPD='/usr/sbin/rpc.idmapd' readonly PATH_BIN_MOUNTD='/usr/sbin/rpc.mountd' readonly PATH_BIN_NFSD='/usr/sbin/rpc.nfsd' readonly PATH_BIN_RPCBIND='/sbin/rpcbind' readonly PATH_BIN_RPC_SVCGSSD='/usr/sbin/rpc.svcgssd' readonly PATH_BIN_STATD='/sbin/rpc.statd' readonly PATH_FILE_ETC_EXPORTS='/etc/exports' readonly PATH_FILE_ETC_IDMAPD_CONF='/etc/idmapd.conf' readonly PATH_FILE_ETC_KRB5_CONF='/etc/krb5.conf' readonly PATH_FILE_ETC_KRB5_KEYTAB='/etc/krb5.keytab' readonly MOUNT_PATH_NFSD='/proc/fs/nfsd' readonly MOUNT_PATH_RPC_PIPEFS='/var/lib/nfs/rpc_pipefs' readonly REGEX_EXPORTS_LINES_TO_SKIP='^\s*#|^\s*$' readonly LOG_LEVEL_INFO='INFO' readonly LOG_LEVEL_DEBUG='DEBUG' readonly STATE_LOG_LEVEL='log_level' readonly STATE_IS_LOGGING_DEBUG='is_logging_debug' readonly STATE_IS_LOGGING_INFO='is_logging_info' readonly STATE_NFSD_THREAD_COUNT='nfsd_thread_count' readonly STATE_NFSD_PORT='nfsd_port' readonly STATE_MOUNTD_PORT='mountd_port' readonly STATE_STATD_PORT_IN='statd_port_in' readonly STATE_STATD_PORT_OUT='statd_port_out' readonly STATE_NFS_VERSION='nfs_version' # "state" is our only global variable, which is an associative array of normalized data declare -A state ###################################################################################### ### string utils ###################################################################################### toupper() { echo "$1" | awk '{ print toupper($0) }' } ###################################################################################### ### logging ###################################################################################### log() { echo "----> $1" } log_warning() { log "WARNING: $1" } log_error() { log '' log "ERROR: $1" log '' } log_header() { echo " ================================================================== $(toupper "$1") ==================================================================" } ###################################################################################### ### error handling ###################################################################################### bail() { log_error "$1" exit 1 } on_failure() { # shellcheck disable=SC2181 if [[ $? -eq 0 ]]; then return fi case "$1" in warn) log_warning "$2" ;; stop) log_error "$2" stop ;; *) bail "$2" ;; esac } ###################################################################################### ### process control ###################################################################################### term_process() { local -r base=$(basename "$1") local -r pid=$(pidof "$base") if [[ -n $pid ]]; then log "terminating $base" kill "$pid" on_failure warn "unable to terminate $base" else log "$base was not running" fi } ###################################################################################### ### teardown ###################################################################################### stop_mount() { local -r path=$1 local -r type=$(basename "$path") if mount | grep -Eq ^"$type on $path\\s+"; then local args=() if is_logging_debug; then args+=('-v') log "un-mounting $type filesystem from $path" fi args+=("$path") umount "${args[@]}" on_failure warn "unable to un-mount $type filesystem from $path" else log "no active mount at $path" fi } stop_nfsd() { log 'terminating nfsd' $PATH_BIN_NFSD 0 on_failure warn 'unable to terminate nfsd. if it had started already, check Docker host for lingering [nfsd] processes' } stop_exportfs() { local args=('-ua') if is_logging_debug; then args+=('-v') fi log 'un-exporting filesystem(s)' $PATH_BIN_EXPORTFS "${args[@]}" on_failure warn 'unable to un-export filesystem(s)' } stop() { log_header 'terminating ...' if is_kerberos_requested; then term_process "$PATH_BIN_RPC_SVCGSSD" fi stop_nfsd if is_idmapd_requested; then term_process "$PATH_BIN_IDMAPD" fi if is_nfs3_enabled; then term_process "$PATH_BIN_STATD" fi term_process "$PATH_BIN_MOUNTD" stop_exportfs term_process "$PATH_BIN_RPCBIND" stop_mount "$MOUNT_PATH_NFSD" stop_mount "$MOUNT_PATH_RPC_PIPEFS" log_header 'terminated' exit 0 } ###################################################################################### ### runtime environment detection ###################################################################################### is_kerberos_requested() { [[ -n "${!ENV_VAR_NFS_ENABLE_KERBEROS}" ]] && return 0 || return 1 } is_nfs3_enabled() { [[ -z "${!ENV_VAR_NFS_DISABLE_VERSION_3}" ]] && return 0 || return 1 } is_idmapd_requested() { [[ -f "$PATH_FILE_ETC_IDMAPD_CONF" ]] && return 0 || return 1 } is_logging_debug() { [[ -n ${state[$STATE_IS_LOGGING_DEBUG]} ]] && return 0 || return 1 } is_kernel_module_loaded() { local -r module=$1 if lsmod | grep -Eq "^$module\\s+" || [[ -d "/sys/module/$module" ]]; then if is_logging_debug; then log "kernel module $module is loaded" fi return 0 fi log "kernel module $module is missing" return 1 } is_granted_linux_capability() { if capsh --print | grep -Eq "^Current: = .*,?${1}(,|$)"; then return 0 fi return 1 } ###################################################################################### ### runtime configuration assertions ###################################################################################### assert_file_provided() { if [[ ! -f "$1" ]]; then bail "please provide $1 to the container" fi } assert_kernel_mod() { local -r module=$1 if is_kernel_module_loaded "$module"; then return fi if [[ ! -d /lib/modules ]] || ! is_granted_linux_capability 'sys_module'; then bail "$module module is not loaded in the Docker host's kernel (try: modprobe $module)" fi log "attempting to load kernel module $module" modprobe -v "$module" on_failure bail "unable to dynamically load kernel module $module. try modprobe $module on the Docker host" if ! is_kernel_module_loaded "$module"; then bail "modprobe claims that it loaded kernel module $module, but it still appears to be missing" fi } assert_port() { local -r variable_name=$1 local -r value=${!variable_name} if [[ -n "$value" && ( "$value" -lt 1 || "$value" -gt 65535 ) ]]; then bail "please set $variable_name to an integer between 1 and 65535 inclusive" fi } ###################################################################################### ### initialization ###################################################################################### init_state_logging() { # if the user didn't request a specific log level, the default is INFO local -r normalized_log_level=$(toupper "${!ENV_VAR_NFS_LOG_LEVEL:-$LOG_LEVEL_INFO}") if ! echo "$normalized_log_level" | grep -Eq 'DEBUG|INFO'; then bail "the only acceptable values for $ENV_VAR_NFS_LOG_LEVEL are: DEBUG, INFO" fi state[$STATE_LOG_LEVEL]=$normalized_log_level; state[$STATE_IS_LOGGING_INFO]=1 if [[ $normalized_log_level = "$LOG_LEVEL_DEBUG" ]]; then state[$STATE_IS_LOGGING_DEBUG]=1 log "log level set to $LOG_LEVEL_DEBUG" fi } init_state_nfsd_thread_count() { local count if [[ -n "${!ENV_VAR_NFS_SERVER_THREAD_COUNT}" ]]; then count="${!ENV_VAR_NFS_SERVER_THREAD_COUNT}" if [[ $count -lt 1 ]]; then bail "please set $ENV_VAR_NFS_SERVER_THREAD_COUNT to a positive integer" fi if is_logging_debug; then log "will use requested rpc.nfsd thread count of $count" fi else count="$(grep -Ec ^processor /proc/cpuinfo)" on_failure bail "unable to detect CPU count. set $ENV_VAR_NFS_SERVER_THREAD_COUNT environment variable" if is_logging_debug; then log "will use $count rpc.nfsd server thread(s) (1 thread per CPU)" fi fi state[$STATE_NFSD_THREAD_COUNT]=$count } init_state_ports() { assert_port "$ENV_VAR_NFS_PORT" assert_port "$ENV_VAR_NFS_PORT_MOUNTD" assert_port "$ENV_VAR_NFS_PORT_STATD_IN" assert_port "$ENV_VAR_NFS_PORT_STATD_OUT" state[$STATE_NFSD_PORT]=${!ENV_VAR_NFS_PORT:-$DEFAULT_NFS_PORT} state[$STATE_MOUNTD_PORT]=${!ENV_VAR_NFS_PORT_MOUNTD:-$DEFAULT_NFS_PORT_MOUNTD} state[$STATE_STATD_PORT_IN]=${!ENV_VAR_NFS_PORT_STATD_IN:-$DEFAULT_NFS_PORT_STATD_IN} state[$STATE_STATD_PORT_OUT]=${!ENV_VAR_NFS_PORT_STATD_OUT:-$DEFAULT_NFS_PORT_STATD_OUT} } init_state_nfs_version() { local -r requested_version="${!ENV_VAR_NFS_VERSION:-$DEFAULT_NFS_VERSION}" echo "$requested_version" | grep -Eq '^3$|^4(\.[1-2])?$' on_failure bail "please set $ENV_VAR_NFS_VERSION to one of: 4.2, 4.1, 4, 3" if ! is_nfs3_enabled && [[ "$requested_version" = '3' ]]; then bail 'you cannot simultaneously enable and disable NFS version 3' fi state[$STATE_NFS_VERSION]=$requested_version } init_trap() { trap stop SIGTERM SIGINT } init_exports() { # first, see if it's bind-mounted if mount | grep -Eq "^[^ ]+ on $PATH_FILE_ETC_EXPORTS type "; then if is_logging_debug; then log "$PATH_FILE_ETC_EXPORTS is bind-mounted" fi # maybe it's baked-in to the image elif [[ -f $PATH_FILE_ETC_EXPORTS && -r $PATH_FILE_ETC_EXPORTS && -s $PATH_FILE_ETC_EXPORTS ]]; then if is_logging_debug; then log "$PATH_FILE_ETC_EXPORTS is baked into the image" fi # fall back to environment variables else local count_valid_exports=0 local exports='' local candidate_export_vars local candidate_export_var # collect all candidate environment variable names candidate_export_vars=$(compgen -A variable | grep -E 'NFS_EXPORT_[0-9]+' | sort) on_failure bail 'failed to detect NFS_EXPORT_* variables' if [[ -z "$candidate_export_vars" ]]; then bail "please provide $PATH_FILE_ETC_EXPORTS to the container or set at least one NFS_EXPORT_* environment variable" fi log "building $PATH_FILE_ETC_EXPORTS from environment variables" for candidate_export_var in $candidate_export_vars; do local line="${!candidate_export_var}" # skip comments and empty lines if [[ "$line" =~ $REGEX_EXPORTS_LINES_TO_SKIP ]]; then log_warning "skipping $candidate_export_var environment variable since it contains only whitespace or a comment" continue; fi local line_as_array read -r -a line_as_array <<< "$line" local dir="${line_as_array[0]}" if [[ ! -d "$dir" ]]; then log_warning "skipping $candidate_export_var environment variable since $dir is not a container directory" continue fi if [[ $count_valid_exports -gt 0 ]]; then exports=$exports$'\n' fi exports=$exports$line (( count_valid_exports++ )) done log "collected $count_valid_exports valid export(s) from NFS_EXPORT_* environment variables" if [[ $count_valid_exports -eq 0 ]]; then bail 'no valid exports' fi echo "$exports" > $PATH_FILE_ETC_EXPORTS on_failure bail "unable to write to $PATH_FILE_ETC_EXPORTS" fi # make sure we have at least one export grep -Evq "$REGEX_EXPORTS_LINES_TO_SKIP" $PATH_FILE_ETC_EXPORTS on_failure bail "$PATH_FILE_ETC_EXPORTS has no exports" } init_runtime_assertions() { if ! is_granted_linux_capability 'cap_sys_admin'; then bail 'missing CAP_SYS_ADMIN. be sure to run this image with --cap-add SYS_ADMIN or --privileged' fi # check kernel modules assert_kernel_mod nfs assert_kernel_mod nfsd # perform Kerberos assertions if is_kerberos_requested; then assert_file_provided "$PATH_FILE_ETC_KRB5_KEYTAB" assert_file_provided "$PATH_FILE_ETC_KRB5_CONF" assert_kernel_mod rpcsec_gss_krb5 fi } ###################################################################################### ### boot helpers ###################################################################################### boot_helper_mount() { local -r path=$1 local -r type=$(basename "$path") local args=('-t' "$type" "$path") if is_logging_debug; then args+=('-vvv') log "mounting $type filesystem onto $path" fi mount "${args[@]}" on_failure stop "unable to mount $type filesystem onto $path" } boot_helper_get_version_flags() { local -r requested_version="${state[$STATE_NFS_VERSION]}" local flags=('--nfs-version' "$requested_version" '--no-nfs-version' 2) if ! is_nfs3_enabled; then flags+=('--no-nfs-version' 3) fi if [[ "$requested_version" = '3' ]]; then flags+=('--no-nfs-version' 4) fi echo "${flags[@]}" } boot_helper_start_daemon() { local -r msg="$1" local -r daemon="$2" shift 2 local -r daemon_args=("$@") log "$msg" "$daemon" "${daemon_args[@]}" on_failure stop "$daemon failed" } boot_helper_start_non_daemon() { local -r msg="$1" local -r process="$2" shift 2 local -r process_args=("$@") log "$msg" "$process" "${process_args[@]}" & local -r bg_pid=$! # somewhat arbitrary assumption that if the process isn't dead already, it will die within a millisecond. for our # purposes this works just fine, but if someone has a better solution please open a PR. sleep .001 kill -0 $bg_pid 2> /dev/null on_failure stop "$process failed" } ###################################################################################### ### primary boot ###################################################################################### boot_main_mounts() { # http://wiki.linux-nfs.org/wiki/index.php/Nfsv4_configuration boot_helper_mount "$MOUNT_PATH_RPC_PIPEFS" boot_helper_mount "$MOUNT_PATH_NFSD" } boot_main_exportfs() { local args=('-ar') if is_logging_debug; then args+=('-v') fi boot_helper_start_daemon 'starting exportfs' $PATH_BIN_EXPORTFS "${args[@]}" } boot_main_mountd() { # https://linux.die.net/man/8/rpc.mountd # # --debug turn on debugging. Valid kinds are: all, auth, call, general and parse. # --port specifies the port number used for RPC listener sockets local version_flags read -r -a version_flags <<< "$(boot_helper_get_version_flags)" local -r port="${state[$STATE_MOUNTD_PORT]}" local args=('--port' "$port" "${version_flags[@]}") if is_logging_debug; then args+=('--debug' 'all') fi # yes, rpc.mountd is required even for NFS v4: https://forums.gentoo.org/viewtopic-p-7724856.html#7724856 boot_helper_start_daemon "starting rpc.mountd on port $port" $PATH_BIN_MOUNTD "${args[@]}" } boot_main_rpcbind() { # https://linux.die.net/man/8/rpcbind # # -d run in debug mode. in this mode, rpcbind will not fork when it starts, will print additional information during # operation, and will abort on certain errors if -a is also specified. with this option, the name-to-address # translation consistency checks are shown in detail # -s cause rpcbind to change to the user daemon as soon as possible. this causes rpcbind to use non-privileged ports # for outgoing connections, preventing non-privileged clients from using rpcbind to connect to services from a # privileged port local args=('-s') if is_logging_debug; then arg+=('-d') fi boot_helper_start_daemon 'starting rpcbind' $PATH_BIN_RPCBIND "${args[@]}" } boot_main_idmapd() { if ! is_idmapd_requested; then return fi # https://linux.die.net/man/8/rpc.idmapd # # -S Server-only: perform no idmapping for any NFS client, even if one is detected # -v increases the verbosity level (can be specified multiple times # -f runs rpc.idmapd in the foreground and prints all output to the terminal local args=('-S') local func=boot_helper_start_daemon if is_logging_debug; then args+=('-vvv' '-f') func=boot_helper_start_non_daemon fi $func 'starting rpc.idmapd' $PATH_BIN_IDMAPD "${args[@]}" } boot_main_statd() { if ! is_nfs3_enabled; then return fi # https://linux.die.net/man/8/rpc.statd # # --no-syslog causes rpc.statd to write log messages on stderr instead of to the system log, if the -F option was # also specified # --foreground keeps rpc.statd attached to its controlling terminal so that NSM operation can be monitored # directly or run under a debugger. if this option is not specified, rpc.statd backgrounds itself # soon after it starts # --no-notify prevents rpc.statd from running the sm-notify command when it starts up, preserving the existing # NSM state number and monitor list # --outgoing-port specifies the source port number the sm-notify command should use when sending reboot notifications # --port specifies the port number used for RPC listener sockets local -r port_in="${state[$STATE_STATD_PORT_IN]}" local -r port_out="${state[$STATE_STATD_PORT_OUT]}" local args=('--no-notify' '--port' "$port_in" '--outgoing-port' "$port_out") local func=boot_helper_start_daemon if is_logging_debug; then args+=('--no-syslog' '--foreground') func=boot_helper_start_non_daemon fi $func "starting rpc.statd on port $port_in (outgoing from port $port_out)" $PATH_BIN_STATD "${args[@]}" } boot_main_nfsd() { # https://linux.die.net/man/8/rpc.nfsd # # --debug enable logging of debugging messages # --port specify a diferent port to listen on for NFS requests. by default, rpc.nfsd will listen on port 2049 # --tcp explicitly enable TCP connections from clients # --udp explicitly enable UCP connections from clients # nproc specify the number of NFS server threads. by default, just one thread is started. however, for optimum # performance several threads should be used. the actual figure depends on the number of and the work load # created by the NFS clients, but a useful starting point is 8 threads. effects of modifying that number can # be checked using the nfsstat(8) program local version_flags read -r -a version_flags <<< "$(boot_helper_get_version_flags)" local -r threads="${state[$STATE_NFSD_THREAD_COUNT]}" local -r port="${state[$STATE_NFSD_PORT]}" local args=('--tcp' '--udp' '--port' "$port" "${version_flags[@]}" "$threads") if is_logging_debug; then args+=('--debug') fi boot_helper_start_daemon "starting rpc.nfsd on port $port with $threads server thread(s)" $PATH_BIN_NFSD "${args[@]}" # rpcbind isn't required for NFSv4, but if it's not running then nfsd takes over 5 minutes to start up. # it's a bug in either nfs-utils or the kernel, and the code of both is over my head. # so as a workaround we start rpcbind always and (in v4-only scenarios) kill it after nfsd starts up if ! is_nfs3_enabled; then term_process "$PATH_BIN_RPCBIND" fi } boot_main_svcgssd() { if ! is_kerberos_requested; then return fi # https://linux.die.net/man/8/rpc.svcgssd # # -f runs rpc.svcgssd in the foreground and sends output to stderr (as opposed to syslogd) # -v increases the verbosity of the output (can be specified multiple times) # -r if the rpcsec_gss library supports setting debug level, increases the verbosity of the output (can be specified # multiple times) # -i if the nfsidmap library supports setting debug level, increases the verbosity of the output (can be specified # multiple times) local args=('-f') if is_logging_debug; then args+=('-vvv' '-rrr' '-iii') fi boot_helper_start_non_daemon 'starting rpc.svcgssd' $PATH_BIN_RPC_SVCGSSD "${args[@]}" } ###################################################################################### ### boot summary ###################################################################################### summarize_nfs_versions() { local -r reqd_version="${state[$STATE_NFS_VERSION]}" local versions='' case "$reqd_version" in 4\.2) versions='4.2, 4.1, 4' ;; 4\.1) versions='4.1, 4' ;; 4) versions='4' ;; *) versions='3' ;; esac if is_nfs3_enabled && [[ "$reqd_version" =~ ^4 ]]; then versions="$versions, 3" fi log "list of enabled NFS protocol versions: $versions" } summarize_exports() { log 'list of container exports:' # if debug is enabled, read /var/lib/nfs/etab as it contains the "real" export data. but it also contains more # information that most people will usually need to see local file_to_read="$PATH_FILE_ETC_EXPORTS" if is_logging_debug; then file_to_read='/var/lib/nfs/etab' fi while read -r export; do # skip comments and empty lines if [[ "$export" =~ $REGEX_EXPORTS_LINES_TO_SKIP ]]; then continue; fi # log it w/out leading and trailing whitespace log " $(echo -e "$export" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" done < "$file_to_read" } summarize_ports() { local -r port_nfsd="${state[$STATE_NFSD_PORT]}" local -r port_mountd="${state[$STATE_MOUNTD_PORT]}" local -r port_statd_in="${state[$STATE_STATD_PORT_IN]}" if ! is_nfs3_enabled; then log "list of container ports that should be exposed: $port_nfsd (TCP)" else log 'list of container ports that should be exposed:' log ' 111 (TCP and UDP)' log " $port_nfsd (TCP and UDP)" log " $port_statd_in (TCP and UDP)" log " $port_mountd (TCP and UDP)" fi } ###################################################################################### ### main routines ###################################################################################### init() { log_header 'setting up ...' init_state_logging init_state_nfsd_thread_count init_state_ports init_state_nfs_version init_exports init_runtime_assertions init_trap log 'setup complete' } boot() { log_header 'starting services ...' boot_main_mounts boot_main_rpcbind boot_main_exportfs boot_main_mountd boot_main_statd boot_main_idmapd boot_main_nfsd boot_main_svcgssd log 'all services started normally' } summarize() { log_header 'server startup complete' summarize_nfs_versions summarize_exports summarize_ports } hangout() { log_header 'ready and waiting for NFS client connections' # wait forever or until we get SIGTERM or SIGINT # https://stackoverflow.com/a/41655546/229920 # https://stackoverflow.com/a/27694965/229920 while :; do sleep 2073600 & wait; done } main() { init boot summarize hangout } main