287 lines
9.7 KiB
Bash
287 lines
9.7 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# bootstrap.sh - standardize a fresh Debian/Ubuntu host.
|
|
#
|
|
# Usage:
|
|
# ./bootstrap.sh # run default modules
|
|
# ./bootstrap.sh --only nvim # run a single module
|
|
# ./bootstrap.sh --skip hardening
|
|
# ./bootstrap.sh --list # show modules
|
|
# ./bootstrap.sh --hostname web01
|
|
#
|
|
set -Eeuo pipefail
|
|
|
|
# resolve repo dir even when invoked via symlink / different cwd
|
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
|
|
# shellcheck source=lib/common.sh
|
|
. "${SCRIPT_DIR}/lib/common.sh"
|
|
|
|
trap 'err "failed at line $LINENO (exit $?)"' ERR
|
|
|
|
# ---- module registry --------------------------------------------------------
|
|
# order matters; "default" controls whether it runs without an explicit --only
|
|
MODULES=(base cli neovim motd shell hardening)
|
|
declare -A MOD_DEFAULT=(
|
|
[base]=1 [cli]=1 [neovim]=1 [motd]=1 [shell]=1 [hardening]=0
|
|
)
|
|
declare -A MOD_DESC=(
|
|
[base]="apt update/upgrade + essential packages (git, curl, tmux, ...)"
|
|
[cli]="modern CLI quality-of-life tools (ripgrep, fd, bat, fzf, btop, ...)"
|
|
[neovim]="neovim + standardized lua config"
|
|
[motd]="dynamic welcome message / login banner"
|
|
[shell]="shared aliases, sane bash defaults, tool symlinks"
|
|
[hardening]="OPT-IN: unattended-upgrades, fail2ban, sshd defaults"
|
|
)
|
|
|
|
# ---- args -------------------------------------------------------------------
|
|
ONLY=""; SKIP=""; SET_HOSTNAME=""
|
|
usage() {
|
|
cat <<EOF
|
|
${C_BOLD}linux-bootstrap${C_RESET}
|
|
|
|
--only <m> run only module <m> (repeatable, comma-ok)
|
|
--skip <m> skip module <m> (repeatable, comma-ok)
|
|
--hostname <h> set the system hostname
|
|
--list list modules and exit
|
|
-h | --help this help
|
|
|
|
Modules (default = runs unless --only given):
|
|
EOF
|
|
for m in "${MODULES[@]}"; do
|
|
local mark=" "; [[ ${MOD_DEFAULT[$m]} -eq 1 ]] && mark="${C_GRN}*${C_RESET} "
|
|
printf ' %s%-10s %s\n' "$mark" "$m" "${MOD_DESC[$m]}"
|
|
done
|
|
echo
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--only) ONLY="${ONLY}${ONLY:+,}$2"; shift 2 ;;
|
|
--skip) SKIP="${SKIP}${SKIP:+,}$2"; shift 2 ;;
|
|
--hostname) SET_HOSTNAME="$2"; shift 2 ;;
|
|
--list) usage; exit 0 ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown arg: $1 (try --help)" ;;
|
|
esac
|
|
done
|
|
|
|
in_csv() { [[ ",$1," == *",$2,"* ]]; }
|
|
|
|
should_run() {
|
|
local m="$1"
|
|
in_csv "$SKIP" "$m" && return 1
|
|
if [[ -n "$ONLY" ]]; then in_csv "$ONLY" "$m"; return; fi
|
|
[[ ${MOD_DEFAULT[$m]} -eq 1 ]]
|
|
}
|
|
|
|
# =============================================================================
|
|
# MODULES
|
|
# =============================================================================
|
|
|
|
mod_base() {
|
|
step "base packages"
|
|
pkg_refresh
|
|
if [[ -z "$ONLY" || "$ONLY" == *base* ]]; then
|
|
log "apt-get upgrade (security + bugfixes)"
|
|
$SUDO apt-get -y -qq upgrade || \
|
|
warn "upgrade had non-fatal issues"
|
|
fi
|
|
# one shot install of the essentials
|
|
pkg_install \
|
|
ca-certificates curl wget git gnupg \
|
|
tmux htop tree unzip zip rsync \
|
|
build-essential \
|
|
dnsutils mtr-tiny net-tools iproute2 \
|
|
jq ncdu lsof
|
|
ok "base packages installed"
|
|
|
|
if [[ -n "$SET_HOSTNAME" ]]; then
|
|
log "setting hostname -> ${SET_HOSTNAME}"
|
|
$SUDO hostnamectl set-hostname "$SET_HOSTNAME" 2>/dev/null || \
|
|
echo "$SET_HOSTNAME" | $SUDO tee /etc/hostname >/dev/null
|
|
ok "hostname set"
|
|
fi
|
|
}
|
|
|
|
mod_cli() {
|
|
step "modern CLI tools"
|
|
# these package names are stable on Debian bookworm+ / Ubuntu 22.04+
|
|
pkg_install ripgrep fd-find bat fzf || warn "some CLI pkgs unavailable on this release"
|
|
# btop fell back to htop if missing in old repos
|
|
ensure_cmd btop btop || warn "btop not in repo (htop already installed)"
|
|
ok "CLI tools installed (symlinks handled by 'shell' module)"
|
|
}
|
|
|
|
mod_neovim() {
|
|
step "neovim + config"
|
|
ensure_cmd nvim neovim
|
|
ensure_cmd rsync rsync # module is self-contained even with --only neovim
|
|
|
|
# config goes to the *invoking* user's home, not root's, when run via sudo
|
|
local target_user target_home
|
|
target_user="${SUDO_USER:-$(id -un)}"
|
|
target_home="$(getent passwd "$target_user" | cut -d: -f6)"
|
|
[[ -d "$target_home" ]] || die "cannot resolve home for ${target_user}"
|
|
|
|
local nvim_dst="${target_home}/.config/nvim"
|
|
log "deploying nvim config -> ${nvim_dst}"
|
|
install -d -m 0755 "${target_home}/.config"
|
|
# mirror repo config; remove stale lua we own, keep user additions elsewhere
|
|
rsync -a --delete "${SCRIPT_DIR}/config/nvim/" "${nvim_dst}/"
|
|
# fix ownership if we wrote as root
|
|
if [[ -n "${SUDO_USER:-}" ]]; then
|
|
chown -R "${target_user}:$(id -gn "$target_user")" "${target_home}/.config/nvim"
|
|
fi
|
|
|
|
# make nvim the default editor system-wide where possible
|
|
if have update-alternatives; then
|
|
$SUDO update-alternatives --install /usr/bin/editor editor "$(command -v nvim)" 100 \
|
|
>/dev/null 2>&1 || true
|
|
fi
|
|
ok "neovim configured for user ${target_user}"
|
|
}
|
|
|
|
mod_motd() {
|
|
step "welcome message / motd"
|
|
|
|
# 1. install our dynamic banner + disable noisy distro defaults
|
|
if [[ -d /etc/update-motd.d ]]; then
|
|
for f in /etc/update-motd.d/10-help-text /etc/update-motd.d/50-motd-news \
|
|
/etc/update-motd.d/00-header; do
|
|
if [[ -e "$f" ]]; then
|
|
$SUDO chmod -x "$f" 2>/dev/null || true
|
|
fi
|
|
done
|
|
if write_if_changed /etc/update-motd.d/01-bootstrap-banner 0755 \
|
|
< "${SCRIPT_DIR}/config/motd/01-banner.sh"; then
|
|
ok "dynamic motd installed"
|
|
else
|
|
log "banner already up to date"
|
|
fi
|
|
else
|
|
"${SCRIPT_DIR}/config/motd/01-banner.sh" 2>/dev/null | $SUDO tee /etc/motd >/dev/null
|
|
ok "static /etc/motd written"
|
|
fi
|
|
|
|
# 2. clear the static /etc/motd (debian license blurb), idempotently
|
|
if [[ -d /etc/update-motd.d && -s /etc/motd ]]; then
|
|
$SUDO truncate -s 0 /etc/motd 2>/dev/null || $SUDO sh -c ': > /etc/motd'
|
|
ok "cleared static /etc/motd"
|
|
fi
|
|
|
|
# 3. neutralize third-party login banners (e.g. ProxmoxVE community-scripts).
|
|
# matched by *content* so unrelated scripts and our own files stay untouched.
|
|
local sig='community-scripts|Provided by:|LXC Container|tteck'
|
|
local hit found=0
|
|
|
|
# profile.d is sourced -> chmod -x is useless; rename out of the *.sh glob.
|
|
# only consider real *.sh (never our already-renamed *.disabled).
|
|
if [[ -d /etc/profile.d ]]; then
|
|
while IFS= read -r hit; do
|
|
[[ -n "$hit" ]] || continue
|
|
case "$hit" in */zz-bootstrap-aliases.sh) continue ;; esac
|
|
$SUDO mv "$hit" "${hit}.disabled"
|
|
ok "disabled banner: ${hit##*/} (-> .disabled)"
|
|
found=1
|
|
done < <($SUDO grep -rlE "$sig" --include='*.sh' /etc/profile.d 2>/dev/null || true)
|
|
fi
|
|
|
|
# update-motd.d is executed -> dropping +x is enough (and idempotent).
|
|
if [[ -d /etc/update-motd.d ]]; then
|
|
while IFS= read -r hit; do
|
|
[[ -n "$hit" ]] || continue
|
|
case "$hit" in */01-bootstrap-banner) continue ;; esac
|
|
if [[ -x "$hit" ]]; then
|
|
$SUDO chmod -x "$hit"
|
|
ok "disabled banner: ${hit##*/}"
|
|
found=1
|
|
fi
|
|
done < <($SUDO grep -rlE "$sig" /etc/update-motd.d 2>/dev/null || true)
|
|
fi
|
|
|
|
[[ $found -eq 0 ]] && log "no third-party login banners found"
|
|
return 0
|
|
}
|
|
|
|
mod_shell() {
|
|
step "shell defaults + aliases"
|
|
# Debian ships fd as 'fdfind' and bat as 'batcat'. add friendly symlinks.
|
|
local bindir="/usr/local/bin"
|
|
have fdfind && [[ ! -e "$bindir/fd" ]] && $SUDO ln -s "$(command -v fdfind)" "$bindir/fd" && ok "symlink fd"
|
|
have batcat && [[ ! -e "$bindir/bat" ]] && $SUDO ln -s "$(command -v batcat)" "$bindir/bat" && ok "symlink bat"
|
|
|
|
# drop a managed aliases file sourced from /etc/profile.d (system-wide)
|
|
if write_if_changed /etc/profile.d/zz-bootstrap-aliases.sh 0644 <<'EOF'
|
|
# managed by linux-bootstrap - do not edit by hand
|
|
alias ll='ls -alh --color=auto'
|
|
alias la='ls -A --color=auto'
|
|
alias l='ls -CF --color=auto'
|
|
alias ..='cd ..'
|
|
alias ...='cd ../..'
|
|
alias grep='grep --color=auto'
|
|
alias df='df -h'
|
|
alias du='du -h'
|
|
alias ip='ip -color=auto'
|
|
command -v bat >/dev/null 2>&1 && alias cat='bat --paging=never --style=plain'
|
|
command -v btop >/dev/null 2>&1 && alias top='btop'
|
|
export EDITOR=nvim
|
|
export VISUAL=nvim
|
|
alias vi='nvim'
|
|
alias vim='nvim'
|
|
EOF
|
|
then ok "system-wide aliases installed"; else log "aliases already up to date"; fi
|
|
}
|
|
|
|
mod_hardening() {
|
|
step "hardening (opt-in)"
|
|
warn "hardening enabled - review before using on hosts you SSH into"
|
|
|
|
# automatic security updates
|
|
pkg_install unattended-upgrades apt-listchanges
|
|
$SUDO dpkg-reconfigure -f noninteractive unattended-upgrades >/dev/null 2>&1 || true
|
|
ok "unattended-upgrades enabled"
|
|
|
|
# fail2ban with sane sshd defaults
|
|
pkg_install fail2ban
|
|
if write_if_changed /etc/fail2ban/jail.d/sshd-bootstrap.conf 0644 <<'EOF'
|
|
[sshd]
|
|
enabled = true
|
|
backend = systemd
|
|
maxretry = 4
|
|
bantime = 1h
|
|
findtime = 10m
|
|
EOF
|
|
then $SUDO systemctl restart fail2ban 2>/dev/null || true; fi
|
|
ok "fail2ban configured for sshd"
|
|
|
|
# NOTE: deliberately NOT touching PasswordAuthentication / PermitRootLogin /
|
|
# firewall here. Locking yourself out of a remote box is too easy. Add those
|
|
# as a separate, explicit step once key-based access is verified.
|
|
log "sshd/firewall changes left to a deliberate manual step (see README)"
|
|
}
|
|
|
|
# =============================================================================
|
|
# RUN
|
|
# =============================================================================
|
|
main() {
|
|
detect_sudo
|
|
detect_distro
|
|
case "$DISTRO_ID:$DISTRO_LIKE" in
|
|
*debian*|*ubuntu*) : ;; # supported
|
|
*) [[ "$PKG_MGR" == "apt" ]] || warn "non-apt distro: best-effort only" ;;
|
|
esac
|
|
|
|
local ran=0
|
|
for m in "${MODULES[@]}"; do
|
|
if should_run "$m"; then
|
|
"mod_${m}"
|
|
ran=$((ran+1))
|
|
fi
|
|
done
|
|
[[ $ran -gt 0 ]] || { warn "no modules ran (check --only/--skip)"; exit 0; }
|
|
|
|
printf '\n%s== done: %d module(s) ==%s\n' "$C_BOLD" "$ran" "$C_RESET"
|
|
ok "re-login or 'source /etc/profile' to pick up aliases"
|
|
}
|
|
main "$@"
|