Script Hub – NinjaOne https://www.ninjaone.com Software For IT Ninjas Mon, 27 Oct 2025 09:46:57 +0000 en-US hourly 1 https://wordpress.org/?v=7.0.5 https://www.ninjaone.com/wp-content/uploads/2024/10/favicon-2024-150x150.png Script Hub – NinjaOne https://www.ninjaone.com 32 32 How to Set Linux Time Zone and NTP Settings with Shell Scripting https://www.ninjaone.com/script-hub/set-linux-time-zone-and-ntp-settings-shell-script/ Thu, 18 Sep 2025 21:49:32 +0000 https://www.ninjaone.com/?post_type=script_hub&p=528298 Accurate system time is the quiet backbone of enterprise IT. Authentication protocols, certificates, distributed logging, and scheduled maintenance all depend on synchronized clocks. This post walks through a production-ready shell script to set the time zone in Linux and NTP timedatectl settings using systemd-timesyncd and timedatectl. If you manage fleets of Linux endpoints—or build policy as an MSP—this is the kind of repeatable automation that keeps incidents out of your pager.

Background

Linux offers multiple ways to manage time: classic ntpd, chrony, and systemd-timesyncd. Many modern distros ship with systemd-timesyncd enabled by default, and timedatectl provides a consistent interface. The script you provided standardizes three tasks IT teams do regularly:

  • List and set the system time zone (e.g., America/Los_Angeles)
  • Configure one or two NTP servers for synchronization
  • Tune NTP polling intervals to match network and compliance requirements

It includes robust validation, safety checks, and idempotent behavior so you can run it repeatedly without creating drift.

The Script

#!/usr/bin/env bash
#
# Description: Set the time zone and NTP timedatectl settings for the local machine.
# By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
# Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
# Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
# Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
# Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
# Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
# Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
# EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
#
# Preset Parameter: --listTimeZones|-l
#		Lists all the time zones available to set.
#
# Preset Parameter: --setTimeZone|-t <arg>
#		Specifies the name of the time zone you would like to set, such as 'America/Los_Angeles'.
#
# Preset Parameter: --setSyncServers|-s <arg>
#		Specifies up to two NTP servers (comma-separated) to synchronize the time with.
#
# Preset Parameter: --syncIntervalMinimumInSeconds|--min <arg>
#		Specifies the minimum interval in seconds for NTP synchronization. The default value for most distributions is 32 seconds.
#
# Preset Parameter: --syncIntervalMaximumInSeconds|--max <arg>
#		Specifies the maximum interval in seconds for NTP synchronization. The default value for most distributions is 2048 seconds.
#
# Preset Parameter: --help|-h
#		Displays this help menu.
#
# Example: --setTimeZone "America/Los_Angeles" --setSyncServers "0.pool.ntp.org" --syncIntervalMinimumInSeconds "32" --syncIntervalMaximumInSeconds "2048"
#
# The current time zone is already set to 'America/Los_Angeles'.
#
# Analyzing the default timedatectl configuration file at '/etc/systemd/timesyncd.conf'.
# The current primary sync server is not currently set.
# The current fallback sync server is not currently set.
# The current minimum sync interval is not currently set.
# The current maximum sync interval is not currently set.
# An update to the current timedatectl configuration file is necessary.
#
# Creating a new timedatectl configuration file at '/etc/systemd/timesyncd.conf.d/1752702365-ntp.conf'.
# Setting primary sync server to '0.pool.ntp.org'.
# Setting minimum sync interval to '32' seconds.
# Setting maximum sync interval to '2048' seconds.
# Successfully created a new timedatectl configuration file.
# Restarting systemd-timesyncd.
# Successfully updated the NTP configuration.
#
# ### Previous timedatectl Settings ###
# Timezone: America/Los_Angeles
# # /etc/systemd/timesyncd.conf
# [Time]
#
# ### Current timedatectl Settings ###
# Timezone: America/Los_Angeles
# # /etc/systemd/timesyncd.conf
# [Time]
#
# # /etc/systemd/timesyncd.conf.d/1752702365-ntp.conf
# [Time]
# NTP=0.pool.ntp.org
# PollIntervalMinSec=32
# PollIntervalMaxSec=2048
#
# ### Current Time ###
# 07/16/2025 02:46:05 PM
#
# Version: 1.0
# Release Notes: Initial Release

_arg_setTimeZone=
_arg_setSyncServers=
_arg_syncServers=
_arg_syncIntervalMinimumInSeconds=
_arg_syncIntervalMaximumInSeconds=
_arg_listTimeZones="off"
exitCode=0

print_help() {
  printf '\n\n%s\n\n' 'Usage: [--listTimeZones|-l] [--setTimeZone|-t <arg>] [--setSyncServers|-s <arg>] [--syncIntervalMinimumInSeconds|--min <arg>] [--syncIntervalMaximumInSeconds|--max <arg>] [--help|-h]'
  printf '%s\n' 'Preset Parameter: --listTimeZones|-l'
  printf '\t%s\n' "Lists all the time zones available to set."
  printf '%s\n' 'Preset Parameter: --setTimeZone|-t <arg>'
  printf '\t%s\n' "Specifies the name of the time zone you would like to set, such as 'America/Los_Angeles'."
  printf '%s\n' 'Preset Parameter: --setSyncServers|-s <arg>'
  printf '\t%s\n' "Specifies up to two NTP servers (comma-separated) to synchronize the time with."
  printf '%s\n' 'Preset Parameter: --syncIntervalMinimumInSeconds|--min <arg>'
  printf '\t%s\n' "Specifies the minimum interval in seconds for NTP synchronization. The default value for most distributions is 32 seconds."
  printf '%s\n' 'Preset Parameter: --syncIntervalMaximumInSeconds|--max <arg>'
  printf '\t%s\n' "Specifies the maximum interval in seconds for NTP synchronization. The default value for most distributions is 2048 seconds."
  printf '%s\n' 'Preset Parameter: --help|-h'
  printf '\t%s\n' "Displays this help menu."
}

die() {
  local _ret="${2:-1}"
  echo "$1" >&2
  test "${_PRINT_HELP:-no}" = yes && print_help >&2
  exit "${_ret}"
}

parse_commandline() {
  while test $# -gt 0; do
    _key="$1"
    case "$_key" in
      --setTimeZone | --settimezone | -t)
        test $# -lt 2 && die "[Error] Missing value for the optional argument '$_key'." 1
        _arg_setTimeZone=$2
        shift
        ;;
      --setTimeZone=*)
        _arg_setTimeZone="${_key##--setTimeZone=}"
        ;;
      --setSyncServers | --setsyncservers | --setSyncServer | --setsyncserver | -s)
        test $# -lt 2 && die "[Error] Missing value for the optional argument '$_key'." 1
        _arg_setSyncServers=$2
        shift
        ;;
      --setSyncServers=*)
        _arg_setSyncServers="${_key##--setSyncServers=}"
        ;;
      --syncIntervalMinimumInSeconds | --syncintervalminimum | --syncMinimum | --syncminimum | --min)
        test $# -lt 2 && die "[Error] Missing value for the optional argument '$_key'." 1
        _arg_syncIntervalMinimumInSeconds=$2
        shift
        ;;
      --syncIntervalMinimumInSeconds=*)
        _arg_syncIntervalMinimumInSeconds="${_key##--syncIntervalMinimumInSeconds=}"
        ;;
      --syncIntervalMaximumInSeconds | --syncintervalmaximum | --syncMaximum | --syncmaximum | --max)
        test $# -lt 2 && die "[Error] Missing value for the optional argument '$_key'." 1
        _arg_syncIntervalMaximumInSeconds=$2
        shift
        ;;
      --syncIntervalMaximumInSeconds=*)
        _arg_syncIntervalMaximumInSeconds="${_key##--syncIntervalMaximumInSeconds=}"
        ;;
      --listTimeZones | --listtimezones | -l)
        _arg_listTimeZones="on"
        ;;
      --help | -h)
        _PRINT_HELP=yes die
        ;;
      *)
        _PRINT_HELP=yes die "[Error] Got an unexpected argument '$1'" 1
        ;;
    esac
    shift
  done
}

echo " "
parse_commandline "$@"

# If script form variables are used, replace the command line paramters with their value
if [[ -n $listTimeZones && $listTimeZones == "true" ]]; then
  _arg_listTimeZones="on"
fi

if [[ -n $setTimeZone ]]; then
  _arg_setTimeZone="$setTimeZone"
fi

if [[ -n $setSyncServers ]]; then
  _arg_setSyncServers="$setSyncServers"
fi

if [[ -n $syncIntervalMinimumInSeconds ]]; then
  _arg_syncIntervalMinimumInSeconds="$syncIntervalMinimumInSeconds"
fi

if [[ -n $syncIntervalMaximumInSeconds ]]; then
  _arg_syncIntervalMaximumInSeconds="$syncIntervalMaximumInSeconds"
fi

# Ensure the script is run with root permissions
if [[ $(id -u) -ne 0 ]]; then
  _PRINT_HELP=no die "[Error] This script must be run with root permissions. Try running it with sudo or as the system/root user." 1
fi

# Check if the timedatectl command is available
if ! command -v "timedatectl" 1> /dev/null; then
  _PRINT_HELP=no die "[Error] This script is designed to configure the time settings in timedatectl, but it was not found.
[Error] You may want to check your distribution's documentation to see the proper way of configuring the system time." 1
fi

if [[ $_arg_listTimeZones == "off" ]]; then
  if ! systemctl is-enabled systemd-timesyncd 1> /dev/null; then
    _PRINT_HELP=no die "[Error] The service 'systemd-timesyncd' is currently disabled. In order to adjust the timedatectl settings you must have it enabled." 1
  fi
fi

# You must provide at least one option
if [[ $_arg_listTimeZones == "off" && -z $_arg_setTimeZone && -z $_arg_setSyncServers && -z $_arg_syncIntervalMinimumInSeconds && -z $_arg_syncIntervalMaximumInSeconds ]]; then
  _PRINT_HELP=yes die "[Error] You must provide at least one valid option." 1
fi

# Validate that 'List Time Zones' is not used with other options
if [[ $_arg_listTimeZones == "on" && (-n $_arg_setTimeZone || -n $_arg_setSyncServers || -n $_arg_syncIntervalMinimumInSeconds || -n $_arg_syncIntervalMaximumInSeconds) ]]; then
  _PRINT_HELP=yes die "[Error] 'List Time Zones' cannot be used with other options." 1
fi

# Validate the specified time zone
if [[ -n $_arg_setTimeZone ]]; then
  _arg_setTimeZone=$(echo "$_arg_setTimeZone" | xargs)

  if [[ -z $_arg_setTimeZone ]]; then
    _PRINT_HELP=yes die "[Error] You must specify a valid time zone to set." 1
  fi

  validTimeZones=$(timedatectl list-timezones --no-pager)
  if ! echo "$validTimeZones" | grep -q -x "$_arg_setTimeZone"; then
    _PRINT_HELP=yes die "[Error] The time zone '$_arg_setTimeZone' is invalid. You must specify a valid time zone to set.
### Valid Time Zones ###
$validTimeZones" 1
  fi
fi

# Validate NTP configuration if sync servers or intervals are specified
if [[ -n $_arg_setSyncServers || -n $_arg_syncIntervalInMinutes ]]; then
  if ! timedatectl show 1> /dev/null; then
    _PRINT_HELP=no die "[Error] Unable to verify if this version of timedatectl is capable of using NTP." 1
  fi

  if timedatectl show | grep "CanNTP=" | grep "no" 1> /dev/null 2> /dev/null; then
    _PRINT_HELP=no die "[Error] This system does not currently have NTP installed and configured with timedatectl. Unable to configure network time synchronization." 1
  fi
fi

# Validate and process sync servers
if [[ -n $_arg_setSyncServers ]]; then
  _arg_setSyncServers=$(echo "$_arg_setSyncServers" | xargs)

  if [[ -z $_arg_setSyncServers ]]; then
    _PRINT_HELP=yes die "[Error] You must specify a valid sync server to set." 1
  fi

  _arg_syncServers=$(echo "$_arg_setSyncServers" | tr "," "\n" | sed "s/^[[:space:]]//" | sed "s/[[:space:]]$//")

  serverCount=$(echo "$_arg_syncServers" | wc -l | xargs)
  if [[ $serverCount -gt 2 ]]; then
    _PRINT_HELP=yes die "[Error] You can only specify up to two valid sync servers." 1
  fi

  if invalidServers=$(echo "$_arg_syncServers" | grep -e "[^a-zA-Z0-9.:_-]"); then
    _PRINT_HELP=yes die "[Error] One or more of the sync servers you specified are invalid. They contain invalid characters; only alphanumeric characters or one of these symbols are valid '.:_-'.
### Invalid Servers ###
$invalidServers" 1
  fi

  # Check if sync servers are responsive on UDP port 123
  while IFS= read -r syncServer; do
    if ! timeout 30 bash -c "echo -n '' > /dev/udp/$syncServer/123"; then
      _PRINT_HELP=yes die "[Error] The sync server '$syncServer' was not responsive on UDP port 123 (NTP). Unable to set sync server." 1
    fi
  done <<< "$_arg_syncServers"

  # Assign primary and fallback sync servers
  primarySyncServer=$(echo "$_arg_syncServers" | head -n 1)
  if [[ $serverCount -gt 1 ]]; then
    fallbackSyncServer=$(echo "$_arg_syncServers" | tail -n 1)
  fi
fi

# Validate and process minimum sync interval
if [[ -n $_arg_syncIntervalMinimumInSeconds ]]; then
  _arg_syncIntervalMinimumInSeconds=$(echo "$_arg_syncIntervalMinimumInSeconds" | xargs)

  if [[ -z $_arg_syncIntervalMinimumInSeconds ]]; then
    _PRINT_HELP=yes die "[Error] You must specify a valid minimum sync interval to set." 1
  fi

  if [[ $_arg_syncIntervalMinimumInSeconds =~ [^0-9] ]]; then
    _PRINT_HELP=yes die "[Error] The sync interval minimum '$_arg_syncIntervalMinimumInSeconds' is invalid. It contains invalid characters; only numeric characters are allowed." 1
  fi

  if [[ $_arg_syncIntervalMinimumInSeconds -lt 1 || $_arg_syncIntervalMinimumInSeconds -gt 86400 ]]; then
    _PRINT_HELP=yes die "[Error] The sync interval minimum '$_arg_syncIntervalMinimumInSeconds' is invalid. The sync interval must be greater than or equal to 1 and less than or equal to 86400." 1
  fi
fi

# Validate and process maximum sync interval
if [[ -n $_arg_syncIntervalMaximumInSeconds ]]; then
  _arg_syncIntervalMaximumInSeconds=$(echo "$_arg_syncIntervalMaximumInSeconds" | xargs)

  if [[ -z $_arg_syncIntervalMaximumInSeconds ]]; then
    _PRINT_HELP=yes die "[Error] You must specify a valid maximum sync interval to set." 1
  fi

  if [[ $_arg_syncIntervalMaximumInSeconds =~ [^0-9] ]]; then
    _PRINT_HELP=yes die "[Error] The sync interval maximum '$_arg_syncIntervalMaximumInSeconds' is invalid. It contains invalid characters; only numeric characters are allowed." 1
  fi

  if [[ $_arg_syncIntervalMaximumInSeconds -lt 1 || $_arg_syncIntervalMaximumInSeconds -gt 86400 ]]; then
    _PRINT_HELP=yes die "[Error] The sync interval maximum '$_arg_syncIntervalMaximumInSeconds' is invalid. The sync interval must be greater than or equal to 1 and less than or equal to 86400." 1
  fi
fi

# Ensure minimum sync interval is less than maximum sync interval
if [[ -n $_arg_syncIntervalMinimumInSeconds && -n $_arg_syncIntervalMaximumInSeconds && $_arg_syncIntervalMinimumInSeconds -gt $_arg_syncIntervalMaximumInSeconds ]]; then
  _PRINT_HELP=yes die "[Error] The maximum sync interval '$_arg_syncIntervalMaximumInSeconds' is less than the minimum sync interval '$_arg_syncIntervalMinimumInSeconds'. The minimum sync interval must be less than the max sync interval." 1
fi

if ! currentConfig=$(systemd-analyze cat-config systemd/timesyncd.conf --no-pager); then
  echo "[Error] Failed to retrieve the current time settings." >&2
  exitCode=1
fi
previousTimeSettings=$(echo "$currentConfig" | awk '/^# \/.*\.conf/ { print; next } /^\s*#/ { next } { print }')

# List time zones if requested
if [[ $_arg_listTimeZones == "on" ]]; then
  if ! currentTimeZone=$(timedatectl show | grep "Timezone=" | sed "s/Timezone=//" | sed "s/^[[:space:]]*//g" | sed "s/[[:space:]]*$//g"); then
    _PRINT_HELP=no die "[Error] Failed to retrieve the current timezone." 1
  fi

  # Display the current time zone.
  echo ""
  echo "The current timezone is '$currentTimeZone'."
  echo ""

  echo "### Valid Time Zones ###"
  if ! timedatectl list-timezones --no-pager; then
    _PRINT_HELP=no die "[Error] Failed to retrieve a list of valid time zones." 1
  fi

  exit "$exitCode"
fi

# Set the time zone if specified
if [[ -n $_arg_setTimeZone ]]; then
  if ! previousTimeZone=$(timedatectl show | grep "Timezone=" | sed "s/Timezone=//" | sed "s/^[[:space:]]*//g" | sed "s/[[:space:]]*$//g"); then
    _PRINT_HELP=no die "[Error] Failed to retrieve the current timezone." 1
  fi

  alreadySetTimeZone="false"

  if [[ $previousTimeZone == "$_arg_setTimeZone" ]]; then
    echo "The current time zone is already set to '$_arg_setTimeZone'."
    alreadySetTimeZone="true"
  else
    echo "Changing the timezone from '$previousTimeZone' to '$_arg_setTimeZone'."

    if ! timedatectl set-timezone "$_arg_setTimeZone"; then
      _PRINT_HELP=no die "[Error] Failed to set the time zone to '$_arg_setTimeZone'." 1
    fi
  fi

  currentTimeZone=$(timedatectl show | grep "Timezone=" | sed "s/Timezone=//" | sed "s/^[[:space:]]*//g" | sed "s/[[:space:]]*$//g")

  if [[ $alreadySetTimeZone == "false" && $currentTimeZone == "$_arg_setTimeZone" ]]; then
    echo "Successfully set the time zone to '$_arg_setTimeZone'."
  elif [[ $alreadySetTimeZone == "false" ]]; then
    _PRINT_HELP=no die "[Error] Failed to set the time zone to '$_arg_setTimeZone'." 1
  fi

  echo ""
fi

# Create the configuration directory if it doesn't exist
if [[ ! -d "/etc/systemd/timesyncd.conf.d" && (-n $_arg_syncServers || -n $_arg_syncIntervalMinimumInSeconds || -n $_arg_syncIntervalMaximumInSeconds) ]]; then
  if ! mkdir "/etc/systemd/timesyncd.conf.d"; then
    _PRINT_HELP=no die "[Error] Failed to create the directory '/etc/systemd/timesyncd.conf.d'." 1
  fi
fi

