#!/bin/bash

#==============================================================================
# Copyright and license info is available in the LICENSE file included with
# the Server Deployment Package (SDP), and also available online:
# https://swarm.workshop.perforce.com/projects/perforce-software-sdp/view/main/LICENSE
#------------------------------------------------------------------------------

#==============================================================================
# Declarations and Environment
set -u

declare ThisScript=${0##*/}
declare Version=1.5.1
declare ThisUser=
declare LiveSudoersFile=
declare TempSudoersFile=
declare BackupSudoersFile=
declare SDPInstance=
declare P4Service=
declare SystemCtlCmd=
declare SystemCtlPath=
declare LslocksPath=
declare LsofPath=
declare TmpFile=
declare -i LimitedSudoers=2
declare -a SystemCtlCmdList
declare -a P4InstanceList
declare -a SudoAliasList
declare Service=
declare TimerService=
declare -i ErrorCount=0
declare -i i=0
declare -i NoOp=1
declare -i Force=0
declare Log=
declare H1="=============================================================================="
declare H2="------------------------------------------------------------------------------"
declare RootHome=

#==============================================================================
# Local Functions

function msg () { echo -e "$*"; }
function errmsg () { msg "\nError: ${1:-Unknown Error}\n"; ErrorCount+=1; }
function bail () { errmsg "${1:-Unknown Error}"; exit "$ErrorCount"; }


#------------------------------------------------------------------------------
# Function: emit_sudoers_cmd_alias
#
# Emit one sudoers Cmnd_Alias from the supplied command list. This intentionally
# keeps generated aliases smaller and logically grouped for compatibility with
# both classic sudo and sudo-rs.
function emit_sudoers_cmd_alias
{
   local aliasName=${1:-}
   shift || true
   local -a cmdList=("$@")
   local -i cmdCount=${#cmdList[@]}
   local -i cmdIndex=0
   local -i lastIndex=0

   [[ -n "$aliasName" ]] || bail "emit_sudoers_cmd_alias called without an alias name."
   [[ "$cmdCount" -gt 0 ]] || return 0

   echo "Cmnd_Alias $aliasName = \\"
   lastIndex=$((cmdCount - 1))

   for cmdIndex in "${!cmdList[@]}"; do
      if [[ "$cmdIndex" -lt "$lastIndex" ]]; then
         echo "   ${cmdList[$cmdIndex]}, \\"
      else
         echo -e "   ${cmdList[$cmdIndex]}\n"
      fi
   done

   SudoAliasList+=("$aliasName")
}

#------------------------------------------------------------------------------
# Function: emit_sudoers_user_spec
#
# Emit the final user specification that references the generated Cmnd_Alias
# definitions.
function emit_sudoers_user_spec
{
   local osUser=${1:-}
   local hostName=${2:-}
   local -i aliasCount=${#SudoAliasList[@]}
   local -i aliasIndex=0
   local -i lastIndex=0

   [[ -n "$osUser" ]] || bail "emit_sudoers_user_spec called without an OS user."
   [[ -n "$hostName" ]] || bail "emit_sudoers_user_spec called without a host name."
   [[ "$aliasCount" -gt 0 ]] || bail "No sudoers command aliases were generated."

   echo "$osUser $hostName,localhost = (root) NOPASSWD: \\"
   lastIndex=$((aliasCount - 1))

   for aliasIndex in "${!SudoAliasList[@]}"; do
      if [[ "$aliasIndex" -lt "$lastIndex" ]]; then
         echo "   ${SudoAliasList[$aliasIndex]}, \\"
      else
         echo "   ${SudoAliasList[$aliasIndex]}"
      fi
   done
}

#------------------------------------------------------------------------------
# Function: validate_sudoers_file
#
# Validate a sudoers file with the active visudo implementation. On Ubuntu 26+
# this is commonly visudo-rs; on older platforms this is classic visudo.
function validate_sudoers_file
{
   local sudoersFile=${1:-}
   local validationOutput=

   [[ -n "$sudoersFile" ]] || bail "validate_sudoers_file called without a file name."
   [[ -r "$sudoersFile" ]] || bail "Cannot read sudoers file for validation: $sudoersFile"

   validationOutput=$(mktemp)

   if visudo -c -f "$sudoersFile" > "$validationOutput" 2>&1; then
      rm -f "$validationOutput"
      return 0
   else
      cat "$validationOutput"
      rm -f "$validationOutput"
      return 1
   fi
}

#------------------------------------------------------------------------------
# Function: terminate
# shellcheck disable=SC2329
function terminate
{
   # Disable signal trapping.
   trap - EXIT SIGINT SIGTERM

   [[ "$Log" != off ]] && \
      msg "Log is: $Log\n${H1}\n"

   # With the trap removed, exit.
   exit "$ErrorCount"
}

#------------------------------------------------------------------------------
# Function: usage (required function)
#
# Input:
# $1 - style, either -h (for short form) or -man (for man-page like format).
# The default is -h.
#
# $2 - error message (optional).  Specify this if usage() is called due to
# user error, in which case the given message displayed first, followed by the
# standard usage message (short or long depending on $1).  If displaying an
# error, usually $1 should be -h so that the longer usage message doesn't
# obscure the error message.
#
# Sample Usage:
# usage
# usage -h
# usage -man
# usage -h "Incorrect command line usage."
#------------------------------------------------------------------------------
function usage
{
   declare style=${1:--h}
   declare errorMessage=${2:-Unset}

   if [[ "$errorMessage" != Unset ]]; then
      msg "\n\nUsage Error:\n\n$errorMessage\n\n"
   fi

   msg "USAGE for $ThisScript v$Version:

$ThisScript {-full|-limited} [-y [-f]] [-L <log>] [-D]

or

$ThisScript [-h|-man]
"
   if [[ $style == -man ]]; then
      msg "
DESCRIPTION:
	This script generates a sudoers file for the OS user that
	owns /p4/common, which is expected to be the same user that the
	Perforce Helix Core service runs as (typically 'perforce').

	By default, the sudoers file is generated for review.  If the '-y'
	option is specified, the newly generated files is installed as
	the live sudoers file by copying to /etc/sudoers.d/<OSUSER> and
	adjusting permissions to 0400.

	If '-full' (full sudo) is specified, a one-line sudoers file is
	generated that looks something like this:

	perforce ALL=(ALL) NOPASSWD: ALL

	If '-limited' is specified, a limited sudoers file is generated
	granting only necessary access to the perforce user.

	If the sudoers file already exits, it will not be updated unless
	'-f' (force) is provided.

	The limited sudoers is recommended for production deployments.

OPTIONS:
 -full
 	Specify '-full' to indicate that a sudoers file is to be generated
	granting full root access to the server machine.

	The '-full' or '-limited' option must be specified.

	This option is discouraged as it is not as secure as the
	'-limited' option.

 -limited
	Specify '-limited' to indicate that a sudoers file is to be
	generated granting limited access to the server machine.

	The '-full' or '-limited' option must be specified.

	This option is recommended for optimal security.

 -y	This is confirmation to install the generated sudoers as the live
	sudoers file.

 -f	Specify '-f' to overwrite an existing limited sudoers file,
	/etc/sudoers.d/<OSUSER>

 -L <log>
	Specify the path to a log file, or the special value 'off' to disable
	logging.  By default, all output (stdout and stderr) goes to:

	$RootHome/${ThisScript%.sh}.<Datestamp>.log

	NOTE: This script is self-logging.  That is, output displayed on the screen
	is simultaneously captured in the log file.

 -D     Enable bash 'set -x' extreme debugging verbosity.

HELP OPTIONS:
 -h	Display short help message
 -man	Display man-style help message
	
EXAMPLES:
	EXAMPLE 1: Generate a limited sudoers file for review.

	cd /p4/sdp/Server/Unix/setup
	./gen_sudoers.sh -limited

	EXAMPLE 2: Generate a limited sudoers file and install it.

	cd /p4/sdp/Server/Unix/setup
	./gen_sudoers.sh -limited -y

	EXAMPLE 3: Generate a limited sudoers file and install it, replacing an
	existing one.

	cd /p4/sdp/Server/Unix/setup
	./gen_sudoers.sh -limited -f -y

	EXAMPLE 4: Generate a full sudoers file and install it, replacing an

	cd /p4/sdp/Server/Unix/setup
	./gen_sudoers.sh -full -f -y
"
   fi

   exit 2
}

#==============================================================================
# Command Line Processing

#------------------------------------------------------------------------------
# Get the home for Root. This is reliably /root on Linux, but varies in UNIX
# distros. If /root does not exist, attempt detection with getent.  As
# a fallback, use /tmp.  This is only used for a location of the log file.
# It must be set before processing command line arguments because this value
# is used in the usage() function.
if [[ -d /root ]]; then
   RootHome=/root
else
   RootHome=$(getent passwd root | cut -d: -f6)
fi

RootHome=${RootHome:-/tmp}

#------------------------------------------------------------------------------
declare -i shiftArgs=0

set +u
while [[ $# -gt 0 ]]; do
   case $1 in
      (-h) RootHome=/root; usage -h;;
      (-man) RootHome=/root; usage -man;;
      (-L) Log="$2"; shiftArgs=1;;
      (-f) Force=1;;
      (-full) LimitedSudoers=0;;
      (-limited) LimitedSudoers=1;;
      (-y) NoOp=0;;
      (-D) set -x;; # Debug; use 'set -x' mode.
      (-*) usage -h "Unknown flag ($1).";;
      (*) usage -h "Unknown parameter ($1).";;
   esac

   # Shift (modify $#) the appropriate number of times.
   shift; while [[ $shiftArgs -gt 0 ]]; do
      [[ $# -eq 0 ]] && usage -h "Incorrect number of arguments."
      shiftArgs=$shiftArgs-1
      shift
   done
done
set -u

#==============================================================================
# Command Line Verification

[[ -n "${Log:-}" ]] || \
   Log="$RootHome/${ThisScript%.sh}.$(date +'%Y%m%d-%H%M%S').log"

[[ "$LimitedSudoers" -eq 2 ]] &&
   usage -h "Specify either '-limited' (limited sudoers) for '-full' (full sudoers)."

#==============================================================================
# Main Program

trap terminate EXIT SIGINT SIGTERM

if [[ "$Log" != off ]]; then
   touch "$Log" || bail "Couldn't touch log file [$Log]."

   # Redirect stdout and stderr to a log file.
      exec > >(tee "$Log")
      exec 2>&1

   msg "${H1}\nLog is: $Log"
fi

ThisUser=$(id -n -u)
msg "Starting $ThisScript v$Version as $ThisUser@${HOSTNAME%%.*} on $(date)."

[[ "$ThisUser" == root ]] || bail "Run this as root, not $ThisUser."

# Determine list of SDP instances based in /p4/*/logs dirs.
i=0
# shellcheck disable=SC2012
for SDPInstance in $(ls -d /p4/*/logs/ 2>/dev/null|cut -d '/' -f 3); do
   P4InstanceList[i]="$SDPInstance"
   i+=1
done

SystemCtlCmdList[0]="start"
SystemCtlCmdList[1]="stop"
SystemCtlCmdList[2]="restart"
SystemCtlCmdList[3]="status"
SystemCtlCmdList[4]="cat"
SystemCtlCmdList[5]="enable"
SystemCtlCmdList[6]="disable"
SystemCtlCmdList[7]="is-enabled"

SystemCtlPath="$(command -v systemctl)"
LslocksPath="$(command -v lslocks)"
LsofPath="$(command -v lsof)"

OSUSER="$(stat -Lc %U /p4/common 2>/dev/null)"
[[ -n "$OSUSER" ]] || bail "Could not owner of /p4/common. Aborting."
[[ "$OSUSER" == root ]] && \
   bail "Owner of /p4/common is unexpectedly root; it should be some other user e.g. 'perforce'. Aborting."

TempSudoersFile=$(mktemp)
LiveSudoersFile="/etc/sudoers.d/$OSUSER"
BackupSudoersFile="$RootHome/etc_sudoers.d_$OSUSER.bak.$(date +'%Y-%m-%d-%H%M%S')"

if [[ "$LimitedSudoers" -eq 1 ]]; then
   {
      declare -a ServiceCmdList
      declare -a TimerCmdList
      declare -a InstanceCmdList
      declare -a AdminCmdList
      declare -i InstanceAliasIndex=0

      SudoAliasList=()

      # General SDP-related systemd services.
      ServiceCmdList=()
      for Service in helix-auth monitor_locks node_exporter p4metrics p4prometheus vmagent; do
         for SystemCtlCmd in "${SystemCtlCmdList[@]}"; do
            ServiceCmdList+=("$SystemCtlPath $SystemCtlCmd $Service")
            ServiceCmdList+=("$SystemCtlPath $SystemCtlCmd ${Service}.service")
         done
      done
      emit_sudoers_cmd_alias P4_SVC_SYSTEMD_SERVICES "${ServiceCmdList[@]}"

      # Timer-backed SDP services. Keep these separate from regular services
      # because each timer-backed service has bare, .service, and .timer forms.
      TimerCmdList=()
      for TimerService in monitor_locks opt_perforce_sdp_backup; do
         for SystemCtlCmd in "${SystemCtlCmdList[@]}"; do
            TimerCmdList+=("$SystemCtlPath $SystemCtlCmd $TimerService")
            TimerCmdList+=("$SystemCtlPath $SystemCtlCmd ${TimerService}.service")
            TimerCmdList+=("$SystemCtlPath $SystemCtlCmd ${TimerService}.timer")
         done
      done
      emit_sudoers_cmd_alias P4_SVC_SYSTEMD_TIMERS "${TimerCmdList[@]}"

      # Instance-specific Helix Core services. Generate one alias per SDP
      # instance so sites with multiple instances do not create an overly large
      # comma-separated command list.
      for SDPInstance in "${P4InstanceList[@]}"; do
         InstanceAliasIndex+=1
         InstanceCmdList=()
         for P4Service in "p4d_${SDPInstance}" "p4broker_${SDPInstance}" "p4p_${SDPInstance}" "p4dtg_${SDPInstance}"; do
            for SystemCtlCmd in "${SystemCtlCmdList[@]}"; do
               InstanceCmdList+=("$SystemCtlPath $SystemCtlCmd $P4Service")
               InstanceCmdList+=("$SystemCtlPath $SystemCtlCmd ${P4Service}.service")
            done
         done
         emit_sudoers_cmd_alias "P4_SVC_INSTANCE_${InstanceAliasIndex}" "${InstanceCmdList[@]}"
      done

      # SDP administrative commands.
      AdminCmdList=("/opt/perforce/helix-sdp/sdp/Server/Unix/p4/common/sdp_upgrade/sdp_upgrade.sh")
      [[ -n "$LslocksPath" ]] && AdminCmdList+=("$LslocksPath")
      [[ -n "$LsofPath" ]] && AdminCmdList+=("$LsofPath")
      emit_sudoers_cmd_alias P4_SVC_ADMIN_TOOLS "${AdminCmdList[@]}"

      emit_sudoers_user_spec "$OSUSER" "$HOSTNAME"

   } > "$TempSudoersFile"
else
   echo "$OSUSER ALL=(ALL) NOPASSWD: ALL" > "$TempSudoersFile"
fi

msg "Generated limited sudoers temp file:\n${H2}\n$(cat "$TempSudoersFile")\n${H2}\n"

if validate_sudoers_file "$TempSudoersFile"; then
   msg "Generated sudoers temp file passed validation with active visudo."
else
   bail "Generated sudoers temp file failed validation with active visudo."
fi

if [[ "$NoOp" -eq 0 ]]; then
   if [[ -r "$LiveSudoersFile" ]]; then
      msg "This file already exists: $LiveSudoersFile:\n${H2}\n$(cat "$LiveSudoersFile")\n${H2}\n"

      if diff -q "$TempSudoersFile" "$LiveSudoersFile" > /dev/null 2>&1; then
         if validate_sudoers_file "$LiveSudoersFile"; then
            msg "\nVerified: The new generated sudoers file matches the currently installed one, and the installed file passes validation with active visudo.\nNo further processing required.\n"
            exit 0
         else
            bail "Installed sudoers file matches the generated file but fails validation with active visudo: $LiveSudoersFile"
         fi
      fi

      if [[ "$Force" -eq 1 ]]; then
         msg "\nOverwriting existing live sudoers file due to -f."
         msg "Creating backup file [$BackupSudoersFile]."
         cp -p "$LiveSudoersFile" "$BackupSudoersFile" ||\
            bail "Aborting: Failed to do: cp -p \"$LiveSudoersFile\" \"$BackupSudoersFile\""
      else
         bail  "Aborting because sudoers file already exists (displayed above).\n\nUse '-f' to replace this file with the newly generated one."
      fi
   fi

   msg "Installing $LiveSudoersFile"

   mv -f "$TempSudoersFile" "$LiveSudoersFile" ||\
      bail "Failed to move generated temp file in place as: $LiveSudoersFile"
   chmod 0400 "$LiveSudoersFile" ||\
      bail "Failed to do: chmod 0400 \"$LiveSudoersFile\""

   if validate_sudoers_file "$LiveSudoersFile"; then
      msg "Installed sudoers file passed validation with active visudo."
   else
      errmsg "Installed sudoers file failed validation with active visudo. Rolling back to prior version if available."
      if [[ -r "$BackupSudoersFile" ]]; then
         mv -f "$BackupSudoersFile" "$LiveSudoersFile" ||\
            bail "Failed to do: mv -f \"$BackupSudoersFile\" \"$LiveSudoersFile\""
         bail "Aborted after successful rollback to earlier version of [$LiveSudoersFile]."
      else
         rm -f "$LiveSudoersFile"
         bail "Aborted after removing invalid newly generated sudoers file [$LiveSudoersFile]."
      fi
   fi

   TmpFile=$(mktemp)
   # shellcheck disable=SC2024
   if sudo -l -U "$OSUSER" > "$TmpFile" 2>&1; then
      if grep -q 'syntax error' "$TmpFile"; then
         errmsg "The newly generated file failed a syntax check. Rolling back to prior version."
         mv -f "$BackupSudoersFile" "$LiveSudoersFile" ||\
            bail "Failed to do: mv -f \"$BackupSudoersFile\" \"$LiveSudoersFile\""
         bail "Aborted after successful rollback to earlier version of [$LiveSudoersFile]."
      else
         msg "Newly generated sudoers file passed a syntax check."
      fi
   else
      bail "Could not test generated sudoers file. If needed, the backup file is: $BackupSudoersFile"
   fi

   msg "\nNew sudoers file successfully installed.\n"
else
   msg "\nBecause -y was not specified, not installing generated file as: $LiveSudoersFile"
fi

rm -f "$TempSudoersFile"

exit "$ErrorCount"
