#!/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 < run only module (repeatable, comma-ok) --skip skip module (repeatable, comma-ok) --hostname 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" # disable distro default motd noise where safe 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 "motd already up to date" fi else # fallback: static /etc/motd "${SCRIPT_DIR}/config/motd/01-banner.sh" 2>/dev/null | $SUDO tee /etc/motd >/dev/null ok "static /etc/motd written" fi } 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 "$@"