# Check if there are existing configuration files in the timesyncd configuration directory
timeSyncConfigFiles=$(ls /etc/systemd/timesyncd.conf.d/*.conf 2> /dev/null)

# If no configuration files exist and the default configuration file is present, analyze it
if [[ -z $timeSyncConfigFiles && -f "/etc/systemd/timesyncd.conf" && (-n $_arg_syncServers || -n $_arg_syncIntervalMinimumInSeconds || -n $_arg_syncIntervalMaximumInSeconds) ]]; then
  echo "Analyzing the default timedatectl configuration file at '/etc/systemd/timesyncd.conf'."

  # Check and update primary and fallback sync servers if necessary
  if [[ -n $_arg_syncServers ]]; then
    currentPrimarySyncServer=$(grep -w -r -H "^NTP" /etc/systemd/timesyncd.conf)
    currentPrimarySyncServer=$(echo "$currentPrimarySyncServer" | grep -v '^$')
    currentPrimaryServerCount=$(echo "$currentPrimarySyncServer" | awk 'NF' | wc -l | xargs)

    currentFallbackSyncServer=$(grep -w -r -H "^FallbackNTP" /etc/systemd/timesyncd.conf)
    currentFallbackSyncServer=$(echo "$currentFallbackSyncServer" | grep -v '^$')
    currentFallbackServerCount=$(echo "$currentFallbackSyncServer" | awk 'NF' | wc -l | xargs)

    # Ensure there are no duplicate primary or fallback sync servers
    if [[ $currentPrimaryServerCount -gt 1 ]]; then
      _PRINT_HELP=no die "[Error] Multiple primary sync servers were found. Please correct this before attempting to adjust the sync servers.
$currentPrimarySyncServer" 1
    fi

    if [[ $currentFallbackServerCount -gt 1 ]]; then
      _PRINT_HELP=no die "[Error] Multiple fallback sync servers were found. Please correct this before attempting to adjust the sync servers.
$currentPrimarySyncServer" 1
    fi

    # Check if the primary sync server needs to be updated
    if [[ -n $currentPrimarySyncServer ]]; then
      if ! echo "$currentPrimarySyncServer" | grep -w "$primarySyncServer" 1> /dev/null 2> /dev/null; then
        currentPrimarySyncValue=$(echo "$currentPrimarySyncServer" | awk -F "=" '{print $2}')

        echo "The current primary sync server needs to be updated from '$currentPrimarySyncValue' to '$primarySyncServer'."
        updateTimeSyncConfig="true"
      else
        echo "The current primary sync server is already $primarySyncServer."
      fi
    else
      echo "The current primary sync server is not currently set."
      updateTimeSyncConfig="true"
    fi

    # Check if the fallback sync server needs to be updated
    if [[ -n $currentFallbackSyncServer ]]; then
      if ! echo "$currentFallbackSyncServer" | grep -w "$fallbackSyncServer" 1> /dev/null 2> /dev/null; then
        currentFallbackSyncValue=$(echo "$currentFallbackSyncServer" | awk -F "=" '{print $2}')

        echo "The current fallback sync server needs to be updated from '$currentFallbackSyncValue' to '$fallbackSyncServer'."
        updateTimeSyncConfig="true"
      else
        echo "The current fallback sync server is already $fallbackSyncServer."
      fi
    else
      echo "The current fallback sync server is not currently set."
      updateTimeSyncConfig="true"
    fi
  fi

  # Check and update the minimum sync interval if necessary
  if [[ -n $_arg_syncIntervalMinimumInSeconds ]]; then
    currentMinimumSyncInterval=$(grep -w -r -H "^PollIntervalMinSec" /etc/systemd/timesyncd.conf)
    currentMinimumSyncInterval=$(echo "$currentMinimumSyncInterval" | grep -v '^$')
    currentMinimumSyncIntervalCount=$(echo "$currentMinimumSyncInterval" | awk 'NF' | wc -l | xargs)

    currentMaximumSyncInterval=$(grep -w -r -H "^PollIntervalMaxSec" /etc/systemd/timesyncd.conf)
    currentMaximumSyncInterval=$(echo "$currentMaximumSyncInterval" | grep -v '^$')
    currentMaximumSyncIntervalCount=$(echo "$currentMaximumSyncInterval" | awk 'NF' | wc -l | xargs)

    # Ensure there are no duplicate minimum or maximum sync intervals
    if [[ $currentMinimumSyncIntervalCount -gt 1 ]]; then
      _PRINT_HELP=no die "[Error] Multiple minimum sync intervals were found. Please correct this before attempting to adjust the minimum sync interval.
$currentMinimumSyncInterval" 1
    fi

    if [[ $currentMaximumSyncIntervalCount -gt 1 ]]; then
      _PRINT_HELP=no die "[Error] Multiple maximum sync intervals were found. Please correct this before attempting to adjust the minimum sync interval.
$currentMaximumSyncInterval" 1
    fi

    # Validate the relationship between minimum and maximum sync intervals
    if [[ -n $currentMaximumSyncInterval && -z $_arg_syncIntervalMaximumInSeconds ]]; then
      currentMaxValue=$(echo "$currentMaximumSyncInterval" | awk -F "=" '{ print $2 }')

      if [[ currentMaxValue -lt $_arg_syncIntervalMinimumInSeconds ]]; then
        _PRINT_HELP=yes die "[Error] The maximum sync interval '$_arg_syncIntervalMaximumInSeconds' is less than the minimum sync interval '$_arg_syncIntervalMinimumInSeconds'. The minimum sync interval must be less than the max sync interval." 1
      fi
    fi

    # Check if the minimum sync interval needs to be updated
    if [[ -n $currentMinimumSyncInterval ]]; then
      if ! echo "$currentMinimumSyncInterval" | grep -w "$_arg_syncIntervalMinimumInSeconds" 1> /dev/null 2> /dev/null; then
        currentMinValue=$(echo "$currentMinimumSyncInterval" | awk -F "=" '{ print $2 }')

        echo "The current minimum sync interval needs to be updated from '$currentMinValue' to '$_arg_syncIntervalMinimumInSeconds'."
        updateTimeSyncConfig="true"
      else
        echo "The current minimum sync interval is already $_arg_syncIntervalMinimumInSeconds."
      fi
    else
      echo "The current minimum sync interval is not currently set."
      updateTimeSyncConfig="true"
    fi
  fi

  # Check and update the maximum sync interval if necessary
  if [[ -n $_arg_syncIntervalMaximumInSeconds ]]; then
    currentMaximumSyncInterval=$(grep -w -r -H "^PollIntervalMaxSec" /etc/systemd/timesyncd.conf)
    currentMaximumSyncInterval=$(echo "$currentMaximumSyncInterval" | grep -v '^$')
    currentMaximumSyncIntervalCount=$(echo "$currentMaximumSyncInterval" | awk 'NF' | wc -l | xargs)

    currentMinimumSyncInterval=$(grep -w -r -H "^PollIntervalMinSec" /etc/systemd/timesyncd.conf)
    currentMinimumSyncInterval=$(echo "$currentMinimumSyncInterval" | grep -v '^$')
    currentMinimumSyncIntervalCount=$(echo "$currentMinimumSyncInterval" | awk 'NF' | wc -l | xargs)

    # Ensure there are no duplicate maximum or minimum sync intervals
    if [[ $currentMaximumSyncIntervalCount -gt 1 ]]; then
      _PRINT_HELP=no die "[Error] Multiple maximum sync intervals were found. Please correct this before attempting to adjust the maximum sync interval.
$currentMaximumSyncInterval" 1
    fi

    if [[ $currentMinimumSyncIntervalCount -gt 1 ]]; then
      _PRINT_HELP=no die "[Error] Multiple minimum sync intervals were found. Please correct this before attempting to adjust the maximum sync interval.
$currentMinimumSyncInterval" 1
    fi

    # Validate the relationship between maximum and minimum sync intervals
    if [[ -n $currentMinimumSyncInterval && -z $_arg_syncIntervalMinimumInSeconds ]]; then
      currentMinValue=$(echo "$currentMinimumSyncInterval" | awk -F "=" '{ print $2 }')

      if [[ currentMinValue -gt $_arg_syncIntervalMaximumInSeconds ]]; then
        _PRINT_HELP=yes die "[Error] The maximum sync interval '$_arg_syncIntervalMaximumInSeconds' is less than the minimum sync interval '$_arg_syncIntervalMinimumInSeconds'. The minimum sync interval must be less than the max sync interval." 1
      fi
    fi

    # Check if the maximum sync interval needs to be updated
    if [[ -n $currentMaximumSyncInterval ]]; then
      if ! echo "$currentMaximumSyncInterval" | grep -w "$_arg_syncIntervalMaximumInSeconds" 1> /dev/null 2> /dev/null; then
        currentMaxValue=$(echo "$currentMaximumSyncInterval" | awk -F "=" '{ print $2 }')

        echo "The current maximum sync interval needs to be updated from '$currentMaxValue' to '$_arg_syncIntervalMaximumInSeconds'."
        updateTimeSyncConfig="true"
      else
        echo "The current maximum sync interval is already $_arg_syncIntervalMaximumInSeconds."
      fi
    else
      echo "The current maximum sync interval is not currently set."
      updateTimeSyncConfig="true"
    fi
  fi

  # Determine if an update to the configuration file is necessary
  if [[ $updateTimeSyncConfig == "true" ]]; then
    echo "An update to the current timedatectl configuration file is necessary."
  else
    echo "No update is necessary at this time."
    echo ""

    echo "### Previous timedatectl Settings ###"
    echo "Timezone: $previousTimeZone"
    echo "$previousTimeSettings"

    echo ""

    if ! currentTimeZone=$(timedatectl show | grep "Timezone=" | sed "s/Timezone=//" | sed "s/^[[:space:]]*//g" | sed "s/[[:space:]]*$//g"); then
      echo "[Error] Failed to retrieve the current timezone." >&2
      exitCode=1
    fi

    echo "### Current timedatectl Settings ###"
    echo "Timezone: $currentTimeZone"
    if ! currentConfig=$(systemd-analyze cat-config systemd/timesyncd.conf --no-pager); then
      echo "[Error] Failed to retrieve the current time settings." >&2
      exitCode=1
    fi
    echo "$currentConfig" | awk '/^# \/.*\.conf/ { print; next } /^\s*#/ { next } { print }'

    echo ""

    echo "### Current Time ###"
    if ! date "+%x %r"; then
      echo "[Error] Failed to retrieve the current time." >&2
      exitCode=1
    fi

    exit "$exitCode"
  fi

  echo ""
fi

# If no configuration files exist and an update is necessary, create a new configuration file
if [[ -z $timeSyncConfigFiles && $updateTimeSyncConfig == "true" ]]; then
  timestamp=$(date "+%s")
  ntpFilePath="/etc/systemd/timesyncd.conf.d/$timestamp-ntp.conf"
  echo "Creating a new timedatectl configuration file at '$ntpFilePath'."

  # Write the new configuration file
  exec 3>&1
  {
    echo "[Time]"
    if [[ -n $_arg_syncServers ]]; then
      echo "NTP=$primarySyncServer"
      echo "Setting primary sync server to '$primarySyncServer'." >&3
      if [[ -n $fallbackSyncServer ]]; then
        echo "FallbackNTP=$fallbackSyncServer"
        echo "Setting fallback sync server to '$fallbackSyncServer'." >&3
      else
        echo "#FallbackNTP=ntp.ubuntu.com"
      fi
    else
      echo "#NTP="
      echo "#FallbackNTP=ntp.ubuntu.com"
    fi
    echo "#RootDistanceMaxSec=5"
    if [[ -n $_arg_syncIntervalMinimumInSeconds ]]; then
      echo "PollIntervalMinSec=$_arg_syncIntervalMinimumInSeconds"
      echo "Setting minimum sync interval to '$_arg_syncIntervalMinimumInSeconds' seconds." >&3
    else
      echo "#PollIntervalMinSec=32"
    fi
    if [[ -n $_arg_syncIntervalMaximumInSeconds ]]; then
      echo "PollIntervalMaxSec=$_arg_syncIntervalMaximumInSeconds"
      echo "Setting maximum sync interval to '$_arg_syncIntervalMaximumInSeconds' seconds." >&3
    else
      echo "#PollIntervalMaxSec=2048"
    fi
    echo "#ConnectionRetrySec=30"
    echo "#SaveIntervalSec=60"
  } > "$ntpFilePath"

  # Validate the creation of the new configuration file
  if [[ ! -s $ntpFilePath ]]; then
    _PRINT_HELP=no die "[Error] Failed to create the file '$ntpFilePath'. Unable to set sync servers or configure the sync interval." 1
  else
    echo "Successfully created a new timedatectl configuration file."

    # Enable NTP if it is currently disabled
    if timedatectl show | grep -w "NTP" | grep "no" 1> /dev/null; then
      echo "Turning on NTP."
      if ! timedatectl set-ntp true; then
        _PRINT_HELP=no die "[Error] Failed to turn on NTP." 1
      fi
    fi

    # Restart the systemd-timesyncd service to apply changes
    echo "Restarting systemd-timesyncd."
    if ! systemctl daemon-reload; then
      _PRINT_HELP=no die "[Error] Failed to run 'systemctl daemon-reload'." 1
    fi

    if ! systemctl restart systemd-timesyncd; then
      _PRINT_HELP=no die "[Error] Failed to restart 'systemd-timesyncd'." 1
    fi

    echo "Successfully updated the NTP configuration."

    echo ""
    echo "### Previous timedatectl Settings ###"
    echo "Timezone: $previousTimeZone"
    echo "$previousTimeSettings"

    echo ""

    if ! currentTimeZone=$(timedatectl show | grep "Timezone=" | sed "s/Timezone=//" | sed "s/^[[:space:]]*//g" | sed "s/[[:space:]]*$//g"); then
      echo "[Error] Failed to retrieve the current timezone." >&2
      exitCode=1
    fi

    echo "### Current timedatectl Settings ###"
    echo "Timezone: $currentTimeZone"

    if ! currentConfig=$(systemd-analyze cat-config systemd/timesyncd.conf --no-pager); then
      echo "[Error] Failed to retrieve the current time settings." >&2
      exitCode=1
    fi
    echo "$currentConfig" | awk '/^# \/.*\.conf/ { print; next } /^\s*#/ { next } { print }'

    echo ""

    echo "### Current Time ###"
    if ! date "+%x %r"; then
      echo "[Error] Failed to retrieve the current time." >&2
      exitCode=1
    fi

    exit "$exitCode"
  fi
fi

# Check if a primary sync server is specified
if [[ -n $primarySyncServer ]]; then
  # Retrieve the current primary sync server configuration
  currentPrimarySyncServer=$(grep -w -r -H "^NTP" /etc/systemd/timesyncd.conf.d/*.conf)
  currentPrimarySyncServer=$(echo "$currentPrimarySyncServer" | grep -v '^$')
  currentPrimaryServerCount=$(echo "$currentPrimarySyncServer" | awk 'NF' | wc -l | xargs)

  # Ensure there are no duplicate primary sync server entries
  if [[ $currentPrimaryServerCount -gt 1 ]]; then
    _PRINT_HELP=no die "[Error] Multiple primary sync servers were found. Please correct this before attempting to adjust the sync servers.
$currentPrimarySyncServer" 1
  fi

  # Update the primary sync server if necessary
  if [[ -n $currentPrimarySyncServer ]]; then
    if ! echo "$currentPrimarySyncServer" | grep -w "$primarySyncServer" 1> /dev/null 2> /dev/null; then
      currentPrimaryValue=$(echo "$currentPrimarySyncServer" | awk -F "=" '{ print $2 }')

      echo "Updating the primary sync server from '$currentPrimaryValue' to '$primarySyncServer'."
      filePath=${currentPrimarySyncServer//:*/}
      timestamp=$(date "+%s")
      updatedFilePath="/etc/systemd/timesyncd.conf.d/$timestamp-ntp.conf"

      # Save the updated configuration file
      echo "Saving the updated configuration file to '$updatedFilePath'."
      sed "s/^NTP=.*/NTP=$primarySyncServer/g" "$filePath" > "$updatedFilePath"

      # Validate the creation of the updated file
      if [[ ! -s $updatedFilePath ]]; then
        _PRINT_HELP=no die "[Error] Failed to create the file '$updatedFilePath'. This is needed to adjust the sync servers." 1
      fi

      # Backup the old configuration file
      echo "Backing up the old configuration file to '$filePath.old'."
      if ! mv "$filePath" "$filePath.old"; then
        _PRINT_HELP=no die "[Error] Failed to remove the old config file at '$filePath'. This is needed to adjust the sync servers." 1
      fi

      echo "Successfully updated the primary sync server."
    else
      echo "The primary NTP server is already set to '$primarySyncServer'."
    fi
  fi

  # Create a new configuration file if no primary sync server exists
  if [[ -z $currentPrimarySyncServer ]]; then
    echo "Setting the primary sync server to '$primarySyncServer'."
    timestamp=$(date "+%s")
    ntpPrimaryFilePath="/etc/systemd/timesyncd.conf.d/$timestamp-ntp-primary-server.conf"
    echo "Creating configuration file at '$ntpPrimaryFilePath'."

    # Write the new configuration file
    {
      echo "[Time]"
      echo "NTP=$primarySyncServer"
      echo "#FallbackNTP=ntp.ubuntu.com"
      echo "#RootDistanceMaxSec=5"
      echo "#PollIntervalMinSec=32"
      echo "#PollIntervalMaxSec=2048"
      echo "#ConnectionRetrySec=30"
      echo "#SaveIntervalSec=60"
    } > "$ntpPrimaryFilePath"

    # Validate the creation of the new file
    if [[ ! -s $ntpPrimaryFilePath ]]; then
      _PRINT_HELP=no die "[Error] Failed to create the file '$ntpPrimaryFilePath'. This is needed to adjust the sync servers." 1
    fi

    echo "Successfully set the primary sync server."
  fi

  # Update the list of configuration files and mark the need to restart timedatectl
  timeSyncConfigFiles=$(ls /etc/systemd/timesyncd.conf.d/*.conf 2> /dev/null)
  needToRestartTimedatectl="yes"
  echo ""
  sleep 1
fi

# Check if a fallback sync server is specified
if [[ -n $fallbackSyncServer ]]; then
  # Retrieve the current fallback sync server configuration
  currentFallbackSyncServer=$(grep -w -r -H "^FallbackNTP" /etc/systemd/timesyncd.conf.d/*.conf)
  currentFallbackSyncServer=$(echo "$currentFallbackSyncServer" | grep -v '^$')
  currentFallbackServerCount=$(echo "$currentFallbackSyncServer" | awk 'NF' | wc -l | xargs)

  # Ensure there are no duplicate fallback sync server entries
  if [[ $currentFallbackServerCount -gt 1 ]]; then
    _PRINT_HELP=no die "[Error] Multiple fallback sync servers were found. Please correct this before attempting to adjust the sync servers.
$currentPrimarySyncServer" 1
  fi

  # Update the fallback sync server if necessary
  if [[ -n $currentFallbackSyncServer ]]; then
    if ! echo "$currentFallbackSyncServer" | grep -w "$fallbackSyncServer" 1> /dev/null 2> /dev/null; then
      currentFallbackValue=$(echo "$currentFallbackSyncServer" | awk -F "=" '{ print $2 }')

      echo "Updating the fallback sync server from '$currentFallbackValue' to '$fallbackSyncServer'."
      filePath=${currentFallbackSyncServer//:*/}
      timestamp=$(date "+%s")
      updatedFilePath="/etc/systemd/timesyncd.conf.d/$timestamp-ntp.conf"

      # Save the updated configuration file
      echo "Saving the updated configuration file to '$updatedFilePath'."
      sed "s/^FallbackNTP=.*/FallbackNTP=$fallbackSyncServer/g" "$filePath" > "$updatedFilePath"

      # Validate the creation of the updated file
      if [[ ! -s $updatedFilePath ]]; then
        _PRINT_HELP=no die "[Error] Failed to create the file '$updatedFilePath'. This is needed to adjust the sync servers." 1
      fi

      # Backup the old configuration file
      echo "Backing up the old configuration file to '$filePath.old'."
      if ! mv "$filePath" "$filePath.old"; then
        _PRINT_HELP=no die "[Error] Failed to remove the old config file at '$filePath'. This is needed to adjust the sync servers." 1
      fi

      echo "Successfully updated the fallback server."
    else
      echo "The fallback NTP server is already set to '$fallbackSyncServer'."
    fi
  fi

  # Create a new configuration file if no fallback sync server exists
  if [[ -z $currentFallbackSyncServer ]]; then
    echo "Setting the fallback sync server to '$fallbackSyncServer'."
    timestamp=$(date "+%s")
    ntpFallbackFilePath="/etc/systemd/timesyncd.conf.d/$timestamp-ntp-fallback-server.conf"

    echo "Creating new configuration file at '$ntpFallbackFilePath'."
    {
      echo "[Time]"
      echo "#NTP="
      echo "FallbackNTP=$fallbackSyncServer"
      echo "#RootDistanceMaxSec=5"
      echo "#PollIntervalMinSec=32"
      echo "#PollIntervalMaxSec=2048"
      echo "#ConnectionRetrySec=30"
      echo "#SaveIntervalSec=60"
    } > "$ntpFallbackFilePath"

    # Validate the creation of the new file
    if [[ ! -s $ntpFallbackFilePath ]]; then
      _PRINT_HELP=no die "[Error] Failed to create the file '$ntpFallbackFilePath'. This is needed to adjust the sync servers." 1
    fi

    echo "Successfully set the fallback sync server."
  fi

  # Update the list of configuration files and mark the need to restart timedatectl
  timeSyncConfigFiles=$(ls /etc/systemd/timesyncd.conf.d/*.conf 2> /dev/null)
  needToRestartTimedatectl="yes"
  echo ""
  sleep 1
fi

# Check if a minimum sync interval is specified
if [[ -n $_arg_syncIntervalMinimumInSeconds ]]; then
  # Retrieve the current minimum and maximum sync interval configurations
  currentMinimumSyncInterval=$(grep -w -r -H "^PollIntervalMinSec" /etc/systemd/timesyncd.conf.d/*.conf)
  currentMinimumSyncInterval=$(echo "$currentMinimumSyncInterval" | grep -v '^$')
  currentMinimumSyncIntervalCount=$(echo "$currentMinimumSyncInterval" | awk 'NF' | wc -l | xargs)

  currentMaximumSyncInterval=$(grep -w -r -H "^PollIntervalMaxSec" /etc/systemd/timesyncd.conf.d/*.conf)
  currentMaximumSyncInterval=$(echo "$currentMaximumSyncInterval" | grep -v '^$')
  currentMaximumSyncIntervalCount=$(echo "$currentMaximumSyncInterval" | awk 'NF' | wc -l | xargs)

  # Ensure there are no duplicate minimum or maximum sync interval entries
  if [[ $currentMinimumSyncIntervalCount -gt 1 ]]; then
    _PRINT_HELP=no die "[Error] Multiple minimum sync intervals were found. Please correct this before attempting to adjust the minimum sync interval.
$currentMinimumSyncInterval" 1
  fi

  if [[ $currentMaximumSyncIntervalCount -gt 1 ]]; then
    _PRINT_HELP=no die "[Error] Multiple maximum sync intervals were found. Please correct this before attempting to adjust the minimum sync interval.
$currentMaximumSyncInterval" 1
  fi

  # Validate the relationship between minimum and maximum sync intervals
  if [[ -n $currentMaximumSyncInterval && -z $_arg_syncIntervalMaximumInSeconds ]]; then
    currentMaxValue=$(echo "$currentMaximumSyncInterval" | awk -F "=" '{ print $2 }')

    if [[ currentMaxValue -lt $_arg_syncIntervalMinimumInSeconds ]]; then
      _PRINT_HELP=yes die "[Error] The maximum sync interval '$_arg_syncIntervalMaximumInSeconds' is less than the minimum sync interval '$_arg_syncIntervalMinimumInSeconds'. The minimum sync interval must be less than the max sync interval." 1
    fi
  fi

  # Update the minimum sync interval if necessary
  if [[ -n $currentMinimumSyncInterval ]]; then
    if ! echo "$currentMinimumSyncInterval" | grep -w "$_arg_syncIntervalMinimumInSeconds" 1> /dev/null 2> /dev/null; then
      currentSyncValue=$(echo "$_arg_syncIntervalMinimumInSeconds" | awk -F "=" '{ print $2 }')

      echo "Updating the minimum sync interval from '$currentSyncValue' to '$_arg_syncIntervalMinimumInSeconds' seconds."
      filePath=${currentMinimumSyncInterval//:*/}
      timestamp=$(date "+%s")
      updatedFilePath="/etc/systemd/timesyncd.conf.d/$timestamp-minimum-syncinterval.conf"

      # Save the updated configuration file
      echo "Saving the updated configuration file to '$updatedFilePath'."
      sed "s/^PollIntervalMinSec=.*/PollIntervalMinSec=$_arg_syncIntervalMinimumInSeconds/g" "$filePath" > "$updatedFilePath"

      # Validate the creation of the updated file
      if [[ ! -s $updatedFilePath ]]; then
        _PRINT_HELP=no die "[Error] Failed to create the file '$updatedFilePath'. This is needed to adjust the minimum sync interval." 1
      fi

      # Backup the old configuration file
      echo "Backing up the old configuration file to '$filePath.old'."
      if ! mv "$filePath" "$filePath.old"; then
        _PRINT_HELP=no die "[Error] Failed to remove the old config file at '$filePath'. This is needed to adjust the minimum sync interval." 1
      fi

      echo "Successfully updated the minimum sync interval."
    else
      echo "The current minimum sync interval is already set to '$_arg_syncIntervalMinimumInSeconds' seconds."
    fi
  fi

  # Create a new configuration file if no minimum sync interval exists
  if [[ -z $currentMinimumSyncInterval ]]; then
    echo "Setting the minimum sync interval to '$_arg_syncIntervalMinimumInSeconds'."
    timestamp=$(date "+%s")
    minIntervalFilePath="/etc/systemd/timesyncd.conf.d/$timestamp-minimum-syncinterval.conf"

    echo "Creating the configuration file at '$minIntervalFilePath'."
    {
      echo "[Time]"
      echo "#NTP="
      echo "#FallbackNTP=ntp.ubuntu.com"
      echo "#RootDistanceMaxSec=5"
      echo "PollIntervalMinSec=$_arg_syncIntervalMinimumInSeconds"
      echo "#PollIntervalMaxSec=2048"
      echo "#ConnectionRetrySec=30"
      echo "#SaveIntervalSec=60"
    } > "$minIntervalFilePath"

    # Validate the creation of the new file
    if [[ ! -s $minIntervalFilePath ]]; then
      _PRINT_HELP=no die "[Error] Failed to create the file '$minIntervalFilePath'. This is needed to adjust the minimum sync interval." 1
    fi

    echo "Successfully set the minimum sync interval."
  fi

  # Update the list of configuration files and mark the need to restart timedatectl
  timeSyncConfigFiles=$(ls /etc/systemd/timesyncd.conf.d/*.conf 2> /dev/null)
  needToRestartTimedatectl="yes"
  echo ""
  sleep 1
fi

# Check if the maximum sync interval argument is provided
if [[ -n $_arg_syncIntervalMaximumInSeconds ]]; then
  # Retrieve the current maximum sync interval configuration
  currentMaximumSyncInterval=$(grep -w -r -H "^PollIntervalMaxSec" /etc/systemd/timesyncd.conf.d/*.conf)
  currentMaximumSyncInterval=$(echo "$currentMaximumSyncInterval" | grep -v '^$')
  currentMaximumSyncIntervalCount=$(echo "$currentMaximumSyncInterval" | awk 'NF' | wc -l | xargs)

  # Retrieve the current minimum sync interval configuration
  currentMinimumSyncInterval=$(grep -w -r -H "^PollIntervalMinSec" /etc/systemd/timesyncd.conf.d/*.conf)
  currentMinimumSyncInterval=$(echo "$currentMinimumSyncInterval" | grep -v '^$')
  currentMinimumSyncIntervalCount=$(echo "$currentMinimumSyncInterval" | awk 'NF' | wc -l | xargs)

  # Ensure there are no duplicate maximum sync interval entries
  if [[ $currentMaximumSyncIntervalCount -gt 1 ]]; then
    _PRINT_HELP=no die "[Error] Multiple maximum sync intervals were found. Please correct this before attempting to adjust the maximum sync interval.
$currentMaximumSyncInterval" 1
  fi

  # Ensure there are no duplicate minimum sync interval entries
  if [[ $currentMinimumSyncIntervalCount -gt 1 ]]; then
    _PRINT_HELP=no die "[Error] Multiple minimum sync intervals were found. Please correct this before attempting to adjust the maximum sync interval.
$currentMinimumSyncInterval" 1
  fi

  # Validate the relationship between minimum and maximum sync intervals
  if [[ -n $currentMinimumSyncInterval && -z $_arg_syncIntervalMinimumInSeconds ]]; then
    currentMinValue=$(echo "$currentMinimumSyncInterval" | awk -F "=" '{ print $2 }')

    if [[ currentMinValue -gt $_arg_syncIntervalMaximumInSeconds ]]; then
      _PRINT_HELP=yes die "[Error] The maximum sync interval '$_arg_syncIntervalMaximumInSeconds' is less than the minimum sync interval '$_arg_syncIntervalMinimumInSeconds'. The minimum sync interval must be less than the max sync interval." 1
    fi
  fi

  # Update the maximum sync interval if necessary
  if [[ -n $currentMaximumSyncInterval ]]; then
    if ! echo "$currentMaximumSyncInterval" | grep -w "$_arg_syncIntervalMaximumInSeconds" 1> /dev/null 2> /dev/null; then
      currentSyncValue=$(echo "$_arg_syncIntervalMaximumInSeconds" | awk -F "=" '{ print $2 }')

      echo "Updating the maximum sync interval from '$currentSyncValue' to '$_arg_syncIntervalMaximumInSeconds' seconds."
      filePath=${currentMaximumSyncInterval//:*/}
      timestamp=$(date "+%s")
      updatedFilePath="/etc/systemd/timesyncd.conf.d/$timestamp-maximum-syncinterval.conf"

      # Save the updated configuration file
      echo "Saving the updated configuration file to '$updatedFilePath'."
      sed "s/^PollIntervalMaxSec=.*/PollIntervalMaxSec=$_arg_syncIntervalMaximumInSeconds/g" "$filePath" > "$updatedFilePath"

      # Validate the creation of the updated file
      if [[ ! -s $updatedFilePath ]]; then
        _PRINT_HELP=no die "[Error] Failed to create the file '$updatedFilePath'. This is needed to adjust the maximum sync interval." 1
      fi

      # Backup the old configuration file
      echo "Backing up the old configuration file to '$filePath.old'."
      if ! mv "$filePath" "$filePath.old"; then
        _PRINT_HELP=no die "[Error] Failed to remove the old config file at '$filePath'. This is needed to adjust the maximum sync interval." 1
      fi

      echo "Successfully updated the maximum sync interval."
    else
      echo "The current maximum sync interval is already set to '$_arg_syncIntervalMaximumInSeconds' seconds."
    fi
  fi

  # Create a new configuration file if no maximum sync interval exists
  if [[ -z $currentMaximumSyncInterval ]]; then
    echo "Setting the maximum sync interval to '$_arg_syncIntervalMaximumInSeconds'."
    timestamp=$(date "+%s")
    maxIntervalFilePath="/etc/systemd/timesyncd.conf.d/$timestamp-maximum-syncinterval.conf"

    echo "Creating the configuration file at '$maxIntervalFilePath'."
    {
      echo "[Time]"
      echo "#NTP="
      echo "#FallbackNTP=ntp.ubuntu.com"
      echo "#RootDistanceMaxSec=5"
      echo "#PollIntervalMinSec=32"
      echo "PollIntervalMaxSec=$_arg_syncIntervalMaximumInSeconds"
      echo "#ConnectionRetrySec=30"
      echo "#SaveIntervalSec=60"
    } > "$maxIntervalFilePath"

    # Validate the creation of the new file
    if [[ ! -s $maxIntervalFilePath ]]; then
      _PRINT_HELP=no die "[Error] Failed to create the file '$maxIntervalFilePath'. This is needed to adjust the maximum sync interval." 1
    fi

    echo "Successfully set the maximum sync interval."
  fi

  # Update the list of configuration files and mark the need to restart timedatectl
  timeSyncConfigFiles=$(ls /etc/systemd/timesyncd.conf.d/*.conf 2> /dev/null)
  needToRestartTimedatectl="yes"
  echo ""
  sleep 1
fi

# Restart timedatectl if necessary
if [[ $needToRestartTimedatectl == "yes" ]]; then
  # Enable NTP if it is currently disabled
  if timedatectl show | grep -w "NTP" | grep "no" 1> /dev/null; then
    echo "Turning on NTP."
    if ! timedatectl set-ntp true; then
      _PRINT_HELP=no die "[Error] Failed to turn on NTP." 1
    fi
  fi

  # Restart the systemd-timesyncd service to apply changes
  if ! systemctl daemon-reload; then
    _PRINT_HELP=no die "[Error] Failed to run 'systemctl daemon-reload'." 1
  fi

  if ! systemctl restart systemd-timesyncd; then
    _PRINT_HELP=no die "[Error] Failed to restart 'systemd-timesyncd'." 1
  fi
fi

echo "### Previous timedatectl Settings ###"
echo "Timezone: $previousTimeZone"
echo "$previousTimeSettings"

echo ""

if ! currentTimeZone=$(timedatectl show | grep "Timezone=" | sed "s/Timezone=//" | sed "s/^[[:space:]]*//g" | sed "s/[[:space:]]*$//g"); then
  echo "[Error] Failed to retrieve the current timezone." >&2
  exitCode=1
fi

echo "### Current timedatectl Settings ###"
echo "Timezone: $currentTimeZone"
if ! currentConfig=$(systemd-analyze cat-config systemd/timesyncd.conf --no-pager); then
  echo "[Error] Failed to retrieve the current time settings." >&2
  exitCode=1
fi
echo "$currentConfig" | awk '/^# \/.*\.conf/ { print; next } /^\s*#/ { next } { print }'

echo ""

echo "### Current Time ###"
if ! date "+%x %r"; then
  echo "[Error] Failed to retrieve the current time." >&2
  exitCode=1
fi

exit "$exitCode"

 

Detailed Breakdown

Here’s what the script does, step by step.

  1. Argument parsing and help
    • Supports:
      –listTimeZones|-l
      –setTimeZone|-t <tz>
      –setSyncServers|-s <server[,server]>
      –syncIntervalMinimumInSeconds|–min <sec>
      –syncIntervalMaximumInSeconds|–max <sec>
    • Shows a concise usage menu and validates combinations (e.g., you can’t use –listTimeZones with other options).
  2. Environment and prerequisites
    • Requires root (id -u check).
    • Requires timedatectl and systemd-timesyncd. If systemd-timesyncd is disabled, it exits with guidance.
    • If NTP-related options are used, verifies the host supports NTP via timedatectl show | CanNTP=.
  3. Input validation
    • Time zone: validates against timedatectl list-timezones.
    • NTP servers: accepts up to two, allows only safe characters, and live-checks UDP/123 reachability using a /dev/udp probe with a timeout.
    • Intervals: numeric, 1–86400 seconds, and ensures min ≤ max.
  4. Discovery of current state
    • Gathers the effective timesyncd configuration with systemd-analyze cat-config systemd/timesyncd.conf, strips comments, and stores a “previous settings” snapshot for before/after reporting.
  5. Time zone changes
    • If requested, calls timedatectl set-timezone <tz> and confirms the result. If already set, it reports that and moves on.
  6. Configuration layering
    • Uses /etc/systemd/timesyncd.conf.d/ drop-ins instead of editing /etc/systemd/timesyncd.conf in place.
    • If the default file exists and differs from desired state, marks that an update is required, then writes a new, timestamped drop-in, e.g., /etc/systemd/timesyncd.conf.d/1752702365-ntp.conf.
  7. Selective updates
    • Updates primary NTP, fallback NTP, PollIntervalMinSec, and PollIntervalMaxSec individually if they exist; otherwise creates specific drop-ins for each.
    • When updating, it writes a new file, then backs up the old one as .old, avoiding fragile in-place edits.
  8. Apply and report
    • Ensures set-ntp true if NTP was disabled.
    • Runs systemctl daemon-reload && systemctl restart systemd-timesyncd.
    • Prints a before/after configuration diff-style view and the current time, so you have immediate confirmation.

Potential Use Cases

Case study: An MSP onboards a 500-node manufacturing client across three plants. Devices were imaged with a generic UTC zone and inconsistent NTP. Kerberos tickets and TLS validation intermittently fail. The engineer pushes this shell script to set the time zone in Linux and NTP timedatectl settings via their RMM with per-site parameters:

  • Site A: –setTimeZone “America/Chicago” –setSyncServers “0.pool.ntp.org,1.pool.ntp.org” –min 32 –max 2048
  • Site B uses a corporate NTP pair; Site C uses regional pool servers.

Within minutes, authentication failures disappear and SIEM correlation is clean.

Comparisons

  • systemd-timesyncd vs chrony: chrony is feature-rich and popular for servers needing advanced controls (slew rates, sources, statistics). For endpoints and many servers, systemd-timesyncd is lightweight and “good enough,” especially when standardized with timedatectl.
  • Editing /etc/localtime manually vs timedatectl set-timezone: Direct symlink edits work but are brittle at scale; timedatectl is declarative and auditable.
  • Editing /etc/systemd/timesyncd.conf directly vs drop-ins: Drop-ins are safer, survive package updates, and are easier to diff/rollback—this script follows best practice.

Implications for Security and Reliability

Correct time underpins Kerberos, JWT/SSO token validity, certificate verification, and log correlation. Misaligned clocks create false positives in SIEMs, fail compliance checks, and weaken forensic timelines. Standardizing NTP, PollIntervalMinSec, and PollIntervalMaxSec reduces drift and speeds convergence after outages.

Recommendations

  • Parameterize by site or tenant: Keep time zones and NTP servers aligned with geography and policy.
  • Use internal NTP first: Prefer corporate stratum servers; fall back to pool servers.
  • Keep intervals sane: Defaults (32–2048 seconds) are reasonable for most environments; tighten only with a clear need.
  • Document and store artifacts: Retain the script’s before/after outputs in your CMDB or ticket.
  • Run as root in maintenance windows: Restarting systemd-timesyncd is low risk but still a service change.
  • Monitor: After rollout, verify timedatectl show across a sample to confirm NTP=yes and correct peers.

Final Thoughts

For IT teams standardizing Linux estates, this shell script to set the time zone and NTP timedatectl settings in Linuxoffers an auditable, idempotent approach that plays nicely with modern distros. Paired with NinjaOne, you can deploy it as a script policy, feed site-specific parameters through Custom Fields, schedule periodic compliance checks, and alert on drift. The result is predictable time across your fleet—and fewer mysteries in your logs.

]]>
How to Set the Time Zone in macOS Using Shell Scripting https://www.ninjaone.com/script-hub/set-the-time-zone-in-macos-using-shell-scripting/ Thu, 18 Sep 2025 21:36:01 +0000 https://www.ninjaone.com/?post_type=script_hub&p=528296 This guide will teach you how to set the time zone in macOS using shell scripting. Accurate system time is critical for IT professionals and Managed Service Providers (MSPs). Whether it’s ensuring proper authentication, synchronizing logs, or maintaining compliance with regulatory requirements, time synchronization is the backbone of reliable IT infrastructure.

On macOS devices, managing time zones and synchronization settings can be tedious if done manually across large fleets. That’s where shell scripting comes into play. This blog post explores a shell script that automates time zone and synchronization management for macOS, providing IT teams with a scalable and reliable solution.

Background

Time configuration might appear to be a minor system setting, but it has far-reaching implications. Incorrect time zones can lead to issues with Kerberos authentication, misaligned logs, and broken scheduling services. For MSPs managing hundreds of endpoints, manual intervention is inefficient and error-prone. The provided shell script simplifies these tasks by automating:

  • Listing available time zones
  • Setting a specific time zone
  • Enabling or disabling automatic time zone detection
  • Configuring a custom network time synchronization server
  • Forcing an immediate time sync

This makes it a versatile tool for IT professionals who need consistency across environments.

The Script

#!/usr/bin/env bash
#
# Description: This script sets the time zone and synchronization settings for macOS.
# By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
# Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
# Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
# Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
# Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
# Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
# Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
# EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
#
# Preset Parameter: --listTimeZones
#   List all time zones available on the system.
#
# Preset Parameter: --setTimeZone
#   Specify a time zone to set. Use the "List Time Zones" option to get a list of available time zones. If the automatic time zone setting is enabled, it will be disabled. Example Time Zone: America/New_York
#
# Preset Parameter: --setTimeZoneAutomatically
#   Set the time zone automatically based on the physical location of the device. Valid values are "Enable" or "Disable". Enabling will also enable Location Services and Wi-Fi if they are not already enabled. Leave blank to not modify the setting.
#
# Preset Parameter: --setSyncServer
#   Specify the server to use for time synchronization. Only one server can be set. Example: time.apple.com
#
# Preset Parameter: --syncNow
#   Sync time after updating settings for the time sync service.
#
# Minimum OS Architecture Supported: macOS 12+
# Version: 1.0
# Release Notes: Initial Release

_arg_listTimeZones=false
_arg_setTimeZone=""
_arg_setTimeZoneAutomatically=""
_arg_setSyncServer=""
_arg_syncNow=false

die() {
    local _ret="${2:-1}"
    echo "$1" >&2
    test "${_PRINT_HELP:-no}" = yes && print_help >&2
    exit "${_ret}"
}

print_help() {
    printf '\n\n%s\n\n' 'Usage: [--listTimeZones] [--setTimeZone <timezone>] [--setTimeZoneAutomatically <Enable|Disable>] [--setSyncServer <server>] [--syncNow]'
    printf '%s\n' 'Preset Parameter: --listTimeZones'
    printf '\t%s\n' 'List all time zones available on the system.'
    printf '%s\n' 'Preset Parameter: --setTimeZone'
    printf '\t%s\n' 'Specify a time zone to set. Use the "List Time Zones" option to get a list of available time zones. If the automatic time zone setting is enabled, it will be disabled. Example Time Zone: America/New_York'
    printf '%s\n' 'Preset Parameter: --setTimeZoneAutomatically'
    printf '\t%s\n' 'Set the time zone automatically based on the physical location of the device. Valid values are "Enable" or "Disable". Enabling will also enable Location Services and Wi-Fi if they are not already enabled. Leave blank to not modify the setting.'
    printf '%s\n' 'Preset Parameter: --setSyncServer'
    printf '\t%s\n' 'Specify the server to use for time synchronization. Only one server can be set. Example: time.apple.com'
    printf '%s\n' 'Preset Parameter: --syncNow'
    printf '\t%s\n' 'Sync time after updating settings for the time sync service.'
}

