Skip to content

Instantly share code, notes, and snippets.

@dot-mike
Last active July 6, 2026 16:47
Show Gist options
  • Select an option

  • Save dot-mike/8a1140535e11f54323e2be126ccd8e69 to your computer and use it in GitHub Desktop.

Select an option

Save dot-mike/8a1140535e11f54323e2be126ccd8e69 to your computer and use it in GitHub Desktop.
Inital setup script for Ubuntu server
#!/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
@k1soltan

Copy link
Copy Markdown

great job !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment