Last active
July 6, 2026 16:47
-
-
Save dot-mike/8a1140535e11f54323e2be126ccd8e69 to your computer and use it in GitHub Desktop.
Inital setup script for Ubuntu server
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # Initial setup / hardening for Ubuntu LTS servers. Runs as root, idempotent. | |
| # sudo ./ubuntu-setup.sh # interactive menu | |
| # ./ubuntu-setup.sh --sample-config >f # make a preset; edit it | |
| # ./ubuntu-setup.sh --dry-run --config f # preview (no root) | |
| # sudo ./ubuntu-setup.sh --config f # apply unattended | |
| if [ -z "${BASH_VERSION:-}" ]; then | |
| if command -v bash >/dev/null 2>&1 && [ -r "$0" ]; then | |
| exec bash "$0" "$@" | |
| fi | |
| echo "This script requires bash. Run it with: bash $0" >&2 | |
| exit 1 | |
| fi | |
| set -Eeuo pipefail | |
| VERSION="3.0.0" | |
| # Runtime configuration | |
| INTERACTIVE=true | |
| ASSUME_YES=false | |
| DRY_RUN=false | |
| DO_LOG=true | |
| LOG_FILE="" | |
| CONFIG_FILE="" | |
| FEATURE_SET="" | |
| ACTIONS="" | |
| USERNAME="" | |
| SSH_KEY="" | |
| PASSWORDLESS_SUDO=false | |
| SSH_HARDEN="" | |
| TIMEZONE="" | |
| NTP_SERVERS="" | |
| EXTRA_PACKAGES="" | |
| HOSTNAME_ARG="" | |
| AUTO_REBOOT=false | |
| AUTO_REBOOT_TIME="02:00" | |
| JOURNAL_MAX="500M" | |
| LOCALE="" | |
| BANNER_TEXT="" | |
| MOTD_TEXT="" | |
| BANNER_SYSINFO=true | |
| BANNER_IFACE="" | |
| MOTD_SYSINFO=true | |
| SSH_DROPIN="/etc/ssh/sshd_config.d/10-hardening.conf" | |
| SSH_BANNER_DROPIN="/etc/ssh/sshd_config.d/20-banner.conf" | |
| SSH_CONFIG_CHANGED=false | |
| APT_UPDATED=false | |
| declare -a SUMMARY=() | |
| APT_ENV=(env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a) | |
| APT_OPTS=(-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold) | |
| # Config keys that must be exactly 'true' or 'false'. | |
| BOOL_KEYS=(PASSWORDLESS_SUDO BANNER_SYSINFO MOTD_SYSINFO AUTO_REBOOT) | |
| # Config-file keys -> the variable each one sets. This single table is the | |
| # whitelist used by load_config() and print_sample_config(); anything not | |
| # listed here is rejected | |
| declare -A CONFIG_KEYS=( | |
| [ACTIONS]=ACTIONS | |
| [FEATURE_SET]=FEATURE_SET | |
| [USERNAME]=USERNAME | |
| [SSH_KEY]=SSH_KEY | |
| [PASSWORDLESS_SUDO]=PASSWORDLESS_SUDO | |
| [SSH_HARDEN]=SSH_HARDEN | |
| [HOSTNAME]=HOSTNAME_ARG | |
| [LOCALE]=LOCALE | |
| [TIMEZONE]=TIMEZONE | |
| [NTP_SERVERS]=NTP_SERVERS | |
| [EXTRA_PACKAGES]=EXTRA_PACKAGES | |
| [BANNER_TEXT]=BANNER_TEXT | |
| [MOTD_TEXT]=MOTD_TEXT | |
| [BANNER_SYSINFO]=BANNER_SYSINFO | |
| [BANNER_IFACE]=BANNER_IFACE | |
| [MOTD_SYSINFO]=MOTD_SYSINFO | |
| [AUTO_REBOOT]=AUTO_REBOOT | |
| [AUTO_REBOOT_TIME]=AUTO_REBOOT_TIME | |
| [JOURNAL_MAX]=JOURNAL_MAX | |
| ) | |
| # Package lists | |
| global_packages=(curl wget htop vim screen unzip bash-completion jq ca-certificates) | |
| development_packages=(git build-essential python3 python3-pip cmake autoconf libtool) | |
| workstation_packages=() | |
| bloat_packages=(apport apport-symptoms popularity-contest ubuntu-report whoopsie snapd telnet) | |
| # Action registry: one table drives the menu, --help and dispatch. | |
| # sshkey must run before ssh so the key lands before password login is disabled. | |
| ACTION_ORDER=( | |
| update user sshkey ssh hostname locale ufw fail2ban swap timezone ntp | |
| packages debloat esm-remove autoupdate journald sysctl-net motd banner | |
| ) | |
| declare -A ACTION_DESC=( | |
| [update]="Update and upgrade all packages" | |
| [user]="Create a new sudo user (with SSH key)" | |
| [ssh]="Harden SSH via drop-in config" | |
| [sshkey]="Add a public SSH key to a user" | |
| [hostname]="Set the system hostname" | |
| [locale]="Generate and set the system locale" | |
| [ufw]="Enable and configure the UFW firewall" | |
| [fail2ban]="Install and configure fail2ban" | |
| [swap]="Create and tune a swap file" | |
| [timezone]="Set the system timezone" | |
| [ntp]="Configure NTP time synchronisation" | |
| [packages]="Install extra base packages" | |
| [debloat]="Remove bloatware and snaps" | |
| [esm-remove]="Uninstall the Ubuntu Pro/ESM client" | |
| [autoupdate]="Enable unattended security upgrades" | |
| [journald]="Persist and cap the systemd journal" | |
| [sysctl-net]="Apply network-hardening sysctls" | |
| [motd]="Disable motd-news and login help/news spam" | |
| [banner]="Set login banners (console, SSH, MOTD)" | |
| ) | |
| # Each action NAME maps to the shell function action_<name> ('-' -> '_'). | |
| # Feature sets may pull in actions on top of installing packages. | |
| declare -A FEATURE_ACTIONS=( | |
| [server]="ufw fail2ban" | |
| ) | |
| # Logging helpers | |
| _ts() { date '+%Y-%m-%dT%H:%M:%S%z'; } | |
| log_info() { printf '%s [INFO] %s\n' "$(_ts)" "$*"; } | |
| log_warn() { printf '%s [WARN] %s\n' "$(_ts)" "$*" >&2; } | |
| log_error() { printf '%s [ERROR] %s\n' "$(_ts)" "$*" >&2; } | |
| default_log() { | |
| echo "/var/log/ubuntu-setup.log" | |
| } | |
| # helpers | |
| # Run a state-changing command, honouring --dry-run. | |
| sh_run() { | |
| if [[ "$DRY_RUN" == true ]]; then | |
| log_info "[dry-run] $*" | |
| else | |
| "$@" | |
| fi | |
| } | |
| # Write stdin to a root-owned file, creating parent dirs. | |
| write_file() { | |
| local dest="$1" | |
| if [[ "$DRY_RUN" == true ]]; then | |
| log_info "[dry-run] write $dest:"; sed 's/^/ | /' | |
| return 0 | |
| fi | |
| mkdir -p "$(dirname "$dest")" | |
| tee "$dest" >/dev/null | |
| } | |
| # Append a line to a file exactly once (idempotent). | |
| append_once() { | |
| local line="$1" file="$2" | |
| if [[ "$DRY_RUN" == true ]]; then | |
| log_info "[dry-run] append to $file: $line"; return 0 | |
| fi | |
| test -f "$file" || touch "$file" | |
| grep -qxF -- "$line" "$file" 2>/dev/null \ | |
| || printf '%s\n' "$line" | tee -a "$file" >/dev/null | |
| } | |
| pkg_installed() { | |
| dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q "install ok installed" | |
| } | |
| snapd_running() { | |
| systemctl is-active --quiet snapd.socket 2>/dev/null \ | |
| || systemctl is-active --quiet snapd 2>/dev/null | |
| } | |
| # Reload sshd across service-name variants (ssh vs sshd). | |
| reload_sshd() { | |
| systemctl reload ssh 2>/dev/null \ | |
| || systemctl reload sshd 2>/dev/null \ | |
| || service ssh reload | |
| } | |
| # Validate sshd config, reload on success, roll back the drop-in on failure. | |
| apply_sshd_dropin() { | |
| local dropin="$1" | |
| [[ "$DRY_RUN" == true ]] && return 0 | |
| command -v sshd >/dev/null 2>&1 || return 0 | |
| if sshd -t; then | |
| reload_sshd | |
| SSH_CONFIG_CHANGED=true | |
| else | |
| log_error "sshd config test failed; removing ${dropin} to avoid lockout." | |
| rm -f "$dropin" | |
| return 1 | |
| fi | |
| } | |
| apt_update_once() { | |
| if [[ "$APT_UPDATED" == false ]]; then | |
| sh_run "${APT_ENV[@]}" apt-get update | |
| APT_UPDATED=true | |
| fi | |
| } | |
| apt_install() { | |
| [[ $# -eq 0 ]] && return 0 | |
| apt_update_once | |
| sh_run "${APT_ENV[@]}" apt-get install -y "${APT_OPTS[@]}" "$@" | |
| } | |
| in_array() { | |
| local needle="$1"; shift | |
| local item | |
| for item in "$@"; do [[ "$item" == "$needle" ]] && return 0; done | |
| return 1 | |
| } | |
| # Resolve a value from a flag, otherwise prompt (interactive) or use default. | |
| need_value() { | |
| local value="$1" prompt="$2" default="${3:-}" reply | |
| if [[ -n "$value" ]]; then printf '%s' "$value"; return; fi | |
| if [[ "$INTERACTIVE" == true ]]; then | |
| read -rp "$prompt" reply | |
| printf '%s' "${reply:-$default}" | |
| else | |
| printf '%s' "$default" | |
| fi | |
| } | |
| confirm() { | |
| local prompt="${1:-Continue?}" reply | |
| [[ "$ASSUME_YES" == true ]] && return 0 | |
| read -rp "${prompt} [y/N] " reply | |
| [[ "$reply" == [yY] ]] | |
| } | |
| getPhysicalMemory() { | |
| local phymem | |
| phymem="$(free -g | awk '/^Mem:/{print $2}')" | |
| [[ "$phymem" == "0" || -z "$phymem" ]] && phymem=1 | |
| echo "$phymem" | |
| } | |
| # CLI | |
| usage() { | |
| cat <<EOF | |
| Ubuntu LTS setup script v${VERSION} | |
| Runs as root. Two ways to use it: | |
| sudo ${0##*/} Interactive menu + prompts | |
| sudo ${0##*/} --config FILE Unattended, driven by a preset | |
| Read the script before running it, and preview any run with --dry-run | |
| (which needs no root). | |
| Options: | |
| -h, --help Show this help and exit | |
| -V, --version Show version and exit | |
| -n, --dry-run Preview every change (files, commands); apply nothing | |
| --config FILE Run unattended from a preset (KEY=VALUE, see --sample-config) | |
| --sample-config Print a commented preset template to stdout and exit | |
| --log FILE Write log to FILE (default: $(default_log)) | |
| --no-log Do not write a log file | |
| Workflow: | |
| ${0##*/} --sample-config > server.conf # create a template | |
| \$EDITOR server.conf # fill it in | |
| ${0##*/} --dry-run --config server.conf # preview (no root) | |
| sudo ${0##*/} --config server.conf # apply | |
| Actions available (select in the menu, or list in ACTIONS=): | |
| EOF | |
| local name | |
| for name in "${ACTION_ORDER[@]}"; do | |
| printf ' %-11s %s\n' "$name" "${ACTION_DESC[$name]}" | |
| done | |
| } | |
| parseArgs() { | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -h|--help) usage; exit 0;; | |
| -V|--version) echo "${VERSION}"; exit 0;; | |
| -n|--dry-run) DRY_RUN=true;; | |
| --config) | |
| [[ -n "${2:-}" ]] || { log_error "--config requires a FILE argument."; exit 2; } | |
| CONFIG_FILE="$2"; shift;; | |
| --sample-config) print_sample_config; exit 0;; | |
| --log) | |
| [[ -n "${2:-}" ]] || { log_error "--log requires a FILE argument."; exit 2; } | |
| LOG_FILE="$2"; shift;; | |
| --no-log) DO_LOG=false;; | |
| *) echo "Unknown option: $1" >&2; usage >&2; exit 2;; | |
| esac | |
| shift | |
| done | |
| } | |
| # Load a preset: whitelisted KEY=VALUE only. Never sources the file, so a | |
| # preset cannot execute code. Unknown keys are a hard error (catches typos). | |
| load_config() { | |
| local file="$1" line key val var lineno=0 | |
| if [[ ! -r "$file" ]]; then | |
| log_error "Config file not readable: $file" | |
| exit 1 | |
| fi | |
| while IFS= read -r line || [[ -n "$line" ]]; do | |
| lineno=$((lineno + 1)) | |
| # trim surrounding whitespace | |
| line="${line#"${line%%[![:space:]]*}"}" | |
| line="${line%"${line##*[![:space:]]}"}" | |
| [[ -z "$line" || "$line" == '#'* ]] && continue | |
| if [[ "$line" != *=* ]]; then | |
| log_error "Config ${file}:${lineno}: expected KEY=VALUE, got: ${line}" | |
| exit 1 | |
| fi | |
| key="${line%%=*}"; val="${line#*=}" | |
| key="${key%"${key##*[![:space:]]}"}"; key="${key#"${key%%[![:space:]]*}"}" | |
| val="${val#"${val%%[![:space:]]*}"}" # left-trim value | |
| # Quoted value: take what is inside the quotes (a '#' there is literal), | |
| # ignore anything after. Unquoted: a whitespace-'#' starts a comment. | |
| if [[ "$val" == \"* ]]; then | |
| val="${val#\"}"; val="${val%%\"*}" | |
| elif [[ "$val" == \'* ]]; then | |
| val="${val#\'}"; val="${val%%\'*}" | |
| else | |
| val="${val%%[[:space:]]#*}" # drop inline comment | |
| val="${val%"${val##*[![:space:]]}"}" # right-trim | |
| fi | |
| var="${CONFIG_KEYS[$key]:-}" | |
| if [[ -z "$var" ]]; then | |
| log_error "Config ${file}:${lineno}: unknown key '${key}'." | |
| log_error "Valid keys: ${!CONFIG_KEYS[*]}" | |
| exit 1 | |
| fi | |
| if in_array "$key" "${BOOL_KEYS[@]}" && [[ "$val" != true && "$val" != false ]]; then | |
| log_error "Config ${file}:${lineno}: ${key} must be 'true' or 'false' (got '${val}')." | |
| exit 1 | |
| fi | |
| printf -v "$var" '%s' "$val" | |
| done < "$file" | |
| log_info "Loaded preset: ${file}" | |
| } | |
| print_sample_config() { | |
| cat <<'EOF' | |
| # ubuntu-setup.sh preset. Lines are KEY=VALUE; # starts a comment. | |
| # Fill in what you need, delete the rest (unset keys fall back to defaults), | |
| # then: ubuntu-setup.sh --dry-run --config THIS_FILE to preview. | |
| # Actions to run, in any order (they run in a fixed safe order regardless). | |
| # Names: update user sshkey ssh hostname locale ufw fail2ban swap timezone | |
| # ntp packages debloat autoupdate journald sysctl-net motd banner | |
| ACTIONS="update user ssh ufw fail2ban swap ntp autoupdate journald sysctl-net motd banner" | |
| # Optional bundle: none | development | server | workstation | |
| # 'server' also pulls in the ufw + fail2ban actions. | |
| FEATURE_SET="server" | |
| # --- user --- | |
| USERNAME="deploy" | |
| SSH_KEY="ssh-ed25519 AAAA...replace-me... you@host" | |
| PASSWORDLESS_SUDO=true # true = NOPASSWD sudo (key-only login) | |
| # --- ssh hardening --- root | password | both | none | |
| # password and both disable password login and REQUIRE an SSH key (SSH_KEY | |
| # above, or one already installed) - the run aborts otherwise, to avoid lockout. | |
| SSH_HARDEN="both" | |
| # --- system basics --- | |
| HOSTNAME="web01" | |
| LOCALE="en_US.UTF-8" | |
| TIMEZONE="Europe/Oslo" | |
| NTP_SERVERS="ntp.ubuntu.com" | |
| # --- packages --- (space separated; empty = built-in default list) | |
| EXTRA_PACKAGES="" | |
| # --- login banners --- (single line each; built-in defaults used if empty) | |
| BANNER_TEXT="" | |
| MOTD_TEXT="" | |
| BANNER_SYSINFO=true # console pre-login: live host/OS/IP block (agetty) | |
| BANNER_IFACE="" # pin NIC for the IP lines, e.g. eth0; empty = auto | |
| MOTD_SYSINFO=true # post-login status; also disables Ubuntu's slower landscape-sysinfo | |
| # --- automatic security updates --- | |
| AUTO_REBOOT=false # true = reboot automatically when required | |
| AUTO_REBOOT_TIME="02:00" | |
| # --- journald --- | |
| JOURNAL_MAX="500M" | |
| EOF | |
| } | |
| # Pre-flight checks | |
| preChecks() { | |
| log_info "Running pre-checks..." | |
| if [[ -r /etc/os-release ]]; then | |
| # shellcheck disable=SC1091 | |
| . /etc/os-release | |
| if [[ "${ID:-}" != "ubuntu" ]]; then | |
| log_warn "This script targets Ubuntu; detected '${ID:-unknown}'. Proceed with care." | |
| confirm "Continue anyway?" || exit 0 | |
| else | |
| log_info "Detected ${PRETTY_NAME:-Ubuntu}." | |
| fi | |
| fi | |
| local avail | |
| avail="$(df -k --output=avail / 2>/dev/null | tail -1 | tr -d ' ' || true)" | |
| if [[ -n "$avail" && "$avail" -lt 4000000 ]]; then | |
| log_warn "Less than ~4GB free on /." | |
| fi | |
| if [[ -f /var/run/reboot-required ]]; then | |
| log_warn "A reboot is required (pending from a previous upgrade)." | |
| confirm "Continue without rebooting?" || exit 0 | |
| fi | |
| } | |
| # Actions | |
| action_update() { | |
| apt_update_once | |
| sh_run "${APT_ENV[@]}" apt-get upgrade -y "${APT_OPTS[@]}" | |
| sh_run "${APT_ENV[@]}" apt-get autoremove -y --purge | |
| sh_run "${APT_ENV[@]}" apt-get clean | |
| } | |
| enablePasswordlessSudo() { | |
| local username="$1" | |
| local sudoers_file="/etc/sudoers.d/90-${username}-nopasswd" | |
| if [[ "$DRY_RUN" == true ]]; then | |
| log_info "[dry-run] write $sudoers_file (NOPASSWD for $username)"; return 0 | |
| fi | |
| printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$username" | tee "$sudoers_file" >/dev/null | |
| chmod 440 "$sudoers_file" | |
| if ! visudo -cf "$sudoers_file"; then | |
| rm -f "$sudoers_file" | |
| log_error "Invalid sudoers entry; reverted." | |
| return 1 | |
| fi | |
| } | |
| # True if $1 parses as an OpenSSH public key. | |
| valid_ssh_pubkey() { | |
| [[ -n "$1" ]] || return 1 | |
| if ! command -v ssh-keygen >/dev/null 2>&1; then | |
| log_warn "ssh-keygen not found; accepting the SSH key unvalidated." | |
| return 0 | |
| fi | |
| ssh-keygen -lf /dev/stdin <<<"$1" >/dev/null 2>&1 | |
| } | |
| addSSHKey() { | |
| local username="$1" sshKey="$2" home_dir entry | |
| if ! valid_ssh_pubkey "$sshKey"; then | |
| log_error "Not a valid OpenSSH public key (check SSH_KEY): '${sshKey}'" | |
| return 1 | |
| fi | |
| # getent exits 2 when the user is absent; guard it against pipefail/set -e. | |
| entry="$(getent passwd "$username" 2>/dev/null || true)" | |
| home_dir="$(printf '%s' "$entry" | cut -d: -f6)" | |
| if [[ -z "$home_dir" ]]; then | |
| if [[ "$DRY_RUN" == true ]]; then | |
| log_info "[dry-run] would add SSH key to ~${username}/.ssh/authorized_keys" | |
| return 0 | |
| fi | |
| log_error "User '$username' not found."; return 1 | |
| fi | |
| sh_run mkdir -p "$home_dir/.ssh" | |
| append_once "$sshKey" "$home_dir/.ssh/authorized_keys" | |
| sh_run chmod 700 "$home_dir/.ssh" | |
| sh_run chmod 600 "$home_dir/.ssh/authorized_keys" | |
| sh_run chown -R "$username:$username" "$home_dir/.ssh" | |
| } | |
| action_user() { | |
| local username sshKey reply | |
| username="$(need_value "$USERNAME" "Username of the new account: " "")" | |
| if [[ -z "$username" ]]; then log_warn "No username provided; skipping."; return 0; fi | |
| if id "$username" &>/dev/null; then | |
| log_info "User '$username' already exists." | |
| else | |
| sh_run adduser --disabled-password --gecos '' "$username" | |
| fi | |
| sh_run usermod -aG sudo "$username" | |
| sshKey="$(need_value "$SSH_KEY" $'Public SSH key for the new user:\n' "")" | |
| [[ -n "$sshKey" ]] && addSSHKey "$username" "$sshKey" | |
| if [[ "$INTERACTIVE" == true && "$PASSWORDLESS_SUDO" != true ]]; then | |
| read -rp "Disable the sudo password prompt (NOPASSWD) for ${username}? [y/N] " reply | |
| [[ "$reply" == [yY] ]] && PASSWORDLESS_SUDO=true | |
| fi | |
| if [[ "$PASSWORDLESS_SUDO" == true ]]; then | |
| enablePasswordlessSudo "$username" | |
| sh_run passwd -l "$username" | |
| elif [[ "$INTERACTIVE" == true ]]; then | |
| log_info "Set a password so '${username}' can use sudo:" | |
| sh_run passwd "$username" | |
| else | |
| log_warn "'${username}' is in the sudo group but has no password and NOPASSWD is not set." | |
| log_warn "sudo will NOT work for '${username}'. Set PASSWORDLESS_SUDO=true in the preset or set a password." | |
| fi | |
| } | |
| action_sshkey() { | |
| local username sshKey | |
| username="$(need_value "$USERNAME" "Username to add the key to: " "")" | |
| sshKey="$(need_value "$SSH_KEY" $'Public SSH key:\n' "")" | |
| if [[ -n "$username" && -n "$sshKey" ]]; then | |
| addSSHKey "$username" "$sshKey" | |
| else | |
| log_warn "Username and key are both required; skipping." | |
| fi | |
| } | |
| # True if root or any /home user has a non-empty authorized_keys file. | |
| any_user_has_authorized_keys() { | |
| local f | |
| [[ -s /root/.ssh/authorized_keys ]] && return 0 | |
| for f in /home/*/.ssh/authorized_keys; do | |
| [[ -s "$f" ]] && return 0 | |
| done | |
| return 1 | |
| } | |
| action_ssh() { | |
| local what="$SSH_HARDEN" choice | |
| if [[ "$INTERACTIVE" == true && -z "$what" ]]; then | |
| while true; do | |
| echo "Select SSH hardening:" | |
| echo " 1. Disable root login" | |
| echo " 2. Disable password authentication (requires an SSH key)" | |
| echo " 3. Both (recommended) (requires an SSH key)" | |
| echo " 0. Skip" | |
| read -rp "Choice [3]: " choice | |
| case "${choice:-3}" in | |
| 1) what="root"; break;; | |
| 2) what="password"; break;; | |
| 3) what="both"; break;; | |
| 0) what="none"; break;; | |
| *) log_warn "Invalid choice '${choice}'. Enter 0, 1, 2 or 3.";; | |
| esac | |
| done | |
| fi | |
| # Unattended runs must state their intent explicitly. | |
| if [[ -z "$what" ]]; then | |
| log_error "ssh action selected but SSH_HARDEN is unset. Set it to root|password|both|none." | |
| return 1 | |
| fi | |
| if [[ "$what" == "none" ]]; then | |
| log_info "SSH hardening skipped." | |
| return 0 | |
| fi | |
| # password/both disable password login, so a key is mandatory or you lock | |
| # yourself out. A key counts if one is already installed or a valid SSH_KEY | |
| # is being added this run. Refuse unattended; degrade in the interactive menu. | |
| if [[ "$what" == "password" || "$what" == "both" ]] \ | |
| && ! any_user_has_authorized_keys && ! valid_ssh_pubkey "$SSH_KEY"; then | |
| log_error "SSH_HARDEN='$what' disables password login but no valid SSH key is present (SSH_KEY unset or malformed)." | |
| [[ "$INTERACTIVE" != true ]] && return 1 | |
| if [[ "$what" == "both" ]]; then | |
| what="root"; log_warn "Applying root-login hardening only; add an SSH key to disable passwords." | |
| else | |
| log_warn "Skipping SSH hardening; add an SSH key first."; return 0 | |
| fi | |
| fi | |
| local -a settings=() | |
| case "$what" in | |
| root) settings=("PermitRootLogin no");; | |
| password) settings=("PasswordAuthentication no" "KbdInteractiveAuthentication no");; | |
| both) settings=("PermitRootLogin no" "PasswordAuthentication no" "KbdInteractiveAuthentication no");; | |
| *) log_error "Invalid SSH_HARDEN value: '$what' (want root|password|both|none)."; return 1;; | |
| esac | |
| { echo "# Managed by ubuntu-setup.sh - sorts before 50-cloud-init.conf" | |
| printf '%s\n' "${settings[@]}"; } | write_file "$SSH_DROPIN" | |
| apply_sshd_dropin "$SSH_DROPIN" || return 1 | |
| if [[ "$DRY_RUN" != true ]]; then | |
| log_info "Effective SSH settings:" | |
| sshd -T 2>/dev/null \ | |
| | grep -Ei '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication)' || true | |
| fi | |
| } | |
| action_hostname() { | |
| local hn | |
| hn="$(need_value "$HOSTNAME_ARG" "Hostname: " "")" | |
| if [[ -z "$hn" ]]; then log_warn "No hostname provided; skipping."; return 0; fi | |
| sh_run hostnamectl set-hostname "$hn" | |
| # Drop stale 127.0.1.1 entries first. | |
| if ! grep -qxF "127.0.1.1 $hn" /etc/hosts 2>/dev/null; then | |
| sh_run sed -i '/^127\.0\.1\.1[[:space:]]/d' /etc/hosts | |
| fi | |
| append_once "127.0.1.1 $hn" /etc/hosts | |
| log_info "Hostname set to '$hn'." | |
| } | |
| action_locale() { | |
| local loc | |
| loc="$(need_value "$LOCALE" "Locale (default en_US.UTF-8): " "en_US.UTF-8")" | |
| apt_install locales | |
| sh_run locale-gen "$loc" | |
| sh_run update-locale LANG="$loc" | |
| log_info "Locale set to ${loc} (LANG); re-login for it to take effect." | |
| } | |
| action_ufw() { | |
| apt_install ufw | |
| sh_run ufw --force default deny incoming | |
| sh_run ufw --force default allow outgoing | |
| sh_run ufw limit OpenSSH | |
| sh_run ufw --force enable | |
| [[ "$DRY_RUN" == true ]] || ufw status verbose || true | |
| } | |
| action_fail2ban() { | |
| apt_install fail2ban | |
| cat <<'EOF' | write_file /etc/fail2ban/jail.local | |
| # Managed by ubuntu-setup.sh | |
| [DEFAULT] | |
| bantime = 1h | |
| findtime = 10m | |
| maxretry = 5 | |
| # systemd backend works on minimal images with no /var/log/auth.log | |
| backend = systemd | |
| [sshd] | |
| enabled = true | |
| EOF | |
| sh_run systemctl enable fail2ban | |
| sh_run systemctl restart fail2ban | |
| [[ "$DRY_RUN" == true ]] || fail2ban-client status sshd 2>/dev/null || true | |
| } | |
| action_swap() { | |
| if [[ "$DRY_RUN" != true ]] && swapon --show 2>/dev/null | grep -q .; then | |
| log_info "Swap already active; skipping swap file creation." | |
| else | |
| local mem swapmem swapfile="/swapfile" fstype | |
| mem="$(getPhysicalMemory)" | |
| swapmem=$(( mem * 2 )) | |
| (( swapmem > 4 )) && swapmem=4 | |
| (( swapmem < 1 )) && swapmem=1 | |
| fstype="$(stat -f -c %T / 2>/dev/null || echo unknown)" | |
| if [[ "$fstype" == "zfs" ]]; then | |
| log_warn "zfs root detected; swap files on ZFS risk deadlocks. Skipping (use a ZFS zvol instead)." | |
| return 0 | |
| fi | |
| if [[ "$fstype" == "btrfs" ]]; then | |
| log_info "btrfs root detected; using dd (fallocate is unsafe on btrfs)." | |
| sh_run dd if=/dev/zero of="$swapfile" bs=1M count=$(( swapmem * 1024 )) status=none | |
| else | |
| sh_run fallocate -l "${swapmem}G" "$swapfile" \ | |
| || sh_run dd if=/dev/zero of="$swapfile" bs=1M count=$(( swapmem * 1024 )) status=none | |
| fi | |
| sh_run chmod 600 "$swapfile" | |
| sh_run mkswap "$swapfile" | |
| sh_run swapon "$swapfile" | |
| append_once '/swapfile none swap sw 0 0' /etc/fstab | |
| fi | |
| { echo '# Managed by ubuntu-setup.sh' | |
| echo 'vm.swappiness=10' | |
| echo 'vm.vfs_cache_pressure=50'; } | write_file /etc/sysctl.d/99-swap.conf | |
| sh_run sysctl --system >/dev/null | |
| } | |
| action_timezone() { | |
| local tz | |
| tz="$(need_value "$TIMEZONE" "Timezone (default Europe/Berlin): " "Europe/Berlin")" | |
| if [[ ! -f "/usr/share/zoneinfo/$tz" ]]; then | |
| log_error "Unknown timezone '${tz}' (no /usr/share/zoneinfo/${tz})." | |
| return 1 | |
| fi | |
| if ! sh_run timedatectl set-timezone "$tz" 2>/dev/null; then | |
| echo "$tz" | write_file /etc/timezone | |
| sh_run ln -fs "/usr/share/zoneinfo/$tz" /etc/localtime | |
| sh_run "${APT_ENV[@]}" dpkg-reconfigure -f noninteractive tzdata | |
| fi | |
| [[ "$DRY_RUN" == true ]] || log_info "Timezone: $(timedatectl show -p Timezone --value 2>/dev/null || cat /etc/timezone)" | |
| } | |
| action_ntp() { | |
| local servers | |
| servers="$(need_value "$NTP_SERVERS" "NTP server(s) (default ntp.ubuntu.com): " "ntp.ubuntu.com")" | |
| { echo '[Time]'; echo "NTP=$servers"; } | write_file /etc/systemd/timesyncd.conf.d/50-ntp.conf | |
| sh_run timedatectl set-ntp true | |
| sh_run systemctl restart systemd-timesyncd 2>/dev/null || true | |
| } | |
| action_packages() { | |
| local -a pkgs=() | |
| if [[ -n "$EXTRA_PACKAGES" ]]; then | |
| read -ra pkgs <<< "$EXTRA_PACKAGES" | |
| elif [[ "$INTERACTIVE" == true ]]; then | |
| echo "Default extra packages: ${global_packages[*]}" | |
| read -rp "Modify the list? [y/N]: " reply | |
| if [[ "$reply" == [yY] ]]; then | |
| echo "Enter packages separated by spaces:"; read -ra pkgs | |
| else | |
| pkgs=("${global_packages[@]}") | |
| fi | |
| else | |
| pkgs=("${global_packages[@]}") | |
| fi | |
| if [[ ${#pkgs[@]} -gt 0 ]]; then | |
| log_info "Installing: ${pkgs[*]}" | |
| apt_install "${pkgs[@]}" | |
| else | |
| log_info "No extra packages selected." | |
| fi | |
| } | |
| action_debloat() { | |
| log_info "Removing bloatware: ${bloat_packages[*]}" | |
| # Remove snaps first (needs snapd), non-base ones before the base snaps. | |
| # Only touch snap when the daemon is actually running, and always cap it with | |
| # `timeout` so a stuck snapd can never hang the whole script. | |
| if command -v snap >/dev/null 2>&1 && snapd_running; then | |
| if [[ "$DRY_RUN" == true ]]; then | |
| local snaps | |
| snaps="$(timeout 30 snap list 2>/dev/null | awk 'NR>1 {print $1}' | tr '\n' ' ' || true)" | |
| log_info "[dry-run] would 'snap remove --purge': ${snaps:-<none>}" | |
| else | |
| local s | |
| for s in $(timeout 30 snap list 2>/dev/null | awk 'NR>1 && $1!="snapd" && $1!~/^core/ && $1!="bare" {print $1}'); do | |
| timeout 60 snap remove --purge "$s" 2>/dev/null || true | |
| done | |
| for s in $(timeout 30 snap list 2>/dev/null | awk 'NR>1 {print $1}'); do | |
| timeout 60 snap remove --purge "$s" 2>/dev/null || true | |
| done | |
| fi | |
| fi | |
| # Purge apt packages only when installed (avoids aborting under set -e). | |
| local p to_purge=() | |
| for p in "${bloat_packages[@]}"; do | |
| pkg_installed "$p" && to_purge+=("$p") | |
| done | |
| if [[ ${#to_purge[@]} -gt 0 ]]; then | |
| sh_run "${APT_ENV[@]}" apt-get purge -y "${to_purge[@]}" | |
| fi | |
| sh_run apt-mark hold snapd 2>/dev/null || true | |
| sh_run systemctl daemon-reload 2>/dev/null || true | |
| sh_run rm -rf /root/snap /var/cache/snapd 2>/dev/null || true | |
| } | |
| action_esm_remove() { | |
| log_info "Removing the Ubuntu Pro / ESM client..." | |
| local pkgs=(ubuntu-advantage-tools ubuntu-pro-client) p to_purge=() | |
| for p in "${pkgs[@]}"; do | |
| pkg_installed "$p" && to_purge+=("$p") | |
| done | |
| if [[ ${#to_purge[@]} -eq 0 ]]; then | |
| log_info "Ubuntu Pro client not installed; nothing to remove." | |
| return 0 | |
| fi | |
| sh_run "${APT_ENV[@]}" apt-get purge -y "${to_purge[@]}" | |
| sh_run "${APT_ENV[@]}" apt-get autoremove -y --purge | |
| sh_run rm -rf /var/lib/ubuntu-advantage | |
| if [[ -f /var/lib/update-notifier/updates-available ]]; then | |
| sh_run truncate -s 0 /var/lib/update-notifier/updates-available | |
| fi | |
| } | |
| action_autoupdate() { | |
| apt_install unattended-upgrades | |
| if [[ "$DRY_RUN" == true ]]; then | |
| log_info "[dry-run] would enable unattended-upgrades (debconf + dpkg-reconfigure)" | |
| else | |
| echo 'unattended-upgrades unattended-upgrades/enable_auto_updates boolean true' \ | |
| | debconf-set-selections | |
| "${APT_ENV[@]}" dpkg-reconfigure -f noninteractive unattended-upgrades | |
| fi | |
| { | |
| echo '// Managed by ubuntu-setup.sh' | |
| echo 'Unattended-Upgrade::Remove-Unused-Dependencies "true";' | |
| echo 'Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";' | |
| if [[ "$AUTO_REBOOT" == true ]]; then | |
| echo 'Unattended-Upgrade::Automatic-Reboot "true";' | |
| echo "Unattended-Upgrade::Automatic-Reboot-Time \"${AUTO_REBOOT_TIME}\";" | |
| fi | |
| } | write_file /etc/apt/apt.conf.d/52unattended-upgrades-local | |
| } | |
| action_journald() { | |
| { echo '[Journal]' | |
| echo 'Storage=persistent' | |
| echo "SystemMaxUse=${JOURNAL_MAX}"; } | write_file /etc/systemd/journald.conf.d/50-persistent.conf | |
| sh_run mkdir -p /var/log/journal | |
| sh_run systemctl restart systemd-journald 2>/dev/null || true | |
| } | |
| action_sysctl_net() { | |
| write_file /etc/sysctl.d/99-network-hardening.conf <<'EOF' | |
| # Managed by ubuntu-setup.sh - network hardening | |
| net.ipv4.conf.all.rp_filter = 1 | |
| net.ipv4.conf.default.rp_filter = 1 | |
| net.ipv4.conf.all.accept_redirects = 0 | |
| net.ipv4.conf.default.accept_redirects = 0 | |
| net.ipv6.conf.all.accept_redirects = 0 | |
| net.ipv6.conf.default.accept_redirects = 0 | |
| net.ipv4.conf.all.send_redirects = 0 | |
| net.ipv4.conf.default.send_redirects = 0 | |
| net.ipv4.conf.all.accept_source_route = 0 | |
| net.ipv4.conf.default.accept_source_route = 0 | |
| net.ipv6.conf.all.accept_source_route = 0 | |
| net.ipv6.conf.default.accept_source_route = 0 | |
| net.ipv4.conf.all.log_martians = 1 | |
| net.ipv4.conf.default.log_martians = 1 | |
| net.ipv4.icmp_echo_ignore_broadcasts = 1 | |
| net.ipv4.icmp_ignore_bogus_error_responses = 1 | |
| net.ipv4.tcp_syncookies = 1 | |
| EOF | |
| sh_run sysctl --system >/dev/null | |
| } | |
| action_motd() { | |
| log_info "Disabling motd-news and login help/news spam..." | |
| if [[ -f /etc/default/motd-news ]]; then | |
| sh_run sed -i 's/^ENABLED=1/ENABLED=0/' /etc/default/motd-news | |
| fi | |
| sh_run systemctl disable --now motd-news.timer 2>/dev/null || true | |
| sh_run systemctl disable --now motd-news.service 2>/dev/null || true | |
| local f | |
| for f in /etc/update-motd.d/50-motd-news /etc/update-motd.d/10-help-text; do | |
| [[ -f "$f" ]] && sh_run chmod -x "$f" 2>/dev/null || true | |
| done | |
| if command -v pro >/dev/null 2>&1; then | |
| sh_run pro config set apt_news=false 2>/dev/null || true | |
| fi | |
| } | |
| default_login_banner() { | |
| cat <<'EOF' | |
| ############################################################### | |
| # AUTHORIZED ACCESS ONLY # | |
| # This system is for the use of authorized users only. All # | |
| # connections are monitored and recorded. Unauthorized use # | |
| # is prohibited and may be prosecuted. Disconnect now if # | |
| # you are not an authorized user. # | |
| ############################################################### | |
| EOF | |
| } | |
| default_motd() { | |
| printf 'Authorized access only. All activity is logged and monitored.\n' | |
| } | |
| # Live system-info block for /etc/issue. These are agetty escapes, re-rendered | |
| # by getty every time the console login prompt is drawn (so the IP stays | |
| # current). agetty does NOT interpret them, so this must never go to issue.net. | |
| issue_sysinfo_block() { | |
| local v4='\4' v6='\6' | |
| if [[ -n "$BANNER_IFACE" ]]; then v4="\\4{$BANNER_IFACE}"; v6="\\6{$BANNER_IFACE}"; fi | |
| cat <<EOF | |
| Host: \n | |
| OS: \S (\m) kernel \r | |
| IPv4: ${v4} IPv6: ${v6} | |
| TTY: \l \d \t | |
| EOF | |
| } | |
| # Post-login MOTD status script (runs at each login via pam_motd/run-parts). | |
| # Written verbatim (quoted heredoc): the \$(...) run at login time, not now. | |
| motd_sysinfo_script() { | |
| cat <<'EOF' | |
| #!/bin/bash | |
| # Managed by ubuntu-setup.sh - post-login system status | |
| printf '\n System status - %s\n' "$(date '+%Y-%m-%d %H:%M:%S %Z')" | |
| printf ' Host: %s\n' "$(hostname -f 2>/dev/null || hostname)" | |
| printf ' Uptime: %s\n' "$(uptime -p 2>/dev/null | sed 's/^up //')" | |
| printf ' Load: %s\n' "$(cut -d' ' -f1-3 /proc/loadavg)" | |
| printf ' Memory: %s / %s used\n' \ | |
| "$(free -h | awk '/^Mem:/{print $3}')" "$(free -h | awk '/^Mem:/{print $2}')" | |
| printf ' Disk /: %s\n' "$(df -h / | awk 'NR==2{print $3" / "$2" ("$5")"}')" | |
| printf ' Services: %s failed\n' "$(systemctl --failed --no-legend 2>/dev/null | wc -l)" | |
| printf '\n' | |
| EOF | |
| } | |
| # Set the login banners: | |
| # /etc/issue - console pre-login: warning + optional live sysinfo | |
| # /etc/issue.net - network/SSH pre-auth (sshd Banner): static warning only | |
| # /etc/motd - post-login: static message + optional dynamic status | |
| action_banner() { | |
| local warn motd_msg | |
| if [[ -n "$BANNER_TEXT" ]]; then warn="$BANNER_TEXT"; else warn="$(default_login_banner)"; fi | |
| if [[ -n "$MOTD_TEXT" ]]; then motd_msg="$MOTD_TEXT"; else motd_msg="$(default_motd)"; fi | |
| if [[ "$BANNER_SYSINFO" == true ]]; then | |
| { printf '%s\n' "$warn"; issue_sysinfo_block; } | write_file /etc/issue | |
| else | |
| printf '%s\n' "$warn" | write_file /etc/issue | |
| fi | |
| printf '%s\n' "$warn" | write_file /etc/issue.net | |
| printf '%s\n' "$motd_msg" | write_file /etc/motd | |
| if [[ "$MOTD_SYSINFO" == true ]]; then | |
| motd_sysinfo_script | write_file /etc/update-motd.d/60-sysinfo | |
| sh_run chmod +x /etc/update-motd.d/60-sysinfo | |
| # Prefer our fast status over Ubuntu's slower Python landscape-sysinfo. | |
| if [[ -f /etc/update-motd.d/50-landscape-sysinfo ]]; then | |
| sh_run chmod -x /etc/update-motd.d/50-landscape-sysinfo | |
| fi | |
| fi | |
| # Own drop-in so it does not clash with the hardening one. | |
| { echo "# Managed by ubuntu-setup.sh" | |
| echo "Banner /etc/issue.net"; } | write_file "$SSH_BANNER_DROPIN" | |
| apply_sshd_dropin "$SSH_BANNER_DROPIN" | |
| } | |
| # Feature sets | |
| setup_feature() { | |
| case "$1" in | |
| development) log_info "Development feature set..."; apt_install "${development_packages[@]}";; | |
| server) log_info "Server feature set (packages handled by ufw/fail2ban actions).";; | |
| workstation) | |
| if [[ ${#workstation_packages[@]} -gt 0 ]]; then apt_install "${workstation_packages[@]}"; fi | |
| ;; | |
| none|"") :;; | |
| *) log_warn "Unknown feature set: $1";; | |
| esac | |
| } | |
| # Main | |
| banner() { | |
| echo "=====================================" | |
| echo " Ubuntu LTS setup script v${VERSION}" | |
| [[ "$DRY_RUN" == true ]] && echo " (dry-run: no changes will be made)" | |
| echo "=====================================" | |
| echo " Read this script before running it. Preview everything with --dry-run." | |
| echo | |
| } | |
| # Real runs require root. --dry-run, --help and --version do not. | |
| require_root() { | |
| if [[ ${EUID:-$(id -u)} -ne 0 && "$DRY_RUN" != true ]]; then | |
| log_error "This script must be run as root." | |
| log_error "Review the script, preview with --dry-run, then run: sudo bash ${0##*/}" | |
| exit 1 | |
| fi | |
| } | |
| resolve_feature_set() { | |
| local choice | |
| if [[ "$INTERACTIVE" == true && -z "$FEATURE_SET" ]]; then | |
| echo "Select a feature set (optional):" | |
| echo " 1. Development (${development_packages[*]})" | |
| echo " 2. Server (${FEATURE_ACTIONS[server]})" | |
| echo " 3. Workstation" | |
| echo " 0. None" | |
| read -rp "Choice (0/1/2/3): " choice | |
| case "$choice" in | |
| 1) FEATURE_SET="development";; | |
| 2) FEATURE_SET="server";; | |
| 3) FEATURE_SET="workstation";; | |
| 0|"") FEATURE_SET="none";; | |
| *) log_error "Invalid choice."; exit 1;; | |
| esac | |
| fi | |
| if [[ -z "$FEATURE_SET" ]]; then FEATURE_SET="none"; fi | |
| } | |
| resolve_actions() { | |
| if [[ "$INTERACTIVE" == true && -z "$ACTIONS" ]]; then | |
| echo "Available actions:" | |
| local name | |
| for name in "${ACTION_ORDER[@]}"; do | |
| printf ' %-11s %s\n' "$name" "${ACTION_DESC[$name]}" | |
| done | |
| read -rp "Enter actions (e.g. 'update ssh ufw'): " ACTIONS | |
| fi | |
| } | |
| run() { | |
| banner | |
| preChecks | |
| resolve_feature_set | |
| resolve_actions | |
| # Parse requested actions (space/comma separated), validate names. | |
| local -a requested=() | |
| read -ra requested <<< "${ACTIONS//,/ }" | |
| # Pull in feature-driven actions. | |
| if [[ -n "${FEATURE_ACTIONS[$FEATURE_SET]:-}" ]]; then | |
| local -a _feat=() | |
| read -ra _feat <<< "${FEATURE_ACTIONS[$FEATURE_SET]}" | |
| requested+=("${_feat[@]}") | |
| fi | |
| local a | |
| for a in "${requested[@]}"; do | |
| if [[ -z "${ACTION_DESC[$a]:-}" ]]; then | |
| log_error "Unknown action: '$a'. Valid: ${ACTION_ORDER[*]}" | |
| exit 2 | |
| fi | |
| done | |
| # Build the ordered, de-duplicated run list from the registry order. | |
| local -a run_list=() | |
| for a in "${ACTION_ORDER[@]}"; do | |
| in_array "$a" "${requested[@]}" && run_list+=("$a") | |
| done | |
| if [[ ${#run_list[@]} -eq 0 && "$FEATURE_SET" == "none" ]]; then | |
| log_info "Nothing to do. Set ACTIONS= in a preset, or run interactively." | |
| exit 0 | |
| fi | |
| echo | |
| log_info "Feature set: ${FEATURE_SET}" | |
| log_info "Actions: ${run_list[*]:-none}" | |
| if ! confirm "Proceed with the above?"; then | |
| log_info "Aborted by user." | |
| exit 0 | |
| fi | |
| for a in "${run_list[@]}"; do | |
| echo | |
| log_info "==> ${ACTION_DESC[$a]} (${a})" | |
| "action_${a//-/_}" | |
| SUMMARY+=("$a") | |
| done | |
| setup_feature "$FEATURE_SET" | |
| echo | |
| log_info "Done. Completed: ${SUMMARY[*]:-none}" | |
| if [[ "$SSH_CONFIG_CHANGED" == true ]]; then | |
| log_info "SSH configuration changed and reloaded (drop-ins in /etc/ssh/sshd_config.d/)." | |
| fi | |
| if [[ -f /var/run/reboot-required ]]; then | |
| log_warn "A reboot is now required to finish applying updates." | |
| fi | |
| } | |
| # ---- | |
| # Lift off | |
| # --- | |
| main() { | |
| parseArgs "$@" | |
| # A preset drives an unattended run: load it, then stop prompting. | |
| if [[ -n "$CONFIG_FILE" ]]; then | |
| load_config "$CONFIG_FILE" | |
| INTERACTIVE=false | |
| ASSUME_YES=true | |
| fi | |
| require_root | |
| trap 'log_error "Failed (rc=$?) at line ${LINENO}: ${BASH_COMMAND}"' ERR | |
| if [[ "$DO_LOG" == true && "$DRY_RUN" != true ]]; then | |
| [[ -z "$LOG_FILE" ]] && LOG_FILE="$(default_log)" | |
| if ( : >>"$LOG_FILE" ) 2>/dev/null; then | |
| printf '\n=================== %s ===================\n' "$(_ts)" >>"$LOG_FILE" | |
| exec > >(tee -a "$LOG_FILE") 2>&1 | |
| log_info "Logging to ${LOG_FILE}" | |
| else | |
| log_warn "Cannot write ${LOG_FILE}; continuing without a log file." | |
| fi | |
| fi | |
| run | |
| } | |
| if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then | |
| main "$@" | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
great job !