parse_commandline() {
    while test $# -gt 0; do
        _key="$1"
        case "$_key" in
        --listTimeZones)
            _arg_listTimeZones=true
            shift
            ;;
        --setTimeZone)
            test $# -lt 2 && die "[Error] Missing value for the optional argument '$_key'." 1
            _arg_setTimeZone="$2"
            shift 2
            ;;
        --setTimeZone=*)
            _arg_setTimeZone="${_key##--setTimeZone=}"
            ;;
        --setTimeZoneAutomatically)
            test $# -lt 2 && die "[Error] Missing value for the optional argument '$_key'." 1
            _arg_setTimeZoneAutomatically="$2"
            shift 2
            ;;
        --setTimeZoneAutomatically=*)
            _arg_setTimeZoneAutomatically="${_key##--setTimeZoneAutomatically=}"
            ;;
        --setSyncServer)
            test $# -lt 2 && die "[Error] Missing value for the optional argument '$_key'." 1
            _arg_setSyncServer="$2"
            shift 2
            ;;
        --setSyncServer=*)
            _arg_setSyncServer="${_key##--setSyncServer=}"
            ;;
        --syncNow)
            _arg_syncNow=true
            shift
            ;;
        --syncNow=*)
            _arg_syncNow="${_key##--syncNow=}"
            ;;
        --help | -h)
            _PRINT_HELP=yes die
            ;;
        *)
            _PRINT_HELP=yes die "[Error] Got an unexpected argument '$1'" 1
            ;;
        esac
    done
}

# Function to get a list of available time zones
function GetTimeZoneList() {
    local timezones
    if timezones=$(systemsetup -listtimezones | grep -v "Time Zone" | awk '{print $1}') && [ -z "$timezones" ]; then
        echo "[Error] No time zones found." 1>&2
        return 1
    fi

    echo "[Info] Available Time Zones:"
    echo "$timezones"
}

# Function to test if location services is enabled
function TestLocationServices() {
    # Check if location services are enabled
    if ! value=$(defaults read /var/db/locationd/Library/Preferences/ByHost/com.apple.locationd LocationServicesEnabled 2>/dev/null); then
        return 1
    elif [[ "$value" -eq 1 ]]; then
        return 0
    else
        return 1
    fi
}

# Function to get the wifi adapter name
function GetWifiAdapter() {
    local adapter
    if ! adapter=$(networksetup -listallhardwareports | awk -F: '/Wi-Fi/{getline; print $2;}' | xargs); then
        return 1
    fi

    echo "$adapter"
}

# Function to test if the given wifi adapter is turned on
function TestWifiAdapter() {
    local wifiAdapter=$1

    wifiStatus=$(networksetup -getairportpower "$wifiAdapter")

    if [[ "$wifiStatus" =~ "Off" ]]; then
        return 1
    else
        return 0
    fi
}

# Function to verify if the requirements for automatic time zone setting are met
function VerifyAutomaticTimeZoneRequirements() {
    local wifiAdapter
    # Check if location services are enabled
    if ! TestLocationServices; then
        autoTZBrokenReason="Location Services disabled"
        return 1
    fi

    # Get the Wi-Fi adapter
    if ! wifiAdapter=$(GetWifiAdapter); then
        autoTZBrokenReason="Wi-Fi adapter not found"
        return 1
    fi

    # Check if the Wi-Fi adapter is available and enabled
    if ! TestWifiAdapter "$wifiAdapter"; then
        autoTZBrokenReason="Wi-Fi adapter not enabled"
        return 1
    fi

    return 0
}

# Function to set the automatic time zone setting
function SetAutomaticTimeZone() {
    local action="$1"
    local wifiAdapter

    if [ -z "$action" ]; then
        echo "[Error] No action specified. Use 'Enable' or 'Disable'."
        return 1
    fi

    if [ "$action" != "Enable" ] && [ "$action" != "Disable" ]; then
        echo "[Error] Invalid action specified. Use 'Enable' or 'Disable'."
        return 1
    fi

    # If enabling automatic time zone, we need to ensure that location services and Wi-Fi are enabled
    if [ "$action" = "Enable" ]; then
        # Check if location services are enabled
        # If not enabled, enable it
        if ! TestLocationServices; then
            echo "[Info] Enabling location services..."
            if ! defaults write /var/db/locationd/Library/Preferences/ByHost/com.apple.locationd LocationServicesEnabled -int 1; then
                echo "[Error] Failed to enable location services."
                return 1
            fi
            echo "[Info] Successfully enabled location services."
        fi

        # Get the Wi-Fi adapter
        if ! wifiAdapter=$(GetWifiAdapter); then
            echo "[Error] No Wi-Fi adapter found. Automatic time zone setting requires Wi-Fi."
            return 1
        fi

        # Check if the Wi-Fi adapter is available and enabled
        # If not enabled, enable it
        if ! TestWifiAdapter "$wifiAdapter"; then
            echo "[Info] Enabling Wi-Fi..."
            if ! networksetup -setairportpower "$wifiAdapter" on; then
                echo "[Error] Failed to enable Wi-Fi."
                return 1
            fi
            echo "[Info] Successfully enabled Wi-Fi."
        fi

        # Clear the autoTZBrokenReason variable in case it was set previously
        autoTZBrokenReason=""
    fi

    # Enable or disable automatic time zone
    if [ "$action" = "Enable" ]; then
        # Set the plist file to enable automatic time zone
        if ! defaults write /private/var/db/timed/Library/Preferences/com.apple.timed.plist TMAutomaticTimeZoneEnabled -bool YES; then
            echo "[Error] Failed to enable automatic time zone setting."
            return 1
        fi
    else
        if ! defaults write /private/var/db/timed/Library/Preferences/com.apple.timed.plist TMAutomaticTimeZoneEnabled -bool NO; then
            echo "[Error] Failed to disable automatic time zone setting."
            return 1
        fi
    fi

    # Modifying the plist file will change the ownership, so we need to set it back to the _timed user
    if ! chown "_timed:_timed" /private/var/db/timed/Library/Preferences/com.apple.timed.plist; then
        echo "[Error] Failed to set ownership of the plist file."
        return 1
    fi

    # Reset the locationd service to apply changes
    if ! killall locationd; then
        echo "[Error] Failed to restart locationd service."
        return 1
    fi
}

# Function to restart the network time service
function RestartNetworkTimeService() {
    # Restart the time server
    if ! systemsetup -setusingnetworktime off 2>/dev/null 1>/dev/null; then
        echo "[Error] Failed to stop time server."
        return 1
    fi
    sleep 1
    if ! systemsetup -setusingnetworktime on 2>/dev/null 1>/dev/null; then
        echo "[Error] Failed to start time server."
        return 1
    fi
}

# Function to set the time zone
function SetTimeZone() {
    local timezone="$1"
    if [ -z "$timezone" ]; then
        echo "[Error] No time zone specified."
        return 1
    fi

    # Check if the time zone contains invalid characters
    if ! [[ "$timezone" =~ ^[a-zA-Z0-9/_]+$ ]]; then
        echo "[Error] Time Zone contains invalid characters. Only alphanumeric characters, underscores, and forward slashes are allowed."
        return 1
    fi

    # Check if the time zone is valid
    if ! systemsetup -listtimezones 2>/dev/null | grep -q "$timezone"; then
        echo "[Error] Invalid time zone: $timezone"
        return 1
    fi

    # If there is no reason for the automatic time zone being broken, enable it to see what the automatic time zone would be
    # Then, warn if the automatic time zone is different than the requested time zone
    if [ -z "$autoTZBrokenReason" ]; then
        if SetAutomaticTimeZone "Enable"; then
            # Wait for the automatic time zone to be set
            sleep 10
            autoTimeZone=$(systemsetup -gettimezone | awk '{print $3}')
            if [ "$autoTimeZone" != "$timezone" ]; then
                echo "[Warning] The automatic time zone for this device would be '$autoTimeZone' but the selected time zone is '$timezone'."
                echo "[Info] If you want to use the automatic time zone, please enable the 'Set Time Zone Automatically' option."
            fi

            # If we are not disabling the automatic time zone later, disable it here so we can set the time zone
            if [ "$_arg_setTimeZoneAutomatically" != "Disable" ]; then
                # Print this message only if the automatic time zone was enabled at script runtime
                if [ "$autoTimeZoneBeforeChange" = "1" ]; then
                    echo "[Info] Disabling the automatic time zone setting so that we can set the specified time zone."
                fi
                SetAutomaticTimeZone "Disable"
            fi
        fi
    fi

    # Set the time zone
    if systemsetup -settimezone "$timezone" 2>/dev/null 1>/dev/null; then
        return 0
    else
        echo "[Error] Failed to set time zone."
        return 1
    fi
}

# Function to set the sync server
function SetSyncServer() {
    local server="$1"

    # Error if a server was not provided
    if [ -z "$server" ]; then
        echo "[Error] No sync server specified."
        return 1
    fi

    # Check if the server responds on UDP 123 (NTP port)
    if ! nc -z -u "$server" 123 > /dev/null 2>&1; then
        echo "[Error] The server at '$server' did not respond on UDP port 123."
        return 1
    fi

    # Check if network time is enabled and enable it if not
    if systemsetup -getusingnetworktime 2>/dev/null | grep -q "Off"; then
        echo "[Info] Network time is currently disabled. Enabling it now."

        if systemsetup -setusingnetworktime on 2>/dev/null 1>/dev/null; then
            echo "[Info] Network time enabled."
        else
            echo "[Error] Failed to enable network time."
            return 1
        fi
    fi

    # Set the sync server
    if systemsetup -setnetworktimeserver "$server" 2>/dev/null 1>/dev/null; then
        return 0
    else
        echo "[Error] Failed to set sync server."
        return 1
    fi
}

# Function to sync the time immediately
function SyncNow() {
    # Look for the sntp or ntpdate command to synchronize time
    if command -v sntp >/dev/null; then
        if sntp -sS "$(systemsetup -getnetworktimeserver | awk '{print $4}')" 2>/dev/null 1>/dev/null; then
            return 0
        else
            return 1
        fi
    elif command -v ntpdate >/dev/null; then
        if ntpdate -u "$(systemsetup -getnetworktimeserver | awk '{print $4}')" 2>/dev/null 1>/dev/null; then
            return 0
        else
            return 1
        fi
    else
        echo "[Error] No time sync command found. Either sntp or ntpdate should be available."
        return 1
    fi
}

# Execute command line parsing function with the passed arguments
parse_commandline "$@"

# If environment variables for the script parameters are set, override the command line argument values
if [[ $listTimeZones == "true" ]]; then
    _arg_listTimeZones=true
fi
if [[ -n $setTimeZone ]]; then
    _arg_setTimeZone=$(echo "$setTimeZone" | xargs) # Remove leading/trailing whitespace
fi
if [[ -n $setTimeZoneAutomatically ]]; then
    _arg_setTimeZoneAutomatically=$(echo "$setTimeZoneAutomatically")
fi
if [[ -n $setSyncServer ]]; then
    _arg_setSyncServer=$(echo "$setSyncServer" | xargs) # Remove leading/trailing whitespace
fi
if [[ $syncNow == "true" ]]; then
    _arg_syncNow=true
fi

# Error if no arguments are provided
if [[ $_arg_listTimeZones == false && -z "$_arg_setTimeZone" && -z "$_arg_setSyncServer" && $_arg_syncNow == false && -z "$_arg_setTimeZoneAutomatically" ]]; then
    _PRINT_HELP=yes die "[Error] No arguments provided. At least one argument is required to run this script." 1
fi

# Check if the script is being run as root. If not, exit with an error message.
if [[ $(id -u) -ne 0 ]]; then
    _PRINT_HELP=no die "[Error] This script must be run with root permissions. Try running it with sudo or as the system/root user." 1
fi

# Initialize the exit code
exitCode=0

# List time zones if requested
if [[ $_arg_listTimeZones == true ]]; then
    if [[ -n "$_arg_setTimeZone" ]] || [[ -n "$_arg_setSyncServer" ]] || [[ $_arg_syncNow == true ]] || [[ -n "$_arg_setTimeZoneAutomatically" ]]; then
        echo "[Error] The 'Set Time Zone', 'Set Time Zone Automatically', 'Set Sync Server', and 'Sync Now' options cannot be used together with 'List Time Zones'."
        echo ""
        exitCode=1
    fi
    if ! GetTimeZoneList; then
        echo "[Error] Failed to retrieve time zone list."
        exitCode=1
    fi
    exit $exitCode
fi

# Error if an invalid value is provided for setTimeZoneAutomatically
if [[ $_arg_setTimeZoneAutomatically && ! $_arg_setTimeZoneAutomatically =~ ^(Enable|Disable)$ ]]; then
    _PRINT_HELP=yes die "[Error] Invalid value for 'Set Time Zone Automatically'. Use 'Enable' or 'Disable'." 1
fi

# Error if setTimeZone is provided and setTimeZoneAutomatically is set to Enable
if [[ $_arg_setTimeZone && $_arg_setTimeZoneAutomatically = "Enable" ]]; then
    _PRINT_HELP=yes die "[Error] You cannot enable automatic time zones and specify a time zone at the same time." 1
fi

# Check if setSyncServer has more than one server
if [ "$(
    echo "$_arg_setSyncServer" |
        tr ',' ' ' |  # Replace commas with spaces
        tr ' ' '\n' | # Split into lines
        wc -l         # Count lines
)" -gt 1 ]; then
    die "[Error] Multiple time sync servers are not supported. Please provide a single server." 1
fi

# Initiate variable to track if settings are changed
_settingsChanged=false

# Get the current time zone and sync server settings
if ! syncServerBeforeChange=$(systemsetup -getnetworktimeserver | awk '{print $4}'); then
    die "[Error] Failed to retrieve current sync server." 1
fi

if ! timeZoneBeforeChange=$(systemsetup -gettimezone | awk '{print $3}'); then
    die "[Error] Failed to retrieve current time zone." 1
fi

if ! autoTimeZoneBeforeChange=$(defaults read /private/var/db/timed/Library/Preferences/com.apple.timed.plist TMAutomaticTimeZoneEnabled 2>/dev/null); then
    autoTimeZoneBeforeChange=0 # Default to 0 if the key does not exist
fi

# Check if automatic time zone is broken so that autoTZBrokenReason can be set
VerifyAutomaticTimeZoneRequirements

echo ""

# Set the time zone if specified
if [ -n "$_arg_setTimeZone" ]; then
    if [ "$timeZoneBeforeChange" = "$_arg_setTimeZone" ]; then
        echo "[Info] Time zone is already set to '$_arg_setTimeZone'."
    else
        echo "[Info] Changing the time zone from '$timeZoneBeforeChange' to '$_arg_setTimeZone'."

        if SetTimeZone "$_arg_setTimeZone"; then
            echo "[Info] Successfully changed the time zone from '$timeZoneBeforeChange' to '$_arg_setTimeZone'."
            _settingsChanged=true
        else
            echo "[Error] Failed to set the time zone to '$_arg_setTimeZone'."
            exitCode=1
        fi
    fi
    echo ""
fi

# Set automatic time zone if specified
if [ -n "$_arg_setTimeZoneAutomatically" ]; then
    currentValue=$(defaults read /private/var/db/timed/Library/Preferences/com.apple.timed.plist TMAutomaticTimeZoneEnabled 2>/dev/null)

    # If a reason for automatic time zone being broken is set, we will force the change
    if [ -n "$autoTZBrokenReason" ]; then
        forceChange=true
    fi

    if [ "$_arg_setTimeZoneAutomatically" = "Enable" ]; then
        if [ "$currentValue" = "1" ] && [ "$forceChange" != true ]; then
            echo "[Info] The automatic time zone setting is already enabled."
        else
            echo "[Info] Enabling the automatic time zone setting."

            # Enable automatic time zone setting
            if SetAutomaticTimeZone "Enable"; then
                sleep 10
                echo "[Info] Successfully enabled the automatic time zone setting."
                _settingsChanged=true
            else
                echo "[Error] Failed to enable the automatic time zone setting."
                exitCode=1
            fi
        fi
    elif [ "$_arg_setTimeZoneAutomatically" = "Disable" ]; then
        if [ "$currentValue" = "0" ] || [ -z "$currentValue" ]; then
            echo "[Info] The automatic time zone setting is already disabled."
        else
            echo "[Info] Disabling the automatic time zone setting."

            # Disable automatic time zone setting
            if SetAutomaticTimeZone "Disable"; then
                echo "[Info] Successfully disabled the automatic time zone setting."
                _settingsChanged=true
            else
                echo "[Error] Failed to disable the automatic time zone setting."
                exitCode=1
            fi
        fi
    fi
    echo ""
fi

# Set the sync server if specified
if [ -n "$_arg_setSyncServer" ]; then
    if [ "$syncServerBeforeChange" = "$_arg_setSyncServer" ]; then
        echo "[Info] Time sync server is already set to '$_arg_setSyncServer'."
    else
        echo "[Info] Changing the time sync server from '$syncServerBeforeChange to '$_arg_setSyncServer'."

        if SetSyncServer "$_arg_setSyncServer"; then
            echo "[Info] Successfully changed the time sync server from '$syncServerBeforeChange' to '$_arg_setSyncServer'."
            _settingsChanged=true
        else
            echo "[Error] Failed to set the sync server to '$_arg_setSyncServer'."
            exitCode=1
        fi
    fi
    echo ""
fi

# If settings were changed, restart the time service and print the previous settings
if [ "$_settingsChanged" = true ]; then
    echo "[Info] Settings have been changed. Restarting the time service to apply changes."

    # Restart the time service to apply changes
    if ! RestartNetworkTimeService; then
        echo "[Error] Failed to restart the time service."
        exitCode=1
    else
        echo "[Info] Time service restarted successfully."
    fi

    echo ""
    echo "*** Previous Time Sync Settings: ***"
    if [ -n "$timeZoneBeforeChange" ]; then
        echo "Time Zone: $timeZoneBeforeChange"
    fi
    if [ -n "$syncServerBeforeChange" ]; then
        echo "Sync Server: $syncServerBeforeChange"
    fi

    if [ -n "$autoTimeZoneBeforeChange" ]; then
        if [ "$autoTimeZoneBeforeChange" = "0" ]; then
            echo "Automatic Time Zone: Disabled"
        else
            if [ -n "$autoTZBrokenReason" ]; then
                echo "Automatic Time Zone: Enabled but not working (Reason: $autoTZBrokenReason)"
            else
                echo "Automatic Time Zone: Enabled"
            fi
        fi
    fi
fi

echo ""

# Retrieve the current time sync settings
echo "*** Current Time Sync Settings: ***"
if ! currentTimeZone=$(systemsetup -gettimezone | awk '{print $3}'); then
    echo "[Error] Failed to retrieve current time zone."
    exitCode=1
fi

if ! currentSyncServer=$(systemsetup -getnetworktimeserver | awk '{print $4}'); then
    echo "[Error] Failed to retrieve current sync server."
    exitCode=1
fi

if ! currentAutoTimeZone=$(defaults read /private/var/db/timed/Library/Preferences/com.apple.timed.plist TMAutomaticTimeZoneEnabled 2>/dev/null); then
    currentAutoTimeZone=0 # Default to 0 if the key does not exist
fi

# Print the current settings
if [ -n "$currentTimeZone" ]; then
    echo "Time Zone: $currentTimeZone"
fi
if [ -n "$currentSyncServer" ]; then
    echo "Sync Server: $currentSyncServer"
fi

if [ -n "$currentAutoTimeZone" ]; then
    if [ "$currentAutoTimeZone" = "0" ]; then
        echo "Automatic Time Zone: Disabled"
    else
        if [ -n "$autoTZBrokenReason" ]; then
            echo "Automatic Time Zone: Enabled but not working (Reason: $autoTZBrokenReason)"
        else
            echo "Automatic Time Zone: Enabled"
        fi
    fi
fi

# Sync the time immediately if requested
if [ "$_arg_syncNow" = "true" ]; then
    echo ""
    echo "[Info] Syncing the time with the new settings."
    if ! SyncNow; then
        die "[Error] Failed to synchronize time." 1
    else
        echo "[Info] Time synchronized successfully."
    fi
fi

# Get the current time and print it
currentTime=$(date +"%Y-%m-%d %T")

echo ""
echo "*** Current System Time: ***"
echo "$currentTime"

exit $exitCode

 

Detailed Breakdown

The script begins by parsing command-line arguments, allowing administrators to choose specific operations:

  1. Listing time zones
    The –listTimeZones flag uses systemsetup -listtimezones to output all available macOS time zones.
  2. Setting a time zone
    The –setTimeZone <timezone> parameter validates the requested time zone and applies it using systemsetup -settimezone.
  3. Automatic time zone management
    Using –setTimeZoneAutomatically Enable|Disable, the script toggles macOS’s automatic time zone detection. If enabling, it ensures Location Services and Wi-Fi are active, restarting services as needed.
  4. Setting a synchronization server
    With –setSyncServer <server>, administrators can point macOS to a preferred NTP server. The script validates connectivity by checking UDP port 123.
  5. Immediate synchronization
    The –syncNow option forces synchronization via sntp or ntpdate.

Behind the scenes, the script also safeguards against conflicts (e.g., preventing simultaneous use of –setTimeZone and –setTimeZoneAutomatically Enable), checks for root permissions, and reports both current and previous configurations for auditing.

Potential Use Cases

Imagine an MSP managing remote macOS devices spread across multiple regions. A client reports authentication failures because their laptops show incorrect times. Instead of remote desktop sessions, the MSP deploys this script via their RMM tool. With one command, they enforce –setSyncServer time.apple.com –syncNow across all endpoints, ensuring consistent time settings and resolving authentication issues within minutes.

Comparisons

Traditionally, administrators would manually adjust settings through System Preferences or use standalone commands like systemsetup. While effective for one machine, these methods lack scalability. Configuration management tools like Jamf provide enterprise-level solutions but may be excessive for smaller environments. This script strikes a balance—lightweight, easily deployable, and compatible with RMM tools like NinjaOne.

Implications

Accurate time synchronization directly impacts IT security. Authentication protocols rely on precise timekeeping, and even slight drift can cause failures. For forensic investigations, misaligned logs can complicate timelines and jeopardize compliance. Automating these configurations reduces human error and enforces organizational standards across distributed environments.

Recommendations

  • Test before deployment: Run the script on a pilot group before broad rollout.
  • Use a trusted NTP server: Prefer vendor or corporate NTP servers to ensure reliability.
  • Audit settings regularly: Use the reporting functionality to validate compliance.
  • Document policies: Define when to use automatic vs. manual time zones.

Final Thoughts

Managing time zone and synchronization settings on macOS doesn’t need to be complex. This shell script provides a straightforward, repeatable solution for IT professionals and MSPs who value precision and efficiency. When paired with NinjaOne’s automation and policy deployment capabilities, administrators can push this script fleet-wide, ensuring all devices remain synchronized and compliant without manual intervention. The result is a more secure, consistent, and reliable environment across the enterprise.

]]>
Guide: macOS Internet Speed Test Script for IT Professionals https://www.ninjaone.com/script-hub/macos-internet-speed-test-script-guide/ Mon, 15 Sep 2025 07:44:20 +0000 https://www.ninjaone.com/?post_type=script_hub&p=524867 Reliable connectivity is now a baseline requirement for modern IT operations. When users complain about “slow internet,” support teams need hard data—not hunches—to diagnose problems quickly and to demonstrate whether the issue is local, network-wide, or ISP-related. This post walks through a shell script for macOS Internet Speed Test that leverages Apple’s built-in networkQuality utility, enriches the raw results (latency stats, adapter details), and optionally saves them to NinjaOne custom fields (multiline or WYSIWYG). If you need to complete an Internet Speed Test on macOS with a shell script, this tool does it in a way that’s repeatable, auditable, and friendly to MSP workflows.

Background

Apple introduced networkQuality in macOS Monterey (12.0) to provide service-quality measurements—download and upload throughput, round-trip times, and more. The script you provided wraps this native capability with operational guardrails and high-value automation:

  • Operational checks: Ensures root privileges, verifies networkQuality availability, validates NinjaOne field names, and locates ninjarmm-cli.
  • Data hygiene: Converts JSON to XML (plist) for robust field extraction, calculates jitter and average latency, and captures interface + MAC address.
  • Reporting at scale: Writes results to either a Multiline Custom Field (plain text) or a WYSIWYG Custom Field (rich HTML with cards + a historical table). An append mode supports longitudinal tracking per device.
  • Fleet-friendly throttling: Optionally sleeps a random 1–60 minutes before running, preventing hundreds of endpoints from testing simultaneously.

For IT professionals and MSPs, this combination turns an Internet Speed Test macOS shell script into a repeatable telemetry probe that can populate asset records automatically.

The Script

#!/usr/bin/env bash
#
# Description: Executes an internet speed test using macOS's built-in speed test. https://support.apple.com/en-us/101942
# By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
# Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
# Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
# Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
# Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
# Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
# Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
# EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
#
# Preset Parameter: --multilineCustomField "nameOfAMultilineCustomField"
#		Optionally specify the name of a multiline custom field to save the results.
#
# Preset Parameter: --wysiwygCustomField "nameOfAWYSIWYGCustomField"
#		Optionally specify the name of a WYSIWYG custom field to save the results.
#
# Preset Parameter: --append
#		If saving the results to a multiline or WYSIWYG custom field, append the results instead of overwriting them.
#
# Preset Parameter: --sleepBeforeRunning
#		Sleep for a random duration between 0 and 60 minutes before running the speed test.
#
# Preset Parameter: --help
#		Displays help text.
#
# Version: 1.0
# Release Notes: Initial Release

_arg_multilineCustomField=
_arg_wysiwygCustomField=
_arg_append="off"
_arg_sleepBeforeRunning="off"

# Converts a string input into an HTML table format.
convertToHTMLTable() {
  # Default delimiter is a space
  local _arg_delimiter=" "
  local _arg_inputObject

  # Parse command-line arguments
  while test $# -gt 0; do
    _key="$1"
    case "$_key" in
      --delimiter | -d)
        test $# -lt 2 && echo "[Error] Missing value for the required argument" >&2 && return 1
        _arg_delimiter=$2
        shift
        ;;
      --*)
        echo "[Error] Got an unexpected argument" >&2
        return 1
        ;;
      *)
        _arg_inputObject=$1
        ;;
    esac
    shift
  done

  # If no input object is provided, check for input from stdin
  if [[ -z $_arg_inputObject ]]; then
    if [ -p /dev/stdin ]; then
      _arg_inputObject=$(cat)
    else
      echo "[Error] Missing input object to convert to table" >&2
      return 1
    fi
  fi

  # Escape special HTML characters in the input
  _arg_inputObject=${_arg_inputObject//&/&amp;}
  _arg_inputObject=${_arg_inputObject//</&lt;}
  _arg_inputObject=${_arg_inputObject//>/&gt;}
  _arg_inputObject=${_arg_inputObject//\"/&quot;}
  _arg_inputObject=${_arg_inputObject//\'/&#39;}

  # Start building the HTML table
  local htmlTable="<table>\n"

  # Add the header row by processing the first line of input
  htmlTable+=$(printf '%b' "$_arg_inputObject" | head -n1 | awk -F "$_arg_delimiter" '{
    printf "<tr>"
    for (i=1; i<=NF; i++) {
      printf "<th>%s</th>", $i
    }
    printf "</tr>"
    }')
  htmlTable+="\n"

  # Add the data rows by processing the remaining lines of input
  htmlTable+=$(printf '%b' "$_arg_inputObject" | tail -n +2 | awk -F "$_arg_delimiter" '{
    printf "<tr>"
    for (i=1; i<=NF; i++) {
      printf "<td>%s</td>", $i
    }
    print "</tr>"
    }')
  htmlTable+="\n</table>"

  # Output the final HTML table
  printf '%b' "$htmlTable" '\n'
}

print_help() {
  printf '\n\n%s\n\n' 'Usage: [--multilineCustomField|-m <arg>] [--wysiwygCustomField|-w <arg>] [--append|-a] [--sleepBeforeRunning|-s] [--help|-h]'
  printf '%s\n' 'Preset Parameter: --multilineCustomField "90"'
  printf '\t%s\n' "Optionally specify the name of a multiline custom field to save the results to."
  printf '%s\n' 'Preset Parameter: --wysiwygCustomField "90"'
  printf '\t%s\n' "Optionally specify the name of a multiline custom field to save the results to."
  printf '%s\n' 'Preset Parameter: --append'
  printf '\t%s\n' "If saving the results to a multiline or WYSIWYG custom field, append the results instead of overwriting them."
  printf '%s\n' 'Preset Parameter: --sleepBeforeRunning'
  printf '\t%s\n' "Sleep for a random duration between 0 and 60 minutes before running the speed test."
  printf '%s\n' 'Preset Parameter: --help'
  printf '\t%s\n' "Displays this help menu."
}

die() {
  local _ret="${2:-1}"
  echo "$1" >&2
  test "${_PRINT_HELP:-no}" = yes && print_help >&2
  exit "${_ret}"
}

parse_commandline() {
  while test $# -gt 0; do
    _key="$1"
    case "$_key" in
      --multilineCustomField | --multilinecustomfield | --multiline | -m)
        test $# -lt 2 && die "[Error] Missing value for the optional argument '$_key'." 1
        _arg_multilineCustomField=$2
        shift
        ;;
      --multilineCustomField=*)
        _arg_multilineCustomField="${_key##--multilineCustomField=}"
        ;;
      --wysiwygCustomField | --wysiwygcustomfield | --wysiwyg | -w)
        test $# -lt 2 && die "[Error] Missing value for the optional argument '$_key'." 1
        _arg_wysiwygCustomField=$2
        shift
        ;;
      --wysiwygCustomField=*)
        _arg_wysiwygCustomField="${_key##--wysiwygCustomField=}"
        ;;
      --append | -a)
        _arg_append="on"
        ;;
      --sleepBeforeRunning | --sleepbeforerunning | --sleep | -s)
        _arg_sleepBeforeRunning="on"
        ;;
      --help | -h)
        _PRINT_HELP=yes die
        ;;
      *)
        _PRINT_HELP=yes die "[Error] Got an unexpected argument '$1'" 1
        ;;
    esac
    shift
  done
}

echo " "
parse_commandline "$@"

# If script form variables are used, replace the command line parameters with their value.
if [[ -n $multilineCustomFieldName ]]; then
  _arg_multilineCustomField="$multilineCustomFieldName"
fi
if [[ -n $wysiwygCustomFieldName ]]; then
  _arg_wysiwygCustomField="$wysiwygCustomFieldName"
fi
if [[ -n $appendToCustomField && $appendToCustomField == "true" ]]; then
  _arg_append="on"
fi
if [[ -n $sleepBeforeRunning && $sleepBeforeRunning == "true" ]]; then
  _arg_sleepBeforeRunning="on"
