From 4f1b5d61c8c58d5dbf6440d7cab5d6e2c27f3e9e Mon Sep 17 00:00:00 2001 From: Patrick Van der Veken Date: Wed, 20 May 2015 14:52:00 +0200 Subject: [PATCH] Initial commit --- README.md | 20 +- manage_sudo.sh | 1188 ++++++++++++++++++++++++++++++++++++++++++++++ update_sudo.conf | 27 ++ update_sudo.pl | 735 ++++++++++++++++++++++++++++ 4 files changed, 1968 insertions(+), 2 deletions(-) create mode 100644 manage_sudo.sh create mode 100644 update_sudo.conf create mode 100644 update_sudo.pl diff --git a/README.md b/README.md index 93d1dcf..f59e9b3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ -# sudo_controls -SUDO Controls is a light-weight SUDO fragments/rules distribution & management framework +# SUDO Controls +SUDO Controls is a light-weight **SUDO fragments/rules** distribution & management framework which: + +* uses a **desired state** model: SUDO Controls pushes fragments from a master server onto client host and applies them according to the central configuration. + +* uses **SSH** as **transport** mechanism: SUDO Controls connects to client hosts through the secure path of SSH. + +* performs operations with **least privileges**: copy/distribute operations are performed with a low-privileged account. Only the actual snippet updates requires super-user privileges. + +* uses a **two-stage** approach to activate **SUDO fragments**: copy (or distribute) and apply. Fragments are first copied into a temporary location on each client hosts - the holding directory - and not applied automatically. Applying or activating fragments on a client host is a separate operation which can be triggered either locally or remotely (from the SUDO master) + +* allows the use of (nested) **groups** in the master configuration: users, fragments and hosts can be grouped in the SUDO master configuration files to allow a simplified configuration. Nesting of groups is allowed up to one level deep. + +* requires **no client agent** component and is **stateless**: SUDO Controls performs operations by pushing fragments or commands to client hosts. Update processes on the client hosts will only be started on-demand. If the SUDO master is - for whatever reason - unavailable then active fragments on a client host remain in place. + +* is **easy** to **configure** and **maintain** (command-line based): the configuration is stored in a limited number of flat files and be easily updated. A very rudimentary syntax checking facility is also available to check the consistency of the most important (master) configuration files. + +More documentation can be found at http://www.kudos.be/Projects/SUDO_Controls.html \ No newline at end of file diff --git a/manage_sudo.sh b/manage_sudo.sh new file mode 100644 index 0000000..ab149c2 --- /dev/null +++ b/manage_sudo.sh @@ -0,0 +1,1188 @@ +#!/bin/ksh +#****************************************************************************** +# @(#) manage_sudo.sh +#****************************************************************************** +# @(#) Copyright (C) 2014 by KUDOS BVBA . All rights reserved. +# +# This program is a free software; you can redistribute it and/or modify +# it under the same terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 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 +#****************************************************************************** +# +# DOCUMENTATION (MAIN) +# ----------------------------------------------------------------------------- +# @(#) MAIN: manage_sudo.sh +# DOES: performs basic functions for SUDO controls: update SUDOers files locally +# or remote, validate SUDO syntax, distribute the SUDO fragment files +# EXPECTS: (see --help for more options) +# REQUIRES: check_config(), check_logging(), check_params(), check_setup(), +# check_syntax(), count_fields(), die(), display_usage(), +# distribute2host(), do_cleanup(), fix2host(), log(), resolve_host(), +# sftp_file(), update2host(), validate_syntax(), warn() +# For other pre-requisites see the documentation in display_usage() +# +# @(#) HISTORY: +# @(#) 2014-12-16: initial version (VRF 1.0.0) [Patrick Van der Veken] +# @(#) 2014-12-20: updated SELinux contexts (VRF 1.0.1) [Patrick Van der Veken] +# @(#) 2015-01-05: added backup feature, see --backup (VRF 1.1.0) [Patrick Van der Veken] +# @(#) 2015-01-19: updated display_usage() (VRF 1.1.1) [Patrick Van der Veken] +# @(#) 2015-02-02: allow fragments files to have extensions in merge_fragments() +# use 'sudo -n' (VRF 1.1.2) [Patrick Van der Veken] +# @(#) 2015-04-10: fix in --fix-local routine (VRF 1.1.3) [Patrick Van der Veken] +# @(#) 2015-05-16: added SSH_OWNER_GROUP (VRF 1.1.4) [Patrick Van der Veken] +# ----------------------------------------------------------------------------- +# DO NOT CHANGE THIS FILE UNLESS YOU KNOW WHAT YOU ARE DOING! +#****************************************************************************** + +#****************************************************************************** +# DATA structures +#****************************************************************************** + +# ------------------------- CONFIGURATION starts here ------------------------- +# define the V.R.F (version/release/fix) +MY_VRF="1.1.4" +# name of the user account performing the SUDO controls copies +# (leave blank for current user) +SUDO_TRANSFER_USER="" +# name of the OS group that should own the SUDO controls files +SUDO_OWNER_GROUP="sudoadmin" +# extra arguments/options for the SFTP command +SFTP_ARGS="-o StrictHostKeyChecking=no -o ConnectTimeout=10 -b - " +# extra arguments/options for the SSH command +SSH_ARGS="-o StrictHostKeyChecking=no -o ConnectTimeout=10 -n" +# location of the local SUDO controls directory +LOCAL_DIR="/sysx/soft/sudo_controls" +# location of the remote SUDO controls directory +REMOTE_DIR="/etc/sudo_controls/holding" +# name of the user account performing the SUDO controls update +# (leave blank for current user but user should have remote sudo root privs) +SUDO_UPDATE_USER="" +# path to the visudo tool +VISUDO_BIN="/usr/sbin/visudo" +# maximum number of background process to spawn (~maxuprc, ~nstrpty etc) +MAX_BACKGROUND_PROCS=30 +# location of the backup directory (for configuration & key files) +BACKUP_DIR="${LOCAL_DIR}/backup" +# location of log directory (default), see --log-dir) +LOG_DIR="/var/log" +# location of temporary working storage +TMP_DIR="/var/tmp" +# ------------------------- CONFIGURATION ends here --------------------------- +# miscelleaneous +PATH=${PATH}:/usr/bin:/usr/local/bin +SCRIPT_NAME=$(basename $0) +SCRIPT_DIR=$(dirname $0) +OS_NAME="$(uname)" +FRAGS_FILE="" +FRAGS_DIR="" +TARGETS_FILE="" +FIX_CREATE=0 +CAN_CHECK_SYNTAX=1 +CAN_REMOVE_TEMP=1 +TMP_FILE="${TMP_DIR}/.${SCRIPT_NAME}.$$" +# command-line parameters +ARG_ACTION=0 # default is nothing +ARG_LOG_DIR="" # location of the log directory (~root etc) +ARG_LOCAL_DIR="" # location of the local SUDO control files +ARG_REMOTE_DIR="" # location of the remote SUDO control files +ARG_TARGETS="" # list of remote targets +ARG_LOG=1 # logging is on by default +ARG_VERBOSE=1 # STDOUT is on by default +ARG_DEBUG=0 # debug is off by default + + +#****************************************************************************** +# FUNCTION routines +#****************************************************************************** + +# ----------------------------------------------------------------------------- +function check_config +{ +# SUDO_TRANSFER_USER +if [[ -z "${SUDO_TRANSFER_USER}" ]] +then + SUDO_TRANSFER_USER="${LOGNAME}" + if [[ -z "${SUDO_TRANSFER_USER}" ]] + then + print -u2 "ERROR: unable to set a value for SUDO_TRANSFER_USER in $0" + exit 1 + fi +fi +# LOCAL_DIR +if [[ -z "${LOCAL_DIR}" ]] +then + print -u2 "ERROR: you must define a value for the LOCAL_DIR setting in $0" + exit 1 +fi +# REMOTE_DIR +if [[ -z "${REMOTE_DIR}" ]] +then + print -u2 "ERROR: you must define a value for the REMOTE_DIR setting in $0" + exit 1 +fi +# SUDO_UPDATE_USER +if [[ -z "${SUDO_UPDATE_USER}" ]] +then + SUDO_UPDATE_USER="${LOGNAME}" + if [[ -z "${SUDO_UPDATE_USER}" ]] + then + print -u2 "ERROR: unable to set a value for SUDO_UPDATE_USER in $0" + exit 1 + fi +fi +# VISUDO_BIN +if [[ -z "${VISUDO_BIN}" ]] +then + print -u2 "ERROR: you must define a value for the VISUDO_BIN setting in $0" + exit 1 +fi +# MAX_BACKGROUND_PROCS +if [[ -z "${MAX_BACKGROUND_PROCS}" ]] +then + print -u2 "ERROR: you must define a value for the MAX_BACKGROUND_PROCS setting in $0" + exit 1 +fi +# BACKUP_DIR +if [[ -z "${BACKUP_DIR}" ]] +then + print -u2 "ERROR: you must define a value for the BACKUP_DIR setting in $0" + exit 1 +fi + +return 0 +} + +# ----------------------------------------------------------------------------- +function check_logging +{ +if (( ARG_LOG )) +then + if [[ ! -d "${LOG_DIR}" ]] + then + if [[ ! -w "${LOG_DIR}" ]] + then + # switch off logging intelligently when needed for permission problems + # since this script may run with root/non-root actions + print -u2 "ERROR: unable to write to the log directory at ${LOG_DIR}, disabling logging" + ARG_LOG=0 + fi + else + if [[ ! -w "${LOG_FILE}" ]] + then + # switch off logging intelligently when needed for permission problems + # since this script may run with root/non-root actions + print -u2 "ERROR: unable to write to the log file at ${LOG_FILE}, disabling logging" + ARG_LOG=0 + fi + fi +fi + +return 0 +} + +# ----------------------------------------------------------------------------- +function check_params +{ +# -- ALL +if (( ARG_ACTION < 1 || ARG_ACTION > 9 )) +then + display_usage + exit 0 +fi +# --fix-local + --fix-dir +if (( ARG_ACTION == 5 )) +then + if [[ -z "${ARG_FIX_DIR}" ]] + then + print -u2 "ERROR: you must specify a value for parameter '--fix-dir" + exit 1 + else + FIX_DIR="${ARG_FIX_DIR}" + fi +fi +# --local-dir +if [[ -n "${ARG_LOCAL_DIR}" ]] +then + if [ \( ! -d "${ARG_LOCAL_DIR}" \) -o \( ! -r "${ARG_LOCAL_DIR}" \) ] + then + print -u2 "ERROR: unable to read directory ${ARG_LOCAL_DIR}" + exit 1 + else + LOCAL_DIR="${ARG_LOCAL_DIR}" + fi +fi +# --log-dir +[[ -z "${ARG_LOG_DIR}" ]] || LOG_DIR="${ARG_LOG_DIR}" +LOG_FILE="${LOG_DIR}/${SCRIPT_NAME}.log" +# --remote-dir +if (( ARG_ACTION == 1 || ARG_ACTION == 2 )) +then + if [[ -n "${ARG_REMOTE_DIR}" ]] + then + REMOTE_DIR="${ARG_REMOTE_DIR}" + fi +fi +# --targets +if [[ -n "${ARG_TARGETS}" ]] +then + > ${TMP_FILE} + # write comma-separated target list to the temporary file + print "${ARG_TARGETS}" | tr -s ',' '\n' | while read TARGET_HOST + do + print ${TARGET_HOST} >>${TMP_FILE} + done +fi +# --update + --fix-local +if (( ARG_ACTION == 4 || ARG_ACTION == 5 )) +then + if [[ -n "${TARGETS}" ]] + then + print -u2 "ERROR: you cannot specify '--targets' in this context!" + exit 1 + fi +fi + +return 0 +} + +# ----------------------------------------------------------------------------- +function check_root_user +{ +(IFS='()'; set -- $(id); print $2) | read UID +if [[ "${UID}" = "root" ]] +then + return 0 +else + return 1 +fi +} + +# ----------------------------------------------------------------------------- +function check_setup +{ +# use added fall back for LOCAL_DIR (the default script directory) +[[ -d "${LOCAL_DIR}" ]] || LOCAL_DIR="${SCRIPT_DIR}" + +# check for basic SUDO control files: grants/alias +for FILE in "${LOCAL_DIR}/grants" "${LOCAL_DIR}/alias" +do + if [[ ! -r "${FILE}" ]] + then + print -u2 "ERROR: cannot read file ${FILE}" + exit 1 + fi +done +# check for basic SUDO control file(s): targets, /var/tmp/targets.$USER (or $TMP_FILE) +if (( ARG_ACTION == 1 || ARG_ACTION == 2 || ARG_ACTION == 6 )) +then + if [[ -z "${ARG_TARGETS}" ]] + then + TARGETS_FILE="${LOCAL_DIR}/targets" + if [ \( ! -r "${TARGETS_FILE}" \) -a \( ! -r "/var/tmp/targets.${USER}" \) ] + then + print -u2 "ERROR: cannot read file ${TARGETS_FILE} nor /var/tmp/targets.${USER}" + exit 1 + fi + # override default targets file + [[ -r "/var/tmp/targets.${USER}" ]] && TARGETS_FILE="/var/tmp/targets.${USER}" + else + TARGETS_FILE=${TMP_FILE} + fi +fi +# check for basic SUDO control file(s): fragments, fragments.d/* +if [[ -d "${LOCAL_DIR}/fragments.d" && -f "${LOCAL_DIR}/fragments" ]] +then + print -u2 "WARN: found both a 'fragments' file (${LOCAL_DIR}/fragments) and a 'fragments.d' directory (${LOCAL_DIR}/fragments.d). Ignoring the 'fragments' file" +fi +if [[ -d "${LOCAL_DIR}/fragments.d" ]] +then + FRAGS_DIR="${LOCAL_DIR}/fragments.d" + if [[ ! -r "${FRAGS_DIR}" ]] + then + print -u2 "ERROR: unable to read directory ${FRAGS_DIR}" + exit 1 + fi +elif [[ -f "${LOCAL_DIR}/fragments" ]] +then + FRAGS_FILE="${LOCAL_DIR}/fragments" + if [[ ! -r "${FRAGS_FILE}" ]] + then + print -u2 "ERROR: cannot read file ${FRAGS_FILE}" + exit 1 + fi +else + print -u2 "ERROR: could not found any SUDO fragment files in ${LOCAL_DIR}!" + exit 1 +fi +# check for SUDO control scripts & configurations (not .local) +if (( ARG_ACTION == 1 || ARG_ACTION == 2 || ARG_ACTION == 4 )) +then + for FILE in "${LOCAL_DIR}/update_sudo.pl" \ + "${LOCAL_DIR}/update_sudo.conf" \ + "${SCRIPT_DIR}/${SCRIPT_NAME}" + do + if [[ ! -r "${FILE}" ]] + then + print -u2 "ERROR: cannot read file ${FILE}" + exit 1 + fi + done +fi +# check if 'visudo' exists +if [[ ! -x "${VISUDO_BIN}" ]] +then + print -u2 "WARN: 'visudo' tool not found, syntax checking is not available" + CAN_CHECK_SYNTAX=0 +fi + +return 0 +} + +# ----------------------------------------------------------------------------- +function check_syntax +{ +# grants should have 2 fields +cat "${LOCAL_DIR}/grants" | grep -v -E -e '^#|^$' | while read GRANTS_LINE +do + GRANTS_FIELDS=$(count_fields "${GRANTS_LINE}" ":") + (( GRANTS_FIELDS != 2 )) && die "line '${GRANTS_LINE}' in grants file has missing or too many field(s) (should be 2)" +done + +# alias should have 2 fields +cat "${LOCAL_DIR}/alias" | grep -v -E -e '^#|^$' | while read ALIAS_LINE +do + ALIAS_FIELDS=$(count_fields "${ALIAS_LINE}" ":") + (( ALIAS_FIELDS != 2 )) && die "line '${ALIAS_LINE}' in alias file has missing or too many field(s) (should be 2)" +done + +return 0 +} + +# ----------------------------------------------------------------------------- +function count_fields +{ +CHECK_LINE="$1" +CHECK_DELIM="$2" + +NUM_FIELDS=$(print "${CHECK_LINE}" | awk -F "${CHECK_DELIM}" '{ print NF }') + +print $NUM_FIELDS + +return ${NUM_FIELDS} +} + +# ----------------------------------------------------------------------------- +function die +{ +NOW="$(date '+%d-%h-%Y %H:%M:%S')" + +if [[ -n "$1" ]] +then + if (( ARG_LOG )) + then + print - "$*" | while read LOG_LINE + do + # filter leading 'ERROR:' + LOG_LINE="${LOG_LINE#ERROR: *}" + print "${NOW}: ERROR: [$$]:" "${LOG_LINE}" >>${LOG_FILE} + done + fi + print - "$*" | while read LOG_LINE + do + # filter leading 'ERROR:' + LOG_LINE="${LOG_LINE#ERROR: *}" + print -u2 "ERROR:" "${LOG_LINE}" + done +fi + +# finish up work +do_cleanup + +exit 1 +} + +# ----------------------------------------------------------------------------- +function display_usage +{ +cat << EOT + +**** ${SCRIPT_NAME} **** +**** (c) KUDOS BVBA - Patrick Van der Veken **** + +Performs basic functions for SUDO controls: update SUDOers files locally or +remote, validate SUDO syntax or copy/distribute the SUDO controls files + +Syntax: ${SCRIPT_DIR}/${SCRIPT_NAME} [--help] | (--backup | --check-syntax | --check-sudo | --preview-global | --update) | + (--apply [--remote-dir=] [--targets=,,...]) | + ((--copy|--distribute) [--remote-dir= [--targets=,,...]]) | + ([--fix-local --fix-dir= [--create-dir]] | [--fix-remote [--create-dir] [--targets=,,...]]) + [--preview-global] [--local-dir=] + [--no-log] [--log-dir=] [--debug] + +Parameters: + +--apply|-a : apply SUDO controls remotely (~targets) +--backup|-b : create a backup of the SUDO controls repository (SUDO master) +--check-syntax|-s : do basic syntax checking on SUDO controls configuration + (grants & alias files) +--check-sudo : validate the SUDO fragments in the holding directory +--copy|-c : copy SUDO control files to remote host (~targets) +--create-dir : also create missing directories when fixing the SUDO controls + repository (see also --fix-local/--fix-remote) +--debug : print extra status messages on STDERR +--distribute|-d : same as --copy +--fix-dir : location of the local SUDO controls client repository +--fix-local : fix permissions on the local SUDO controls repository + (local SUDO controls repository given by --fix-dir) +--fix-remote : fix permissions on the remote SUDO controls repository +--help|-h : this help text +--local-dir : location of the SUDO control files on the local filesystem. + [default: ${LOCAL_DIR}] +--log-dir : specify a log directory location. +--no-log : do not log any messages to the script log file. +--preview-global|-p : dump the global grant namespace (after alias resolution) +--remote-dir : directory where SUDO control files are/should be + located/copied on/to the target host + [default: ${REMOTE_DIR}] +--targets : comma-separated list of target hosts to operate on. Override the + hosts contained in the 'targets' configuration file. +--update|-u : apply SUDO controls locally +--version|-V : show the script version/release/fix + +Note 1: distribute and update actions are run in parallel across a maximum of + ${MAX_BACKGROUND_PROCS} clients at the same time. + +Note 2: make sure correct 'sudo' rules are setup on the target systems to allow + the SUDO controls script to run with elevated privileges. + +EOT + +return 0 +} + +# ----------------------------------------------------------------------------- +# distribute SUDO controls to a single host/client +function distribute2host +{ +SERVER="$1" + +# convert line to hostname +SERVER=${SERVER%%;*} +resolve_host ${SERVER} +if (( $? )) +then + warn "could not lookup host ${SERVER}, skipping" + return 1 +fi + +# specify copy objects as 'filename!permissions' +# 1) config files & scripts +for FILE in "${LOCAL_DIR}/grants!660" \ + "${LOCAL_DIR}/alias!660" \ + "${LOCAL_DIR}/update_sudo.pl!770" \ + "${LOCAL_DIR}/update_sudo.conf!660" \ + "${SCRIPT_DIR}/${SCRIPT_NAME}!770" +do + # sftp transfer + sftp_file ${FILE} ${SERVER} + COPY_RC=$? + if (( ! COPY_RC )) + then + log "transferred ${FILE%!*} to ${SERVER}:${REMOTE_DIR}" + else + warn "failed to transfer ${FILE%!*} to ${SERVER}:${REMOTE_DIR} [RC=${COPY_RC}]" + fi +done +# 2) fragments files +# are fragments stored in a file or a directory? +if [[ -n "${FRAGS_DIR}" ]] +then + TMP_WORK_DIR="${TMP_DIR}/$0.${RANDOM}" + mkdir -p ${TMP_WORK_DIR} + if (( $? )) + then + die "unable to create temporary directory ${TMP_WORK_DIR} for mangling of 'fragments' file" + fi + # merge fragments file(s) before copy (in a temporary location) + merge_fragments ${TMP_WORK_DIR} + if (( $? )) + then + die "failed to merge fragments into the temporary file ${TMP_MERGE_FILE}" + fi + # sftp transfer + sftp_file "${TMP_MERGE_FILE}!440" ${SERVER} + COPY_RC=$? + if (( ! COPY_RC )) + then + log "transferred ${TMP_MERGE_FILE} to ${SERVER}:${REMOTE_DIR}" + else + warn "failed to transfer ${TMP_MERGE_FILE%!*} to ${SERVER}:${REMOTE_DIR} [RC=${COPY_RC}]" + fi + [[ -d ${TMP_WORK_DIR} ]] && rm -rf ${TMP_WORK_DIR} 2>/dev/null +else + sftp_file "${FRAGS_FILE}!440" ${SERVER} + COPY_RC=$? + if (( ! COPY_RC )) + then + log "transferred ${FRAGS_FILE} to ${SERVER}:${REMOTE_DIR}" + else + warn "failed to transfer ${FRAGS_FILE} to ${SERVER}:${REMOTE_DIR} [RC=${COPY_RC}]" + fi +fi + +return 0 +} + +# ----------------------------------------------------------------------------- +function do_cleanup +{ +log "performing cleanup ..." + +# remove temporary file(s) +[[ -f ${TMP_FILE} ]] && rm -f ${TMP_FILE} >/dev/null 2>&1 +[[ -f ${TMP_MERGE_FILE} ]] && rm -f ${TMP_MERGE_FILE} >/dev/null 2>&1 +# temporary scan file (syntax check) +if (( CAN_REMOVE_TEMP )) +then + [[ -f ${TMP_SCAN_FILE} ]] && rm -f ${TMP_SCAN_FILE} >/dev/null 2>&1 +fi + +log "*** finish of ${SCRIPT_NAME} [${CMD_LINE}] ***" + +return 0 +} + +# ----------------------------------------------------------------------------- +# fix SUDO controls on a single host/client (permissions/ownerships) +# !! requires appropriate 'sudo' rules on remote client for privilege elevation +function fix2host +{ +SERVER="$1" +SERVER_DIR="$2" + +# convert line to hostname +SERVER=${SERVER%%;*} +resolve_host ${SERVER} +if (( $? )) +then + warn "could not lookup host ${SERVER}, skipping" + return 1 +fi + +log "fixing sudo controls on ${SERVER} ..." +if [[ -z "${SUDO_UPDATE_USER}" ]] +then + # own user w/ sudo + log "$(ssh ${SSH_ARGS} ${SERVER} sudo -n ${REMOTE_DIR}/${SCRIPT_NAME} --fix-local --fix-dir=${SERVER_DIR})" +elif [[ "${SUDO_UPDATE_USER}" != "root" ]] +then + # other user w/ sudo + log "$(ssh ${SSH_ARGS} ${SUDO_UPDATE_USER}@${SERVER} sudo -n ${REMOTE_DIR}/${SCRIPT_NAME} --fix-local --fix-dir=${SERVER_DIR})" +else + # root user w/o sudo + log "$(ssh ${SSH_ARGS} ${SUDO_UPDATE_USER}@${SERVER} ${REMOTE_DIR}/${SCRIPT_NAME} --fix-local --fix-dir=${SERVER_DIR})" +fi +# no error checking possible here due to log(), done in called script + +return 0 +} + +# ----------------------------------------------------------------------------- +function log +{ +NOW="$(date '+%d-%h-%Y %H:%M:%S')" + +if [[ -n "$1" ]] +then + if (( ARG_LOG )) + then + print - "$*" | while read LOG_LINE + do + # filter leading 'INFO:' + LOG_LINE="${LOG_LINE#INFO: *}" + print "${NOW}: INFO: [$$]:" "${LOG_LINE}" >>${LOG_FILE} + done + fi + if (( ARG_VERBOSE )) + then + print - "$*" | while read LOG_LINE + do + # filter leading 'INFO:' + LOG_LINE="${LOG_LINE#INFO: *}" + print "INFO:" "${LOG_LINE}" + done + fi +fi + +return 0 +} + +# ----------------------------------------------------------------------------- +# merge fragments into a temporary file +function merge_fragments +{ +# initialize temporary working copy (need be different for each background job) +# do not use 'mktemp' here as we need a fixed file name +TMP_MERGE_FILE="$1/fragments" +> ${TMP_MERGE_FILE} +(( $? )) && die "unable to create temporary file for mangling of 'fragments' file" + +log "fragments are stored in a DIRECTORY, first merging all fragments into ${TMP_MERGE_FILE}" +# merge fragments with '%%%' headers +ls -1 ${FRAGS_DIR}/* | while read FILE +do + # header first, file base name without extension + BASE_FILE=${FILE##*/} + print "%%%${BASE_FILE%%.*}" >>${TMP_MERGE_FILE} + # content next + cat ${FILE} >>${TMP_MERGE_FILE} +done + +# merge file should not be empty +[[ -s ${TMP_MERGE_FILE} ]] || return 1 + +return 0 +} + +# ----------------------------------------------------------------------------- +# resolve a host (check) +function resolve_host +{ +LOOKUP_HOST="$1" + +nslookup $1 2>/dev/null | grep -q -E -e 'Address:.*([0-9]{1,3}[\.]){3}[0-9]{1,3}' + +return $? +} + +# ----------------------------------------------------------------------------- +# transfer a file using sftp +function sftp_file +{ +TRANSFER_FILE="$1" +TRANSFER_HOST="$2" + +# find the local directory & permission bits +TRANSFER_DIR="${TRANSFER_FILE%/*}" +TRANSFER_PERMS="${TRANSFER_FILE##*!}" +# cut out the permission bits and the directory path +TRANSFER_FILE="${TRANSFER_FILE%!*}" +SOURCE_FILE="${TRANSFER_FILE##*/}" +OLD_PWD=$(pwd) && cd ${TRANSFER_DIR} + +# transfer, chmod the file to/on the target server (keep STDERR) +# chmod is not possible in the used security model as files should be +# owned by root, so must be disabled. This requires a fix operation right +# after the very first initial SUDO controls distribution: +# ./manage_sudo.sh --fix-local --fix-dir=/etc/sudo_controls +sftp ${SFTP_ARGS} ${SUDO_TRANSFER_USER}@${TRANSFER_HOST} >/dev/null </dev/null) + then + (( ARG_DEBUG )) && print -u2 "DEBUG: ${PID} is still alive" + set -- "$@" "${PID}" + # wait for sigchild, catching child exit codes is unreliable because + # the child might have already ended before we get here (caveat emptor) + elif $(wait ${PID}) + then + log "child process ${PID} exited" + else + log "child process ${PID} exited" + WAIT_ERRORS=$(( WAIT_ERRORS + 1 )) + fi + done + # break loop if we no child PIDs left + (($# > 0)) || break + sleep 1 # required to avoid race conditions +done + +return ${WAIT_ERRORS} +} + +# ----------------------------------------------------------------------------- +function warn +{ +NOW="$(date '+%d-%h-%Y %H:%M:%S')" + +if [[ -n "$1" ]] +then + if (( ARG_LOG )) + then + print - "$*" | while read LOG_LINE + do + # filter leading 'WARN:' + LOG_LINE="${LOG_LINE#WARN: *}" + print "${NOW}: WARN: [$$]:" "${LOG_LINE}" >>${LOG_FILE} + done + fi + if (( ARG_VERBOSE )) + then + print - "$*" | while read LOG_LINE + do + # filter leading 'WARN:' + LOG_LINE="${LOG_LINE#WARN: *}" + print "WARN:" "${LOG_LINE}" + done + fi +fi + +return 0 +} + + +#****************************************************************************** +# MAIN routine +#****************************************************************************** + +# parse arguments/parameters +CMD_LINE="$@" +for PARAMETER in ${CMD_LINE} +do + case ${PARAMETER} in + -a|-apply|--apply) + ARG_ACTION=1 + ;; + -b|-backup|--backup) + ARG_ACTION=9 + ;; + -c|-copy|--copy) + ARG_ACTION=2 + ;; + -debug|--debug) + ARG_DEBUG=1 + ;; + -d|-distribute|--distribute) + ARG_ACTION=2 + ;; + -p|--preview-global|-preview-global) + ARG_ACTION=7 + ;; + -fix-local|--fix-local) + ARG_ACTION=5 + ;; + -fix-remote|--fix-remote) + ARG_ACTION=6 + ;; + -s|-check-syntax|--check-syntax) + ARG_ACTION=8 + ;; + -check-sudo|--check-sudo) + ARG_ACTION=3 + ARG_LOG=0 + CAN_CHECK_SYNTAX=1 + CAN_REMOVE_TEMP=1 + ;; + -u|-update|--update) + ARG_ACTION=4 + ;; + -create-dir|--create-dir) + FIX_CREATE=1 + ;; + -fix-dir=*) + ARG_FIX_DIR="${PARAMETER#-fix-dir=}" + ;; + --fix-dir=*) + ARG_FIX_DIR="${PARAMETER#--fix-dir=}" + ;; + -local-dir=*) + ARG_LOCAL_DIR="${PARAMETER#-local-dir=}" + ;; + --local-dir=*) + ARG_LOCAL_DIR="${PARAMETER#--local-dir=}" + ;; + -log-dir=*) + ARG_LOG_DIR="${PARAMETER#-log-dir=}" + ;; + --log-dir=*) + ARG_LOG_DIR="${PARAMETER#--log-dir=}" + ;; + -no-log|--no-log) + ARG_LOG=0 + ;; + -remote-dir=*) + ARG_REMOTE_DIR="${PARAMETER#-remote-dir=}" + ;; + --remote-dir=*) + ARG_REMOTE_DIR="${PARAMETER#--remote-dir=}" + ;; + -targets=*) + ARG_TARGETS="${PARAMETER#-targets=}" + ;; + --targets=*) + ARG_TARGETS="${PARAMETER#--targets=}" + ;; + -V|-version|--version) + print "INFO: $0: ${MY_VRF}" + exit 0 + ;; + \? | -h | -help | --help) + display_usage + exit 0 + ;; + esac +done + +# startup checks +check_params && check_config && check_setup && check_logging + +# catch shell signals +trap 'do_cleanup; exit' 1 2 3 15 + +log "*** start of ${SCRIPT_NAME} [${CMD_LINE}] ***" +(( ARG_LOG )) && log "logging takes places in ${LOG_FILE}" + +log "runtime info: LOCAL_DIR is set to: ${LOCAL_DIR}" + +case ${ARG_ACTION} in + 1) # apply SUDO controls remotely + log "ACTION: apply SUDO controls remotely" + # build clients list (in array) + cat "${TARGETS_FILE}" | grep -v -E -e '^#' -e '^$' |\ + { + I=0 + set -A CLIENTS + while read LINE + do + CLIENTS[${I}]="${LINE}" + I=$(( I + 1 )) + done + } + # set max updates in background + COUNT=${MAX_BACKGROUND_PROCS} + for CLIENT in ${CLIENTS[@]} + do + update2host ${CLIENT} & + PID=$! + log "updating ${CLIENT} in background [PID=${PID}] ..." + # add PID to list of all child PIDs + PIDS="${PIDS} ${PID}" + COUNT=$(( COUNT - 1 )) + if (( COUNT <= 0 )) + then + # wait until all background processes are completed + wait_for_children ${PIDS} || \ + warn "$? background jobs failed to complete correctly" + PIDS='' + # reset max updates in background + COUNT=${MAX_BACKGROUND_PROCS} + fi + done + # final wait for background processes to be finished completely + wait_for_children ${PIDS} || \ + warn "$? background jobs failed to complete correctly" + + log "finished applying SUDO controls remotely" + ;; + 2) # copy/distribute SUDO controls + log "ACTION: copy/distribute SUDO controls" + # build clients list (in array) + cat "${TARGETS_FILE}" | grep -v -E -e '^#' -e '^$' |\ + { + I=0 + set -A CLIENTS + while read LINE + do + CLIENTS[${I}]="${LINE}" + I=$(( I + 1 )) + done + } + # set max updates in background + COUNT=${MAX_BACKGROUND_PROCS} + for CLIENT in ${CLIENTS[@]} + do + distribute2host ${CLIENT} & + PID=$! + log "copying/distributing to ${CLIENT} in background [PID=${PID}] ..." + # add PID to list of all child PIDs + PIDS="${PIDS} ${PID}" + COUNT=$(( COUNT - 1 )) + if (( COUNT <= 0 )) + then + # wait until all background processes are completed + wait_for_children ${PIDS} || \ + warn "$? background jobs failed to complete correctly" + PIDS='' + # reset max updates in background + COUNT=${MAX_BACKGROUND_PROCS} + fi + done + # final wait for background processes to be finished completely + wait_for_children ${PIDS} || \ + warn "$? background jobs failed to complete correctly" + log "finished copying/distributing SUDO controls" + ;; + 3) # perform syntax checking + log "ACTION: validating SUDO fragments" + # are fragments stored in a file or a directory? + if [[ -n "${FRAGS_DIR}" ]] + then + TMP_WORK_DIR="${TMP_DIR}/$0.${RANDOM}" + mkdir -p ${TMP_WORK_DIR} + if (( $? )) + then + die "unable to create temporary directory ${TMP_WORK_DIR} for mangling of 'fragments' file" + fi + merge_fragments ${TMP_WORK_DIR} + fi + # remove '%%%' headers + TMP_SCAN_FILE=$(mktemp) + (( $? )) && die "unable to create temporary file for validation of 'fragments' file(s)" + if [[ -n "${FRAGS_DIR}" ]] + then + cat ${TMP_MERGE_FILE} | grep -v '^%%%' >${TMP_SCAN_FILE} + [[ -d ${TMP_WORK_DIR} ]] && rm -rf ${TMP_WORK_DIR} 2>/dev/null + else + cat ${FRAGS_FILE} | grep -v '^%%%' >${TMP_SCAN_FILE} + fi + # run syntax check + if (( CAN_CHECK_SYNTAX )) + then + CHECK_RESULT="$(${VISUDO_BIN} -c -f ${TMP_SCAN_FILE} 2>/dev/null)" + if (( $? )) + then + warn "SUDO syntax check: FAILED: ${CHECK_RESULT})" + CAN_REMOVE_TEMP=0 + else + log "SUDO syntax check: PASSED" + fi + fi + log "finished validating SUDO fragments" + ;; + 4) # apply SUDO controls locally (root user) + log "ACTION: apply SUDO controls locally" + log "$(${LOCAL_DIR}/update_sudo.pl -v)" + # no error checking possible here due to log(), done in called script + log "finished applying SUDO controls locally" + ;; + 5) # fix directory structure/perms/ownerships + log "ACTION: fix local SUDO controls repository" + check_root_user || die "must be run as user 'root'" + if (( FIX_CREATE )) + then + log "you requested to create directories (if needed)" + else + log "you requested NOT to create directories (if needed)" + fi + + # check if the SUDO control repo is already there + if [[ ${FIX_CREATE} = 1 && ! -d "${FIX_DIR}" ]] + then + # create stub directories + mkdir -p "${FIX_DIR}/holding" 2>/dev/null || \ + warn "failed to create directory ${FIX_DIR}/holding" + mkdir -p "${FIX_DIR}/sudoers.d" 2>/dev/null || \ + warn "failed to create directory ${FIX_DIR}/sudoers.d" + fi + # fix permissions & ownerships + if [[ -d "${FIX_DIR}" ]] + then + # updating default directories + chmod 755 "${FIX_DIR}" 2>/dev/null && \ + chown root:sys "${FIX_DIR}" 2>/dev/null + if [[ -d "${FIX_DIR}/holding" ]] + then + chmod 2775 "${FIX_DIR}/holding" 2>/dev/null && \ + chown root:${SUDO_OWNER_GROUP} "${FIX_DIR}/holding" 2>/dev/null + fi + if [[ -d "${FIX_DIR}/sudoers.d" ]] + then + chmod 755 "${FIX_DIR}/sudoers.d" 2>/dev/null && \ + chown root:sys "${FIX_DIR}/sudoers.d" 2>/dev/null + fi + # checking files (sudoers.d/* are fixed by update_sudo.pl) + for FILE in grants alias fragments update_sudo.conf + do + if [[ -f "${FIX_DIR}/holding/${FILE}" ]] + then + chmod 660 "${FIX_DIR}/holding/${FILE}" 2>/dev/null && \ + chown root:${SUDO_OWNER_GROUP} "${FIX_DIR}/holding/${FILE}" 2>/dev/null + fi + done + for FILE in manage_sudo.sh update_sudo.pl + do + if [[ -f "${FIX_DIR}/holding/${FILE}" ]] + then + chmod 770 "${FIX_DIR}/holding/${FILE}" 2>/dev/null && \ + chown root:${SUDO_OWNER_GROUP} "${FIX_DIR}/holding/${FILE}" 2>/dev/null + fi + done + # log file + if [[ -f "${LOG_FILE}" ]] + then + chmod 664 "${LOG_FILE}" 2>/dev/null && \ + chown root:${SUDO_OWNER_GROUP} "${LOG_FILE}" 2>/dev/null + fi + # check for SELinux labels + case ${OS_NAME} in + *Linux*) + case "$(getenforce)" in + *Permissive*|*Enforcing*) + chcon -R -t etc_t "${FIX_DIR}/sudoers.d" + ;; + *Disabled*) + : + ;; + esac + ;; + *) + : + ;; + esac + else + die "SUDO controls repository at "${FIX_DIR}" does not exist?" + fi + log "finished applying fixes to the local SUDO control repository" + ;; + 6) # fix remote directory structure/perms/ownerships + log "ACTION: fix remote SUDO controls repository" + check_root_user && die "must NOT be run as user 'root'" + # derive SUDO controls repo from $REMOTE_DIR: + # /etc/sudo_controls/holding -> /etc/sudo_controls + FIX_DIR="$(print ${REMOTE_DIR%/*})" + [[ -z "${FIX_DIR}" ]] && \ + die "could not determine SUDO controls repo path from \$REMOTE_DIR?" + # build clients list (in array) + cat "${TARGETS_FILE}" | grep -v -E -e '^#' -e '^$' |\ + { + I=0 + set -A CLIENTS + while read LINE + do + CLIENTS[${I}]="${LINE}" + I=$(( I + 1 )) + done + } + # set max updates in background + COUNT=${MAX_BACKGROUND_PROCS} + for CLIENT in ${CLIENTS[@]} + do + fix2host ${CLIENT} "${FIX_DIR}" & + PID=$! + log "copying/distributing to ${CLIENT} in background [PID=${PID}] ..." + # add PID to list of all child PIDs + PIDS="${PIDS} ${PID}" + COUNT=$(( COUNT - 1 )) + if (( COUNT <= 0 )) + then + # wait until all background processes are completed + wait_for_children ${PIDS} || \ + warn "$? background jobs failed to complete correctly" + PIDS='' + # reset max updates in background + COUNT=${MAX_BACKGROUND_PROCS} + fi + done + # final wait for background processes to be finished completely + wait_for_children ${PIDS} || \ + warn "$? background jobs failed to complete correctly" + log "finished applying fixes to the remote SUDO control repository" + ;; + 7) # dump the configuration namespace + log "ACTION: dumping the global grant namespace with resolved aliases ..." + ${LOCAL_DIR}/update_sudo.pl --preview --global + log "finished dumping the global namespace" + ;; + 8) # check syntax of the grants/alias files + log "ACTION: syntax-checking the configuration files ..." + check_syntax + log "finished syntax-checking the configuration files" + ;; + 9) # make backup copy of configuration & fragment files + log "ACTION: backing up the current configuration & fragment files ..." + if [[ -d ${BACKUP_DIR} ]] + then + TIMESTAMP="$(date '+%Y%m%d-%H%M')" + BACKUP_TAR_FILE="${BACKUP_DIR}/backup_repo_${TIMESTAMP}.tar" + if [ \( -f ${BACKUP_TAR_FILE} \) -o \( -f "${BACKUP_TAR_FILE}.gz" \) ] + then + die "backup file ${BACKUP_TAR_FILE}(.gz) already exists" + fi + # fragments files + if [[ -n "${FRAGS_DIR}" ]] + then + log "$(tar -cvf ${BACKUP_TAR_FILE} ${FRAGS_DIR} 2>/dev/null)" + else + log "$(tar -cvf ${BACKUP_TAR_FILE} ${FRAGS_FILE} 2>/dev/null)" + fi + # configuration files + for FILE in "${LOCAL_DIR}/grants" "${LOCAL_DIR}/alias ${LOCAL_DIR}/targets" + do + log "$(tar -rvf ${BACKUP_TAR_FILE} ${FILE} 2>/dev/null)" + done + log "$(gzip ${BACKUP_TAR_FILE} 2>/dev/null)" + log "resulting backup file is: $(ls -1 ${BACKUP_TAR_FILE}* 2>/dev/null)" + else + die "could not find backup directory ${BACKUP_DIR}. Host is not an SUDO master?" + fi + log "finished backing up the current configuration & fragment files" + ;; +esac + +# finish up work +do_cleanup + +#****************************************************************************** +# END of script +#****************************************************************************** diff --git a/update_sudo.conf b/update_sudo.conf new file mode 100644 index 0000000..fb74e92 --- /dev/null +++ b/update_sudo.conf @@ -0,0 +1,27 @@ +#****************************************************************************** +# update_sudo.pl configuration file +#****************************************************************************** +# +# Lines starting with '#' (hash) are comment lines +# +# Format: option= +# +# Do not use double or single quotes around the option values +# + +# target directory for sudo fragment files +fragments_dir=/etc/sudo_controls/sudoers.d + +# path to the visudo tool +visudo_bin=/usr/sbin/visudo + +# file name of the immutable 'self' sudo fragment (w/o path) +immutable_self_file=sudo_update + +# sudo rule for the immutable 'self' fragment (to run the sudo_update.pl script) +immutable_self_cmd=%sudoadmin ALL=(root) NOPASSWD:/etc/sudo_controls/holding/update_sudo.pl + + +#****************************************************************************** +# End of FILE +#****************************************************************************** \ No newline at end of file diff --git a/update_sudo.pl b/update_sudo.pl new file mode 100644 index 0000000..46a4911 --- /dev/null +++ b/update_sudo.pl @@ -0,0 +1,735 @@ +#!/usr/bin/env perl +#****************************************************************************** +# @(#) update_sudo.pl +#****************************************************************************** +# @(#) Copyright (C) 2014 by KUDOS BVBA . All rights reserved. +# +# This program is a free software; you can redistribute it and/or modify +# it under the same terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 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 +#****************************************************************************** +# This script distributes SUDO fragments to the appropriate files into a +# designated repository based on the 'grants', 'alias' and 'fragments' files. +# Superfluous usage of 'hostname' reporting in log messages is encouraged to +# make reading of multiplexed output from update_sudo.pl through backgrounded +# jobs via manage_sudo.sh much easier. +# +# @(#) HISTORY: see perldoc 'update_sudo.pl' +# ----------------------------------------------------------------------------- +# DO NOT CHANGE THIS FILE UNLESS YOU KNOW WHAT YOU ARE DOING! +#****************************************************************************** + +#****************************************************************************** +# PRAGMAs/LIBs +#****************************************************************************** + +use strict; +use Data::Dumper; +use Getopt::Long; +use Pod::Usage; +use File::Basename; +use File::Temp qw(tempfile); + + +#****************************************************************************** +# DATA structures +#****************************************************************************** + +# ------------------------- CONFIGURATION starts here ------------------------- +# define the V.R.F (version/release/fix) +my $MY_VRF = "1.0.3"; +# name of global configuration file (no path, must be located in the script directory) +my $global_config_file = "update_sudo.conf"; +# name of localized configuration file (no path, must be located in the script directory) +my $local_config_file = "update_sudo.conf.local"; +# selinux context label of sudoers fragment files +my $selinux_context = "etc_t"; +# ------------------------- CONFIGURATION ends here --------------------------- +# initialize variables +my ($debug, $verbose, $preview, $global) = (0,0,0,0); +my (@config_files, $fragments_dir, $visudo_bin, $immutable_self_file, $immutable_self_cmd); +my (%options, %aliases, %frags, @grants); +my ($os, $host, $hostname, $run_dir); +my ($selinux_status, $selinux_context, $has_selinux) = ("","",0); +$|++; + + +#****************************************************************************** +# SUBroutines +#****************************************************************************** + +# ----------------------------------------------------------------------------- +sub do_log { + + my $message = shift; + + if ($message =~ /^ERROR:/ || $message =~ /^WARN:/) { + print STDERR "$message\n"; + } elsif ($message =~ /^DEBUG:/) { + print STDOUT "$message\n" if ($debug); + } else { + print STDOUT "$message\n" if ($verbose); + } + + return (1); +} + +# ----------------------------------------------------------------------------- +sub parse_config_file { + + my $config_file = shift; + + unless (open (CONF_FD, "<", $config_file)) { + do_log ("ERROR: failed to open the configuration file ${config_file} [$! $hostname]") and \ + exit (1); + } + while () { + chomp (); + # parse settings + if (/^\s*$/ || /^#/) { + next; + } else { + if (/^\s*fragments_dir\s*=\s*([0-9A-Za-z_\-\.\/~]+)\s*$/) { + $fragments_dir = $1; + do_log ("DEBUG: picking up setting: fragments_dir=${fragments_dir}"); + } + if (/^\s*visudo_bin\s*=\s*([0-9A-Za-z_\-\.\/~]+)\s*$/) { + $visudo_bin = $1; + do_log ("DEBUG: picking up setting: visudo_bin=${visudo_bin}"); + } + if (/^\s*immutable_self_file\s*=\s*([0-9A-Za-z_\-\.\/~]+)\s*$/) { + $immutable_self_file = $1; + do_log ("DEBUG: picking up setting: immutable_self_file=${immutable_self_file}"); + } + if (/^\s*immutable_self_cmd\s*=\s*([0-9A-Za-z_\-\.\/~%:=\(\) ]+)\s*$/) { + $immutable_self_cmd = $1; + do_log ("DEBUG: picking up setting: immutable_self_cmd=${immutable_self_cmd}"); + } + } + } + + # parameter checks + if (not defined ($immutable_self_file) or $immutable_self_file eq "") { + do_log ("ERROR: 'immutable_self_file' parameter not defined [$hostname]") + and exit(1); + } + + return (1); +} + +# ----------------------------------------------------------------------------- +sub resolve_aliases +{ + my $input = shift; + my (@tmp_array, @new_array, $entry); + + @tmp_array = split (/,/, $input); + foreach $entry (@tmp_array) { + if ($entry =~ /^\@/) { + ($aliases{$entry}) + ? push (@new_array, @{$aliases{$entry}}) + : do_log ("WARN: unable to resolve alias $entry [$hostname]"); + } else { + ($entry) + ? push (@new_array, $entry) + : do_log ("WARN: unable to resolve alias $entry [$hostname]"); + } + } + return (@new_array); +} + +# ----------------------------------------------------------------------------- +sub set_file { + + my ($file, $perm, $uid, $gid) = @_; + + chmod ($perm, "$file") + or do_log ("ERROR: cannot set permissions on $file [$! $hostname]") + and exit (1); + chown ($uid, $gid, "$file") + or do_log ("ERROR: cannot set ownerships on $file [$! $hostname]") + and exit (1); + + return (1); +} + + +#****************************************************************************** +# MAIN routine +#****************************************************************************** + +# ----------------------------------------------------------------------------- +# process script arguments & options +# ----------------------------------------------------------------------------- + +if ( @ARGV > 0 ) { + Getopt::Long::Configure ('prefix_pattern=(--|-|\/)', 'bundling', 'no_ignore_case'); + GetOptions (\%options, + qw( + debug|d + help|h|? + global|g + preview|p + verbose|v + version|V + )) || pod2usage(-verbose => 0); +} +pod2usage(-verbose => 0) unless (%options); + +# check version parameter +if ($options{'version'}) { + $verbose = 1; + do_log ("INFO: $0: version $MY_VRF"); + exit (0); +} +# check help parameter +if ($options{'help'}) { + pod2usage(-verbose => 3); + exit (0); +}; +# check global parameter +if ($options{'global'}) { + $global = 1; +} +# check preview parameter +if ($options{'preview'}) { + $preview = 1; + $verbose = 1; + if ($global) { + do_log ("INFO: running in GLOBAL PREVIEW mode"); + } else { + do_log ("INFO: running in PREVIEW mode"); + } +} else { + do_log ("INFO: running in UPDATE mode"); +} +# debug & verbose +if ($options{'debug'}) { + $debug = 1; + $verbose = 1; +} +$verbose = 1 if ($options{'verbose'}); + +# what am I? +$os = `uname`; +chomp ($os); +# who am I? +unless ($preview and $global) { + if ($< != 0) { + do_log ("ERROR: script must be invoked as user 'root' [$hostname]") + and exit (1); + } +} +# where am I? +$host = `hostname`; +chomp ($host); +if ($host =~ /\./) { + ($hostname) = $host =~ /(.*?)\./; +} else { + $hostname = $host; +} +$0 =~ /^(.+[\\\/])[^\\\/]+[\\\/]*$/; +my $run_dir = $1 || "."; +$run_dir =~ s#/$##; # remove trailing slash + +do_log ("INFO: runtime info: ".getpwuid ($<)."; ${hostname}\@${run_dir}; Perl v$]"); + +# ----------------------------------------------------------------------------- +# check/process configuration files, environment checks +# ----------------------------------------------------------------------------- + +# don't do anything without configuration file(s) +do_log ("INFO: parsing configuration file(s) ..."); +push (@config_files, "$run_dir/$global_config_file") if (-f "$run_dir/$global_config_file"); +push (@config_files, "$run_dir/$local_config_file") if (-f "$run_dir/$local_config_file"); +unless (@config_files) { + do_log ("ERROR: unable to find any configuration file, bailing out [$hostname]") + and exit (1); +} + +# process configuration file: global first, local may override +foreach my $config_file (@config_files) { + parse_config_file ($config_file); +} + +# is the target directory for fragments present? (not for global preview) +unless ($preview and $global) { + do_log ("INFO: checking for SUDO control mode ..."); + if (-d $fragments_dir) { + do_log ("INFO: host is under SUDO control via $fragments_dir"); + } else { + do_log ("ERROR: host is not under SUDO control [$hostname]") + and exit (1); + } +} + +# is syntax checking possible? (not for global preview) +unless ($preview and $global) { + unless (-x $visudo_bin) { + do_log ("ERROR: 'visudo' tool could not be found, will not continue [$hostname]") + and exit (1); + } +} + +# ----------------------------------------------------------------------------- +# read aliases for teams, servers and users +# result: %aliases +# ----------------------------------------------------------------------------- + +do_log ("INFO: reading 'alias' file ..."); + +open (ALIASES, "<", "${run_dir}/alias") + or do_log ("ERROR: cannot read 'alias' file [$! $hostname]") and exit (1); +while () { + + my ($key, $value, @values); + + chomp (); + next if (/^$/ || /\#/); + s/\s+//g; + ($key, $value) = split (/:/); + next unless ($value); + @values = sort (split (/\,/, $value)); + $aliases{$key} = [@values]; +}; +close (ALIASES); +do_log ("DEBUG: dumping unexpanded aliases:"); +print Dumper (\%aliases) if $debug; + +# we can nest aliases one level deep, so do a one-level recursive sort of lookup +# of the remaining '@' aliases. Input should be passed as comma-separated +# string to resolve_aliases so don't forget to smash everything back together +# first. +foreach my $key (keys (%aliases)) { + + $aliases{$key} = [resolve_aliases (join (",", @{$aliases{$key}}))]; +} + +do_log ("INFO: ".scalar (keys (%aliases))." aliases found on $hostname"); +do_log ("DEBUG: dumping expanded aliases:"); +print Dumper (\%aliases) if $debug; + +# ----------------------------------------------------------------------------- +# read SUDO fragments stored in a single 'fragments' file or in +# individual fragment files from a 'fragments.d' directory +# result: %frags +# ----------------------------------------------------------------------------- + +do_log ("INFO: reading 'fragment' file(s) ..."); + +my @frag_files; + +# check if the SUDO fragments are stored in a directory or file +if (-d "${run_dir}/fragments.d" && -f "${run_dir}/fragments") { + do_log ("WARN: found both a 'fragments' file and 'fragments.d' directory. Ignoring the 'fragments' file [$hostname]") +} +if (-d "${run_dir}/fragments.d") { + do_log ("INFO: local 'fragments' are stored in a DIRECTORY on $hostname"); + opendir (FRAGS_DIR, "${run_dir}/fragments.d") + or do_log ("ERROR: cannot open 'fragments.d' directory [$! $hostname]") + and exit (1); + while (my $frag_file = readdir (FRAGS_DIR)) { + next if ($frag_file =~ /^\./); + push (@frag_files, "${run_dir}/fragments.d/$frag_file"); + } + closedir (FRAGS_DIR); +} elsif (-f "${run_dir}/fragments") { + do_log ("INFO: local 'fragments' are stored in a FILE on $hostname"); + push (@frag_files, "${run_dir}/fragments"); +} else { + do_log ("ERROR: cannot find any SUDO fragments in the repository! [$hostname]") + and exit (1); +} + +# process 'fragments' files +foreach my $frag_file (@frag_files) { + open (FRAGS, "<", $frag_file) + or do_log ("ERROR: cannot read 'fragments' file [$! $hostname]") + and exit (1); + do_log ("INFO: reading SUDO fragments from file: $frag_file"); + + my @frag_file = ; + + # check for fragments header(s): if there is no fragment header, then we + # consider this a single fragment file, otherwise we consider it a + # collection of fragments that needs to be broken down in individual fragments + + if (grep { /^%%%/s } @frag_file) { + + do_log ("INFO: fragment file $frag_file contains multiple fragments, parsing ..."); + + my ($frag_file, $frag_def); + my $count = 1; + + foreach (@frag_file) { + + # first header found + if (/^%%%/ && (not defined ($frag_def) or $frag_def eq "")) { + + # look for fragment file name + ($frag_file) = (split (/%%%/, $_))[1]; + chomp ($frag_file); + unless (defined ($frag_file) && $frag_file ne "") { + do_log ("WARN: no fragment file name found in header at line $count [$hostname]") + } + # next header found, flush previous fragment + } elsif (/^%%%/ && (defined ($frag_def) or $frag_def ne "")) { + if (defined ($frag_file) && $frag_file ne "") { + $frags{$frag_file} = $frag_def; + undef $frag_def; + } else { + do_log ("WARN: fragment without file name? (to line: $count) [$hostname]"); + } + undef $frag_file; + # get new file name + ($frag_file) = (split ('%%%', $_))[1]; + chomp ($frag_file); + unless (defined ($frag_file) && $frag_file ne "") { + do_log ("WARN: no fragment file name found in header at line $count [$hostname]") + } + } else { + # process fragment definition + $frag_def .= $_; + } + # check for last fragment + if ($frag_file && $frag_def ne "") { + $frags{$frag_file} = $frag_def; + } + $count++; + }; + } else { + # strip off path from file name for hash key + $frag_file = fileparse ($frag_file, qr/\.[^.]*/); + do_log ("INFO: fragment file $frag_file contains only 1 fragment on $hostname"); + $frags{$frag_file} = join (/\n/, @frag_file); + } + close (FRAGS); +} + +do_log ("INFO: ".scalar (keys (%frags))." SUDO fragment(s) found on $hostname"); +print Dumper(\%frags) if $debug; + +# ----------------------------------------------------------------------------- +# syntax checking sudo fragments (visudo) +# ----------------------------------------------------------------------------- + +do_log ("INFO: syntax checking sudo fragments ..."); + +# create one large sudoers file out of the fragments, if the syntax check fails +# then we keep the temporary file for further inspection +my ($sudo_fh, $sudo_file) = tempfile(UNLINK => 0); +print $sudo_fh join("\n", map { "$frags{$_}" } keys %frags); +$sudo_fh->flush; +my @syntax_check = `${visudo_bin} -c -f $sudo_file 2>/dev/null`; +if ($? == 0) { + do_log ("INFO: syntax check of sudo fragments is OK on $hostname"); + unlink $sudo_file; +} else { + do_log "ERROR: visudo check failed: ".join ("\n", @syntax_check)." [$hostname]" + and exit(1); +} + +# ----------------------------------------------------------------------------- +# read grant definitions +# result: @grants (array): fragments for which grants have been defined +# for this server. +# ----------------------------------------------------------------------------- + +do_log ("INFO: reading 'grants' file ..."); + +open (GRANTS, "<", "${run_dir}/grants") + or do_log ("ERROR: cannot read 'grants' file [$! $hostname]") and exit (1); +while () { + + my ($what, $where, @what, @where); + + chomp (); + next if (/^$/ || /\#/); + s/\s+//g; + ($what, $where) = split (/:/); + next unless ($where); + @what = resolve_aliases ($what); + @where = resolve_aliases ($where); + unless (@what and @where) { + do_log ("WARN: ignoring line $. in 'grants' due to missing/non-resolving values [$hostname]"); + next; + } + + foreach my $grant (sort (@what)) { + foreach my $server (sort (@where)) { + do_log ("DEBUG: adding grants for $grant on $server in \@grants") + if ($server eq $hostname); + # add sudo fragment to grants list if the entry is for this host + push (@grants, $grant) if ($server eq $hostname); + } + } +}; +close (GRANTS); + +# remove duplicates in @grants +@grants = keys (%{{ map { $_ => 1 } @grants}}); + +do_log ("INFO: ".scalar (@grants)." SUDO fragments with applicable grants requested on $hostname"); +print Dumper(\@grants) if $debug; + +# ----------------------------------------------------------------------------- +# global preview, show full configuration data only +# ----------------------------------------------------------------------------- + +if ($preview && $global) { + + open (GRANTS, "<", "${run_dir}/grants") + or do_log ("ERROR: cannot read 'grants' file [$! $hostname]") and exit (1); + while () { + + my ($what, $where, @what, @where); + + chomp (); + next if (/^$/ || /\#/); + s/\s+//g; + ($what, $where) = split (/:/); + next unless ($where); + @what = resolve_aliases ($what); + @where = resolve_aliases ($where); + unless (@what and @where) { + do_log ("WARN: ignoring line $. in 'grants' due to missing/non-resolving values [$hostname]"); + next; + } + + foreach my $grant (sort (@what)) { + foreach my $server (sort (@where)) { + do_log ("$grant|$server") + } + } + }; + close (GRANTS); + + exit (0); +} + +# ----------------------------------------------------------------------------- +# distribute sudo fragments into $fragments_dir +# ----------------------------------------------------------------------------- + +do_log ("INFO: (de)-activating SUDO fragments ...."); + +# check for SELinux +unless ($preview) { + SWITCH: { + $os eq "Linux" && do { + $selinux_status = qx#/usr/sbin/getenforce 2>/dev/null#; + chomp ($selinux_status); + if ($selinux_status eq "Permissive" or $selinux_status eq "Enforcing") { + do_log ("INFO: runtime info: detected active SELinux system on $hostname"); + $has_selinux = 1; + } + last SWITCH; + }; + } +} + +# remove previous fragment files first +opendir (FRAGS_DIR, "${fragments_dir}") + or do_log ("ERROR: cannot open ${fragments_dir} directory [$! $hostname]") + and exit (1); +while (my $frag_file = readdir (FRAGS_DIR)) { + next if ($frag_file =~ /^\./ or $frag_file eq $immutable_self_file); + # safe to ignore . (dot) files as sudo also does as well + + unless ($preview) { + + my $frag_file = "$fragments_dir/$frag_file"; + + if (unlink ($frag_file)) { + do_log ("INFO: de-activating fragment file $frag_file on $hostname"); + } else { + do_log ("ERROR: cannot de-activate fragment file(s) [$! $hostname]"); + exit (1); + } + } +} +closedir (FRAGS_DIR); + +# re-active current fragments +foreach my $grant (@grants) { + + # do not create empty sudo files + if (exists ($frags{$grant})) { + + my $sudo_file = "$fragments_dir/$grant"; + + unless ($preview) { + open (SUDO_FILE, "+>", $sudo_file) + or do_log ("ERROR: cannot open file for writing in $fragments_dir [$! $hostname]") + and exit (1); + } + print SUDO_FILE "$frags{$grant}\n" unless $preview; + do_log ("INFO: activating fragment $grant on $hostname"); + close (SUDO_FILE) unless $preview; + + # set permissions to world readable & SELinux contexts + unless ($preview) { + SWITCH: { + $os eq "HP-UX" && do { + set_file ($sudo_file, 0440, 2, 2); + last SWITCH; + }; + $os eq "Linux" && do { + if ($has_selinux) { + system ("/usr/bin/chcon -t $selinux_context $sudo_file") || + do_log ("WARN: failed to set SELinux context $selinux_context on $sudo_file [$hostname]"); + } + set_file ($sudo_file, 0440, 0, 0); + last SWITCH; + }; + } + } + } else { + do_log ("WARN: no matching SUDO rule found available for $grant [$hostname]"); + } +} + +# re-apply the immutable self fragment, just in case ;-) +unless ($preview) { + + my $self_file = "$fragments_dir/$immutable_self_file"; + + open (SELF_FILE, "+>", $self_file) + or do_log ("ERROR: cannot open file for writing in $fragments_dir [$! $hostname]") + and exit (1); + + print SELF_FILE "# THIS IS THE IMMUTABLE SELF FRAGMENT OF SUDO CONTROLS\n"; + print SELF_FILE $immutable_self_cmd."\n"; + do_log ("INFO: activating immutable self fragment $immutable_self_file on $hostname"); + SWITCH: { + $os eq "HP-UX" && do { + set_file ($self_file, 0440, 2, 2); + last SWITCH; + }; + $os eq "Linux" && do { + if ($has_selinux) { + system ("/usr/bin/chcon -t $selinux_context $self_file") || + do_log ("WARN: failed to set SELinux context $selinux_context on $self_file [$hostname]"); + } + set_file ($self_file, 0440, 0, 0); + last SWITCH; + }; + } + close (SELF_FILE); +} + +exit (0); + +#****************************************************************************** +# End of SCRIPT +#****************************************************************************** + +#****************************************************************************** +# POD +#****************************************************************************** + +# ----------------------------------------------------------------------------- + +=head1 NAME + +update_sudo.pl - distributes SUDO fragments according to a desired state model. + +=head1 SYNOPSIS + + update_sudo.pl [-d|--debug] + [-h|--help] + ([-p|--preview] [-g|--global]) + [-v|--verbose] + [-V|--version] + + +=head1 DESCRIPTION + +B distributes SUDO fragments into the C<$fragments_dir> repository based on the F, F and F files. +This script should be run on each host where SUDO is the required method of privilege escalation. + +For update SUDO fragments must be stored in a generic F file within the same directory as B script. +Alternatively SUDO fragments may be stored as set of individual files within a called sub-directory called F. +Both methods are mutually exclusive and the latter always take precedence. + +=head1 CONFIGURATION + +B requires the presence of at least one of the following configuration files: + +=over 2 + +=item * F + +=item * F + +=back + +Use F for localized settings per host. Settings in the localized configuration file will always override other values. + +Following settings must be configured: + +=over 2 + +=item * B : target directory for SUDO fragments files + +=item * B : path to the visudo tool (for sudo rules syntax checking) + +=item * B : name of the file that contains sudo code to allow this script to run with elevated privileges + +=back + +=head1 OPTIONS + +=over 2 + +=item -d | --debug + +S< >Be I verbose during execution; show array/hash dumps. + +=item -h | --help + +S< >Show the help page. + +=item -p | --preview + +S< >Do not actually distribute any SUDO fragments, nor update/remove SUDO files. + +=item -p | --global + +S< >Must be used in conjunction with the --preview option. This will dump the global namespace/configuration to STDOUT. + +=item -v | --verbose + +S< >Be verbose during exection. + +=item -V | --version + +S< >Show version of the script. + +=back + +=head1 NOTES + +=over 2 + +=item * Options may be preceded by a - (dash), -- (double dash) or a / (slash). + +=item * Options may be bundled (e.g. -vp) + +=back + +=head1 AUTHOR + +(c) KUDOS BVBA, Patrick Van der Veken + +=head1 HISTORY + +@(#) 2014-12-04: VRF 1.0.0: first version [Patrick Van der Veken] +@(#) 2014-12-16: VRF 1.0.1: added SELinux context [Patrick Van der Veken] +@(#) 2014-12-16: VRF 1.0.2: fixed a problem with the immutable self fragment code [Patrick Van der Veken] +@(#) 2015-02-02: VRF 1.0.3: changed 'basename' into 'fileparse' call to support fragment files with extensions [Patrick Van der Veken] \ No newline at end of file