#!/usr/bin/env bash # Ensure bash path is found # shellcheck source=/etc/os-release # use shellcheck to declare which file to source # Using set to make the script safer set -e # Exit on error set -u # Exit on undefined variable set -x # Print commands for debugging set -a # Export all variables set -C # Disable overwriting of files set -o pipefail # Exit on pipe error # set -euxaCo pipefail # All options # Use the built-in trap command to catch error lines and signal numbers trap 'printf "Error on line %d with signal %s" "$LINENO" "$?"' ERR # Exit on error # Also use trap to catch interrupt signals and exit cleanly with a message to the user and a return code trap 'printf "Interrupted on line %d with signal %s" "$LINENO" "$?"' INT SIGHUP SIGINT SIGTERM # Get script name using parameter expansion to not spawn a new subprocess SCRIPT_NAME="${0##*/}" # We will be using printf instead of echo because it is more standardised. # Also we will be using the test command's functionality as # [[ because like this it constitutes a keyword and not a command. # Functions will be defined as function "name" {body}" to make them # more clear and () will not be used since using the keyword function renders them redundant # We are taking for granted that the os-release file is in /etc as # it has become a standard in most GNU/Linux distributions using systemd. # We'll be using the -e flag just in case it is actually a symlink to another location. # The get_distro function will use short if statements to check for the os-release file existence and readability. # Then it will source it and output the distribution's name or exit in case of failure of either case. function get_distro { if [[ -e /etc/os-release ]] && [[ -r /etc/os-release ]]; then # Check if file exists and is readable . /etc/os-release # Source the file printf "%s" "$NAME" # Output the distribution's name else # If the file does not exist or is not readable printf "%s" "File os-release not found or not readable" # Output error message exit 1 # Exit with error code 1 fi } # The get_package_manager function will take the output of the get_distro function and determine # which is the package manager used for the most popular server distros and exit if it is not found. function get_package_manager { local distro # Declare distro as a local variable distro="$(get_distro)" # Get the distribution name case "$distro" in # Use case to check for the distribution name "Ubuntu" | "Debian GNU/Linux") # If the distribution is Ubuntu or Debian printf "%s" "apt" # Output apt ;; "CentOS Linux" | "Fedora" | "Red Hat Enterprise Linux Server") # If the distribution is CentOS, Fedora or RHEL printf "%s" "dnf" # Output dnf ;; "openSUSE Leap") # If the distribution is OpenSUSE printf "%s" "zypper" # Output zypper ;; *) # If the distribution is none of the above, output unsupported distribution # and exit with error code 1 printf "%s" "Unsupported distribution" exit 1 # Exit with error code 1 ;; esac } # The install_packages function will take the output of the get_package_manager function and install any # package passed as an argument to it. It will also check if the package manager is known and exit if it is not. function install_packages { local package_manager # Declare package_manager as a local variable package_manager="$(get_package_manager)" # Get the package manager case "$package_manager" in # Use case to check for the package manager "apt") # If the package manager is apt printf "%s" 'debconf debconf/frontend select Noninteractive' | sudo debconf-set-selections export DEBIAN_FRONTEND=noninteractive export NEEDRESTART_MODE=a export DEBIAN_PRIORITY=critical # Running sudo with -E will preserve the environment variables set in the script sudo -E apt update -y && sudo apt upgrade -y # Update the package list and upgrade the packages sudo -E apt install -y "$@" # Install the packages passed as arguments ;; "dnf") # If the package manager is dnf sudo dnf upgrade -y # Update the package list sudo dnf install -y "$@" # Install the packages passed as arguments ;; "zypper") # If the package manager is zypper sudo zypper update -y # Update the package list sudo zypper install -y "$@" # Install the packages passed as arguments ;; *) # If the package manager is not one of the above, output unsupported package manager # and exit with error code 1 printf "%s" "Unsupported package manager" exit 1 # Exit with error code 1 ;; esac } # The check_dependencies function will check if the dependencies defined in a local array are not installed # and store the ones that are indeed absent in another local array. # Then it will install the packages that are missing by invoking the install_packages function. function check_dependencies { # systemd-container is for machinectl local dependencies=(fuse-overlayfs dbus-user-session uidmap slirp4netns systemd-container at htop curl git sudo vim ssh wget fail2ban) # Declare dependencies as a local array #> see what to do with name differences between distros if any <# local missing_dependencies=() # Declare missing_dependencies as a local array for dependency in "${dependencies[@]}"; do # Loop through the dependencies array # If the dependency is not installed, add it to the missing_dependencies array ! command -v "$dependency" &> /dev/null && missing_dependencies+=("$dependency") done # If the missing_dependencies array is not empty, install the packages [[ ${#missing_dependencies[@]} -ne 0 ]] && install_packages "${missing_dependencies[@]}" } # Global array of the service names to be restarted services=() # The hardenSSH function will use sed to modify the sshd_config file to have the following settings: # - Allow ssh access to users in the sudo group only # - Change the port to 22100 if it is available # - Configure idle timeout to 5 minutes # - Limit the number of authentication attempts to 3 # - Disable root login # - Disable empty passwords # - Disable ssh protocol 1 # - Disable password authentication and only allow public key authentication # - Disable X11 forwarding for security reasons (X11 forwarding is not needed for ssh) # - Disable agent forwarding to prevent ssh-agent hijacking # Then it will store the sshd service name in the services array. function hardenSSH { # Check if the sshd_config file exists and is readable # If it is, then modify it using sed and restart the sshd service # If it is not, then output an error message and exit with error code 1 # The -i flag is used to modify the file in place # We split the sed command into multiple lines for readability purposes # and to avoid calling it multiple times if [[ -e /etc/ssh/sshd_config ]] && [[ -r /etc/ssh/sshd_config ]]; then sudo sed -i \ -e 's/^#AllowGroups.*/AllowGroups sudo/' \ -e 's/^#Port.*/Port 22100/' \ -e 's/^#ClientAliveInterval.*/ClientAliveInterval 300/' \ -e 's/^#ClientAliveCountMax.*/ClientAliveCountMax 3/' \ -e 's/^#PermitRootLogin.*/PermitRootLogin no/' \ -e 's/^#PermitEmptyPasswords.*/PermitEmptyPasswords no/' \ -e 's/^#Protocol.*/Protocol 2/' \ -e 's/^#PasswordAuthentication.*/PasswordAuthentication no/' \ -e 's/^#X11Forwarding.*/X11Forwarding no/' \ -e 's/^X11Forwarding.*/X11Forwarding no/' \ -e 's/^#AllowAgentForwarding.*/AllowAgentForwarding no/' \ /etc/ssh/sshd_config services+=("sshd") # Add sshd to the services array else printf "%s" "File sshd_config not found or not readable" exit 1 fi } function getCorrectFirewall { local distro # Declare distro as a local variable distro="$(get_distro)" # Get the distribution name case "$distro" in # Use case to check for the distribution name "Ubuntu" | "Debian GNU/Linux") # If the distribution is Ubuntu or Debian printf "%s" 'debconf debconf/frontend select Noninteractive' | sudo debconf-set-selections export DEBIAN_FRONTEND=noninteractive export NEEDRESTART_MODE=a export DEBIAN_PRIORITY=critical sudo -E apt install ufw -y # Install ufw printf "%s" "ufw" # Output ufw ;; "CentOS Linux" | "Fedora" | "Red Hat Enterprise Linux Server") # If the distribution is CentOS, Fedora or RHEL sudo dnf install firewalld -y # Install firewalld printf "%s" "firewalld" # Output firewalld ;; "openSUSE Leap") # If the distribution is OpenSUSE sudo zypper install firewalld -y # Install firewalld printf "%s" "firewalld" # Output firewalld ;; *) # If the distribution is none of the above, output unsupported distribution # and exit with error code 1 printf "%s" "Unsupported distribution" exit 1 # Exit with error code 1 ;; esac } function getCorrectKernelSecurityModule { local distro # Declare distro as a local variable distro="$(get_distro)" # Get the distribution name case "$distro" in # Use case to check for the distribution name "Ubuntu") # If the distribution is Ubuntu sudo apt install apparmor-profiles apparmor-utils apparmor-profiles-extra -y # Install apparmor printf "%s" "apparmor" # Output apparmor ;; "Debian GNU/Linux") # If the distribution is Debian printf "%s" 'debconf debconf/frontend select Noninteractive' | sudo debconf-set-selections export DEBIAN_FRONTEND=noninteractive export NEEDRESTART_MODE=a export DEBIAN_PRIORITY=critical sudo -E apt install apparmor apparmor-profiles apparmor-profiles-extra apparmor-utils auditd python3-apparmor -y # Install apparmor printf "%s" "apparmor" # Output apparmor ;; "CentOS Linux" | "Fedora" | "Red Hat Enterprise Linux Server") # If the distribution is CentOS, Fedora or RHEL sudo dnf install selinux container-selinux -y # Install selinux printf "%s" "selinux" # Output selinux ;; "openSUSE Leap") # If the distribution is OpenSUSE sudo zypper install -t pattern apparmor -y # Install apparmor printf "%s" "apparmor" # Output apparmor ;; *) # If the distribution is none of the above, output unsupported distribution # and exit with error code 1 printf "%s" "Unsupported distribution" exit 1 # Exit with error code 1 ;; esac } function firewallInit { getCorrectFirewall # Get the correct firewall installed # Determine if ufw or firewalld is installed whereis ufw | grep -q /ufw && currentFirewall="ufw" || currentFirewall="firewalld" case "$currentFirewall" in ufw) sudo ufw default allow outgoing # Allow outgoing connections sudo ufw default deny incoming # Deny incoming connections sudo ufw allow 22100/tcp # Allow ssh connections on port 22100 sudo ufw allow 8000/tcp # Allow portainer connections on port 8000 sudo ufw allow 9443/tcp # Allow portainer connections on port 9443 ;; firewalld) sudo systemctl enable firewalld # Enable the firewall on boot sudo firewall-cmd --permanent --add-port=22100/tcp # Allow ssh connections on port 22100 sudo firewall-cmd --permanent --add-port=8000/tcp # Allow portainer connections on port 8000 sudo firewall-cmd --permanent --add-port=9443/tcp # Allow portainer connections on port 9443 ;; *) printf "%s" "Unsupported firewall" exit 1 ;; esac } function kernelSecurityModuleInit { getCorrectKernelSecurityModule # Get the correct kernel security module installed # Determine if apparmor or selinux is installed whereis apparmor | grep -q /apparmor && kernelSecurityModule="apparmor" || kernelSecurityModule="selinux" case "$kernelSecurityModule" in apparmor) sudo systemctl enable --now apparmor # Enable the kernel security module on boot and start it sudo aa-enforce /etc/apparmor.d/* # Enforce all apparmor profiles ;; selinux) sudo systemctl enable --now selinux # Enable the kernel security module on boot and start it ## printf "%s" "{\"selinux-enabled\":true}" | sudo tee /etc/docker/daemon.json # Enable selinux in docker sudo setenforce 1 # Enforce selinux sudo sed -i 's/^SELINUX=.*/SELINUX=enforcing/' /etc/selinux/config # Set selinux to enforcing ## sudo systemctl restart docker # Restart docker ## sudo restorecon -Rv /var/lib/docker # Restore the selinux context of the docker directory ## sudo restorecon -Rv /usr/bin # Restore the selinux context of the docker directory ;; *) printf "%s" "Unsupported kernel security module" exit 1 ;; esac } function dockerInit { # Set up rootless docker # Enable linger for the secdep user sudo loginctl enable-linger secdep # Enable dbus for the secdep user sudo machinectl shell secdep@ /bin/bash -c "systemctl --user enable --now dbus" # Install rootless docker sudo machinectl shell secdep@ /bin/bash -c "curl -fsSL https://get.docker.com/rootless | sh" # Add important environment variables to the secdep user's .bashrc sudo su secdep << 'EOF' printf "%s\n" "export PATH=/home/$USER/bin:$PATH" >> "$HOME/.bashrc" printf "%s\n" "export DOCKER_HOST=unix:///run/user/$UID/docker.sock" >> "$HOME/.bashrc" EOF # Enable the user to bind to ports below 1024 sudo setcap cap_net_bind_service=ep /home/secdep/bin/rootlesskit # Restart docker sudo machinectl shell secdep@ /bin/bash -c "systemctl --user restart docker" # # Create a new docker network to dissalow communication between containers # ##sudo docker network create --driver bridge -o "com.docker.network.bridge.enable_icc"="false" dockerNetworkNoICC # Get all arguments passed to the function and store them in the dockerImages array local dockerImages=("$@") # Using -f instead of -e to check if the file exists AND that it is a regular file [[ -f /root/docker-compose.yml ]] && sudo mv /root/docker-compose.yml /home/secdep/docker-compose.yml [[ -f /home/admin/docker-compose.yml ]] && sudo mv /home/admin/docker-compose.yml /home/secdep/docker-compose.yml [[ -f /home/ec2-user/docker-compose.yml ]] && sudo mv /home/ec2-user/docker-compose.yml /home/secdep/docker-compose.yml [[ -f /home/centos/docker-compose.yml ]] && sudo mv /home/centos/docker-compose.yml /home/secdep/docker-compose.yml [[ -f /home/fedora/docker-compose.yml ]] && sudo mv /home/fedora/docker-compose.yml /home/secdep/docker-compose.yml [[ -f /home/ubuntu/docker-compose.yml ]] && sudo mv /home/ubuntu/docker-compose.yml /home/secdep/docker-compose.yml # Since FileDeployment does not work and we used ScriptFileDeployment which might make the file owned by another user # we need to make sure the file is owned by the secdep user. # When using [[ -f /home/secdep/docker-compose.yml ]] && sudo chown secdep:secdep /home/secdep/docker-compose.yml # it doesn't get executed somehow so we'll send the "no such file or directory" error to /dev/null sudo chown secdep:secdep /home/secdep/docker-compose.yml > /dev/null 2>&1 # Since FileDeployment does not work and we used ScriptFileDeployment which automatically makes the file executable # we need to make sure the file is not executable. # when using [[ -f /home/secdep/docker-compose.yml ]] && sudo chmod -x /home/secdep/docker-compose.yml # it doesn't get executed somehow so we'll send the "no such file or directory" error to /dev/null sudo chmod -x /home/secdep/docker-compose.yml > /dev/null 2>&1 sudo machinectl shell secdep@ /bin/bash -c 'curl -SL https://github.com/docker/compose/releases/download/v2.20.3/docker-compose-linux-x86_64 -o /home/secdep/bin/docker-compose' sudo machinectl shell secdep@ /bin/bash -c 'chmod +x /home/secdep/bin/docker-compose' sudo machinectl shell secdep@ "$(which bash)" -c '[[ -f "$HOME/docker-compose.yml" ]] && DOCKER_HOST=unix:///run/user/$UID/docker.sock /home/secdep/bin/docker-compose -f /home/secdep/docker-compose.yml up -d' # Read the docker-compose.yml file for port mappings to add to the firewall CMD_PORTS="cat /home/secdep/docker-compose.yml | sed '/^[[:space:]]*$/d' | grep -A1 ports | grep '[0-9]:[0-9]' | rev | cut -d':' -f1 | rev | grep -Eow '[[:digit:]]+' | tr '\n' ' '" sudo -E runuser - secdep -c "$CMD_PORTS" > /dev/null 2>&1 && PORTS="$(sudo -E runuser - secdep -c "$CMD_PORTS")" || PORTS="" # Loop through the ports in the PORTS variable if [[ -n "$PORTS" ]]; then for port in $PORTS; do # Allow the port in the firewall case "$currentFirewall" in ufw) sudo ufw allow "$port"/tcp ;; firewalld) sudo firewall-cmd --permanent --add-port="$port"/tcp ;; *) printf "%s" "Unsupported firewall" exit 1 ;; esac done fi # Portainer is a docker image that provides a web interface for docker # which will be installed and run on port 9443 by default to make it easier to manage docker # url to follow after the installation is complete: https://vps_ip:9443 # the https:// part is important as portainer will not work without it sudo -E runuser - secdep -c 'docker run -d -p 8000:8000 -p 9443:9443 --name=portainer --restart=always -v /run/user/$UID/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce' # Check if the dockerImages array is empty and return 0 if it is [[ "${#dockerImages[@]}" -eq 0 ]] && return 0 # Loop through the dockerImages array # The dockerImages array contains all the docker images to install and run for dockerImage in "${dockerImages[@]}"; do # If dockerImage contains a ":" character (if it is a docker image with a tag) # then store the docker image name in the dockerImageName variable [[ "$dockerImage" == *":"* ]] && dockerImageName="${dockerImage%:*}" || dockerImageName="$dockerImage" # Same goes for "/" [[ "$dockerImageName" == *"/"* ]] && dockerImageName="${dockerImageName%/*}" # No need to pull the docker image as the run command will do it automatically # Run the docker image in the background, # with the restart always option and the name of the docker image # The --security-opt=no-new-privileges option will prevent the docker image from gaining new privileges # The --cap-drop all option will drop all capabilities from the docker image # The --cap-add NET_BIND_SERVICE option will add the NET_BIND_SERVICE capability to the docker image # The --read-only option will mount the docker image as read-only # The --tmpfs /opt option will mount the /opt directory as a tmpfs # The --network dockerNetworkNoICC option will connect the docker image to the dockerNetworkNoICC network # The -v /:/host option will enable the docker rootless mode # # The --user secdep option will run the docker image as the secdep user to prevent privilege escalation # sudo -u secdep bash -c 'mkdir -p /home/secdep/opt' CMD="docker pull $dockerImage" # CMD="docker run -d --restart always --name $dockerImageName --security-opt=no-new-privileges --cap-drop all --cap-add NET_BIND_SERVICE --tmpfs /home/secdep/opt -v /:/host $dockerImage" # CMD="docker run -d --restart always --name $dockerImageName --security-opt=no-new-privileges --cap-drop all --cap-add NET_BIND_SERVICE --read-only --tmpfs /home/secdep/opt -v /:/host $dockerImage" sudo -E runuser - secdep -c "$CMD" done } # The selinuxConfig function will set up and configure selinux with sane defaults. # function selinuxConfig { # # Set the selinux boolean to allow docker to use the network # sudo setsebool -P docker_connect_any 1 # } # Fix banaction ufw with iptables # Does not always persist after reboot. function configureFail2ban { FAIL2BAN_LOCAL=$(cat <<'EOF' [Definition] logtarget = /var/log/fail2ban/fail2ban.log allowipv6 = auto EOF ) printf "%s" "$FAIL2BAN_LOCAL" | sudo tee /etc/fail2ban/fail2ban.local FAIL2BAN_SSH_JAIL_LOCAL=$(cat <<'EOF' [sshd] backend = systemd enabled = true filter = sshd banaction = ufw maxretry = 3 # 3 failed attempts in 600 seconds = 10 minutes findtime = 1d bantime = 1d ignoreip = 127.0.0.1/8 EOF ) FAIL2BAN_JAIL_LOCAL=$(cat <<'EOF' [DEFAULT] backend = systemd bantime = 1d EOF ) printf "%s" "$FAIL2BAN_JAIL_LOCAL" | sudo tee /etc/fail2ban/jail.local printf "%s" "$FAIL2BAN_SSH_JAIL_LOCAL" | sudo tee /etc/fail2ban/jail.d/sshd.local FAIL2BAN_FILTER=$(cat <<'EOF' [Definition] failregex = ^.*DROP_.*SRC= DST=.*$ journalmatch = _TRANSPORT=kernel EOF ) printf "%s" "$FAIL2BAN_FILTER" | sudo tee /etc/fail2ban/filter.d/fwdrop.local HARDEN_FAIL2BAN_SERVICE=$(cat <<'EOF' [Service] PrivateDevices=yes PrivateTmp=yes ProtectHome=read-only ProtectSystem=strict ReadWritePaths=-/var/run/fail2ban ReadWritePaths=-/var/lib/fail2ban ReadWritePaths=-/var/log/fail2ban ReadWritePaths=-/var/spool/postfix/maildrop ReadWritePaths=/run/xtables.lock CapabilityBoundingSet=CAP_AUDIT_READ CAP_DAC_READ_SEARCH CAP_NET_ADMIN CAP_NET_RAW EOF ) sudo mkdir -p /etc/systemd/system/fail2ban.service.d printf "%s" "$HARDEN_FAIL2BAN_SERVICE" | sudo tee /etc/systemd/system/fail2ban.service.d/override.conf sudo systemctl daemon-reload sudo systemctl enable --now fail2ban printf "%s" "LogLevel VERBOSE" | sudo tee -a /etc/ssh/sshd_config } function enableServices { for service in "${services[@]}"; do sudo systemctl restart "$service" done whereis ufw | grep -q /ufw && currentFirewall="ufw" || currentFirewall="firewalld" # With the if block it doesn't error out at firewalld check so we do it this way # For ufw if [[ "$currentFirewall" == "ufw" ]]; then # Enable the firewall sudo ufw --force enable # Enable and start the firewall on boot sudo systemctl enable --now ufw # For firewalld elif [[ "$currentFirewall" == "firewalld" ]]; then # Reload the firewall sudo firewall-cmd --reload else printf "%s" "Somehow there is no firewall installed" exit 1 fi } # Sometimes the user is not deleted after the script is run function deleteRemainingUsers { # Delete possible remaining users cat << EOF | sudo tee /root/delete_users.sh [[ -d /home/admin ]] && sudo userdel -r admin && sudo groupdel admin [[ -d /home/ec2-user ]] && sudo userdel -r ec2-user && sudo groupdel ec2-user [[ -d /home/centos ]] && sudo userdel -r centos && sudo groupdel centos [[ -d /home/fedora ]] && sudo userdel -r fedora && sudo groupdel fedora [[ -d /home/ubuntu ]] && sudo userdel -r ubuntu && sudo groupdel ubuntu sudo rm -f /root/delete_users.sh EOF sudo systemctl enable --now atd # Use at as root because if it is run as one of the users above it will fail sudo at now + 1 minute <<< "bash /root/delete_users.sh" } # The main function will call the declared functions in order and exit if any of them fails. # It will also pass any arguments passed to the script to the dockerInit function. # Then it will output a message to the user and reboot the system in 2 minutes. function main { printf "%s" "$SCRIPT_NAME script started" check_dependencies || exit 1 # Check dependencies and exit if it fails printf "%s" "Dependencies installed" hardenSSH || exit 1 # Harden ssh and exit if it fails printf "%s" "SSH hardened" firewallInit || exit 1 # Initialize the firewall and exit if it fails printf "%s" "Firewall initialized" kernelSecurityModuleInit || exit 1 # Initialize the kernel security module and exit if it fails printf "%s" "Kernel security module initialized" configureFail2ban || exit 1 # Initialize fail2ban and exit if it fails printf "%s" "Fail2ban configured" # selinuxConfig # Configure selinux # Call the dockerInit function with the arguments passed to the script dockerInit "$@" || exit 1 # Initialize docker and exit if it fails enableServices || exit 1 deleteRemainingUsers || exit 1 printf "%s" "$SCRIPT_NAME script finished" # Output message to the user printf "%s" "System will reboot momentarily" # Output message to the user # Reboot the system in 2 minutes sudo shutdown -r +2 } # Call the main function main "$@" exit 0 # The right and proper way to exit a script