fi

# Ensure the script is run with root permissions
if [[ $(id -u) -ne 0 ]]; then
  _PRINT_HELP=no die "[Error] This script must be run with root permissions. Try running it with sudo or as the system/root user." 1
fi

# Check if the networkQuality command is available and the macOS version is sufficient
networkQualityAvailable=$(command -v networkQuality)
currentOSVersion=$(sw_vers -productVersion)
if [[ -z "$networkQualityAvailable" ]]; then
  _PRINT_HELP=no die "[Error] This script requires the networkQuality command, which is included with macOS starting with version 12.0 (Monterey). You are currently running '$currentOSVersion'." 1
fi

# Validate the multiline custom field name if provided
if [[ -n "$_arg_multilineCustomField" ]]; then
  _arg_multilineCustomField=$(echo "$_arg_multilineCustomField" | awk '{$1=$1; print}')

  if [[ -z "$_arg_multilineCustomField" ]]; then
    _PRINT_HELP=yes die "[Error] The 'Multiline Custom Field Name' is invalid.
    [Error] Please provide a valid multiline custom field name to save the results, or leave it blank.
    [Error] https://ninjarmm.zendesk.com/hc/articles/360060920631-Custom-Field-Setup" 1
  fi

  if [[ "$_arg_multilineCustomField" =~ [^[:alnum:]] ]]; then
    _PRINT_HELP=yes die "[Error] The 'Multiline Custom Field Name' is invalid.
    [Error] Please provide a valid multiline custom field name to save the results, or leave it blank.
    [Error] https://ninjarmm.zendesk.com/hc/articles/360060920631-Custom-Field-Setup" 1
  fi
fi

# Validate the WYSIWYG custom field name if provided
if [[ -n "$_arg_wysiwygCustomField" ]]; then
  _arg_wysiwygCustomField=$(echo "$_arg_wysiwygCustomField" | awk '{$1=$1; print}')

  if [[ -z "$_arg_wysiwygCustomField" ]]; then
    _PRINT_HELP=yes die "[Error] The 'WYSIWYG Custom Field Name' is invalid.
    [Error] Please provide a valid WYSIWYG custom field name to save the results, or leave it blank.
    [Error] https://ninjarmm.zendesk.com/hc/articles/360060920631-Custom-Field-Setup" 1
  fi

  if [[ "$_arg_wysiwygCustomField" =~ [^[:alnum:]] ]]; then
    _PRINT_HELP=yes die "[Error] The 'WYSIWYG Custom Field Name' is invalid.
    [Error] Please provide a valid WYSIWYG custom field name to save the results, or leave it blank.
    [Error] https://ninjarmm.zendesk.com/hc/articles/360060920631-Custom-Field-Setup" 1
  fi
fi

# Ensure the multiline and WYSIWYG custom fields are not identical
if [[ -n "$_arg_multilineCustomField" && -n "$_arg_wysiwygCustomField" && "$_arg_multilineCustomField" == "$_arg_wysiwygCustomField" ]]; then
  _PRINT_HELP=yes die "[Error] The multiline and WYSIWYG custom fields you have given '$_arg_multilineCustomField' are identical.
    [Error] Please provide two different valid custom fields to save the results, or leave one or more blank.
    [Error] https://ninjarmm.zendesk.com/hc/articles/360060920631-Custom-Field-Setup" 1
fi

# Locate the ninjarmm-cli tool if custom fields are specified
if [[ -n "$_arg_multilineCustomField" || -n "$_arg_wysiwygCustomField" ]]; then
  if [[ -n $NINJA_DATA_PATH && -f "$NINJA_DATA_PATH/ninjarmm-cli" ]]; then
    ninjarmmcliPath="$NINJA_DATA_PATH/ninjarmm-cli"
  elif [[ -f "/Applications/NinjaRMMAgent/programdata/ninjarmm-cli" ]]; then
    ninjarmmcliPath=/Applications/NinjaRMMAgent/programdata/ninjarmm-cli
  else
    _PRINT_HELP=no die "[Error] Unable to locate ninjarmm-cli. Ninjarmm-cli is required to set custom fields.
    https://ninjarmm.zendesk.com/hc/articles/4405408656013-Custom-Fields-and-Documentation-CLI-and-Scripting#h_01FB4NZ6GCG2V53NS4T1RQX8PY" 1
  fi
fi

# Ensure a valid custom field name is provided if appending results
if [[ "$_arg_append" == "on" && -z "$_arg_multilineCustomField" && -z "$_arg_wysiwygCustomField" ]]; then
  _PRINT_HELP=yes die "[Error] You must provide a valid custom field name to append to.
    [Error] https://ninjarmm.zendesk.com/hc/articles/360060920631-Custom-Field-Setup" 1
fi

# Initialize the exit code
if [[ -z "$exitCode" ]]; then
  exitCode=0
fi

# Sleep for a random duration if requested
if [[ "$_arg_sleepBeforeRunning" == "on" ]]; then
  sleepForInSeconds=$((1 + RANDOM % 3600))
  sleepForInMinutes=$(awk "BEGIN {printf \"%.2f\", $sleepForInSeconds / 60}")
  echo "Waiting for $sleepForInMinutes minutes before performing the Speedtest as requested."
  echo ""
  sleep $sleepForInSeconds
fi

# Start the network speed test
echo "Starting the network speed test."
if ! speedTestResultsJSON=$(mktemp); then
  _PRINT_HELP=no die "[Error] Failed to create a temporary file to save results." 1
fi

speedTestStartDate=$(date "+%x %r")
if ! networkQuality -svc > "$speedTestResultsJSON"; then
  _PRINT_HELP=no die "[Error] Failed to complete the speed test." 1
fi

# Ensure the results file is not empty
if [[ ! -s "$speedTestResultsJSON" ]]; then
  echo "[Error] The speed test results are empty." >&2
  if ! rm "$speedTestResultsJSON"; then
    echo "[Error] Failed to clean up '$speedTestResultsJSON'." >&2
  fi

  exit 1
fi
echo "The network speed test has completed."
echo ""

# Format the results of the speed test
echo "Formatting the results of the speed test."
if ! speedTestResultsXML=$(mktemp); then
  _PRINT_HELP=no die "[Error] Failed to create a temporary file to save the formatted results." 1
fi

jsonResults=$(cat "$speedTestResultsJSON")
if ! plutil -convert xml1 -o - "$speedTestResultsJSON" > "$speedTestResultsXML"; then
  if ! rm "$speedTestResultsJSON"; then
    echo "[Error] Failed to clean up '$speedTestResultsJSON'." >&2
  fi

  if ! rm "$speedTestResultsXML"; then
    echo "[Error] Failed to clean up '$speedTestResultsXML'." >&2
  fi

  _PRINT_HELP=no die "[Error] Failed to convert the JSON results to a plist.
  ### JSON Results ###
  $jsonResults
  " 1
fi

# Clean up the temporary JSON file
if ! rm "$speedTestResultsJSON"; then
  echo "[Error] Failed to clean up '$speedTestResultsJSON'." >&2
fi

# Ensure the XML results file is not empty
if [[ ! -s "$speedTestResultsXML" ]]; then
  echo "[Error] The speed test results are empty." >&2
  if ! rm "$speedTestResultsXML"; then
    echo "[Error] Failed to clean up '$speedTestResultsXML'." >&2
  fi

  exit 1
fi

# Extract the server used for the speed test
speedTestServer=$(/usr/libexec/PlistBuddy -c "Print :test_endpoint:" "$speedTestResultsXML")
if [[ -z "$speedTestServer" ]]; then
  echo "[Error] Failed to retrieve the server used for the speed test." >&2
  exitCode=1
fi

# Extract the network adapter information
if interfaceName=$(/usr/libexec/PlistBuddy -c "Print :interface_name:" "$speedTestResultsXML"); then
  networkSetup=$(networksetup -listallhardwareports | grep -A1 -B1 "$interfaceName")
  hardwarePort=$(echo "$networkSetup" | grep "Hardware Port: " | sed "s/Hardware Port: //")
  macAddress=$(echo "$networkSetup" | grep "Ethernet Address: " | sed "s/Ethernet Address: //")

  case $hardwarePort in
    "Wi-Fi")
      adapterType="Wi-Fi"
      ;;
    "Ethernet Adapter")
      adapterType="Wired"
      ;;
    *)
      adapterType="Other"
      ;;
  esac
fi

# Ensure the network adapter information is valid
if [[ -z "$interfaceName" ]]; then
  echo "[Error] Failed to retrieve the network adapter used for the test." >&2
  exitCode=1
fi

# Extract the download speed and convert it to Mbps
if downloadSpeed=$(/usr/libexec/PlistBuddy -c "Print :dl_throughput:" "$speedTestResultsXML") 2> /dev/null; then
  downloadSpeed=$(echo "$downloadSpeed" | awk '{ printf "%.2f\n", $1 / 1000000 }')
else
  echo "[Error] The results did not contain a download speed." >&2
  exitCode=1
fi

# Extract the upload speed and convert it to Mbps
if uploadSpeed=$(/usr/libexec/PlistBuddy -c "Print :ul_throughput:" "$speedTestResultsXML") 2> /dev/null; then
  uploadSpeed=$(echo "$uploadSpeed" | awk '{ printf "%.2f\n", $1 / 1000000 }')
else
  echo "[Error] The results did not contain an upload speed." >&2
  exitCode=1
fi

# Extract latency information and calculate statistics
numberOfLatencyEntries=0
latencyEntries=
minLatency=
maxLatency=
while /usr/libexec/PlistBuddy -c "Print :il_h2_req_resp:$numberOfLatencyEntries" "$speedTestResultsXML" &> /dev/null; do
  latencyEntry=$(/usr/libexec/PlistBuddy -c "Print :il_h2_req_resp:$numberOfLatencyEntries" "$speedTestResultsXML")
  latencyEntries+=$latencyEntry$'\n'
  latencyEntry=$(awk "BEGIN { printf \"%.3f\", $latencyEntry }")

  if [[ -z "$maxLatency" ]]; then
    maxLatency="$latencyEntry"
  fi

  if [[ -z "$minLatency" ]]; then
    minLatency="$latencyEntry"
  fi

  if (($(awk "BEGIN {print ($latencyEntry > $maxLatency)}"))); then
    maxLatency="$latencyEntry"
  fi

  if (($(awk "BEGIN {print ($latencyEntry < $minLatency)}"))); then
    minLatency="$latencyEntry"
  fi

  numberOfLatencyEntries=$((numberOfLatencyEntries + 1))
done

# Calculate average latency and jitter
if [[ -n "$latencyEntries" ]]; then
  latencyEntries=$(echo "$latencyEntries" | sed '/^[[:space:]]*$/d')

  avgLatency=$(awk 'NF && $1 ~ /^[0-9.]+$/ { sum += $1; count++ } END { if (count > 0) printf "%.3f\n", sum / count }' <<< "$latencyEntries")
  jitter=$(echo "$latencyEntries" | awk '
  { sum += $1; data[NR] = $1 }
  END {
    avg = sum / NR
    for (i = 1; i <= NR; i++) {
      diff = data[i] - avg
      sq_diff += diff * diff
    }
    stdev = sqrt(sq_diff / NR)
    printf "%.3f\n", stdev
  }')
fi

# Clean up the temporary XML file
if ! rm "$speedTestResultsXML"; then
  echo "[Error] Failed to clean up '$speedTestResultsXML'." >&2
fi

echo ""

# Save results to the multiline custom field if specified
if [[ -n "$_arg_multilineCustomField" ]]; then
  echo "Attempting to set the custom field '$_arg_multilineCustomField'."

  multilineValue="Date : $speedTestStartDate
Server : $speedTestServer
Down : $downloadSpeed Mbps
Up : $uploadSpeed Mbps
Interface : $interfaceName
MacAddress : $macAddress
Jitter : $jitter ms
Latency : $avgLatency ms
Low : $minLatency ms
High : $maxLatency ms"

  # Append existing information if requested
  if [[ "$_arg_append" == "on" ]]; then
    echo "Retrieving existing information from '$_arg_multilineCustomField'."

    if ! existingMultiline=$("$ninjarmmcliPath" get "$_arg_multilineCustomField" 2>&1); then
      echo "[Warning] $existingMultiline"
    else
      if [[ -n "$existingMultiline" ]]; then
        existingMultiline=$(echo "$existingMultiline" | grep -v "This information has been truncated to accommodate the character limit of 10,000 characters.")
        multilineValue+=$'\n\n'"$existingMultiline"
      fi

      echo "Successfully retrieved the existing information from the custom field '$_arg_multilineCustomField'."
    fi
  fi

  # Trim the output if it exceeds the character limit
  characterCount=${#multilineValue}
  if [[ "$characterCount" -gt 9500 ]]; then
    echo "[Warning] The character count exceeds the character limit of 10,000 characters."
    echo "[Warning] Trimming the output until the character limit is satisfied."

    multilineValue="This information has been truncated to accommodate the character limit of 10,000 characters."$'\n'"$multilineValue"
    while [[ $characterCount -gt 9500 ]]; do
      totalLines=$(echo "$multilineValue" | wc -l | xargs)

      exceededAmount=$((characterCount - 9500))
      if [[ "$exceededAmount" -lt 1000 ]]; then
        batchSize=1
      else
        batchSize=$(((exceededAmount + 999) / 1000))
        [[ $batchSize -lt 1 ]] && batchSize=1
      fi

      # Keep only the remaining lines within the limit
      linesToKeep=$((totalLines - batchSize))
      multilineValue=$(echo "$multilineValue" | head -n $linesToKeep)

      characterCount=${#multilineValue}
    done
  fi

  # Attempt to set the multiline custom field with the formatted value
  if ! output=$("$ninjarmmcliPath" set "$_arg_multilineCustomField" "$multilineValue" 2>&1); then
    echo "[Error] $output" >&2
    ExitCode=1
  else
    echo "Successfully set the custom field '$_arg_multilineCustomField'."
  fi

  echo ""
fi

# Check if the WYSIWYG custom field is specified
if [[ -n "$_arg_wysiwygCustomField" ]]; then
  echo "Attempting to set the custom field '$_arg_wysiwygCustomField'."

  # Initialize the WYSIWYG value with a container div
  wysiwygValue="<div>"

  # Create the speed test results card with HTML structure
  speedTestCard="<div class='card flex-grow-1'>
    <div class='card-title-box'>
        <div class='card-title'><i class='fa-solid fa-gauge-high'></i>&nbsp;&nbsp;Speed Test Results</div>
        <div class='card-link-box'>
            <a href='https://support.apple.com/en-us/101942' target='_blank' class='card-link' rel='nofollow noopener noreferrer'>
                <i class='fas fa-arrow-up-right-from-square' style='color: #337ab7;'></i>
            </a>
        </div>
    </div>
    <div class='card-body' style='white-space: nowrap'>
        <div class='container' style='padding-left: 0px'>
            <div class='row'>
                <div class='col-sm'>
                    <p class='card-text'><b>Date</b><br>$speedTestStartDate</p>
                </div>
                <div class='col-md'>
                    <p class='card-text' style='white-space: nowrap;'><b>Speed Test Server</b><br>$speedTestServer</p>
                </div>
                <div class='col-md'>
                    <p class='card-text' style='white-space: nowrap;'><b>$interfaceName</b><br><i class='fa-solid fa-ethernet'></i>&nbsp;&nbsp;$macAddress</p>
                </div>
            </div>
        </div>
        <div class='container my-4' style='width: 70em'>
            <div class='row' style='padding-left: 0px'>
                <div class='col-sm-4'>
                    <div class='stat-card' style='display: flex;'>
                        <div class='stat-value' style='color: #008001; white-space: nowrap;'>$downloadSpeed Mbps</div>
                        <div class='stat-desc'><i class='fa-solid fa-circle-down'></i>&nbsp;&nbsp;Download</div>
                    </div>
                </div>
                <div class='col-sm-4'>
                    <div class='stat-card' style='display: flex;'>
                        <div class='stat-value' style='color: #008001; white-space: nowrap;'>$uploadSpeed Mbps</div>
                        <div class='stat-desc'><i class='fa-solid fa-circle-up'></i>&nbsp;&nbsp;Upload</div>
                    </div>
                </div>
                <div class='col-sm-4'>
                    <div class='stat-card' style='display: flex;'>
                        <div class='stat-value' style='color: #008001; white-space: nowrap;'>$jitter ms</div>
                        <div class='stat-desc'><i class='fa-solid fa-chart-line'></i>&nbsp;&nbsp;Jitter</div>
                    </div>
                </div>
            </div>
            <div class='row' style='padding-left: 0px'>
                <div class='col-sm-4'>
                    <div class='stat-card' style='display: flex;'>
                        <div class='stat-value' style='color: #008001; white-space: nowrap;'>$avgLatency ms</div>
                        <div class='stat-desc'><i class='fa-solid fa-server'></i>&nbsp;&nbsp;Latency</div>
                    </div>
                </div>
                <div class='col-sm-4'>
                    <div class='stat-card' style='display: flex;'>
                        <div class='stat-value' style='color: #008001; white-space: nowrap;'>$maxLatency ms</div>
                        <div class='stat-desc'><i class='fa-solid fa-chevron-up'></i>&nbsp;&nbsp;High</div>
                    </div>
                </div>
                <div class='col-sm-4'>
                    <div class='stat-card' style='display: flex;'>
                        <div class='stat-value' style='color: #008001; white-space: nowrap;'>$minLatency ms</div>
                        <div class='stat-desc'><i class='fa-solid fa-chevron-down'></i>&nbsp;&nbsp;Low</div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>"

  # Adjust the icon based on the adapter type
  case $adapterType in
    "Wi-Fi")
      speedTestCard=${speedTestCard//"fa-solid fa-ethernet"/"fa-solid fa-wifi"}
      ;;
    "Other")
      speedTestCard=${speedTestCard//"fa-solid fa-ethernet"/"fa-solid fa-circle-question"}
      ;;
  esac

  # Append the speed test card to the WYSIWYG value
  wysiwygValue+=$'\n'"$speedTestCard"

  # Prepare past results for appending if requested
  pastResults="$speedTestStartDate;|;$speedTestServer;|;$downloadSpeed Mbps;|;$uploadSpeed Mbps;|;$interfaceName;|;$macAddress;|;$jitter ms;|;$avgLatency ms;|;$minLatency ms;|;$maxLatency ms"
  if [[ "$_arg_append" == "on" ]]; then
    echo "Retrieving existing information from '$_arg_wysiwygCustomField'."

    # Retrieve existing WYSIWYG field data
    if ! existingWysiwyg=$("$ninjarmmcliPath" get "$_arg_wysiwygCustomField" 2>&1); then
      echo "[Warning] $existingWysiwyg"
    else
      if [[ -n "$existingWysiwyg" ]]; then
        wysiwygJSON=$(mktemp)
        wysiwygXML=$(mktemp)
        echo "$existingWysiwyg" > "$wysiwygJSON"

        # Convert existing data to XML and extract past results
        if plutil -convert xml1 -o - "$wysiwygJSON" > "$wysiwygXML"; then
          codeBlock=$(plutil -extract html raw -o - "$wysiwygXML" | awk '/<code class="d-none">/ {codeBlock=1} codeBlock' | awk '/<\/code>/ {exit} {print}' | grep -v "Date;|;Server" | tail -n +2)
          codeBlock=$(echo "$codeBlock" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')
          pastResults+=$'\n'$codeBlock

          echo "Successfully retrieved the existing information from the custom field '$_arg_wysiwygCustomField'."
        else
          echo "[Error] Failed to parse the existing information." >&2
          exitCode=1
        fi

        # Clean up temporary files
        if ! rm "$wysiwygJSON"; then
          echo "[Error] Failed to clean up '$wysiwygJSON'." >&2
          exitCode=1
        fi

        if ! rm "$wysiwygXML"; then
          echo "[Error] Failed to clean up '$wysiwygXML'." >&2
          exitCode=1
        fi
      else
        echo "Successfully retrieved the existing information from the custom field '$_arg_wysiwygCustomField'."
      fi
    fi
  fi

  # Format past results into an HTML table
  totalLines=$(echo "$pastResults" | wc -l | xargs)
  if [[ "$totalLines" -gt 1 ]]; then
    pastResults="Date;|;Server;|;Down;|;Up;|;Interface;|;MAC Address;|;Jitter;|;Latency;|;Low;|;High"$'\n'"$pastResults"
    echo "$pastResults" > "/tmp/pastresults.txt"
    pastResultsTable=$(echo "$pastResults" | awk 'NR != 2' | convertToHTMLTable -d ";[|];")
    pastResultsTable=$(sed -e 's/<th>/<th><b>/g' \
      -e 's/<\/th>/<\/b><\/th>/g' \
      -e "s/<th><b>Date/<th style='width: 14em'><b>Date/g" \
      -e "s/<th><b>Down/<th style='width: 9em'><i class='fa-solid fa-circle-down'><\/i>\&nbsp;\&nbsp;<b>Down/g" \
      -e "s/<th><b>Up/<th style='width: 9em'><i class='fa-solid fa-circle-up'><\/i>\&nbsp;\&nbsp;<b>Up/g" \
      -e "s/<th><b>Jitter/<th style='width: 7em'><i class='fa-solid fa-chart-line'><\/i>\&nbsp;\&nbsp;<b>Jitter/g" \
      -e "s/<th><b>Latency/<th style='width: 7em'><i class='fa-solid fa-server'><\/i>\&nbsp;\&nbsp;<b>Latency/g" \
      -e "s/<th><b>High/<th style='width: 7em'><i class='fa-solid fa-chevron-up'><\/i>\&nbsp;\&nbsp;<b>High/g" \
      -e "s/<th><b>Low/<th style='width: 7em'><i class='fa-solid fa-chevron-down'><\/i>\&nbsp;\&nbsp;<b>Low/g" \
      <<< "$pastResultsTable")

    # Create a card for past results
    pastResultsCard="<div class='card flex-grow-1'>
  <div class='card-title-box'>
    <div class='card-title'><i class='fa-solid fa-book'></i>&nbsp;&nbsp;Past Results</div>
  </div>
  <div class='card-body' style='white-space: nowrap'>
    $pastResultsTable
  </div>
</div>"
    wysiwygValue+=$'\n'"$pastResultsCard"
  fi

  # Append hidden code block with past results
  wysiwygValue+=$'\n'"<code class='d-none'>
  $pastResults
</code>"

  # Close the WYSIWYG container div
  wysiwygValue+=$'\n'"</div>"

  # Trim the output if it exceeds the character limit
  characterCount=${#wysiwygValue}
  if [[ "$characterCount" -gt 195000 ]]; then
    echo "[Warning] The character count exceeds the character limit of 200,000 characters."
    echo "[Warning] Trimming the output until the character limit is satisfied."

    while [[ "$characterCount" -gt 195000 ]]; do
      totalLines=$(echo "$pastResults" | wc -l | xargs)

      exceededAmount=$((characterCount - 195000))
      if [[ "$exceededAmount" -lt 1000 ]]; then
        batchSize=3
      else
        batchSize=$(((exceededAmount + 999) / 1000))
        [[ $batchSize -lt 1 ]] && batchSize=1
      fi

      # Keep only the remaining lines within the limit
      linesToKeep=$((totalLines - batchSize))

      pastResults=$(echo "$pastResults" | head -n "$linesToKeep")
      pastResultsTable=$(echo "$pastResults" | awk 'NR != 2' | convertToHTMLTable -d ";[|];")
      pastResultsTable=$(sed -e 's/<th>/<th><b>/g' \
      -e 's/<\/th>/<\/b><\/th>/g' \
      -e "s/<th><b>Date/<th style='width: 14em'><b>Date/g" \
      -e "s/<th><b>Down/<th style='width: 9em'><i class='fa-solid fa-circle-down'><\/i>\&nbsp;\&nbsp;<b>Down/g" \
      -e "s/<th><b>Up/<th style='width: 9em'><i class='fa-solid fa-circle-up'><\/i>\&nbsp;\&nbsp;<b>Up/g" \
      -e "s/<th><b>Jitter/<th style='width: 7em'><i class='fa-solid fa-chart-line'><\/i>\&nbsp;\&nbsp;<b>Jitter/g" \
      -e "s/<th><b>Latency/<th style='width: 7em'><i class='fa-solid fa-server'><\/i>\&nbsp;\&nbsp;<b>Latency/g" \
      -e "s/<th><b>High/<th style='width: 7em'><i class='fa-solid fa-chevron-up'><\/i>\&nbsp;\&nbsp;<b>High/g" \
      -e "s/<th><b>Low/<th style='width: 7em'><i class='fa-solid fa-chevron-down'><\/i>\&nbsp;\&nbsp;<b>Low/g" \
      <<< "$pastResultsTable")

      pastResultsCard="<div class='card flex-grow-1'>
  <div class='card-title-box'>
    <div class='card-title'><i class='fa-solid fa-book'></i>&nbsp;&nbsp;Past Results</div>
  </div>
  <div class='card-body' style='white-space: nowrap'>
    <h1>This information has been truncated to accommodate the character limit of 200,000 characters.</h1>
    $pastResultsTable
  </div>
</div>"

      wysiwygValue="<div>"
      wysiwygValue+=$'\n'$speedTestCard
      wysiwygValue+=$'\n'$pastResultsCard
      wysiwygValue+=$'\n'"<code class='d-none'>
  $pastResults
</code>"
      wysiwygValue+=$'\n'"</div>"
      characterCount=${#wysiwygValue}
    done
  fi

  # Attempt to set the WYSIWYG custom field with the formatted value
  if ! output=$("$ninjarmmcliPath" set "$_arg_wysiwygCustomField" "$wysiwygValue" 2>&1); then
    echo "[Error] $output" >&2
    ExitCode=1
  else
    echo "Successfully set the custom field '$_arg_wysiwygCustomField'."
  fi

  echo ""
fi

# Print the speed test results to the console
echo "### Speed Test Results ###"
echo "Date : $speedTestStartDate
Server : $speedTestServer
Down : $downloadSpeed Mbps
Up : $uploadSpeed Mbps
Interface : $interfaceName
MacAddress : $macAddress
Jitter : $jitter ms
Latency : $avgLatency ms
Low : $minLatency ms
High : $maxLatency ms"

# Exit the script with the appropriate exit code
exit "$exitCode"

 

Detailed Breakdown

Key implementation details:

  • Argument parsing supports:
    • --multilineCustomField|-m, --wysiwygCustomField|-w to target NinjaOne fields.
    • --append|-a to merge with existing field content.
    • --sleepBeforeRunning|-s to stagger execution.
    • Script-form variables (e.g., multilineCustomFieldName, appendToCustomField) can override CLI flags—useful in RMM parameters.
  • Environment checks: Fails fast if not root, if networkQuality isn’t present (pre-Monterey), or if ninjarmm-cli cannot be found at $NINJA_DATA_PATH/ninjarmm-cli or /Applications/NinjaRMMAgent/programdata/ninjarmm-cli.
  • Extraction & metrics:
    • Throughput values (dl_throughput, ul_throughput) are converted from bytes/sec to Mbps with two-decimal precision.
    • Latency samples (il_h2_req_resp) are aggregated to compute average latency, jitter (as standard deviation), and min/max.
    • Interface name is matched via networksetup -listallhardwareports to show Wi-Fi vs Wired and the MAC address.
  • Persisting results:
    • Multiline field: saves a concise, newline-delimited summary. If --append, prior content is fetched and prepended. A soft cap keeps content under 10,000 chars.
    • WYSIWYG field: renders a card-style report (Font Awesome icons, labeled stats) and builds a historical HTML table from a hidden <code> block. Append mode parses existing WYSIWYG HTML via plutil to rebuild the table. A safety mechanism trims content under 200,000 chars and clearly marks truncation when needed.
  • Console output: Always prints a human-readable summary (date, server, down/up, interface, MAC, jitter, average, low, high), and exits with a success/failure code that your RMM can act on.

Potential Use Cases

Case study: An MSP supports a multi-site retail chain where PoS terminals intermittently lag during checkout. The team deploys this Shell script for macOS Internet Speed Test to all store Macs:

  • Schedules it weekly with --append --wysiwygCustomField "SpeedTestHistory" --sleepBeforeRunning.
  • Over two weeks, they correlate jitter spikes and upload drops to a specific ISP window.
  • With historical tables embedded in device records, they present evidence to the ISP, who finds congestion on the upstream. SLA credits follow—and checkout delays vanish.

Comparisons

  • Commercial speed test clients: Rich dashboards, but often require agents, license management, or external storage. This script is zero-cost, uses Apple’s native tool, and stores results where your techs already work (NinjaOne).
  • Browser-based tests: Useful ad-hoc, but lack repeatability, scheduling, or standard output; headless execution is awkward.
  • iperf: Great for point-to-point diagnostics you control, but it needs a server endpoint and doesn’t reflect “real-world” internet paths the way networkQuality does.
  • Homegrown curl/dd tricks: Quick hacks, not representative, and hard to standardize across a fleet.

Implications

Speed test telemetry is more than a number. Jitter and latency variance directly affect VoIP, video calls, and interactive apps. Consistent, per-device histories help you:

  • Disambiguate root cause: Device/adapter vs. WLAN vs. WAN vs. ISP.
  • Prove performance to stakeholders and ISPs with time-bounded evidence.
  • Harden security posture indirectly: When connectivity is healthy, patch windows, EDR updates, and identity flows are more predictable. Conversely, chronic packet delay can mask or exacerbate failure modes that look like security issues (timeouts, auth retries). Storing results alongside device metadata allows faster triage and fewer misattributed incidents.

Recommendations

  • Use append + sleep in fleets: --append --sleepBeforeRunning reduces test storms and builds a usable timeline.
  • Pick one field type per audience: Multiline for quick text diffs, WYSIWYG for exec-friendly visuals and tables.
  • Standardize naming: Keep custom field names alphanumeric (as the script enforces) and consistent across policies.
  • Schedule off-peak: Run after-hours so tests don’t collide with backups or software distribution.
  • Correlate with events: Pair results with Wi-Fi channel changes, switch port errors, or ISP maintenance windows.
  • Alert on outliers: Have your RMM parse the console output or CF values to flag high jitter/latency thresholds.

Final Thoughts

Capturing speed, latency, and jitter is table stakes; operationalizing that data inside your endpoint records is where teams win back time. This Internet Speed Test macOS shell script meets that bar by marrying Apple’s networkQuality with sensible parsing and NinjaOne-native storage. Within NinjaOne, you can deploy the script at scale, parameterize custom fields, append histories, and surface rich cards to technicians and stakeholders—all from the same pane of glass you already use for patching, alerting, and remediation. That unified workflow is what turns one-off diagnostics into durable, fleet-wide insight.

]]>
How to Uninstall TeamViewer on macOS Using a Script https://www.ninjaone.com/script-hub/uninstall-teamviewer-on-macos-using-a-script/ Mon, 15 Sep 2025 07:20:22 +0000 https://www.ninjaone.com/?post_type=script_hub&p=525063 Removing software from macOS systems can sometimes be more complicated than dragging an app to the Trash. Applications like TeamViewer install background services, launch daemons, and support files that must be properly removed to ensure system stability and security.

For IT professionals and Managed Service Providers (MSPs), automating this process can save significant time across fleets of machines. This post explores a shell script designed specifically to uninstall TeamViewer on macOS systems quickly, thoroughly, and reliably.

Background

TeamViewer is widely used for remote access and support, but in enterprise environments there are often policies requiring its removal once a project ends or when a different remote support solution is adopted. Manual uninstallation is error-prone and time-consuming, particularly when managing dozens or hundreds of endpoints. A shell script to uninstall TeamViewer on macOS provides IT teams with a repeatable, automated process that ensures consistency and reduces the chance of leaving residual files behind.

This script is especially valuable for MSPs who manage environments remotely through platforms like NinjaOne, where automation scripts can be executed across multiple client devices with minimal intervention.

The Script

#!/usr/bin/env bash
#
# Description: Uninstalls TeamViewer.
# By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
# Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
# Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
# Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
# Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
# Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
# Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
# EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
#
# Example: (No Parameters)
#
# Verifying that TeamViewer is still installed.
# Removing TeamViewer using '"/Library/Application Support/TeamViewer/TeamViewerUninstaller.app/Contents/Helpers/UninstallTeamViewer" --force'
# Uninstalling...
# Done.
# Verifying that TeamViewer has been removed.
# TeamViewer has been successfully removed.
#
# Version: 1.0
# Release Notes: Initial Release

# Function to wait for the TeamViewer process to stop, with a timeout of 5 minutes.
waitForTeamViewer() {
  # Set timeout to 5 minutes from now.
  timeout=$(date -v+5M "+%s")

  # Check if the TeamViewer process is running.
  if pgrep -ifl "TeamViewer" 1>/dev/null; then
    echo "Waiting for the TeamViewer process to stop."
  fi

  # Loop until the TeamViewer process stops or the timeout is reached.
  while pgrep -ifl "TeamViewer" 1>/dev/null; do
    if [[ $(date "+%s") -ge "$timeout" ]]; then
      echo "[Warning] The five minute timeout has been reached."
      break
    fi

    # Wait briefly before checking again.
    sleep 0.1
  done
}

# Function to print an error message and exit with a specified return code.
die() {
  local _ret="${2:-1}"
  echo "$1" >&2
  exit "${_ret}"
}

echo " "

# Initialize the exit code variable if not already set.
if [[ -z "$exitCode" ]]; then
  exitCode=0
fi

# Ensure the script is run with root permissions.
if [[ $(id -u) -ne 0 ]]; then
  _PRINT_HELP=no die "[Error] This script must be run with root permissions. Try running it with sudo or as the system/root user." 1
fi

echo "Verifying that TeamViewer is still installed."

# Check if the TeamViewer uninstaller is available using `mdfind`.
if mdfindResult=$(mdfind kind:application "TeamViewer" 2>/dev/null | grep -v '^$'); then
  teamViewerUninstaller=$(echo "$mdfindResult" | grep "TeamViewerUninstaller")
  teamViewerInstalled="true"
fi

# Check if the TeamViewer application directory exists.
if [[ -d "/Applications/TeamViewer.app" ]]; then
  teamViewerInstalled="true"
fi

# Check if the specific uninstaller path still exists; if so, exit with an error.
if [[ -f "/Library/Application Support/TeamViewer/TeamViewerUninstaller.app/Contents/Helpers/UninstallTeamViewer" ]]; then
  teamViewerUninstaller="/Library/Application Support/TeamViewer/TeamViewerUninstaller.app"
  teamViewerInstalled="true"
fi

# If TeamViewer is not installed, check for the QuickSupport tool and exit with an error.
if [[ "$teamViewerInstalled" != "true" ]]; then
  if pgrep -ifl "TeamViewer" 1>/dev/null; then
    echo "[Alert] The TeamViewer QuickSupport tool was detected. The QuickSupport tool is not installed on the system because it is a portable app."
  fi
  _PRINT_HELP=no die "[Error] TeamViewer is not installed on this system." 1
fi

# Attempt to uninstall TeamViewer using the uninstaller(s) found by `mdfind`.
if [[ -n "$teamViewerUninstaller" ]]; then
  while IFS= read -r uninstaller; do
    echo "Removing TeamViewer using '\"$uninstaller/Contents/Helpers/UninstallTeamViewer\" --force'"

    # Run the uninstaller with the `--force` option.
    if ! "$uninstaller/Contents/Helpers/UninstallTeamViewer" --force; then
      _PRINT_HELP=no die "[Error] Failed to uninstall TeamViewer." 1
    fi

    # Wait for the TeamViewer process to stop.
    waitForTeamViewer

    # Check if the uninstaller is still present; if not, break the loop.
    if ! mdfind kind:application "TeamViewer" 2>/dev/null | grep "TeamViewerUninstaller" 1>/dev/null; then
      break
    fi
  done <<< "$teamViewerUninstaller"

  uninstallAttempted="true"
fi

# Attempt to uninstall TeamViewer using a specific uninstaller path.
if [[ -f "/Library/Application Support/TeamViewer/TeamViewerUninstaller.app/Contents/Helpers/UninstallTeamViewer" ]]; then
  echo "Removing TeamViewer using '\"/Library/Application Support/TeamViewer/TeamViewerUninstaller.app/Contents/Helpers/UninstallTeamViewer\" --force'"

  # Run the uninstaller with the `--force` option.
  if ! "/Library/Application Support/TeamViewer/TeamViewerUninstaller.app/Contents/Helpers/UninstallTeamViewer" --force; then
    _PRINT_HELP=no die "[Error] Failed to uninstall TeamViewer." 1
  fi

  # Wait for the TeamViewer process to stop.
  waitForTeamViewer

  uninstallAttempted="true"
fi

# If no uninstallation attempt was made, exit with an error.
if [[ "$uninstallAttempted" != "true" ]]; then
  _PRINT_HELP=no die "[Error] Failed to find the TeamViewer uninstaller." 1
fi

echo "Verifying that TeamViewer has been removed."

# Check if the app is still present; if so, exit with an error.
if foundApplications=$(mdfind kind:application "TeamViewer" 2>/dev/null); then
  while IFS= read -r application; do
    if [[ -f "$application" || -d "$application" ]]; then
      _PRINT_HELP=no die "[Error] Failed to uninstall TeamViewer." 1
    fi
  done <<< "$foundApplications"
fi

# Check if the app is still present; if so, exit with an error.
if [[ -d "/Applications/TeamViewer.app" ]]; then
  _PRINT_HELP=no die "[Error] Failed to uninstall TeamViewer." 1
fi

# Check if the specific uninstaller path still exists; if so, exit with an error.
if [[ -f "/Library/Application Support/TeamViewer/TeamViewerUninstaller.app/Contents/Helpers/UninstallTeamViewer" ]]; then
  _PRINT_HELP=no die "[Error] Failed to uninstall TeamViewer." 1
fi

echo "TeamViewer has been successfully removed."

# Exit the script with the specified exit code.
exit "$exitCode"

 

Access 300+ scripts in the NinjaOne Dojo

Get Access

Detailed Breakdown

The script follows a structured flow to ensure a clean uninstallation:

  1. Permission Check

    • It verifies the script is run with root privileges. Without elevated permissions, the script exits immediately since uninstalling system-level apps requires administrative access.
  2. Detection Phase

    • It searches for TeamViewer using mdfind (Spotlight index) and direct path checks (/Applications/TeamViewer.app).
    • It checks if the TeamViewer Uninstaller app exists at /Library/Application Support/TeamViewer/TeamViewerUninstaller.app.
    • If no installation is detected, the script identifies whether the portable TeamViewer QuickSupport tool is running, then exits gracefully with an error.
  3. Uninstallation Process

    • Executes the uninstaller binary with the –force flag to remove TeamViewer and associated services.
    • Implements a function waitForTeamViewer that continuously monitors whether TeamViewer processes are still running, with a five-minute timeout. This ensures that background services fully terminate before moving forward.
  4. Verification Phase

    • After the uninstaller finishes, the script double-checks the system by searching for residual application files and uninstaller paths. If any are found, the script raises an error.
  5. Exit

    • If successful, it outputs: “TeamViewer has been successfully removed.”

Visually, the process can be represented as:

Detection → Uninstallation → Waiting for process termination → Verification → Success/Failure

Potential Use Cases

Imagine an MSP rolling out a new remote management solution. Before deploying the new software, the IT team needs to ensure that TeamViewer is fully removed from all client devices. Instead of manually uninstalling on each macOS machine, the technician pushes this script via NinjaOne. Within minutes, TeamViewer is gone across the environment, and compliance reports confirm removal.

Comparisons

  • Manual Removal: Dragging the app to Trash leaves background services intact.
  • Third-Party Uninstaller Tools: These may remove the app but introduce licensing and trust concerns.
  • Native TeamViewer Uninstaller: Works well but requires manual initiation.

This shell script combines the reliability of the official uninstaller with automation, verification, and repeatability—making it far superior for enterprise workflows.

Implications

Removing remote access tools like TeamViewer strengthens endpoint security by eliminating unmonitored access points. In compliance-driven industries, this script provides assurance that deprecated software is completely removed, reducing risk exposure.

Recommendations

  • Always run the script with root privileges (sudo).
  • Deploy in test environments before pushing to production.
  • Use alongside reporting tools in NinjaOne to confirm successful uninstallation.
  • Keep the script updated as new TeamViewer versions are released.

Confirm uninstalls, track outcomes, and prove success.

Start your free trial of NinjaOne Reporting.

Final Thoughts

For IT professionals seeking a reliable way to uninstall TeamViewer on macOS, this shell script delivers a complete solution. It automates detection, execution, and verification, ensuring no remnants remain. When combined with NinjaOne’s automation and reporting capabilities, MSPs can confidently remove TeamViewer across large environments while maintaining compliance and security.

This is a professional-grade approach to uninstall TeamViewer on macOS with a shell script, offering repeatability and efficiency that manual methods cannot match.

]]>
How to Show or Disable Password Hints in macOS with a Shell Script https://www.ninjaone.com/script-hub/disable-password-hints-in-macos-using-a-shell-script/ Fri, 12 Sep 2025 21:53:40 +0000 https://www.ninjaone.com/?post_type=script_hub&p=524851 This guide will help you learn on how to show or disable password hints in macOS using a shell script. Password policies aren’t just about complexity and rotation—they’re also about the user experience when something goes wrong. On macOS, the login window can display a password hint after a specified number of failed attempts. For managed fleets, toggling that behavior consistently is invaluable.

This post walks through a Show Password Hints macOS shell script that lets you centrally configure how many failed attempts (0–5) should be allowed before showing a hint—or disable hints entirely. If you’ve ever needed information on how to Show Password Hints in macOS with a script , or a Shell script for macOS to Show Password Hints, you’re in the right place.

Background

macOS stores login window settings in preference domains under /Library/Preferences. The key that governs password hints is RetriesUntilHint in the com.apple.loginwindow domain. Setting RetriesUntilHint to a positive integer will display a user’s password hint after that many failed attempts; setting it to 0 disables hints. For IT teams and MSPs, centrally controlling that value reduces support friction (fewer lockout tickets) while respecting security posture (avoiding overly revealing hints).

The script below codifies that control path. It brings guardrails (input validation, idempotence, clear messaging) and is designed to run as root locally, via an MDM, or from an RMM like NinjaOne.

The Script

#!/usr/bin/env bash
#
# Description: Configure the number of failed password attempts before displaying the password hint. Set to 0 to disable password hints.
# By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
# Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
# Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
# Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
# Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
# Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
# Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
# EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
#
# Preset Parameter: --numberOfFailedAttempts 3
#		Enter the number of failed password attempts before displaying the password hint to the user. This value must be between 0 and 5. 0 disables password hints.
#
# Preset Parameter: --help
#		Displays some help text.
#
# Version: 1.0
# Release Notes: Initial Release

_arg_attempts=

print_help() {
    printf '\n%s\n\n' 'Usage: [--numberOfFailedAttempts|-n <arg>] [--help|-h]'
    printf '%s\n' 'Preset Parameter: --numberOfFailedAttempts 3'
    printf '\t%s\n' "Enter the number of failed password attempts before displaying the password hint to the user. This value must be between 0 and 5. 0 disables password hints."
    printf '%s\n' 'Preset Parameter: --help'
    printf '\t%s\n' "Displays this help menu."
}

die() {
    local _ret="${2:-1}"
    echo "$1" >&2
    test "${_PRINT_HELP:-no}" = yes && print_help >&2
    exit "${_ret}"
}

parse_commandline() {
    while test $# -gt 0; do
        _key="$1"
        case "$_key" in
        --numberoffailedattempts | --numberOfFailedAttempts | -n)
            test $# -lt 2 && die "[Error] Missing value for the required argument '$_key'." 1
            _arg_attempts=$2
            shift
            ;;
        --numberoffailedattempts=* | --numberOfFailedAttempts=* | -n=*)
            _arg_attempts="${_key##*=}"
            ;;
        --help | -h)
            _PRINT_HELP=yes die "" 0
            ;;
        *)
            _PRINT_HELP=yes die "[Error] Got an unexpected argument '$1'" 1
            ;;
        esac
        shift
    done
}

