diff --git a/.env.example b/.env.example index e28822f..0c77102 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ +ENABLE_REAPER=false +REAPER_TIMEOUT=0 SSH_AUTHORIZED_KEYS= SSH_CHROOT_DIRECTORY=%h SSH_INHERIT_ENVIRONMENT=false diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c8ee41..82b2054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,9 @@ Summary of release changes. - Adds exec proxy function to `sshd-wrapper` used to pass through nice. - Adds double quotes around value containing spaces. - Adds `/docs` directory for supplementary documentation and simplify README. +- Adds feature to optionally exit the container after a specified timout period. +- Adds `ENABLE_REAPER` with a default value of `false` to enable the `reaper` service. +- Adds `REAPER_TIMEOUT` with a default value of `0` seconds (i.e no timeout delay). - Fixes validation failure of 0 second --timeout value in `test/health_status`. - Removes `ENABLE_SSHD_BOOTSTRAP` from docker-compose example configuration. - Removes `ENABLE_SSHD_WRAPPER` from docker-compose example configuration. diff --git a/Dockerfile b/Dockerfile index c9e77e9..f643b5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,7 @@ RUN rpm --rebuilddb \ openssl-1.0.2k-19.el7 \ python-setuptools-0.9.8-7.el7 \ sudo-1.8.23-4.el7 \ + sysvinit-tools-2.88-14.dsf.el7 \ yum-plugin-versionlock-1.1.31-52.el7 \ && yum versionlock add \ inotify-tools \ @@ -40,6 +41,7 @@ RUN rpm --rebuilddb \ openssh-clients \ python-setuptools \ sudo \ + sysvinit-tools \ yum-plugin-versionlock \ && yum clean all \ && easy_install \ @@ -88,7 +90,7 @@ RUN ln -sf \ && chmod 644 \ /etc/{supervisord.conf,supervisord.d/{20-sshd-bootstrap,50-sshd-wrapper}.conf} \ && chmod 700 \ - /usr/{bin/healthcheck,sbin/{scmi,sshd-{bootstrap,wrapper},system-{timezone,timezone-wrapper}}} + /usr/{bin/healthcheck,sbin/{reaper,scmi,sshd-{bootstrap,wrapper},system-{timezone,timezone-wrapper}}} EXPOSE 22 @@ -96,9 +98,11 @@ EXPOSE 22 # Set default environment variables # ------------------------------------------------------------------------------ ENV \ + ENABLE_REAPER="false" \ ENABLE_SSHD_BOOTSTRAP="true" \ ENABLE_SSHD_WRAPPER="true" \ ENABLE_SUPERVISOR_STDOUT="false" \ + REAPER_TIMEOUT="0" \ SSH_AUTHORIZED_KEYS="" \ SSH_CHROOT_DIRECTORY="%h" \ SSH_INHERIT_ENVIRONMENT="false" \ diff --git a/default.mk b/default.mk index 29c97fe..3fefbf9 100644 --- a/default.mk +++ b/default.mk @@ -41,9 +41,11 @@ DOCKER_PUBLISH := $(shell \ define DOCKER_CONTAINER_PARAMETERS --name $(DOCKER_NAME) \ --restart $(DOCKER_RESTART_POLICY) \ +--env "ENABLE_REAPER=$(ENABLE_REAPER)" \ --env "ENABLE_SSHD_BOOTSTRAP=$(ENABLE_SSHD_BOOTSTRAP)" \ --env "ENABLE_SSHD_WRAPPER=$(ENABLE_SSHD_WRAPPER)" \ --env "ENABLE_SUPERVISOR_STDOUT=$(ENABLE_SUPERVISOR_STDOUT)" \ +--env "REAPER_TIMEOUT=$(REAPER_TIMEOUT)" \ --env "SSH_AUTHORIZED_KEYS=$(SSH_AUTHORIZED_KEYS)" \ --env "SSH_CHROOT_DIRECTORY=$(SSH_CHROOT_DIRECTORY)" \ --env "SSH_INHERIT_ENVIRONMENT=$(SSH_INHERIT_ENVIRONMENT)" \ diff --git a/docker-compose.yml b/docker-compose.yml index 9a0f654..dd75137 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,8 @@ services: context: "." dockerfile: "Dockerfile" environment: + ENABLE_REAPER: "${ENABLE_REAPER}" + REAPER_TIMEOUT: "${REAPER_TIMEOUT}" SSH_AUTHORIZED_KEYS: "${SSH_AUTHORIZED_KEYS}" SSH_CHROOT_DIRECTORY: "${SSH_CHROOT_DIRECTORY}" SSH_INHERIT_ENVIRONMENT: "${SSH_INHERIT_ENVIRONMENT}" diff --git a/environment.mk b/environment.mk index 36ed28e..4872901 100644 --- a/environment.mk +++ b/environment.mk @@ -23,9 +23,11 @@ STARTUP_TIME ?= 2 # ------------------------------------------------------------------------------ # Application container configuration # ------------------------------------------------------------------------------ +ENABLE_REAPER ?= false ENABLE_SSHD_BOOTSTRAP ?= true ENABLE_SSHD_WRAPPER ?= true ENABLE_SUPERVISOR_STDOUT ?= false +REAPER_TIMEOUT ?= 0 SSH_AUTHORIZED_KEYS ?= SSH_CHROOT_DIRECTORY ?= %h SSH_INHERIT_ENVIRONMENT ?= false diff --git a/src/etc/supervisord.d/00-reaper.conf b/src/etc/supervisord.d/00-reaper.conf new file mode 100644 index 0000000..619f44e --- /dev/null +++ b/src/etc/supervisord.d/00-reaper.conf @@ -0,0 +1,10 @@ +[program:reaper] +autorestart = false +autostart = %(ENV_ENABLE_REAPER)s +command = /usr/sbin/reaper --monochrome --verbose --timeout %(ENV_REAPER_TIMEOUT)s --wall-timeout 30 --wall="Session expiring in 30 seconds." +priority = 1 +startsecs = 0 +stderr_logfile = /dev/stderr +stderr_logfile_maxbytes = 0 +stdout_logfile = /dev/stdout +stdout_logfile_maxbytes = 0 diff --git a/src/etc/systemd/system/centos-ssh@.service b/src/etc/systemd/system/centos-ssh@.service index 9787182..6a1e016 100644 --- a/src/etc/systemd/system/centos-ssh@.service +++ b/src/etc/systemd/system/centos-ssh@.service @@ -56,9 +56,11 @@ Environment="DOCKER_IMAGE_PACKAGE_PATH=/var/opt/scmi/packages" Environment="DOCKER_IMAGE_TAG={{RELEASE_VERSION}}" Environment="DOCKER_PORT_MAP_TCP_22=2020" Environment="DOCKER_USER=jdeathe" +Environment="ENABLE_REAPER=false" Environment="ENABLE_SSHD_BOOTSTRAP=true" Environment="ENABLE_SSHD_WRAPPER=true" Environment="ENABLE_SUPERVISOR_STDOUT=false" +Environment="REAPER_TIMEOUT=0" Environment="SSH_AUTHORIZED_KEYS=" Environment="SSH_CHROOT_DIRECTORY=%%h" Environment="SSH_INHERIT_ENVIRONMENT=false" @@ -129,9 +131,11 @@ ExecStartPre=-/bin/bash -c \ ExecStart=/bin/bash -c \ "exec /usr/bin/docker run \ --name %p.%i \ + --env \"ENABLE_REAPER=${ENABLE_REAPER}\" \ --env \"ENABLE_SSHD_BOOTSTRAP=${ENABLE_SSHD_BOOTSTRAP}\" \ --env \"ENABLE_SSHD_WRAPPER=${ENABLE_SSHD_WRAPPER}\" \ --env \"ENABLE_SUPERVISOR_STDOUT=${ENABLE_SUPERVISOR_STDOUT}\" \ + --env \"REAPER_TIMEOUT=${REAPER_TIMEOUT}\" \ --env \"SSH_AUTHORIZED_KEYS=${SSH_AUTHORIZED_KEYS}\" \ --env \"SSH_CHROOT_DIRECTORY=${SSH_CHROOT_DIRECTORY}\" \ --env \"SSH_INHERIT_ENVIRONMENT=${SSH_INHERIT_ENVIRONMENT}\" \ diff --git a/src/opt/scmi/default.sh b/src/opt/scmi/default.sh index 977a152..654cb90 100644 --- a/src/opt/scmi/default.sh +++ b/src/opt/scmi/default.sh @@ -46,9 +46,11 @@ fi # Common parameters of create and run targets DOCKER_CONTAINER_PARAMETERS="--name ${DOCKER_NAME} \ --restart ${DOCKER_RESTART_POLICY} \ +--env \"ENABLE_REAPER=${ENABLE_REAPER}\" \ --env \"ENABLE_SSHD_BOOTSTRAP=${ENABLE_SSHD_BOOTSTRAP}\" \ --env \"ENABLE_SSHD_WRAPPER=${ENABLE_SSHD_WRAPPER}\" \ --env \"ENABLE_SUPERVISOR_STDOUT=${ENABLE_SUPERVISOR_STDOUT}\" \ +--env \"REAPER_TIMEOUT=${REAPER_TIMEOUT}\" \ --env \"SSH_AUTHORIZED_KEYS=${SSH_AUTHORIZED_KEYS}\" \ --env \"SSH_CHROOT_DIRECTORY=${SSH_CHROOT_DIRECTORY}\" \ --env \"SSH_INHERIT_ENVIRONMENT=${SSH_INHERIT_ENVIRONMENT}\" \ diff --git a/src/opt/scmi/environment.sh b/src/opt/scmi/environment.sh index 682c9ca..68f71e7 100644 --- a/src/opt/scmi/environment.sh +++ b/src/opt/scmi/environment.sh @@ -24,9 +24,11 @@ STARTUP_TIME="${STARTUP_TIME:-2}" # ------------------------------------------------------------------------------ # Application container configuration # ------------------------------------------------------------------------------ +ENABLE_REAPER="${ENABLE_REAPER:-false}" ENABLE_SSHD_BOOTSTRAP="${ENABLE_SSHD_BOOTSTRAP:-true}" ENABLE_SSHD_WRAPPER="${ENABLE_SSHD_WRAPPER:-true}" ENABLE_SUPERVISOR_STDOUT="${ENABLE_SUPERVISOR_STDOUT:-false}" +REAPER_TIMEOUT="${REAPER_TIMEOUT:-0}" SSH_AUTHORIZED_KEYS="${SSH_AUTHORIZED_KEYS:-}" SSH_CHROOT_DIRECTORY="${SSH_CHROOT_DIRECTORY:-%h}" SSH_INHERIT_ENVIRONMENT="${SSH_INHERIT_ENVIRONMENT:-false}" diff --git a/src/opt/scmi/service-unit.sh b/src/opt/scmi/service-unit.sh index 6c6c131..55e1bc0 100644 --- a/src/opt/scmi/service-unit.sh +++ b/src/opt/scmi/service-unit.sh @@ -6,9 +6,11 @@ readonly SERVICE_UNIT_ENVIRONMENT_KEYS=" DOCKER_IMAGE_PACKAGE_PATH DOCKER_IMAGE_TAG DOCKER_PORT_MAP_TCP_22 + ENABLE_REAPER ENABLE_SSHD_BOOTSTRAP ENABLE_SSHD_WRAPPER ENABLE_SUPERVISOR_STDOUT + REAPER_TIMEOUT SSH_AUTHORIZED_KEYS SSH_CHROOT_DIRECTORY SSH_INHERIT_ENVIRONMENT diff --git a/src/usr/sbin/reaper b/src/usr/sbin/reaper new file mode 100755 index 0000000..e4299b2 --- /dev/null +++ b/src/usr/sbin/reaper @@ -0,0 +1,495 @@ +#!/usr/bin/env bash + +set -e + +function __cleanup () +{ + __delete_lock +} + +function __create_lock () +{ + if [[ -n ${lock_file} ]] + then + touch "${lock_file}" + fi +} + +function __create_state () +{ + if [[ -n ${state_file} ]] + then + printf -- \ + '%s %s\n' \ + "${session_start}" \ + "$(( ${session_start} + ${timeout} ))" \ + > "${state_file}" + fi +} + +function __delete_lock () +{ + if [[ -f ${lock_file} ]] + then + rm -f "${lock_file}" + fi +} + +function __print_message () +{ + local -r type="${1}" + + local colour_debug='\033[1;30m' + local colour_err='\033[1;31m' + local colour_info='\033[1;37m' + local colour_notice='\033[1;32m' + local colour_reset='\033[0m' + local colour_warning='\033[1;33m' + local exit_code="${3:-0}" + local message="${2}" + local output_debug="${output_debug:-false}" + local output_quiet="${output_quiet:-false}" + local output_silent="${output_silent:-false}" + local output_verbose="${output_verbose:-false}" + local prefix="" + + if [[ ${monochrome} == true ]] + then + unset \ + colour_debug \ + colour_err \ + colour_info \ + colour_notice \ + colour_reset \ + colour_warning + fi + + case "${type}" in + err|error) + prefix="$( + printf -- \ + '%bERROR%b %s' \ + "${colour_err}" \ + "${colour_reset}" \ + "${0##*/}" + )" + ;; + warning|warn) + prefix="$( + printf -- \ + '%bWARN%b %s' \ + "${colour_warning}" \ + "${colour_reset}" \ + "${0##*/}" + )" + ;; + notice) + prefix="$( + printf -- \ + '%bNOTICE%b %s: ' \ + "${colour_notice}" \ + "${colour_reset}" \ + "${0##*/}" + )" + ;; + info) + prefix="$( + printf -- \ + '%bINFO%b %s: ' \ + "${colour_info}" \ + "${colour_reset}" \ + "${0##*/}" + )" + ;; + debug) + prefix="$( + printf -- \ + '%bDEBUG%b %s: ' \ + "${colour_debug}" \ + "${colour_reset}" \ + "${0##*/}" + )" + ;; + *) + message="${type}" + ;; + esac + + if [[ ${output_quiet} == true ]] \ + && [[ ! ${type} =~ ^(err|error|warning|warn)$ ]] + then + return 0 + elif [[ ${output_silent} == true ]] \ + && [[ ! ${type} =~ ^(err|error)$ ]] + then + return 0 + elif [[ ${output_silent} == true ]] \ + && [[ ${type} =~ ^(err|error)$ ]] + then + return 1 + elif [[ ${type} =~ ^(err|error)$ ]] + then + logger \ + --priority "err" \ + --stderr \ + --tag "${prefix}" \ + -- \ + "${message}" + elif [[ ${type} =~ ^(warning|warn)$ ]] + then + logger \ + --priority "warning" \ + --stderr \ + --tag "${prefix}" \ + -- \ + "${message}" + elif [[ ${output_debug} != true ]] \ + && [[ ${type} == debug ]] + then + return 0 + elif [[ ${output_verbose} != true ]] \ + && [[ ${type} == info ]] + then + return 0 + else + printf -- \ + '%s%s\n' \ + "${prefix}" \ + "${message}" + fi + + if [[ ${exit_code} -gt 0 ]] + then + exit ${exit_code} + fi +} + +function __is_valid_get () +{ + local -r get_options='^(end|start|ttl)$' + local -r value="${1}" + + if [[ ${value} =~ ${get_options} ]] + then + return 0 + fi + + return 1 +} + +function __is_valid_positive_integer () +{ + local -r positive_integer='^[0-9]+$' + local -r value="${1}" + + if [[ ${value} =~ ${positive_integer} ]] + then + return 0 + fi + + return 1 +} + +function __is_valid_timeout () +{ + __is_valid_positive_integer "${@}" +} + +function __is_valid_wall_timeout () +{ + __is_valid_positive_integer "${@}" +} + +function __reap () +{ + kill \ + -s "${signal:-TERM}" \ + "${pid:-1}" + + __cleanup +} + +function __usage () +{ + local help="${help:-false}" + local output_quiet="${output_quiet:-false}" + local output_silent="${output_silent:-false}" + + if [[ ${output_silent} != true ]] \ + || [[ ${help} == true ]] + then + cat <<-USAGE + + Usage: ${0##*/} [OPTIONS] + + Options: + -g, --get KEY Used to get values from a running ${0##*/} + process. The keys and values they return are: + - start : returns session start timestamp. + - end : returns session end timestamp. + - ttl : returns remaining session time to live. + -h, --help Display help text and exit. + --monochrome Output colour is suppressed. + -p, --pid PID Send the termination signal to the process with + the pid value PID. + If not specified the default is pid 1. + -q, --quiet Do not output notice, info or debug messages. + -qq, --silent Do not output any messages. + -s, --signal SIG Send the signal SIG to the process. + If not specified the default is SIGTERM. + -T, --wall-timeout SECONDS Set the time before session end to send the + wall message. The default is 30 seconds. + Set to 0 to disable sending a wall message. + -t, --timeout SECONDS Time in seconds to wait before sending the + signal to the process. The default is 0 seconds + which indicates no delay. + -v, --verbose Output info messages. + -vv, --debug Output debug messages. + -w, --wall MESSAGE Set a wall message to send before session end. + USAGE + fi + + if [[ ${help} != true ]] + then + exit 1 + fi + + exit 0 +} + +function main () +{ + local -r lock_file="/var/lock/subsys/reaper" + local -r state_file="/var/lib/misc/reaper" + + local current_time + local get + local help + local monochrome="false" + local output_debug="false" + local output_quiet="false" + local output_silent="false" + local output_verbose="false" + local pid="1" + local session_end + local session_start + local signal="TERM" + local state_value + local timeout="0" + local wall_message + local wall_timeout="30" + + if [[ ${EUID} -ne 0 ]] + then + __print_message \ + "error" \ + "must be run as root" \ + 1 + fi + + while [[ "${#}" -gt 0 ]] + do + case "${1}" in + --get=*) + get="${1#*=}" + shift 1 + ;; + -g|--get) + get="${2}" + shift 2 || break + ;; + -h|--help) + help="true" + __usage + ;; + --monochrome) + monochrome="true" + shift 1 + ;; + --pid=*) + pid="${1#*=}" + shift 1 + ;; + -p|--pid) + pid="${2}" + shift 2 || break + ;; + -q|--quiet) + output_quiet="true" + shift 1 + ;; + -qq|--silent) + output_quiet="true" + output_silent="true" + shift 1 + ;; + --signal=*) + signal="${1#*=}" + shift 1 + ;; + -s|--signal) + signal="${2}" + shift 2 || break + ;; + --timeout=*) + timeout="${1#*=}" + shift 1 + ;; + -t|--timeout) + timeout="${2}" + shift 2 || break + ;; + -v|--verbose) + output_verbose="true" + shift 1 + ;; + -vv|--debug) + output_debug="true" + output_verbose="true" + shift 1 + ;; + --wall=*) + wall_message="${1#*=}" + shift 1 + ;; + -w|--wall) + wall_message="${2}" + shift 2 || break + ;; + --wall-timeout=*) + wall_timeout="${1#*=}" + shift 1 + ;; + --wall-timeout) + wall_timeout="${2}" + shift 2 || break + ;; + *) + __print_message \ + "error" \ + "unknown option ${1}" + __usage + ;; + esac + done + + if [[ -f ${lock_file} ]] + then + if [[ -n ${get} ]] + then + state_value="$(< "${state_file}")" + session_end="${state_value##* }" + session_start="${state_value%% *}" + + case "${get}" in + end) + printf -- \ + '%s\n' \ + "${session_end}" + ;; + start) + printf -- \ + '%s\n' \ + "${session_start}" + ;; + ttl) + current_time="$( + date -u +%s + )" + + printf -- \ + '%s\n' \ + "$(( ${session_end} - ${current_time} ))" + ;; + *) + __print_message \ + "error" \ + "unknown get value ${get}" + __usage + ;; + esac + + exit 0 + else + __print_message \ + "error" \ + "lock detected - aborting" \ + 1 + fi + elif [[ -n ${get} ]] + then + __print_message \ + "error" \ + "is not running" + __usage + fi + + trap __cleanup \ + EXIT INT TERM + __create_lock + + if ! __is_valid_timeout "${timeout}" + then + __print_message \ + "error" \ + "invalid --timeout" + __usage + fi + + if ! __is_valid_wall_timeout "${wall_timeout}" + then + __print_message \ + "error" \ + "invalid --wall-timeout" + __usage + fi + + if [[ -z ${wall_message} ]] \ + || (( timeout <= wall_timeout )) + then + wall_timeout="0" + fi + + session_start="$( + date -u +%s + )" + + trap __reap \ + EXIT INT TERM + + __create_state + + if (( timeout > 0 )) + then + __print_message \ + "info" \ + "session will expire after ${timeout} seconds" + + if coproc read -t "$(( ${timeout} - ${wall_timeout} ))" + then + wait "${!}" || : + + if (( wall_timeout > 0 )) + then + wall "${wall_message}" || : + + if coproc read -t "${wall_timeout}" + then + wait "${!}" || : + fi + fi + fi + fi + + __print_message \ + "info" \ + "expiring session after ${timeout} seconds" + + __print_message \ + "warn" \ + "expiring session" + + exit 0 +} + +main "${@}"