parse_commandline "$@"

# Import script variables
if [[ -n $numberOfFailedAttempts ]]; then
    _arg_attempts="$numberOfFailedAttempts"
fi

# Error if not running as root
if [[ $(id -u) -ne 0 ]]; then
    _PRINT_HELP=no
    die "[Error] This script must be run with root permissions. Try running it with sudo or as the system/root user." 1
fi

# Error if length of _arg_attempts is 0 (no argument entered)
if [[ -z $_arg_attempts ]]; then
    _PRINT_HELP=yes
    die "[Error] Number of failed attempts must be specified. Please enter a number between 0 and 5. 0 will disable password hints." 1
fi

# Error if _arg_attempts is not an integer
if ! [[ $_arg_attempts =~ ^-?[0-9]+$ ]]; then
    _PRINT_HELP=yes
    die "[Error] '$_arg_attempts' is not an integer. Number of failed attempts must be an integer between 0 and 5. 0 will disable password hints." 1
fi

# Validate number of attempts is within range
if [[ $_arg_attempts -lt 0 || $_arg_attempts -gt 5 ]]; then
    _PRINT_HELP=yes
    die "[Error] '$_arg_attempts' is out of range. Number of failed attempts must be between 0 and 5. 0 will disable password hints." 1
fi

# Set defaults path and name variables
defaults_path="/Library/Preferences/com.apple.loginwindow"
defaults_name="RetriesUntilHint"

# Capture the current value of the setting
currentValue=$(defaults read "$defaults_path" "$defaults_name" 2>&1)

# If the value does not exist, set it to 0
if [[ "$currentValue" == *"does not exist"* ]]; then
    currentValue=0
fi

# Check if we are disabling password hints
disabling=$(if [[ $_arg_attempts -eq 0 ]]; then echo "TRUE"; else echo "FALSE"; fi)

# Check if value is already set to the desired value
# If not, then set the value
if [[ $currentValue -eq $_arg_attempts ]]; then
    if [[ $disabling == "TRUE" ]]; then
        echo "[Info] Password hints are already disabled."
    else
        echo "[Info] Attempts limit for password hints is already set to $_arg_attempts."
    fi
else
    # Change output if we are disabling password hints
    if [[ $disabling == "TRUE" ]]; then
        echo "[Info] Disabling password hints."
    else
        echo "[Info] Setting attempts limit for password hints to $_arg_attempts."
    fi

    # Attempt to set the new value
    if ! defaultsOutput=$(defaults write "$defaults_path" "$defaults_name" -int "$_arg_attempts" 2>&1); then
        echo "[Error] Failed to set attempts limit for password hints to value '$_arg_attempts'." >&2
        _PRINT_HELP=no
        die "[Error] $defaultsOutput" 1
    else
        if [[ $disabling == "TRUE" ]]; then
            echo "[Info] Successfully disabled password hints."
        else
            echo "[Info] Successfully set attempts limit for password hints to $_arg_attempts."
        fi
    fi
fi

 

Detailed Breakdown

What the script does

  • Accepts –numberOfFailedAttempts (or -n) with a value 0–5.
  • Validates that it’s run as root.
  • Verifies the argument is present, is an integer, and is within range.
  • Reads the current RetriesUntilHint value.
  • If the target value matches the current one, it exits gracefully.
  • Otherwise, it writes the new value and reports success.

Key components

  1. Argument parsing and help
    • Supports –numberOfFailedAttempts|-n and –help|-h.
    • Also honors an environment variable numberOfFailedAttempts (useful when injecting parameters from your RMM/MDM).
    • On invalid or missing input, it prints a concise help menu and exits.
  2. Privilege check

Ensures the script runs with root privileges:
if [[ $(id -u) -ne 0 ]]; then … fi

  • Without root, defaults write to /Library/Preferences will fail.
  1. Input validation
    • Ensures a value was supplied.
    • Ensures the value is an integer.
    • Enforces the range 0..5 and fails fast if out of bounds.
  2. Current state detection

Uses:
defaults read /Library/Preferences/com.apple.loginwindow RetriesUntilHint

  • If the key doesn’t exist, it treats the current value as 0 for comparison (a common macOS pattern where absence implies the system default).
  1. Desired state calculation
    • Determines whether the instruction is to disable hints (0) or enable them after N attempts (1–5).
    • Example messages:
      • “Password hints are already disabled.”
      • “Attempts limit for password hints is already set to 3.”
  2. Idempotent write

Only writes when the current value differs:
defaults write /Library/Preferences/com.apple.loginwindow RetriesUntilHint -int “$_arg_attempts”

  • Clean success and error paths with helpful context.

Potential Use Cases

Case study: An MSP supports a finance firm with a strict lockout policy. Users often forget passwords after vacation. The MSP chooses to show hints after 3 failed attempts to reduce help desk tickets, but disables hints entirely on administrative and shared kiosk devices.

  • A NinjaOne policy targets general endpoints with –numberOfFailedAttempts 3.
  • An override policy targets privileged devices with –numberOfFailedAttempts 0.
  • Ticket volume drops for routine resets, while privileged systems maintain tighter security.

Comparisons

  • Graphical method: Historically, admins might toggle hint-related behavior via System Settings or by guiding users to maintain good hints. That doesn’t scale and isn’t enforceable across fleets.
  • Configuration profile (MDM): You can deploy a com.apple.loginwindow payload setting RetriesUntilHint. Profiles enforce a managed state but are less ad hoc than a script. Profiles are ideal for “always-on” baselines; scripts are great for remediation, testing, or conditional logic.
  • Login window scripts vs. direct plist edits: This script abstracts defaults usage and adds validation, idempotence, and clear logging—safer than ad-hoc commands.

Implications

Enabling hints after a few failures improves usability and reduces low-value tickets. However, poorly written hints can leak sensitive information (“First dog’s name + birth year”). Disabling hints hardens the login experience but may increase lockouts. The right balance depends on your risk tolerance, device role, and user population.

From a compliance lens, documenting why and where you enable hints—and ensuring hints follow secure composition guidance—helps auditors understand your rationale. From a security perspective, apply stricter settings on privileged, shared, and kiosk systems.

Recommendations

  • Choose per-role defaults: Workstations 3, executive and admin 0–1, kiosks 0.
  • Govern hint content: Provide guidance so hints never reveal actual password material or sensitive PII.
  • Automate drift correction: Run this script on a schedule or enforce via MDM profiles for baselines; use the script for remediation.
  • Log outcomes: Capture “[Info]” lines to your RMM/MDM logs for change tracking.
  • Test on a pilot group: Validate behavior across macOS versions and login scenarios before broad rollout.
  • Pair with lockout policy: Align attempts-to-hint with lockout thresholds to avoid unintended lockouts.

Final Thoughts

When you need consistent, auditable control over password hint behavior, this script is a practical lever. In NinjaOne, you can package it as a reusable macOS script, parameterize numberOfFailedAttempts per policy or dynamic group, and report on outcomes centrally. Combine it with targeted policies for privileged devices, and you’ll strike the right balance between user experience and security—without manual effort on every Mac.

]]>
How to Set or Modify DNS Server Addresses with PowerShell https://www.ninjaone.com/script-hub/modify-dns-server-addresses-with-powershell/ Fri, 12 Sep 2025 21:33:34 +0000 https://www.ninjaone.com/?post_type=script_hub&p=524845 This guide includes information on how to Modify DNS Server Addresses with PowerShell. Managing DNS settings is a core responsibility for IT administrators and Managed Service Providers (MSPs). The ability to set, modify, or reset DNS server addresses ensures that endpoints remain reliable, secure, and properly connected to organizational resources. Manually updating DNS settings can be tedious and error-prone, especially across multiple devices. That’s where automation through a PowerShell script becomes invaluable.

The script provided here is a comprehensive tool designed to set or modify the IPv4 DNS server addresses on Windows 10 workstations. It adds automation, error handling, and validation to a traditionally manual process—helping IT teams streamline operations, enforce compliance, and reduce misconfiguration risks.

Background

DNS misconfigurations are one of the most common causes of connectivity and application issues. For IT teams managing hundreds of devices, ensuring DNS is correctly configured is critical to maintaining uptime. MSPs, in particular, face the challenge of maintaining consistent DNS configurations across distributed environments with diverse needs.

This PowerShell script addresses those challenges by offering flexibility: administrators can list active network adapters, set custom DNS servers, or reset configurations back to automatic. It also includes validation logic, security checks, and region-specific requirements—features that elevate it beyond simple scripting.

The Script

#Requires -Version 5.1

<#
.SYNOPSIS
    Set or modify the IPv4 DNS servers of a specified active network adapter. If no adapter is specified, the script will attempt to modify the active adapter.
.DESCRIPTION
    Set or modify the IPv4 DNS servers of a specified active network adapter. If no adapter is specified, the script will attempt to modify the active adapter.
By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
    Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
    Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
    Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
    Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
    Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
    Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
    EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).

.PARAMETER -ListNetworkAdapters
    List all active/connected network adapters. If this is selected, the script will ignore any other parameters.
.PARAMETER -Region
    Select your region. This is required if specifying IP addresses for the primary and secondary DNS servers. Valid options are NA, CA, U2, EU, and OC.
.PARAMETER -NetworkAdapter "Ethernet"
    Enter the alias  or index of the network adapter to modify. If not specified, the script will attempt to modify the active adapter.
.PARAMETER -PrimaryDNSIP "1.1.1.1"
    Enter the IP address of the primary DNS server.
.PARAMETER -SecondaryDNSIP "8.8.8.8"
    Enter the IP address of the secondary DNS server.
.PARAMETER -ResetToAutomatic
    Check this box to reset the DNS server settings to automatic.

.EXAMPLE
    -ListNetworkAdapters

    InterfaceAlias              InterfaceIndex ConnectionState DNSServers
    --------------              -------------- --------------- ----------
    Ethernet                                14       Connected 192.168.176.1

.EXAMPLE
    -NetworkAdapter "Ethernet" -Region "NA" -PrimaryDNSIP "8.8.8.8" -SecondaryDNSIP "1.1.1.1"

    [Info] Using the network adapter with alias 'Ethernet' and index '14'.

    [Info] Setting the DNS servers for the network adapter 'Ethernet' to '8.8.8.8, 1.1.1.1'...
    [Info] The DNS servers for the network adapter 'Ethernet' have been set to '8.8.8.8, 1.1.1.1'.

    ### Previous DNS server configuration: ###

    InterfaceAlias InterfaceIndex PrimaryDNSServer SecondaryDNSServer AutomaticDNS
    -------------- -------------- ---------------- ------------------ ------------
    Ethernet                   14 192.168.176.1                               True

    ### Current DNS server configuration: ###

    InterfaceAlias InterfaceIndex PrimaryDNSServer SecondaryDNSServer AutomaticDNS
    -------------- -------------- ---------------- ------------------ ------------
    Ethernet                   14 8.8.8.8          1.1.1.1                   False

.EXAMPLE
    -NetworkAdapter "Ethernet" -ResetToAutomatic

    [Info] Using the network adapter with alias 'Ethernet' and index '14'.

    [Info] Resetting DNS server addresses for the network adapter 'Ethernet' to automatic...
    [Info] DNS server addresses for the network adapter 'Ethernet' have been reset to automatic.

    ### Previous DNS server configuration: ###

    InterfaceAlias InterfaceIndex PrimaryDNSServer SecondaryDNSServer AutomaticDNS
    -------------- -------------- ---------------- ------------------ ------------
    Ethernet                   14 8.8.8.8          1.1.1.1                   False

    ### Current DNS server configuration: ###

    InterfaceAlias InterfaceIndex PrimaryDNSServer SecondaryDNSServer AutomaticDNS
    -------------- -------------- ---------------- ------------------ ------------
    Ethernet                   14 192.168.176.1                               True

.NOTES
    Minimum OS Architecture Supported: Windows 10
    Version: 1.0
    Release Notes: Initial Release
#>

[CmdletBinding()]
param (
    [Parameter()]
    [switch]$ListNetworkAdapters = [System.Convert]::ToBoolean($env:ListNetworkAdapters),
    [Parameter()]
    [string]$Region,
    [Parameter()]
    [string]$NetworkAdapter,
    [Parameter()]
    [string]$PrimaryDNSIP,
    [Parameter()]
    [string]$SecondaryDNSIP,
    [Parameter()]
    [switch]$ResetToAutomatic = [System.Convert]::ToBoolean($env:ResetToAutomatic)
)

begin {
    # Check if the operating system build version is less than 10240 (Windows 10) minimum requirement)
    if ([System.Environment]::OSVersion.Version.Build -lt 10240) {
        Write-Host -Object "`n[Warning] The minimum OS version supported by this script is Windows 10 (10240)."
        Write-Host -Object "[Warning] OS build '$([System.Environment]::OSVersion.Version.Build)' detected. This could lead to errors or unexpected results.`n"
    }

    # Import the script variables
    if ($env:Region) { $Region = $env:Region }
    if ($env:NetworkAdapter) { $NetworkAdapter = $env:NetworkAdapter }
    if ($env:PrimaryDNSIP) { $PrimaryDNSIP = $env:PrimaryDNSIP }
    if ($env:SecondaryDNSIP) { $SecondaryDNSIP = $env:SecondaryDNSIP }

    # Function to test if this script is running on a server OS
    function Test-IsServer {
        [CmdletBinding()]
        param()

        # Determine the method to retrieve the operating system information based on PowerShell version
        $OS = if ($PSVersionTable.PSVersion.Major -lt 3) {
            Get-WmiObject -Class Win32_OperatingSystem
        } else {
            Get-CimInstance -ClassName Win32_OperatingSystem
        }

        # Check if the ProductType is "2", which indicates that the system is a domain controller or is a server
        if (($OS.ProductType -eq "2" -or $OS.ProductType -eq "3") -and $OS.OperatingSystemSku -ne "175") {
            return $true
        }
    }

    # Error if a server OS is detected
    if (Test-IsServer) {
        Write-Host -Object "[Error] This script is not supported on server operating systems. Only workstations are supported."
        exit 1
    }

    # Warn if the user is trying to list network adapters while also providing DNS settings
    if ($ListNetworkAdapters -and ($Region -or $NetworkAdapter -or $PrimaryDNSIP -or $SecondaryDNSIP -or $ResetToAutomatic)) {
        Write-Host -Object "[Warning] The 'List Network Adapters' option is selected, but other settings are also provided. The script will only list the active network adapters and will not modify any DNS settings."
        $Region = $null
        $NetworkAdapter = $null
        $PrimaryDNSIP = $null
        $SecondaryDNSIP = $null
        $ResetToAutomatic = $null
    }

    # Validate the region if provided
    if ($Region) {
        $Region = $Region.Trim()

        $SupportedRegions = @("NA", "US2", "EU", "CA", "OC")

        if ([string]::IsNullOrWhiteSpace($Region)) {
            Write-Host -Object "[Error] The region is empty or whitespace. Please provide one of the following regions: $($SupportedRegions -join ", ")"
            exit 1
        }

        # Check if the region is one of the supported regions
        if ($SupportedRegions -notcontains $Region) {
            Write-Host -Object "[Error] The region '$Region' is not supported. Please provide one of the following regions: $($SupportedRegions -join ', ')."
            exit 1
        }
    }

    # Validate the primary DNS IP if provided
    if ($PrimaryDNSIP) {
        $PrimaryDNSIP = $PrimaryDNSIP.Trim()

        if ([string]::IsNullOrWhiteSpace($PrimaryDNSIP)) {
            Write-Host -Object "[Error] The primary DNS IP address is empty or whitespace. Please provide a valid IP address or leave it blank."
            exit 1
        }

        # Check if the primary DNS IP is formatted correctly as an IPv4 address
        if ($PrimaryDNSIP -notmatch "^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") {
            Write-Host -Object "[Error] The primary DNS IP address '$PrimaryDNSIP' is not formatted correctly as an IPv4 address. Please provide a valid IP address in the format 'x.x.x.x'."
            exit 1
        }

        # Check if the primary DNS IP is a valid IPv4 address
        if (-not [System.Net.IPAddress]::TryParse($PrimaryDNSIP, [ref]$null)) {
            Write-Host -Object "[Error] The primary DNS IP address '$PrimaryDNSIP' is not a valid IPv4 address. Please provide a valid IP address."
            exit 1
        }
    }

    # Validate the secondary DNS IP if provided
    if ($SecondaryDNSIP) {
        $SecondaryDNSIP = $SecondaryDNSIP.Trim()

        if ([string]::IsNullOrWhiteSpace($SecondaryDNSIP)) {
            Write-Host -Object "[Error] The secondary DNS IP address is empty or whitespace. Please provide a valid IP address or leave it blank."
            exit 1
        }

        # Check if the secondary DNS IP is formatted correctly as an IPv4 address
        if ($SecondaryDNSIP -notmatch "^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") {
            Write-Host -Object "[Error] The secondary DNS IP address '$SecondaryDNSIP' is not formatted correctly as an IPv4 address. Please provide a valid IP address in the format 'x.x.x.x'."
            exit 1
        }

        # Check if the secondary DNS IP is a valid IPv4 address
        if (-not [System.Net.IPAddress]::TryParse($SecondaryDNSIP, [ref]$null)) {
            Write-Host -Object "[Error] The secondary DNS IP address '$SecondaryDNSIP' is not a valid IPv4 address. Please provide a valid IP address."
            exit 1
        }

        # Check if the secondary DNS IP is the same as the primary DNS IP
        if ($PrimaryDNSIP -eq $SecondaryDNSIP) {
            Write-Host -Object "[Error] The primary and secondary DNS IP addresses cannot be the same. Please provide different IP addresses."
            exit 1
        }
    }

    # Function to retrieve and display all active network adapters
    function Get-ActiveNetworkAdapters {
        param (
            [switch]$WriteToHost
        )

        # Identify loopback adapters on the device by if they have an IP address that starts with 127.
        try {
            $LoopbackAdapters = Get-NetIPAddress -ErrorAction Stop | Where-Object {$_.IPAddress -match "^127\."}
        }
        catch {
            Write-Host -Object "[Error] Unable to retrieve loopback adapters."
            exit 1
        }

        # Find the active IPv4 network adapters
        try {
            $ActiveNetworkAdaptersObject = Get-NetIPInterface -AddressFamily IPv4 -ConnectionState Connected -ErrorAction Stop | Sort-Object -Property InterfaceAlias |
                Select-Object -Property InterfaceAlias, InterfaceIndex, ConnectionState, @{
                    Name = "DNSServers";
                    Expression = {
                        (Get-DnsClientServerAddress -InterfaceIndex $_.InterfaceIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue).ServerAddresses
                    }
                }
        }
        catch {
            Write-Host -Object "[Error] Unable to retrieve network adapters."
            Write-Host -Object "[Error] $($_.Exception.Message)"
            exit 1
        }

        # Filter out the loopback addresses
        $ActiveNetworkAdaptersObject = $ActiveNetworkAdaptersObject | Where-Object {$_.InterfaceIndex -notin $LoopbackAdapters.InterfaceIndex}

        # If WriteToHost is selected, format the output as a table and write it to the host
        # Otherwise, return the object
        if ($WriteToHost) {
            $ActiveNetworkAdaptersObject | Format-Table -AutoSize | Out-String | Write-Host
        }
        else {
            return $ActiveNetworkAdaptersObject
        }
    }

    # Error if the user is trying to reset to automatic while also providing DNS settings
    if ($ResetToAutomatic -and ($PrimaryDNSIP -or $SecondaryDNSIP)) {
        Write-Host -Object "[Error] You cannot reset the DNS settings to automatic while also providing primary or secondary DNS IP addresses. Please choose one option or the other."
        exit 1
    }

    # Validate the network adapter if provided
    if ($NetworkAdapter) {
        $NetworkAdapter = $NetworkAdapter.Trim()

        if ([string]::IsNullOrWhiteSpace($NetworkAdapter)) {
            Write-Host -Object "[Error] The network adapter name is empty or whitespace. Please provide a valid network adapter name or leave it blank."
            exit 1
        }

        # If the network adapter provided is only digits, treat it as an index and validate it
        # Otherwise, treat it as an alias
        if ($NetworkAdapter -match "^\d+$") {
            try {
                $NetworkAdapterIndex = [int]$NetworkAdapter
            }
            catch {
                Write-Host -Object "[Error] The network adapter index '$NetworkAdapter' is not a valid integer. Please provide a valid and connected network adapter index or leave it blank."
                Write-Host -Object "[Error] $($_.Exception.Message)"
                Get-ActiveNetworkAdapters -WriteToHost
                exit 1
            }

            try {
                $NetworkAdapterToModify = Get-NetAdapter -InterfaceIndex $NetworkAdapterIndex -ErrorAction Stop
            }
            catch {
                Write-Host -Object "[Error] Failed to retrieve a network adapter with the index '$NetworkAdapterIndex'. Please provide a valid and connected network adapter index or leave it blank."
                Write-Host -Object "[Error] $($_.Exception.Message)"
                Get-ActiveNetworkAdapters -WriteToHost
                exit 1
            }
        }
        else {
            $NetworkAdapterAlias = $NetworkAdapter

            try {
                $NetworkAdapterToModify = Get-NetAdapter -InterfaceAlias $NetworkAdapterAlias -ErrorAction Stop
            }
            catch {
                Write-Host -Object "[Error] Failed to retrieve a network adapter with the alias '$NetworkAdapterAlias'. Please provide a valid and connected network adapter alias or leave it blank."
                Write-Host -Object "[Error] $($_.Exception.Message)"
                Get-ActiveNetworkAdapters -WriteToHost
                exit 1
            }
        }
    }

    # Error if neither servers are provided and we are not resetting to default
    if (-not $ListNetworkAdapters -and [string]::IsNullOrWhiteSpace($NetworkAdapter) -and [string]::IsNullOrWhiteSpace($PrimaryDNSIP) -and [string]::IsNullOrWhiteSpace($SecondaryDNSIP) -and -not $ResetToAutomatic) {
        Write-Host -Object "[Error] No DNS options were selected or filled out. Please choose or fill out at least one option."
        exit 1
    }

    # Error if a network adapter was provided but no DNS options were selected
    if ($NetworkAdapter -and [string]::IsNullOrWhiteSpace($PrimaryDNSIP) -and [string]::IsNullOrWhiteSpace($SecondaryDNSIP) -and -not $ResetToAutomatic) {
        Write-Host -Object "[Error] A network adapter was specified, but no DNS options were selected. Please provide a primary or secondary DNS IP address, or check the 'Reset To Automatic' option."
        exit 1
    }

    # Error if DNS IPs were specified but a region was not provided
    if (-not $Region -and ($PrimaryDNSIP -or $SecondaryDNSIP)) {
        Write-Host -Object "[Error] A region must be chosen when specifying DNS IP addresses. Please provide a valid region or use the 'Reset To Automatic' option."
        exit 1
    }

    function Test-IsElevated {
        [CmdletBinding()]
        param ()

        # Get the current Windows identity of the user running the script
        $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()

        # Create a WindowsPrincipal object based on the current identity
        $p = New-Object System.Security.Principal.WindowsPrincipal($id)

        # Check if the current user is in the Administrator role
        # The function returns $True if the user has administrative privileges, $False otherwise
        # 544 is the value for the Built In Administrators role
        # Reference: https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsbuiltinrole
        $p.IsInRole([System.Security.Principal.WindowsBuiltInRole]'544')
    }

    if (!$ExitCode) {
        $ExitCode = 0
    }
}
process {
    # Attempt to determine if the current session is running with Administrator privileges.
    try {
        $IsElevated = Test-IsElevated -ErrorAction Stop
    }
    catch {
        Write-Host -Object "[Error] $($_.Exception.Message)"
        Write-Host -Object "[Error] Unable to determine if the account '$env:Username' is running with Administrator privileges."
        exit 1
    }

    if (!$IsElevated) {
        Write-Host -Object "[Error] Access Denied: The user '$env:Username' does not have administrator privileges, or the script is not running with elevated permissions."
        exit 1
    }

    # If the user requested to list the network adapters, list them and exit
    if ($ListNetworkAdapters) {
        Get-ActiveNetworkAdapters -WriteToHost
        exit 0
    }

    # Initialize the changesMade variable
    $changesMade = $False

    # Define a list of NinjaOne agent and patcher hostnames to verify DNS resolution against
    $RequiredAgentHostnames = @(
        # Global agent/patcher endpoints
        [PSCustomObject]@{ Hostname = "resources.ninjarmm.com"; Region = "Global" }
        [PSCustomObject]@{ Hostname = "agent-tun-usw-1.ninjarmm.com"; Region = "Global" }
        [PSCustomObject]@{ Hostname = "agent-tun-usw-2.ninjarmm.com"; Region = "Global" }
        [PSCustomObject]@{ Hostname = "agent-tun-usw-3.ninjarmm.com"; Region = "Global" }
        [PSCustomObject]@{ Hostname = "ninjauploads.s3.amazonaws.com"; Region = "Global" }
        # NA region agent/patcher endpoints
        [PSCustomObject]@{ Hostname = "app.ninjarmm.com"; Region = "NA" }
        [PSCustomObject]@{ Hostname = "fts-prod-oregon-1.ninjarmm.com"; Region = "NA" }
        [PSCustomObject]@{ Hostname = "fts-prod-oregon-2.ninjarmm.com"; Region = "NA" }
        [PSCustomObject]@{ Hostname = "rtc-us-west-1.ninjarmm.com"; Region = "NA" }
        [PSCustomObject]@{ Hostname = "rtc-us-west-2.ninjarmm.com"; Region = "NA" }
        # US2 region agent/patcher endpoints
        [PSCustomObject]@{ Hostname = "fts-1.us2.ninjarmm.com"; Region = "US2" }
        [PSCustomObject]@{ Hostname = "rtc-us2.us2.ninjarmm.com"; Region = "US2" }
        [PSCustomObject]@{ Hostname = "agent-tun-use2-0.us2.ninjarmm.com"; Region = "US2" }
        # EU region agent/patcher endpoints
        [PSCustomObject]@{ Hostname = "agent-eu-central.ninjarmm.com"; Region = "EU" }
        [PSCustomObject]@{ Hostname = "fts-prod-frankfurt.ninjarmm.com"; Region = "EU" }
        [PSCustomObject]@{ Hostname = "rtc-eu-central-1.ninjarmm.com"; Region = "EU" }
        [PSCustomObject]@{ Hostname = "agent-tun-euc-0.ninjarmm.com"; Region = "EU" }
        # CA region agent/patcher endpoints
        [PSCustomObject]@{ Hostname = "ca.ninjarmm.com"; Region = "CA" }
        [PSCustomObject]@{ Hostname = "agent-ca.ninjarmm.com"; Region = "CA" }
        [PSCustomObject]@{ Hostname = "rtc-ca-central.ninjarmm.com"; Region = "CA" }
        [PSCustomObject]@{ Hostname = "tun-ca-central-1.ninjarmm.com"; Region = "CA" }
        [PSCustomObject]@{ Hostname = "connect-ca-central-s1.ninjarmm.com"; Region = "CA" }
        [PSCustomObject]@{ Hostname = "connect-ca-central-s2.ninjarmm.com"; Region = "CA" }
        [PSCustomObject]@{ Hostname = "connect-ca-central-s3.ninjarmm.com"; Region = "CA" }
        # OC region agent/patcher endpoints
        [PSCustomObject]@{ Hostname = "oc.ninjarmm.com"; Region = "OC" }
        [PSCustomObject]@{ Hostname = "rtc-ap-southeast-2-0.ninjarmm.com"; Region = "OC" }
        [PSCustomObject]@{ Hostname = "tun-apse2-0.ninjarmm.com"; Region = "OC" }
        [PSCustomObject]@{ Hostname = "fts-prod-sydney.ninjarmm.com"; Region = "OC" }
    )

    # Determine the hostnames to test against based on the provided region
    $HostnamesToTest = $RequiredAgentHostnames | Where-Object {$_.Region -eq "$Region" -or $_.Region -eq "Global"} | Select-Object -ExpandProperty Hostname

    # Test the primary DNS server
    if ($PrimaryDNSIP) {
        foreach ($hostname in $HostnamesToTest) {
            try {
                $DNSResult = Resolve-DnsName -Name $hostname -Server $PrimaryDNSIP -Type A -DnsOnly -NoHostsFile -ErrorAction Stop

                # DNS servers blocking a specific domain usually return 0.0.0.0 as the IP address
                if ($DNSResult.IPAddress -contains "0.0.0.0") {
                    throw "The server at '$PrimaryDNSIP' returned an invalid IP address for '$hostname'. It is likely being blocked by the server."
                }
            }
            catch {
                Write-Host -Object "[Error] DNS resolution failed for the primary DNS server '$PrimaryDNSIP' while testing '$hostname'."
                Write-Host -Object "[Error] Please check the DNS server configuration or choose a different DNS server."
                Write-Host -Object "[Error] Ninja whitelisting information: https://ninjarmm.zendesk.com/hc/en-us/articles/211406886-Global-Allowlist-Whitelist-Information"
                Write-Host -Object "[Error] $($_.Exception.Message)"
                exit 1
            }
        }
    }

    # Test the secondary DNS server
    if ($SecondaryDNSIP) {
        foreach ($hostname in $HostnamesToTest) {
            try {
                $DNSResult = Resolve-DnsName -Name $hostname -Server $SecondaryDNSIP -Type A -DnsOnly -NoHostsFile -ErrorAction Stop

                # DNS servers blocking a specific domain usually return 0.0.0.0 as the IP address
                if ($DNSResult.IPAddress -contains "0.0.0.0") {
                    throw "The server at '$PrimaryDNSIP' returned an invalid IP address for '$hostname'. It is likely being blocked by the server."
                }
            }
            catch {
                Write-Host -Object "[Error] DNS resolution failed for the secondary DNS server '$SecondaryDNSIP' while testing '$hostname'."
                Write-Host -Object "[Error] Please check the DNS server configuration or choose a different DNS server."
                Write-Host -Object "[Error] Ninja whitelisting information: https://ninjarmm.zendesk.com/hc/en-us/articles/211406886-Global-Allowlist-Whitelist-Information"
                Write-Host -Object "[Error] $($_.Exception.Message)"
                exit 1
            }
        }
    }

    # If a network adapter wasn't specified, attempt to find an active one
    if (-not $NetworkAdapterToModify) {
        Write-Host -Object "[Info] No network adapter specified. Attempting to find an active network adapter..."
        $ActiveAdapters = Get-ActiveNetworkAdapters

        # If there is only one active network adapter, use it
        # Otherwise, error out and list the active adapters
        if (($ActiveAdapters | Measure-Object).Count -eq 1) {
            $NetworkAdapterToModify = Get-NetAdapter -InterfaceIndex $ActiveAdapters.InterfaceIndex
            Write-Host -Object "[Info] Found a single active network adapter with alias '$($NetworkAdapterToModify.InterfaceAlias)' and index '$($NetworkAdapterToModify.InterfaceIndex)'."
        }
        else {
            Write-Host -Object "[Error] Multiple active network adapters found. Please specify a network adapter to modify."
            Get-ActiveNetworkAdapters -WriteToHost
            exit 1
        }
    }
    elseif ($NetworkAdapterToModify.Count -gt 1) {
        Write-Host -Object "[Error] Multiple network adapters found with the alias or index '$NetworkAdapter'. Please specify a unique network adapter to modify."
        Get-ActiveNetworkAdapters -WriteToHost
        exit 1
    }
    else {
        Write-Host -Object "[Info] Using the network adapter with alias '$($NetworkAdapterToModify.InterfaceAlias)' and index '$($NetworkAdapterToModify.InterfaceIndex)'."
    }

    Write-Host ""

    # Get the current DNS servers of the network adapter
    try {
        $StartingDNSServers = (Get-DnsClientServerAddress -InterfaceIndex $NetworkAdapterToModify.InterfaceIndex -AddressFamily IPv4 -ErrorAction Stop).ServerAddresses
    }
    catch {
        Write-Host -Object "[Error] Unable to retrieve DNS server addresses for the network adapter '$($NetworkAdapterToModify.InterfaceAlias)'."
        Write-Host -Object "[Error] $($_.Exception.Message)"
        exit 1
    }

    # Retrieve the network adapter settings from the registry
    try {
        $AdapterSettings = Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\$($NetworkAdapterToModify.InterfaceGuid)" -ErrorAction Stop
    }
    catch {
        Write-Host "[Error] Failed to retrieve interface settings for the network adapter '$($NetworkAdapterToModify.InterfaceAlias)'."
        Write-Host "[Error] $($_.Exception.Message)"
        exit 1
    }

    # If DNS for this adapter is configured manually, the name servers will be present in this property separated by commas
    $ManuallyConfiguredDNSServers = $AdapterSettings.NameServer -split ","

    # Determine if the DNS servers are set to automatic or manually configured
    if ([string]::IsNullOrWhiteSpace($ManuallyConfiguredDNSServers)) {
        $StartingAutomaticDNSStatus = $true
        $CurrentAutomaticDNSStatus = $true
    }
    else {
        $StartingAutomaticDNSStatus = $false
        $CurrentAutomaticDNSStatus = $false
        $StartingManualPrimaryDNSAddress = $ManuallyConfiguredDNSServers[0]
        $StartingManualSecondaryDNSAddress = $ManuallyConfiguredDNSServers[1]
    }

    # If the user requested to reset to automatic, reset the DNS settings
    if ($ResetToAutomatic) {
        # If the DNS servers are already set to automatic, inform the user
        # Otherwise, continue to reset the DNS settings
        if ($StartingAutomaticDNSStatus) {
            Write-Host -Object "[Info] The network adapter '$($NetworkAdapterToModify.InterfaceAlias)' is already using automatic DNS settings."
        }
        else {
            $DHCPEnabled = $AdapterSettings.EnableDhcp
            $DHCPServer = $AdapterSettings.DhcpServer
            $DNSServerFromDhcp = $AdapterSettings.DhcpNameServer

            # Error if the network adapter does not have DHCP enabled
            if ($DHCPEnabled -eq 0) {
                Write-Host -Object "[Error] The network adapter '$($NetworkAdapterToModify.InterfaceAlias)' does not have DHCP enabled. Please enable DHCP on the adapter to reset DNS settings to automatic."
                $DHCPError = $true
                $ExitCode = 1
            }

            # Error if the DHCP server is not set or is invalid
            if (-not $DHCPError -and ($DHCPServer -eq "255.255.255.255" -or [string]::IsNullOrWhiteSpace($DHCPServer))) {
                Write-Host -Object "[Error] The network adapter '$($NetworkAdapterToModify.InterfaceAlias)' does not have a DHCP server identified. Please ensure your DHCP server is working correctly and try again."
                $DHCPError = $true
                $ExitCode = 1
            }

            # Error if the DHCP server is not providing any DNS servers
            if (-not $DHCPError -and ([string]::IsNullOrWhiteSpace($DNSServerFromDhcp))) {
                Write-Host -Object "[Error] The DHCP server at $DHCPServer does not provide any DNS servers."
                $DHCPError = $true
                $ExitCode = 1
            }

            # If there are no DHCP-related errors, continue to reset the DNS settings
            if (-not $DHCPError) {
                try {
                    Write-Host -Object "[Info] Resetting DNS server addresses for the network adapter '$($NetworkAdapterToModify.InterfaceAlias)' to automatic..."
                    Set-DnsClientServerAddress -InterfaceIndex $NetworkAdapterToModify.InterfaceIndex -ResetServerAddresses -ErrorAction Stop
                    Write-Host -Object "[Info] DNS server addresses for the network adapter '$($NetworkAdapterToModify.InterfaceAlias)' have been reset to automatic."
                    $CurrentAutomaticDNSStatus = $true
                    $changesMade = $true
                }
                catch {
                    Write-Host -Object "[Error] Failed to reset DNS server addresses for the network adapter '$($NetworkAdapterToModify.InterfaceAlias)'."
                    Write-Host -Object "[Error] $($_.Exception.Message)"
                    $ExitCode = 1
                }
            }
        }
    }

    # If the user provided both primary and secondary DNS IPs, set them
    if ($PrimaryDNSIP -and $SecondaryDNSIP) {
        # If both primary and secondary DNS IPs are already manually configured, check if they match the provided values
        if ($StartingManualPrimaryDNSAddress -eq $PrimaryDNSIP -and $StartingManualSecondaryDNSAddress -eq $SecondaryDNSIP) {
            Write-Host -Object "[Info] The DNS servers of '$($NetworkAdapterToModify.InterfaceAlias)' are already set to '$($StartingDNSServers -join ", ")'."
        }
        else {
            $DNSServersToSet = @($PrimaryDNSIP, $SecondaryDNSIP)
            try {
                Write-Host -Object "[Info] Setting the DNS servers for the network adapter '$($NetworkAdapterToModify.InterfaceAlias)' to '$($DNSServersToSet -join ", ")'..."
                Set-DnsClientServerAddress -InterfaceIndex $NetworkAdapterToModify.InterfaceIndex -ServerAddresses $DNSServersToSet -ErrorAction Stop
                Write-Host -Object "[Info] The DNS servers for the network adapter '$($NetworkAdapterToModify.InterfaceAlias)' have been set to '$($DNSServersToSet -join ", ")'."
                $CurrentAutomaticDNSStatus = $false
                $changesMade = $true
            }
            catch {
                Write-Host -Object "[Error] Failed to set primary DNS server for the network adapter '$($NetworkAdapterToModify.InterfaceAlias)'."
                Write-Host -Object "[Error] $($_.Exception.Message)"
                $ExitCode = 1
            }
        }
    }

    # If the user provided a primary DNS IP but not a secondary DNS IP, set the primary DNS IP
    if ($PrimaryDNSIP -and -not $SecondaryDNSIP) {
        switch ($PrimaryDNSIP) {
            # Error if the requested primary DNS IP is the same as the current secondary DNS IP
            $StartingManualSecondaryDNSAddress {
                Write-Host -Object "[Error] The primary DNS server cannot be set to the same IP address as the secondary DNS server. Please provide a different primary DNS IP address."
                $ExitCode = 1
            }
            # If the requested primary DNS IP is the same as the current primary DNS IP, inform the user
            $StartingManualPrimaryDNSAddress {
                Write-Host -Object "[Info] The primary DNS server of '$($NetworkAdapterToModify.InterfaceAlias)' is already set to '$PrimaryDNSIP'."
            }
            # Otherwise, continue to set the primary DNS server
            default {
                $DNSServersToSet = @($PrimaryDNSIP, $StartingManualSecondaryDNSAddress)
                Write-Host -Object "[Info] Setting the primary DNS server for the network adapter '$($NetworkAdapterToModify.InterfaceAlias)' to '$PrimaryDNSIP'..."
                try {
                    Set-DnsClientServerAddress -InterfaceIndex $NetworkAdapterToModify.InterfaceIndex -ServerAddresses $DNSServersToSet -ErrorAction Stop
                    Write-Host -Object "[Info] The primary DNS server for the network adapter '$($NetworkAdapterToModify.InterfaceAlias)' has been set to '$PrimaryDNSIP'."
                    $CurrentAutomaticDNSStatus = $false
                    $changesMade = $true
                }
                catch {
                    Write-Host -Object "[Error] Failed to set primary DNS server for the network adapter '$($NetworkAdapterToModify.InterfaceAlias)'."
                    Write-Host -Object "[Error] $($_.Exception.Message)"
                    $ExitCode = 1
                }
            }
        }
    }

    # If the user provided a secondary DNS IP but not a primary DNS IP, set the secondary DNS IP
    if ($SecondaryDNSIP -and -not $PrimaryDNSIP) {
        # If automatic DNS is enabled and we are only setting a secondary DNS IP, error out
        if ($StartingAutomaticDNSStatus) {
            Write-Host -Object "[Error] The network adapter '$($NetworkAdapterToModify.InterfaceAlias)' is currently using automatic DNS, but a manual primary IP address is not being configured. Please provide a primary DNS IP in order to set the secondary DNS IP properly."
            $ExitCode = 1
        }
        else {
            switch ($SecondaryDNSIP) {
                # Error if the requested secondary DNS IP is the same as the current primary DNS IP
                $StartingManualPrimaryDNSAddress {
                    Write-Host -Object "[Error] The secondary DNS server cannot be set to the same IP address as the primary DNS server. Please provide a different secondary DNS IP address."
                    $ExitCode = 1
                }
                # If the requested secondary DNS IP is the same as the current secondary DNS IP, inform the user
                $StartingManualSecondaryDNSAddress {
                    Write-Host -Object "[Info] The secondary DNS server of '$($NetworkAdapterToModify.InterfaceAlias)' is already set to '$SecondaryDNSIP'."
                }
                # Otherwise, continue to set the secondary DNS IP
                default {
                    $DNSServersToSet = @($StartingManualPrimaryDNSAddress, $SecondaryDNSIP)
                    Write-Host -Object "[Info] Setting the secondary DNS server for the network adapter '$($NetworkAdapterToModify.InterfaceAlias)' to '$SecondaryDNSIP'..."
                    try {
                        Set-DnsClientServerAddress -InterfaceIndex $NetworkAdapterToModify.InterfaceIndex -ServerAddresses $DNSServersToSet -ErrorAction Stop
                        Write-Host -Object "[Info] The secondary DNS server for the network adapter '$($NetworkAdapterToModify.InterfaceAlias)' has been set to '$SecondaryDNSIP'."
                        $CurrentAutomaticDNSStatus = $false
                        $changesMade = $true
                    }
                    catch {
                        Write-Host -Object "[Error] Failed to set secondary DNS server for the network adapter '$($NetworkAdapterToModify.InterfaceAlias)'."
                        Write-Host -Object "[Error] $($_.Exception.Message)"
                        $ExitCode = 1
                    }
                }
            }
        }
    }

    Write-Host ""

    if ($changesMade) {
        # Output the previous DNS configuration of the network adapter
        Write-Host -Object "### Previous DNS server configuration: ###`n"
        ([PSCustomObject]@{
            InterfaceAlias = $NetworkAdapterToModify.InterfaceAlias
            InterfaceIndex = $NetworkAdapterToModify.InterfaceIndex
            PrimaryDNSServer = $StartingDNSServers[0]
            SecondaryDNSServer = $StartingDNSServers[1]
            AutomaticDNS = $StartingAutomaticDNSStatus
        } | Format-Table -AutoSize | Out-String).Trim() | Write-Host
        Write-Host -Object ""
    }

    # Retrieve the current DNS server addresses after modification
    try {
        $CurrentDNSServers = (Get-DnsClientServerAddress -InterfaceIndex $NetworkAdapterToModify.InterfaceIndex -AddressFamily IPv4 -ErrorAction Stop).ServerAddresses
    }
    catch {
        Write-Host -Object "[Error] Unable to retrieve DNS server addresses for the network adapter '$($NetworkAdapterToModify.InterfaceAlias)'. Changes made: $changesMade"
        Write-Host -Object "[Error] $($_.Exception.Message)"
        exit 1
    }

    # Output the current DNS configuration of the network adapter
    Write-Host -Object "### Current DNS server configuration: ###`n"
    ([PSCustomObject]@{
        InterfaceAlias = $NetworkAdapterToModify.InterfaceAlias
        InterfaceIndex = $NetworkAdapterToModify.InterfaceIndex
        PrimaryDNSServer = $CurrentDNSServers[0]
        SecondaryDNSServer = $CurrentDNSServers[1]
        AutomaticDNS = $CurrentAutomaticDNSStatus
    } | Format-Table -AutoSize | Out-String).Trim() | Write-Host

    exit $ExitCode
}
end {
    
    
    
}

 

Detailed Breakdown

The script’s workflow can be divided into several functional stages:

  1. Parameter Handling
    • Accepts input for adapter name, region, primary/secondary DNS, or a reset-to-automatic option.
    • Environment variables are also supported, making it compatible with automation platforms like NinjaOne.
  2. System and Security Checks
    • Confirms the device is running Windows 10 or later.
    • Blocks execution on server operating systems (ensuring it runs only on workstations).
    • Verifies administrator privileges, since DNS changes require elevated rights.
  3. Adapter Identification
    • Lists active adapters with their alias, index, and current DNS settings.
    • Supports both adapter alias (e.g., “Ethernet”) and index numbers.
  4. Validation and Safety Nets
    • Ensures DNS IPs are correctly formatted and unique.
    • Validates the chosen region (NA, US2, EU, CA, OC) to align with NinjaOne’s infrastructure.
    • Blocks conflicting options (e.g., providing DNS IPs and requesting reset-to-automatic simultaneously).
  5. DNS Resolution Testing
    • Before applying changes, the script tests the specified DNS servers against NinjaOne’s agent and patcher hostnames.
    • If the DNS server blocks or misresolves these endpoints, the script halts—avoiding broken configurations.
  6. DNS Configuration Changes
    • Supports three modes:
      • Reset to Automatic (DHCP-managed).
      • Set Primary/Secondary DNS Servers.
      • Modify just one DNS entry while retaining the other.
    • Displays both the previous and current DNS configuration after changes.

Potential Use Cases

Consider an MSP managing multiple clients across different regions:

  • A client in Europe needs all workstations pointed to a specific DNS for compliance.
  • Another client requests DNS reset to automatic after a VPN migration.
  • An IT admin wants to confirm which adapters are active and what DNS servers they are using.

Using this script within NinjaOne’s automation engine, the MSP can deploy these changes at scale—without touching each machine individually.

Comparisons

Traditional methods include:

  • Control Panel GUI: Manual, not scalable, prone to human error.
  • Netsh commands: Functional but less intuitive, with limited validation.
  • Group Policy: Centralized, but less flexible across mixed environments.

This PowerShell script blends automation, validation, and reporting into one tool, striking a balance between precision and scalability.

Implications

DNS settings affect not only connectivity but also security posture. Incorrect DNS entries may prevent critical services like NinjaOne agents from communicating with their cloud infrastructure. Worse, malicious DNS misconfigurations can expose organizations to phishing or data exfiltration risks. By enforcing validated and secure DNS settings, IT teams improve both reliability and security.

Recommendations

  • Always test DNS servers in a staging environment before deploying broadly.
  • Use this script with automation tools like NinjaOne to ensure consistency.
  • Document the DNS settings per client or region for audit readiness.
  • When possible, favor trusted DNS providers that support filtering, logging, and redundancy.

Final Thoughts

This script demonstrates the power of PowerShell automation in modern IT management. By enabling administrators to set or modify DNS server addresses with precision, it reduces errors, saves time, and enhances endpoint security. When integrated with NinjaOne, IT professionals gain centralized control and visibility—ensuring DNS consistency across diverse, distributed environments.

]]>
How to Uninstall Notepad++ with PowerShell Scripting https://www.ninjaone.com/script-hub/uninstall-notepad-with-powershell-scripting/ Fri, 12 Sep 2025 21:32:24 +0000 https://www.ninjaone.com/?post_type=script_hub&p=524823 This guide provides a thorough explanation on how to uninstall Notepad++ with PowerShell scripting. Managing software across multiple systems is a critical responsibility for IT administrators and Managed Service Providers (MSPs). One common challenge is ensuring that unwanted or outdated applications are completely removed from client machines. Notepad++, though widely used, may occasionally need to be uninstalled for compliance, standardization, or troubleshooting. The PowerShell script provided offers an automated, reliable, and administrator-friendly way to handle this task across Windows environments.

Background

Manual uninstalls often lead to incomplete removals, leftover files, or errors when the application is running. For IT teams managing fleets of devices, doing this individually isn’t feasible. By using PowerShell, administrators can automate the process, enforce consistency, and avoid manual errors. This script focuses specifically on using PowerShell to uninstall Notepad++, handling both MSI and EXE installations, while also accounting for scenarios where the application is still running.

The Script

#Requires -Version 5.1

<#
.SYNOPSIS
    Uninstall Notepad++. If the app is running, and the Force Close Notepad checkbox is unchecked, the uninstall will fail. If the Force Close Notepad is checked, and the app is running, unsaved work will not be saved.
.DESCRIPTION
    Uninstall Notepad++. If the app is running, and the Force Close Notepad checkbox is unchecked, the uninstall will fail. If the Force Close Notepad is checked, and the app is running, unsaved work will not be saved.
By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
    Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
    Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
    Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
    Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
    Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
    Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
    EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
.EXAMPLE
    (No Parameters)
    Verifying that Notepad++ is installed.
    Removing Notepad++ using 'C:\Program Files (x86)\Notepad++\uninstall.exe /S'.
    Verifying that Notepad++ has been removed.
    Notepad++ has been successfully removed.

.PARAMETER ForceCloseNotepad
    Verifying that Notepad++ is installed.
    Notepad++ was successfully force closed.
    Removing Notepad++ using 'C:\Program Files\Notepad++\uninstall.exe /S'.
    Verifying that Notepad++ has been removed.
    Notepad++ has been successfully removed.

.NOTES
    Minimum OS Architecture Supported: Windows 10, Windows Server 2016
    Version: 1.0
    Release Notes: Initial Release
#>

[CmdletBinding()]
param (
    [Parameter()]
    [Switch]$ForceCloseNotepad = [System.Convert]::ToBoolean(${env:forceCloseNotepad++})
)

begin {
    # Check if the operating system build version is less than 10240 (Windows 10 or Windows Server 2016 minimum requirement)
    if ([System.Environment]::OSVersion.Version.Build -lt 10240) {
        Write-Host -Object "`n[Warning] The minimum OS version supported by this script is Windows 10 (10240) or Windows Server 2016 (14393)."
        Write-Host -Object "[Warning] OS build '$([System.Environment]::OSVersion.Version.Build)' detected. This could lead to errors or unexpected results.`n"
    }

    function Find-InstallKey {
        [CmdletBinding()]
        param (
            [Parameter(ValueFromPipeline = $True)]
            [String]$DisplayName,
            [Parameter()]
            [Switch]$UninstallString
        )
        process {
            # Initialize a list to store found installation keys
            $InstallList = New-Object System.Collections.Generic.List[Object]

            # Search in the standard HKLM paths
            # Search in the 32-bit uninstall registry key and add results to the list
            try {
                $Result = Get-ChildItem -Path "Registry::HKEY_LOCAL_MACHINE\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" | Get-ItemProperty | Where-Object { $_.DisplayName -like "*$DisplayName*" }
                if ($Result) { $InstallList.Add($Result) }
            } catch {
                Write-Host -Object "[Warning] Failed to retrieve registry keys at 'HKEY_LOCAL_MACHINE\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'."
                throw $_
            }

            # Search in the 64-bit uninstall registry key and add results to the list
            try {
                $Result = Get-ChildItem -Path "Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" | Get-ItemProperty | Where-Object { $_.DisplayName -like "*$DisplayName*" }
                if ($Result) { $InstallList.Add($Result) }
            } catch {
                Write-Host -Object "[Warning] Failed to retrieve registry keys at 'HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'."
                throw $_
            }
            # If the UninstallString switch is set, return only the UninstallString property of the found keys
            if ($UninstallString) {
                $InstallList | Select-Object -ExpandProperty UninstallString -ErrorAction SilentlyContinue
            } else {
                $InstallList
            }
        }
    }

    function Test-IsElevated {
        [CmdletBinding()]
        param ()

        # Get the current Windows identity of the user running the script
        $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()

        # Create a WindowsPrincipal object based on the current identity
        $p = New-Object System.Security.Principal.WindowsPrincipal($id)

        # Check if the current user is in the Administrator role
        # The function returns $True if the user has administrative privileges, $False otherwise
        # 544 is the value for the Built In Administrators role
        # Reference: https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsbuiltinrole
        $p.IsInRole([System.Security.Principal.WindowsBuiltInRole]'544')
    }

    function Wait-ForNotepad {
        [CmdletBinding()]
        param()

        $Start = Get-Date
        $Timeout = [TimeSpan]::FromMinutes(5)

        if (Get-Process -Name "notepad++" -ErrorAction SilentlyContinue) {
            Write-Host -Object "Waiting for the Notepad++ process to stop."
        }

        while (Get-Process -Name "notepad++" -ErrorAction SilentlyContinue) {
            if (((Get-Date) - $Start) -ge $Timeout) {
                Write-Host -Object "[Warning] The five minute timeout has been reached."
                break
            }
            Start-Sleep -Milliseconds 100
        }
    }

    if (!$ExitCode) {
        $ExitCode = 0
    }
}
process {
    # Attempt to determine if the current session is running with Administrator privileges.
    try {
        $IsElevated = Test-IsElevated -ErrorAction Stop
    } catch {
        Write-Host -Object "[Error] $($_.Exception.Message)"
        Write-Host -Object "[Error] Unable to determine if the account '$env:Username' is running with Administrator privileges."
        exit 1
    }

    if (!$IsElevated) {
        Write-Host -Object "[Error] Access Denied: The user '$env:Username' does not have administrator privileges, or the script is not running with elevated permissions."
        exit 1
    }

    # Check if Notepad++ is still installed by searching uninstall registry keys
    Write-Host -Object "Verifying that Notepad++ is installed."
    try {
        $NotepadInstalled = Find-InstallKey -DisplayName "Notepad++" -UninstallString -ErrorAction Stop
    } catch {
        # Output error if unable to verify installation
        Write-Host -Object "[Error] $($_.Exception.Message)"
        Write-Host -Object "[Error] Failed to verify whether Notepad++ is installed."
        exit 1
    }

    # Check for Notepad++ uninstaller in Program Files (x86)
    if (Test-Path -Path "${env:ProgramFiles(x86)}\Notepad++\uninstall.exe" -PathType Leaf -ErrorAction SilentlyContinue) {
        $NotepadUninstallerFound = $True
    }

    # Check for Notepad++ uninstaller in Program Files
    if (Test-Path -Path "${env:ProgramFiles}\Notepad++\uninstall.exe" -PathType Leaf -ErrorAction SilentlyContinue) {
        $NotepadUninstallerFound = $True
    }

    # Check if Notepad++ is currently running
    $NotepadRunning = Get-Process -Name "notepad++" -ErrorAction SilentlyContinue

    # If Notepad++ is running and force close is selected, attempt to close the application before uninstalling.
    if ($ForceCloseNotepad -and $NotepadRunning){
        Write-Host -Object "Found Notepad++ running, attempting to close it."
        try {
            Stop-Process -Name "notepad++" -Force
            Write-Host -Object "Notepad++ was successfully force closed."
        } catch {
            Write-Host -Object "[Error] $($_.Exception.Message)"
            Write-Host -Object "[Error] Unable to force close Notepad++"
            exit 1
        }
    }

    # If force close option is not selected and notepad++ running and installed, exit.
    if (!$ForceCloseNotepad -and $NotepadRunning -and ($NotepadInstalled -or $NotepadUninstallerFound)) {
        Write-Host -Object "[Error] Unable to uninstall Notepad++ because it is currently in use. Attempt to rerun the script with the Force Close Notepad checkbox enabled."
        exit 1
    }

    # If neither registry nor uninstaller found, Notepad is not installed
    if (!$NotepadInstalled -and !$NotepadUninstallerFound) {
        if ($NotepadRunning) {
            Write-Host -Object "[Alert] The version of Notepad++ running on this system is the portable edition."
        }
        Write-Host -Object "[Error] Notepad++ is not installed on this system."
        exit 1
    }

    # If Notepad++ was installed via MSI, uninstall using MsiExec
    if ($NotepadInstalled -match "MsiExec\.exe /I") {
        $NotepadInstalled | ForEach-Object {
            # Prepare MSI uninstall arguments
            $MsiExecArguments = @(
                ($_ -replace "^MsiExec\.exe " -replace "/I", "/x")
                "/qn"
                "/norestart"
            )

            try {
                Write-Host -Object "Removing Notepad++ using '$env:WINDIR\System32\MsiExec.exe $($MsiExecArguments -join " ")'."
                # Start MSI uninstall process
                $UninstallProcess = Start-Process -FilePath "$env:WINDIR\System32\MsiExec.exe" -ArgumentList $MsiExecArguments -NoNewWindow -Wait -PassThru -ErrorAction Stop
                if ($UninstallProcess.ExitCode -ne 0) {
                    Write-Host -Object "[Warning] The last exit code '$($UninstallProcess.ExitCode)' does not indicate success."
                }
            } catch {
                # Output error if MSI uninstall fails
                Write-Host -Object "[Error] Exit Code: $($UninstallProcess.ExitCode)"
                Write-Host -Object "[Error] $($_.Exception.Message)"
                Write-Host -Object "[Error] Failed to uninstall Notepad++."
                $ExitCode = 1
            }

            try {
                # Wait for Notepad++ processes to exit
                Wait-ForNotepad -ErrorAction Stop
            } catch {
                Write-Host -Object "[Error] $($_.Exception.Message)"
                Write-Host -Object "[Error] Failed to wait for the Notepad++ uninstaller process to complete."
                $ExitCode = 1
            }
        }
    }

    # If Notepad++ was installed with the exe, run the uninstaller
    if ($NotepadInstalled -match "uninstall\.exe") {
        $NotepadInstalled | ForEach-Object {
            # Extract the uninstall.exe path from the uninstall string
            $UninstallString = $_ -split '"([^"]+)"'
            $FilePath = $UninstallString | Where-Object { $_ -match "uninstall\.exe" }

            # Prepare silent uninstall arguments
            $UninstallArguments = @(
                "/S"
            )

            try {
                Write-Host -Object "Removing Notepad++ using '$FilePath $($UninstallArguments -join " ")'."
                # Start uninstall process
                $UninstallProcess = Start-Process -FilePath $FilePath -ArgumentList $UninstallArguments -NoNewWindow -Wait -PassThru -ErrorAction Stop
                if ($UninstallProcess.ExitCode -ne 0) {
                    Write-Host -Object "[Warning] The last exit code '$($UninstallProcess.ExitCode)' does not indicate success."
                }
            } catch {
                # Output error if uninstall.exe fails
                Write-Host -Object "[Error] Exit Code: $($UninstallProcess.ExitCode)"
                Write-Host -Object "[Error] $($_.Exception.Message)"
                Write-Host -Object "[Error] Failed to uninstall Notepad++."
                $ExitCode = 1
            }

            try {
                # Wait for Notepad++ processes to exit
                Wait-ForNotepad -ErrorAction Stop
            } catch {
                Write-Host -Object "[Error] $($_.Exception.Message)"
                Write-Host -Object "[Error] Failed to wait for the Notepad++ uninstaller process to complete."
                $ExitCode = 1
            }
        }
    }

    # Fallback: If uninstall.exe exists in Program Files (x86), run it directly
    if (Test-Path -Path "${env:ProgramFiles(x86)}\Notepad++\uninstall.exe" -PathType Leaf -ErrorAction SilentlyContinue) {
        try {
            Write-Host -Object "Removing Notepad++ using '${env:ProgramFiles(x86)}\Notepad++\uninstall.exe /S'."
            $UninstallProcess = Start-Process -FilePath "${env:ProgramFiles(x86)}\Notepad++\uninstall.exe" -ArgumentList "/S" -NoNewWindow -PassThru -ErrorAction Stop
            if ($UninstallProcess.ExitCode -ne 0) {
                Write-Host -Object "[Warning] The last exit code '$($UninstallProcess.ExitCode)' does not indicate success."
            }
        } catch {
            # Output error if uninstall.exe fails
            Write-Host -Object "[Error] Exit Code: $($UninstallProcess.ExitCode)"
            Write-Host -Object "[Error] $($_.Exception.Message)"
            Write-Host -Object "[Error] Failed to uninstall Notepad++."
            $ExitCode = 1
        }

        try {
            # Wait for Notepad++ processes to exit
            Wait-ForNotepad -ErrorAction Stop
        } catch {
            Write-Host -Object "[Error] $($_.Exception.Message)"
            Write-Host -Object "[Error] Failed to wait for the Notepad++ uninstaller process to complete."
            $ExitCode = 1
        }
    }

    # Fallback: If uninstall.exe exists in Program Files, run it directly
    if (Test-Path -Path "${env:ProgramFiles}\Notepad++\uninstall.exe" -PathType Leaf -ErrorAction SilentlyContinue) {
        try {
            Write-Host -Object "Removing Notepad++ using '${env:ProgramFiles}\Notepad++\uninstall.exe /S'."
            $UninstallProcess = Start-Process -FilePath "${env:ProgramFiles}\Notepad++\uninstall.exe" -ArgumentList "/S" -NoNewWindow -PassThru -ErrorAction Stop
            if ($($UninstallProcess.ExitCode) -ne 0) {
                Write-Host -Object "[Warning] The last exit code '$($UninstallProcess.ExitCode)' does not indicate success."
            }
        } catch {
            # Output error if uninstall.exe fails
            Write-Host -Object "[Error] Exit Code: $($UninstallProcess.ExitCode)"
            Write-Host -Object "[Error] $($_.Exception.Message)"
            Write-Host -Object "[Error] Failed to uninstall Notepad++."
            $ExitCode = 1
        }

        try {
            # Wait for Notepad++ processes to exit
            Wait-ForNotepad -ErrorAction Stop
        } catch {
            Write-Host -Object "[Error] $($_.Exception.Message)"
            Write-Host -Object "[Error] Failed to wait for the Notepad++ uninstaller process to complete."
            $ExitCode = 1
        }
    }

    # Final verification that Notepad++ has been removed
    Write-Host -Object "Verifying that Notepad++ has been removed."
    try {
        $NotepadInstalled = Find-InstallKey -DisplayName "Notepad++" -UninstallString -ErrorAction Stop
    } catch {
        # Output error if unable to verify removal
        Write-Host -Object "[Error] $($_.Exception.Message)"
        Write-Host -Object "[Error] Failed to verify whether Notepad++ is still installed."
        exit 1
    }

    # Check for notepad++.exe in both Program Files location, it tends to stay if the application was uninstalled without closing the app first.
    if (Test-Path -Path "${env:ProgramFiles(x86)}\Notepad++\notepad++.exe" -PathType Leaf -ErrorAction SilentlyContinue) {
        $NotepadUninstallerFoundAgain = $True
    }

    if (Test-Path -Path "${env:ProgramFiles}\Notepad++\notepad++.exe" -PathType Leaf -ErrorAction SilentlyContinue) {
        $NotepadUninstallerFoundAgain = $True
    }

    # If neither registry nor uninstaller found, removal was successful
    if (!$NotepadInstalled -and !$NotepadUninstallerFoundAgain) {
        Write-Host -Object "Notepad++ has been successfully removed."
    } else {
        Write-Host -Object "[Error] Notepad++ is still installed."
        exit 1
    }

    exit $ExitCode
}
end {
    
    
    
}

 

Detailed Breakdown

The script has several core functions and logic paths that make it versatile:

  1. Environment Validation
    • Checks whether the OS build supports the script (Windows 10 or Server 2016 and later).
    • Verifies administrator privileges before proceeding.
  2. Registry and File Checks
    • Uses a custom Find-InstallKey function to locate Notepad++ uninstall entries in both 32-bit and 64-bit registry locations.
    • Searches for uninstall.exe in both Program Files and Program Files (x86) directories.
  3. Process Handling
    • Determines if Notepad++ is running.
    • If the ForceCloseNotepad parameter is set, it will forcibly terminate the process. If not, the uninstall fails gracefully with an error.
  4. Uninstallation Logic
    • If installed via MSI, the script calls MsiExec with silent uninstall flags.
    • If installed via EXE, it executes the uninstall.exe /S command.
    • Includes fallback mechanisms to run uninstallers directly if registry entries are missing.
  5. Verification
    • After running the uninstall, the script verifies removal by re-checking registry entries and file paths.
    • Provides clear messages for success or failure.

Potential Use Cases

Imagine a company that recently decided to standardize on Visual Studio Code. Notepad++ must be removed from all endpoints. Using this script, an MSP can push the script via NinjaOne across hundreds of systems. If some users still have Notepad++ open, the ForceCloseNotepad option ensures removal still occurs, though IT teams must weigh the risk of unsaved work being lost.

Comparisons

Other ways to remove Notepad++ include:

  • Manual uninstall via Control Panel or Settings, which doesn’t scale well.
  • Group Policy or Intune uninstall commands, which work but require more setup and lack robust verification.
  • Third-party uninstaller tools, which may add overhead and licensing costs.

This PowerShell script is lightweight, fully auditable, and integrates seamlessly with automation platforms like NinjaOne.

Implications

Automating application removal has significant implications for IT security and compliance. Outdated or unauthorized applications can introduce vulnerabilities. Ensuring clean, automated removal minimizes attack surfaces, enforces policy, and reduces IT overhead.

Recommendations

  • Always test the script in a controlled environment before mass deployment.
  • Use the ForceCloseNotepad switch only when necessary, since it risks data loss.
  • Run the script within a deployment tool like NinjaOne to ensure consistency and logging.
  • Document and communicate with users if software removal may impact workflows.

Final Thoughts

This PowerShell script provides IT teams with a reliable method to uninstall Notepad++ across multiple machines. It accounts for different installation methods, handles running processes, and validates completion. When integrated with a management platform like NinjaOne, administrators can automate software lifecycle management at scale—saving time, reducing errors, and ensuring systems remain compliant and secure.

]]>
How to Force Close Applications with a PowerShell Script https://www.ninjaone.com/script-hub/force-close-applications-with-powershell/ Fri, 12 Sep 2025 21:28:59 +0000 https://www.ninjaone.com/?post_type=script_hub&p=524821 This guide includes step-by-step instructions on how to force close applications with PowerShell. Closing a hung application is easy when you’re at the keyboard; it’s harder when you’re managing hundreds or thousands of endpoints. “End Task” doesn’t scale. That’s why many IT teams look for a predictable, scriptable way to force close processes across fleets. This article walks through a PowerShell script designed to do exactly that—How to Force Close Application with PowerShell, safely, consistently, and with the right guardrails for enterprise use.

Background

For managed service providers (MSPs) and internal IT, zombie processes can derail patch windows, block software deployments, or degrade user experience. Native tools exist—Stop-Process, taskkill, and even UI automation—but each can be brittle in unattended, multi-user, or least-privilege scenarios. The script reviewed here operationalizes the task: it validates input, checks elevation, optionally targets a specific user context, and exits with machine-readable codes that RMM platforms can understand. It’s a pragmatic answer to how to use PowerShell to Force Close an Application in real production environments.

The Script

#Requires -Version 5.1

<#
.SYNOPSIS
    Force close the specified process.
.DESCRIPTION
    Force close the specified process.
By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
    Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
    Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
    Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
    Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
    Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
    Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
    EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).

.EXAMPLE
    -ProcessNames "notepad.exe"

    Attempting to stop the process 'notepad'.
    Successfully stopped the process 'notepad', which was running under the account 'cheart'.

.PARAMETER ProcessNames
    A comma-separated list of process names you would like to force close, such as 'notepad'.

.PARAMETER Username
    Close only processes running under the specified target username.

.NOTES
    Minimum OS Architecture Supported: Windows 10, Windows Server 2016
    Version: 1.0
    Release Notes: Initial Release
#>

[CmdletBinding()]
param (
    [Parameter()]
    [String]$ProcessNames,
    [Parameter()]
    [String]$Username
)

begin {
    if ($env:processNames) { $ProcessNames = $env:processNames }
    if ($env:targetUsername) { $Username = $env:targetUsername }

    # Check if the operating system build version is less than 10240 (Windows 10 or Windows Server 2016 minimum requirement)
    if ([System.Environment]::OSVersion.Version.Build -lt 10240) {
        # Warn the user about unsupported OS versions
        Write-Host -Object "`n[Warning] The minimum OS version supported by this script is Windows 10 (10240) or Windows Server 2016 (14393)."
        Write-Host -Object "[Warning] OS build '$([System.Environment]::OSVersion.Version.Build)' detected. This could lead to errors or unexpected results.`n"
    }

    # Initialize a list to store valid process names
    $ProcessesToClose = New-Object System.Collections.Generic.List[String]

    # Split the provided process names by comma and validate each one
    $ProcessNames -split ',' | ForEach-Object {
        $Process = $_.Trim() # Remove leading and trailing whitespace

        # Check if the process name is blank
        if ([String]::IsNullOrWhiteSpace($Process)) {
            Write-Host -Object "[Error] A blank process name was provided in '$ProcessNames'. Please provide a valid process name."
            $ExitCode = 1
            return
        }

        # Check for invalid characters in the process name
        if ($Process -match "[``\\/:*?`"<>`]") {
            Write-Host -Object "[Error] The process '$Process' is invalid. It contains one of the following invalid characters: '\/:`*?`"<>'"
            $ExitCode = 1
            return
        }

        # Attempt to remove the file extension from the process name
        try {
            [System.IO.Path]::GetFileNameWithoutExtension($Process) | Out-Null
        } catch {
            Write-Host -Object "[Error] $($_.Exception.Message)"
            Write-Host -Object "[Error] Failed to check and remove the file extension."
            $ExitCode = 1
            return
        }

        # Add the validated process name to the list
        $ProcessesToClose.Add($Process)
    }

    # Ensure at least one valid process name was provided
    if ($ProcessesToClose.Count -lt 1) {
        Write-Host -Object "[Error] You must provide at least one valid process name to close."
        exit 1
    }

    # Validate the username if provided
    if ($Username) {
        # Check if the username is blank
        if ([String]::IsNullOrWhiteSpace($Username)) {
            Write-Host -Object "[Error] The username you provided is blank. Please provide a valid username."
            exit 1
        }

        $Username = $Username.Trim() # Remove leading and trailing whitespace

        # Check for invalid characters in the username
        if ($Username -match '[\[\]:;|=+*?<>/\\,@"]') {
            Write-Host -Object ("[Error] The username provided '$Username' is invalid. It contains one of the following invalid characters: " + '"[]:;|=+*?<>/\,@')
            exit 1
        }

        # Check if the username contains spaces
        if ($Username -match '\s') {
            Write-Host -Object "[Error] The username provided '$Username' is invalid. It contains a space."
            exit 1
        }

        # Check if the username exceeds the maximum length
        if ($Username.Length -gt 20) {
            Write-Host -Object "[Error] The username provided '$Username' is invalid. It is greater than 20 characters."
            exit 1
        }
    }

    function Test-IsElevated {
        [CmdletBinding()]
        param ()

        # Get the current Windows identity of the user running the script
        $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()

        # Create a WindowsPrincipal object based on the current identity
        $p = New-Object System.Security.Principal.WindowsPrincipal($id)

        # Check if the current user is in the Administrator role
        # The function returns $True if the user has administrative privileges, $False otherwise
        # 544 is the value for the Built In Administrators role
        # Reference: https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsbuiltinrole
        $p.IsInRole([System.Security.Principal.WindowsBuiltInRole]'544')
    }

    if (!$ExitCode) {
        $ExitCode = 0
    }
}
process {
    # Check if the script is running with Administrator privileges
    try {
        $IsElevated = Test-IsElevated -ErrorAction Stop
    } catch {
        # Handle errors when determining elevation status
        Write-Host -Object "[Error] $($_.Exception.Message)"
        Write-Host -Object "[Error] Unable to determine if the account '$env:Username' is running this script in an elevated session"
        exit 1
    }

    # Ensure elevated permissions if targeting a specific username
    if (!$IsElevated -and $Username -and $Username -ne $env:Username) {
        Write-Host -Object "[Error] The user '$env:USERNAME' is not running this script in an elevated session. Please run this script as System or in an elevated session."
        exit 1
    }

    # Retrieve the list of processes, optionally filtering by username
    try {
        if ($Username -and $IsElevated) {
            # Get processes for the specified username
            $CurrentProcesses = Get-Process -IncludeUserName -ErrorAction Stop | Where-Object { $_.Username -match "\\$Username$" }
        } elseif ($IsElevated) {
            # Get all processes
            $CurrentProcesses = Get-Process -IncludeUserName -ErrorAction Stop
        } else {
            $CurrentProcesses = Get-Process -ErrorAction Stop
        }
    } catch {
        # Handle errors when retrieving processes
        Write-Host -Object "[Error] $($_.Exception.Message)"
        Write-Host -Object "[Error] Failed to gather the current list of processes."
        exit 1
    }

    # Iterate through the list of processes to close
    $ProcessesToClose | ForEach-Object {
        $ProcessToClose = $_

        # Check if the process exists in the current list
        if ($CurrentProcesses.ProcessName -notcontains $ProcessToClose -and $CurrentProcesses.ProcessName -notcontains $([System.IO.Path]::GetFileNameWithoutExtension($ProcessToClose))) {
            Write-Host -Object "The process '$ProcessToClose' was not found."
            return
        }

        try {
            # Attempt to stop the process
            if (!$Username -and $IsElevated) {
                Write-Host -Object "Attempting to stop the process '$ProcessToClose'."
            }

            if ($Username) {
                Write-Host -Object "Attempting to stop the process '$ProcessToClose' running under the account '$Username'."
            }

            if (!$Username -and !$IsElevated) {
                Write-Host -Object "Attempting to stop the process '$ProcessToClose' running under the account '$env:USERNAME'."
            }

            $ProcessStopProcess = $CurrentProcesses | Where-Object { $_.ProcessName -eq $ProcessToClose -or $_.ProcessName -eq $([System.IO.Path]::GetFileNameWithoutExtension($ProcessToClose)) } | Stop-Process -Force -PassThru -ErrorAction SilentlyContinue

            if ($IsElevated) {
                $ProcessStopProcess = $ProcessStopProcess | Sort-Object -Property "UserName" -ErrorAction SilentlyContinue
            }

            if (!$ProcessStopProcess) {
                throw (New-Object System.Exception("Failed to stop the process '$ProcessToClose'."))
            }

            # Iterate through each stopped process
            foreach ($Process in $ProcessStopProcess) {
                # If running with elevated privileges, extract the username of the stopped process
                if ($IsElevated) {
                    $StoppedUser = $Process.UserName -replace '^.*\\'
                }

                # Verify if the process has exited successfully
                if (!$Process.HasExited -and $IsElevated) {
                    Write-Host -Object "[Error] The process '$ProcessToClose' running under the account '$StoppedUser' failed to exit."
                    $ExitCode = 1
                }

                if (!$Process.HasExited) {
                    Write-Host -Object "[Error] The process '$ProcessToClose' failed to exit."
                    $ExitCode = 1
                }

                # Log success message based on elevation status
                if ($IsElevated -and $Process.HasExited) {
                    Write-Host -Object "Successfully stopped the process '$ProcessToClose', which was running under the account '$StoppedUser'."
                } elseif ($Process.HasExited) {
                    Write-Host -Object "Successfully stopped the process '$ProcessToClose'."
                }
            }
        } catch {
            # Handle errors when stopping the process
            Write-Host -Object "[Error] $($_.Exception.Message)"
            Write-Host -Object "[Error] Failed to stop the process '$ProcessToClose'."
            $ExitCode = 1
            return
        }
    }

    exit $ExitCode
}
end {
    
    
    
}

 

Detailed Breakdown

What the script does

  • Accepts a comma-separated list of process names via -ProcessNames (e.g., notepad.exe, calc) and an optional -Username to constrain which user’s processes are closed.
  • Also reads environment variables processNames and targetUsername, which is ideal for RMM parameterization without editing command lines.
  • Validates OS build (warns if older than Windows 10/Server 2016), sanitizes inputs, enforces username hygiene (length, spaces, and character checks), and confirms elevation when needed.
  • Enumerates processes (with usernames if elevated), kills matches with Stop-Process -Force -PassThru, verifies termination via .HasExited, and returns a meaningful exit code.

Key implementation choices

  • Input hardening: Rejects blank names and characters like \ / : * ? ” < >. For process names, it tolerates .exeby stripping the extension before matching.
  • Elevation logic: A helper, Test-IsElevated, checks membership in the Built-in Administrators group (role 544). If targeting another user’s processes without elevation, it fails fast with a clear message.
  • User scoping: When elevated, the script fetches Get-Process -IncludeUserName and uses a regex \\$Username$to match the tail of DOMAIN\Username.
  • Verification step: After Stop-Process -Force, it validates .HasExited. If any process refuses to die, the script sets $ExitCode = 1—a critical detail for automation.
  • Operator feedback: Messages make outcomes explicit: “Attempting to stop…”, “Successfully stopped…”, or “[Error] … failed to exit.”

Potential Use Cases

Hypothetical case study

An MSP schedules a monthly third-party update across 900 laptops. A few persistent applications (a legacy PDF viewer and a tray utility) routinely block the installer.

On shared, kiosk, or multi-user machines, they omit -Username and run the task as System to ensure the processes are closed regardless of the active session. Output logs provide per-process confirmation and a nonzero exit code signals any sites that need a second pass.

Comparisons

  • Plain Stop-Process -Name app -Force: Works locally but lacks input validation, username scoping, exit code discipline, and post-kill verification. Good for ad hoc use; weaker for unattended runs.
  • taskkill /IM app.exe /F: Effective and fast, but offers limited programmatic feedback and no built-in username filter when invoked broadly. Parsing stdout for success conditions is brittle.
  • WMI/CIM approaches (Get-CimInstance Win32_Process | Stop-Process): More flexible for remote querying, but adds complexity and still needs elevation for other users’ processes.
  • CloseMainWindow-first patterns: Gentler but unreliable for hung GUIs and service-backed apps; this script explicitly uses -Force for deterministic results.

The script’s differentiator is operational hygiene: validated inputs, user scoping, and a dependable exit code—everything you want when building a repeatable procedure.

Implications

Process termination at scale is a double-edged sword. On the upside, it unblocks deployments, improves endpoint stability, and reduces manual touch. On the downside, forced termination can create data loss or file corruption for unsaved work, and indiscriminate use may affect security tools or EDR components. By validating inputs and scoping to a user when appropriate, the script mitigates these risks, but governance still matters—especially around which process names are allowed in automation.

Recommendations

  • Run as System for fleet jobs so you can enumerate usernames and reliably close cross-session processes.
  • Use allowlists for sanctioned process names; avoid wildcards or broad patterns.
  • Schedule before maintenance windows so every endpoint starts the change in a known-good state.
  • Log and alert on nonzero exits to trigger retries or technician review.
  • Pair with user communication; warn users that applications may be closed, especially in interactive environments.
  • Test on a pilot group to ensure no business-critical processes are impacted.
  • Consider a “gentle first” pass (optional CloseMainWindow) if you later extend the script; keep this -Forceversion for stuck cases.

Final Thoughts

In large environments, closing apps can’t depend on manual effort. This script brings the right mix of predictability and safety for unattended operations. Inside NinjaOne, you can parameterize processNames and targetUsername as environment variables, run the script as System, and capture results centrally in activity logs and policies. That combination—policy-driven execution, scoped inputs, and clear exit codes—turns a simple capability into a reliable operational control for every maintenance window.

]]>
How to Enable Active Directory Recycle Bin with PowerShell https://www.ninjaone.com/script-hub/how-to-enable-active-directory-recycle-bin-with-powershell/ Fri, 12 Sep 2025 21:26:38 +0000 https://www.ninjaone.com/?post_type=script_hub&p=524818 This guide provides effective instructions on how to enable Active Directory Recycle Bin with PowerShell. Accidental deletions in Active Directory (AD) are one of those scenarios that keep IT teams on edge. A misplaced click or an over-broad script can take down users, groups, or OUs in seconds. Microsoft’s Active Directory Recycle Bin dramatically reduces recovery pain by allowing you to restore deleted objects with their attributes intact—no authoritative restores, no downtime. The PowerShell script you provided streamlines enabling that feature safely and consistently, with built-in guardrails for production environments.

Background

The Active Directory Recycle Bin is a forest-wide feature that, once enabled, lets administrators reanimate deleted objects without resorting to backups. For MSPs managing many domains—or internal IT teams shepherding complex forests—standardizing how you enable this capability matters. Doing it by hand in the GUI can be error-prone and inconsistent across technicians. A PowerShell script to Enable Active Directory Recycle Bin ensures checks are run (domain controller? elevated privileges? functional levels?) and that the irreversible nature of the change is explicitly acknowledged.

This script is designed to run on a domain controller, targets Windows Server 2016+ for platform consistency, and verifies that your forest and domain functional levels meet the minimum of Windows Server 2008 R2—the Microsoft prerequisite for the Recycle Bin. It also clearly warns that the action cannot be undone, aligning with change-management best practices.

The Script

#Requires -Version 5.1

<#
.SYNOPSIS
    Enables the Active Directory Recycle Bin feature. Must be run on a domain controller. Please note this action CANNOT be undone, per Microsoft.
.DESCRIPTION
    Enables the Active Directory Recycle Bin feature. Must be run on a domain controller. Please note this action CANNOT be undone, per Microsoft.
By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
    Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
    Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
    Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
    Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
    Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
    Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
    EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).

.PARAMETER ConfirmAcceptance
    Confirms acknowledgment that enabling the Active Directory Recycle Bin is a permanent action and cannot be undone. This parameter is required to proceed with enabling the recycle bin.

.EXAMPLE
    -ConfirmAcceptance

    [Info] Enabling the Active Directory Recycle Bin feature for domain 'test2.lan'.
    [Info] Successfully enabled the Active Directory Recycle Bin feature for domain 'test2.lan'.

.NOTES
    Minimum OS Architecture Supported: Windows Server 2016+
    Version: 1.0
    Release Notes: Initial Release
#>

[CmdletBinding()]
param (
    [Parameter()]
    [switch]$ConfirmAcceptance = [System.Convert]::ToBoolean($env:confirmAcceptanceActionCannotBeUndone)
)

begin {
    # Check if the operating system build version is less than 14393 (Windows Server 2016 minimum requirement)
    if ([System.Environment]::OSVersion.Version.Build -lt 14393) {
        Write-Host -Object "`n[Warning] The minimum OS version supported by this script is Windows Server 2016 (14393)."
        Write-Host -Object "[Warning] OS build '$([System.Environment]::OSVersion.Version.Build)' was detected. This could lead to errors or unexpected results.`n"
    }

    function Test-IsDomainController {
        # Determine the method to retrieve the operating system information based on PowerShell version
        try {
            $OS = if ($PSVersionTable.PSVersion.Major -lt 3) {
                Get-WmiObject -Class Win32_OperatingSystem -ErrorAction Stop
            } else {
                Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
            }
        } catch {
            Write-Host -Object "[Error] Unable to validate whether or not this device is a domain controller."
            Write-Host -Object "[Error] $($_.Exception.Message)"
            exit 1
        }

        # Check if the ProductType is "2", which indicates that the system is a domain controller
        if ($OS.ProductType -eq "2") {
            return $true
        }
    }

    function Test-IsElevated {
        [CmdletBinding()]
        param ()

        # Get the current Windows identity of the user running the script
        $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()

        # Create a WindowsPrincipal object based on the current identity
        $p = New-Object System.Security.Principal.WindowsPrincipal($id)

        # Check if the current user is in the Administrator role
        # The function returns $True if the user has administrative privileges, $False otherwise
        # 544 is the value for the Built In Administrators role
        # Reference: https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsbuiltinrole
        $p.IsInRole([System.Security.Principal.WindowsBuiltInRole]'544')
    }
}
process {
    # Attempt to determine if the current session is running with Administrator privileges.
    try {
        $IsElevated = Test-IsElevated -ErrorAction Stop
    }
    catch {
        Write-Host -Object "[Error] $($_.Exception.Message)"
        Write-Host -Object "[Error] Unable to determine if the account '$env:Username' is running with Administrator privileges."
        exit 1
    }

    if (!$IsElevated) {
        Write-Host -Object "[Error] Access Denied: The user '$env:Username' does not have administrator privileges, or the script is not running with elevated permissions."
        exit 1
    }

    # Error if not running on a domain controller
    if (-not (Test-IsDomainController)) {
        Write-Host -Object "[Error] This script must be run on a domain controller."
        exit 1
    }

    # If the user did not confirm acceptance that this action cannot be undone, exit the script
    if (-not $ConfirmAcceptance) {
        Write-Host -Object "[Error] Because this action cannot be undone, you must use the 'Confirm Acceptance' option to continue."
        Write-Host -Object "[Error] Please run this script again using the 'Confirm Acceptance' option."
        exit 1
    }

    # Get the current AD forest information
    try {
        $ForestInfo = Get-ADForest -ErrorAction Stop
    }
    catch {
        Write-Host -Object "[Error] Unable to retrieve the current Active Directory forest information."
        Write-Host -Object "[Error] $($_.Exception.Message)"
        exit 1
    }

    # Get the current AD domain information
    try {
        $DomainInfo = Get-ADDomain -ErrorAction Stop
    }
    catch {
        Write-Host -Object "[Error] Unable to retrieve the current domain name."
        Write-Host -Object "[Error] $($_.Exception.Message)"
        exit 1
    }

    # Get the domain name from the domain information
    $DomainName = $DomainInfo.DNSRoot

    if (-not $DomainName) {
        Write-Host -Object "[Error] Unable to retrieve the domain name."
        exit 1
    }

    # Check the forest and domain functional levels for Recycle Bin support
    $ForestLevel = $ForestInfo.ForestMode
    $DomainLevel = $DomainInfo.DomainMode

    if ($ForestLevel -lt 'Windows2008R2Forest' -or $DomainLevel -lt 'Windows2008R2Domain') {
        Write-Host -Object "[Error] The Active Directory Recycle Bin feature requires the forest and domain functional levels to be at least Windows Server 2008 R2."
        Write-Host -Object "[Error] The current detected levels are:"
        Write-Host -Object "Forest Level: $ForestLevel"
        Write-Host -Object "Domain Level: $DomainLevel"
        exit 1
    }

    # Get info about the Active Directory Recycle Bin feature
    try {
        $RecycleBinFeature = Get-ADOptionalFeature -Filter { Name -eq 'Recycle Bin Feature' } -ErrorAction Stop
    }
    catch {
        Write-Host -Object "[Error] Unable to retrieve information about the Active Directory Recycle Bin feature."
        Write-Host -Object "[Error] $($_.Exception.Message)"
        exit 1
    }

    # Check if the Recycle Bin feature is already enabled
    if ($RecycleBinFeature.EnabledScopes -and $RecycleBinFeature.EnabledScopes -like "*$($DomainInfo.DistinguishedName)") {
        Write-Host -Object "[Info] The Active Directory Recycle Bin feature is already enabled for domain '$DomainName'."
        exit 0
    }

    # Enable the Active Directory Recycle Bin feature
    try {
        Write-Host -Object "[Info] Enabling the Active Directory Recycle Bin feature for domain '$DomainName'."
        Enable-ADOptionalFeature -Identity $RecycleBinFeature.DistinguishedName -Scope ForestOrConfigurationSet -Target "$DomainName" -Confirm:$false -WarningAction SilentlyContinue -ErrorAction Stop
    }
    catch {
        Write-Host -Object "[Error] Failed to enable the Active Directory Recycle Bin feature."
        Write-Host -Object "[Error] $($_.Exception.Message)"
        exit 1
    }

    Write-Host -Object "[Info] Successfully enabled the Active Directory Recycle Bin feature for domain '$DomainName'."

    exit 0
}
end {
    
    
    
}

 

Detailed Breakdown

Key elements:

  • Parameter & Acknowledgment
    • -ConfirmAcceptance is a required acknowledgement. If omitted, the script aborts with a clear message.
    • The parameter defaults from the environment variable confirmAcceptanceActionCannotBeUndone. If that variable evaluates to True, the script proceeds without an interactive switch, which is handy for automation tools.
  • Platform Sanity Check
    • Warns if the OS build is below 14393 (Windows Server 2016). While AD’s Recycle Bin needs only 2008 R2 functional levels, standardizing on newer OS builds tends to reduce surprises.
  • Role and Privilege Validation
    • Test-IsElevated ensures the session is running with administrative rights (checks membership in the Built-in Administrators group).
    • Test-IsDomainController queries OS product type to confirm the script is executing on a domain controller. If not, it exits early to avoid risky remote assumptions.
  • Directory Services Prerequisites
    • Retrieves forest and domain information via Get-ADForest and Get-ADDomain (requires the ActiveDirectory module, which is present on DCs).
    • Verifies forest and domain modes are at least Windows2008R2Forest and Windows2008R2Domain.
  • Feature State Check
    • Uses Get-ADOptionalFeature -Filter { Name -eq ‘Recycle Bin Feature’ } to fetch the feature, then inspects EnabledScopes. If the current domain’s DN is already present, the script reports that Recycle Bin is enabled and exits cleanly.
  • Enablement
    • Executes Enable-ADOptionalFeature with -Scope ForestOrConfigurationSet -Target <DomainDNSRoot> -Confirm:$false.
    • Emits success output and exits with code 0.

Operational ergonomics:

  • Clear [Info], [Warning], and [Error] messages for easy log parsing.
  • Explicit exit codes (exit 0 on success, exit 1 on failure) to integrate with RMMs and CI/CD pipelines.

Potential Use Cases

Case Study: A regional MSP inherits a client with a single-forest, multi-domain deployment. The client recently suffered a helpdesk-triggered OU deletion that forced a lengthy restore from backup. The MSP standardizes a post-onboarding hardening checklist that includes How to Enable Active Directory Recycle Bin with a PowerShell script:

  1. Stage the script via the RMM (e.g., NinjaOne).
  2. Push an environment variable confirmAcceptanceActionCannotBeUndone=1 for audited acknowledgment.
  3. Schedule a maintenance task to run on each domain controller and capture output centrally.
  4. Validate success via logs and a quick test delete/restore of a lab user in a non-production OU.

The result: faster recoveries, fewer escalations, and measurable reduction in high-severity incidents.

Comparisons

  • GUI (Active Directory Administrative Center / ADAC): Click-through enablement works but is manual, less repeatable, and harder to audit across many domains.
  • Raw PowerShell one-liners: You can run Enable-ADOptionalFeature directly, but you lose the preflight validations this script provides (DC check, elevation check, functional level verification, clear acceptance).
  • Directory Services Restore (without Recycle Bin): Restores from backups are slower, riskier, and often lose some attribute fidelity. With Recycle Bin enabled, restores (Restore-ADObject) are straightforward and preserve most attributes.

In short, using a PowerShell script to Enable Active Directory Recycle Bin offers repeatability, safety checks, and better automation pipeline integration.

Implications

Enabling the Recycle Bin reduces time-to-restore and preserves object attributes, lowering risk in day-to-day operations. From a security perspective, it also tightens blast-radius control on accidental deletions and provides breathing room during incidents. You should still review retention policies and auditing; while the Recycle Bin is restorative, it’s not a substitute for least-privilege, change approvals, or regular backups. Importantly, because the feature is forest-wide, you’ll want formal change control and stakeholder notification across all domains before enabling it.

Recommendations

  • Plan and communicate: Treat this as a forest-wide change with CAB/CHG documentation.
  • Verify health first: Ensure AD replication is healthy (repadmin /replsummary) and time is in sync.
  • Run on a DC, as Admin: Let the script enforce this, and resist the urge to bypass checks.
  • Use explicit acknowledgment: Prefer the environment variable or -ConfirmAcceptance in automation so your change record shows explicit consent.
  • Log everything: Capture stdout/stderr and exit codes centrally for audit and rollback plans.
  • Test restores: After enablement, perform a controlled delete/restore in a lab OU to validate your runbook.
  • Pair with monitoring: Alert on mass deletions and privilege changes; Recycle Bin helps you recover, but prevention still matters.

Final Thoughts

For organizations looking to Enable Active Directory Recycle Bin with PowerShell, this script packages the right mix of safety checks and automation-readiness. In NinjaOne, you can deploy it as a reusable policy step: set the confirmAcceptanceActionCannotBeUndone variable in a script parameter, scope execution to domain controllers, and capture results to a shared log for compliance. Combine that with scheduled health checks and a simple “test restore” runbook, and you’ll turn a risky, one-off change into a controlled, auditable standard—exactly the kind of operational lift that pays dividends when the unexpected happens.

]]>
How to Use Shell Scripting to Synchronize the Time on Linux https://www.ninjaone.com/script-hub/synchronize-the-time-on-linux-using-shell-scripting/ Fri, 25 Jul 2025 07:20:54 +0000 https://www.ninjaone.com/?post_type=script_hub&p=497582 Time synchronization is a cornerstone of modern IT infrastructure. From accurate logging and secure communications to distributed systems coordination, consistent system time across servers and endpoints is essential. For IT professionals and Managed Service Providers (MSPs), having a reliable and automated method to synchronize time on Linux systems is critical. This post delves into a robust shell script designed for synchronizing time using chronyd or systemd-timesyncd, two widely supported network time protocol (NTP) services in Linux.

Background

Many IT environments, especially those relying on centralized management and automation platforms like NinjaOne, require strict time synchronization. Log correlations, file versioning, authentication tokens, and audit trails all depend on precise system clocks. Although NTP services like chronyd and systemd-timesyncd often handle this quietly in the background, misconfiguration, service failures, or system image inconsistencies can throw off synchronization.

This shell script addresses those issues head-on by automatically detecting available time sync services, ensuring they’re properly configured, temporarily starting them if needed, synchronizing the time, and reporting detailed diagnostics. It also gracefully manages services, returning systems to their previous state if time synchronization services were initially disabled.

The Script:

#!/usr/bin/env bash
#
# Description: Synchronize the time on Linux using the system's network time server. Expects chrony or systemd-timesyncd to be installed and running.
# By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
# Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. 
# Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. 
# Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. 
# Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. 
# Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. 
# Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. 
# EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
#
# Minimum OS Architecture Supported: Debian 11 (Bullseye)+, Red Hat Enterprise Linux (RHEL) 8+
#
# Release Notes: Initial Release
#

# When run directly without testing, the "__()" function does nothing.
test || __() { :; }

die() {
    local _ret="${2:-1}"
    echo "$1" >&2
    exit "${_ret}"
}

function GetChronyConfigFile() {
    local _configFile
    # Check if there is a chrony config folder
    if [[ -d "/etc/chrony" ]]; then
        # Check if the config file exists
        if [[ -f "/etc/chrony/chrony.conf" ]]; then
            _configFile="/etc/chrony/chrony.conf"
        fi
    fi
    # Check if primary default config file exists, this takes precedence over the chrony folder
    if [[ -f "/etc/chrony.conf" ]]; then
        _configFile="/etc/chrony.conf"
    fi
    # Get the system's default config file for chronyd
    _chronySystemDefaultConfigFile=$(
        # Get man page for chronyd
        # Looking for a line containing this "The compiled-in default value is /etc/chrony/chrony.conf."
        man chronyd |
            # Get the default config file location
            grep /chrony.conf | grep default |
            # Get the last item in the line
            awk '{print $NF}' |
            # Remove the trailing period
            sed 's/\.$//'
    )
    # Check if the config file exists
    if [[ -f "$_chronySystemDefaultConfigFile" ]]; then
        _configFile="$_chronySystemDefaultConfigFile"
    fi

    # Validate that the config file exists
    if [[ -n "${_configFile}" && -f "$_configFile" ]]; then
        echo "$_configFile"
    else
        echo "[Error] No chrony config file found"
        exit 1
    fi
}

function GetNtpPoolServers() {
    local -a _configFiles
    local -a _pools
    local -a _servers
    local -a _timedatectlConfigFiles
    _timedatectlConfigFiles=(
        # Last take precedence over previous ones
        "/usr/lib/systemd/timesyncd.conf.d"
        "/usr/local/lib/systemd/timesyncd.conf.d"
        "/run/systemd/timesyncd.conf.d"
        "/etc/systemd/timesyncd.conf.d"
        "/usr/lib/systemd/timesyncd.conf"
        "/usr/local/lib/systemd/timesyncd.conf"
        "/run/systemd/timesyncd.conf"
        "/etc/systemd/timesyncd.conf"
    )
    # Get the NTP pool servers for the specified region

    echo "[Info] Getting NTP pools and servers"
    if command -v chronyc &>/dev/null; then
        # Get the NTP pool servers for chrony
        if [[ -f "${_chronyConfigFile}" ]]; then
            _pools+=$(grep -E "^pool" "${_chronyConfigFile}" | awk '{print $2}' | sort -u)
            _servers+=$(grep -E "^server" "${_chronyConfigFile}" | awk '{print $2}' | sort -u)
        fi
    elif command -v timedatectl &>/dev/null; then
        # Get the NTP pool servers for systemd-timesyncd
        for configFile in "${_timedatectlConfigFiles[@]}"; do
            if [[ -d "$configFile" ]]; then
                # If the config file is a directory, check for .conf files inside it
                for confFile in "$configFile"/*.conf; do
                    if [[ -f "$confFile" ]]; then
                        # Append the contents of the .conf file to the list of config files
                        _pools+=$(grep -E "^NTP=" "$configFile" | awk -F '=' '{print $2}' | sort -u)
                        _pools+=$(grep -E "^FallbackNTP=" "$configFile" | awk -F '=' '{print $2}' | sort -u)
                    fi
                done
            elif [[ -f "$configFile" ]]; then
                _pools+=$(grep -E "^NTP=" "$configFile" | awk -F '=' '{print $2}' | sort -u)
                _pools+=$(grep -E "^FallbackNTP=" "$configFile" | awk -F '=' '{print $2}' | sort -u)
            fi
        done
    else
        echo "[Error] chronyc or timedatectl not found"
        exit 1
    fi

    # Return the list of NTP pools and servers
    if [[ -n "${_pools}" ]]; then
        echo "[Info] NTP pools:"
        echo "$_pools"
    fi
    if [[ -n "${_servers}" ]]; then
        echo "[Info] NTP servers:"
        echo "$_servers"
    fi
    if [[ -z "${_pools}" && -z "${_servers}" ]]; then
        # If no pools or servers were found
        echo "[Info] Distribution's default NTP servers are being used."
    fi
}

function GetNtpConfiguration() {

    # Get the NTP configuration for the specified region

    if command -v chronyc &>/dev/null; then
        echo "[Info] NTP configuration:"
        if ! [[ -f "$_chronyConfigFile" ]]; then
            _chronyConfigFile="$(GetChronyConfigFile)"
        fi

        # Get the NTP configuration for chrony
        grep -E "^server|^pool" "${_chronyConfigFile}" 2>/dev/null
        if [[ -f "${_chronyConfigFile}" ]]; then
            grep -E "^sourcedir" "${_chronyConfigFile}" 2>/dev/null | awk '{print $2}' | while read -r _dir; do
                if [[ -d "$_dir" ]]; then
                    for confFile in "$_dir"/*.conf; do
                        # Check if the file exists before grepping
                        if [[ -f "$confFile" ]]; then
                            grep -E "^server|^pool" <"$confFile" 2>/dev/null
                        fi
                    done
                fi
            done
            grep -E "^confdir" "${_chronyConfigFile}" 2>/dev/null | awk '{print $2}' | while read -r _dir; do
                if [[ -d "$_dir" ]]; then
                    for confFile in "$_dir"/*.conf; do
                        # Check if the file exists before grepping
                        if [[ -f "$confFile" ]]; then
                            grep -E "^server|^pool" <"$confFile" 2>/dev/null
                        fi
                    done
                fi
            done
        fi
    elif command -v timedatectl &>/dev/null; then
        # Get the NTP configuration for systemd-timesyncd
        if (("$(timedatectl --version | grep systemd | awk '{print $2}')" >= 239)); then # Try to get the NTP configuration
            echo "[Info] NTP configuration:"
            timedatectl show-timesync 2>/dev/null
        else
            # If it fails then we are running and older version of systemd, then get the status
            echo "[Warn] systemd version is older than 239, show-timesync is not available."
        fi
    else
        echo "[Error] No NTP configuration found."
        exit 1
    fi
}

function IsChronySecurityConfigured() {
    # Check if the keyfile is set in chrony.conf
    if grep -q "^keyfile" "${_chronyConfigFile}" 2>/dev/null; then
        # Check if commandkey is set in chrony.conf
        if grep -q "^commandkey" "${_chronyConfigFile}" 2>/dev/null; then
            echo "[Info] Keyfile and commandkey are set in ${_chronyConfigFile}"
            _id=$(grep "^keyfile" "${_chronyConfigFile}" 2>/dev/null | awk '{print $2}')
            _keyfile=$(grep "^commandkey" "${_chronyConfigFile}" 2>/dev/null | awk '{print $2}')
            # Check if the keyfile exists
            if [[ -f "$_keyfile" ]]; then
                # Check if commandkey exists in keyfile
                if grep -q "^$_id" "$_keyfile"; then
                    # Keyfile exists and commandkey is set
                    return 0
                else
                    # Keyfile exists but commandkey is not set
                    echo "[Error] Keyfile $_keyfile exists but commandkey $_id is not set in it"
                    return 1
                fi
            else
                # Keyfile does not exist
                echo "[Error] Keyfile $_keyfile does not exist"
                return 1
            fi
        else
            # Keyfile is set but commandkey is not set
            return 1
        fi
    else
        # Keyfile is not set
        return 1
    fi
}

__ begin __

# Check if the script is being run as root. If not, exit with an error message.
if [[ $(id -u) -ne 0 ]]; then
    die "[Error] This script must be run with root permissions. Try running it with sudo or as the system/root user." 1
fi

_is_enabled=0
_is_active=0

# Check if time synchronization services are installed
if systemctl list-unit-files | grep -q -E "(systemd-timesyncd|chronyd).service"; then
    echo "[Info] Time synchronization service(s) found:"
    # Check if systemd-timesyncd or chronyd is running
    while read -r service; do                                          # Read each service name
        if systemctl is-enabled --quiet "${service}" 2>/dev/null; then # Check if the service is enabled
            echo "[Info] ${service} is enabled"
            _is_enabled=1
        else
            echo "[Info] ${service} is not enabled"
        fi
        if systemctl is-active --quiet "${service}" 2>/dev/null; then # Check if the service is active/running
            echo "[Info] ${service} is running"
            _is_active=1
        else
            echo "[Info] ${service} is not running"
        fi
    done < <(
        systemctl list-unit-files |                         # List all unit files
            grep -E "(systemd-timesyncd|chronyd).service" | # Filter for systemd-timesyncd or chronyd
            awk '{print $1}'                                # Get the first column (service name)
    )
    _service_name=$(systemctl list-unit-files | grep -E "(systemd-timesyncd|chronyd).service" | awk '{print $1}')
    if [[ -z "${_service_name}" ]] && [[ $_is_enabled -eq 0 ]] && [[ "${_is_active}" -eq 0 ]]; then
        die "[Error] No time synchronization service found. Please install systemd-timesyncd, or chrony." 1
    fi
    if [[ "${_is_active}" -eq 0 ]]; then
        echo "[Info] Time synchronization service is not running. Will start it now, sync it, and stop it after."
        # Start the service
        if [[ "${_service_name}" == *"chronyd"* ]]; then
            # If chronyd is available, start it as it takes precedence over systemd-timesyncd
            # Start chronyd
            echo "[Info] Starting time synchronization service: chronyd"
            systemctl start chronyd.service 2>/dev/null || die "[Error] Failed to start chronyd service" 1
            sleep 2
        elif [[ "${_service_name}" == *"systemd-timesyncd"* ]]; then
            # chronyd is not available, so we will use systemd-timesyncd
            # Start systemd-timesyncd
            echo "[Info] Starting time synchronization service: systemd-timesyncd"
            systemctl start systemd-timesyncd.service 2>/dev/null || die "[Error] Failed to start systemd-timesyncd service" 1
            sleep 2
        else
            # If neither chronyd nor systemd-timesyncd is available, exit with an error
            die "[Error] No time synchronization service found. Please install systemd-timesyncd, or chrony." 1
        fi
    fi
else
    die "[Error] No time synchronization service found. Please install systemd-timesyncd, or chrony." 1
fi

_shouldError=0

# Sync time now
echo "[Info] Syncing time now"
if command -v chronyc &>/dev/null; then
    # Check if keyfile is set in chrony.conf
    if IsChronySecurityConfigured; then
        # Use chronyc to sync time
        echo "[Info] Syncing time using chronyc with keyfile in ${_chronyConfigFile}"
        if [[ "$(chronyc -a makestep 2>/dev/null)" == *"OK"* ]]; then
            echo "[Info] Time syncing"
        else
            echo "[Error] Failed to sync time using chronyc"
            _shouldError=1
        fi
    else
        # Sync time using chronyc
        echo "[Info] Syncing time using chronyc"
        if [[ "$(chronyc makestep 2>/dev/null)" == *"OK"* ]]; then
            echo "[Info] Time syncing"
        else
            echo "[Error] Failed to sync time using chronyc"
            _shouldError=1
        fi
    fi

    # Verify that time is synced if we had no errors
    if ((_shouldError == 0)); then
        # Sleep for 5 seconds to allow some time to sync after restarting chronyd
        sleep 5

        # Check if time is synced with chronyc tracking
        case $(chronyc tracking 2>/dev/null | grep "Leap status") in
        *"Leap Second"* | *"Normal"*)
            # If the leap status is "Leap Second" or "Normal", then time is synced
            echo "[Info] Time synced successfully"
            ;;
        *"Insert second"*)
            # If the leap status is "Insert second", then time is being synced
            echo "[Info] Chrony is in the process of inserting a second and will be synced over time to prevent time jumps."
            ;;
        *"Delete second"*)
            # If the leap status is "Delete second", then time is being synced
            echo "[Info] Chrony is in the process of deleting a second and will be synced over time to prevent time jumps."
            ;;
        *"Not synchronized"*)
            # If the leap status is "Not synchronized", then time is not synced
            echo "[Error] Chrony is not synchronized."
            _shouldError=1
            ;;
        *"Unknown"*)
            # If the leap status is "Unknown", then time is not synced
            echo "[Error] Unknown status from chronyc tracking."
            _shouldError=1
            ;;
        *)
            # If the leap status is not recognized, then time is not synced
            echo "[Error] Failed to get sync status from chronyc"
            _shouldError=1
            ;;
        esac

        # Stop chronyd if it was not running before
        if ((_is_active == 0)); then
            sleep 5
            # Get the service name
            _service_name=$(systemctl list-unit-files | grep -E "chronyd.service" | awk '{print $1}')
            echo "[Info] Stopping time synchronization service: ${_service_name}"
            # Stop the service
            systemctl stop "$_service_name" 2>/dev/null
        fi
    fi
elif command -v timedatectl &>/dev/null; then
    # Sync time using timedatectl
    echo "[Info] Syncing time using timedatectl"
    # Restart systemd-timesyncd to force a sync
    if systemctl restart systemd-timesyncd.service 2>/dev/null; then
        if timedatectl status 2>/dev/null | grep "synchronized" | grep -q "yes"; then
            echo "[Info] Time synced successfully"
        else
            echo "[Error] Failed to sync time using timedatectl"
            _shouldError=1
        fi
    else
        echo "[Error] Failed to sync time using timedatectl"
        _shouldError=1
    fi

    # Stop systemd-timesyncd if it was not running before
    if ((_is_active == 0)); then
        sleep 5
        # Get the service name
        _service_name=$(systemctl list-unit-files | grep -E "systemd-timesyncd.service" | awk '{print $1}')
        echo "[Info] Stopping time synchronization service: ${_service_name}"
        # Stop the service
        systemctl stop "$_service_name" 2>/dev/null
    fi
else
    echo "[Error] No time synchronization service found. Please install systemd-timesyncd, or chrony."
    exit 1
fi

echo ""

GetNtpConfiguration
GetNtpPoolServers

if ((_shouldError == 1)); then
    exit 1
fi

 

Detailed Breakdown

This script is structured into multiple functions and sections for modular control and clarity. Here’s a breakdown of its main components:

1. Pre-flight Checks

  • Root Permission Check
    Ensures the script is run as root since time and service control require elevated privileges.
  • Service Discovery
    Searches for either chronyd or systemd-timesyncd. If neither is installed, the script exits with a clear error message.

2. Service Control and Sync

  • Service State Evaluation
    Determines whether each service is enabled and/or running. If not running, it starts the appropriate one temporarily.
  • Time Synchronization
    • If using chronyc: Issues the makestep command to immediately synchronize time.
    • If using timedatectl: Restarts systemd-timesyncd and checks sync status.
  • Service Restoration
    If a service was initially inactive, the script stops it again after syncing.

3. Configuration Inspection

  • GetChronyConfigFile(): Locates the chrony configuration file based on system conventions or man page defaults.
  • GetNtpPoolServers(): Collects NTP pools and server configurations from chronyd or timesyncd config files.
  • GetNtpConfiguration(): Outputs all server, pool, and possibly sourced configurations to help validate NTP settings.
  • IsChronySecurityConfigured(): Verifies that chronyd has security parameters like keyfile and commandkey set correctly.

Potential Use Cases

Case Study: Remote Patch Management

An MSP is managing Linux systems across various time zones. A recent patch deployed via NinjaOne reports inconsistent installation timestamps. The administrator rolls out this script as a pre-check step in a NinjaOne policy. The script ensures all systems sync to the same NTP pool before the patch process begins. As a result, logs align properly, and reporting is accurate.

Comparisons

Method Advantages Limitations
ntpdate (legacy) Quick, standalone Deprecated, unreliable
chronyd/systemd-timesyncd Modern, secure, persistent Requires proper config
This Script Automated detection, validation, security checks Initial root access needed

Unlike ad-hoc commands or legacy tools, this script provides a structured, error-aware, and service-agnostic method suitable for automated deployment.

Implications

Poor time synchronization can lead to a host of issues — failed logins due to token mismatch, corrupted logs, and broken replication. By validating configuration files, checking service security, and providing audit-friendly logs, this script enhances infrastructure reliability and security. It’s particularly important for systems involved in identity management, secure communications, and compliance-heavy environments.

Recommendations

  • Pre-Deployment Test: Always run the script in a staging environment first.
  • Pair With Monitoring: Use NinjaOne or similar tools to monitor service status after deployment.
  • Include in Base Image: Add this script as part of post-install configuration to prevent future drift.
  • Log Output: Redirect script output to a log file for auditability (./sync-time.sh >> /var/log/time-sync.log).

Final Thoughts

Time synchronization is foundational to operational integrity across all layers of IT. This script offers a resilient, portable, and standards-compliant way to synchronize time on Linux using shell scripting. Integrated with platforms like NinjaOne, this script empowers IT professionals and MSPs to maintain uniformity across their fleet effortlessly — improving audit readiness, data accuracy, and system reliability.

For those seeking to synchronize the time on Linux using shell scripting, this tool is both robust and automation-friendly. By embedding it into lifecycle processes, you can ensure consistent and secure timekeeping across your infrastructure.

]]>