commit bb83b30aa9bd0f2326340a9848b3db121d07fcad Author: moritzrfs Date: Sun Jun 14 13:04:03 2026 +0200 Add scripts diff --git a/01-banner.sh b/01-banner.sh new file mode 100644 index 0000000..90746e5 --- /dev/null +++ b/01-banner.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Dynamic login banner. Installed to /etc/update-motd.d/ (executable) or +# rendered once into /etc/motd as a fallback. Keep it fast & dependency-free. + +# --- colors (only if stdout is a tty; motd.d output is captured, so keep plain) +B=$'\e[1m'; D=$'\e[2m'; G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; C=$'\e[36m'; N=$'\e[0m' + +host="$(hostname -f 2>/dev/null || hostname)" +os="$(. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-Linux}")" +kern="$(uname -r)" +up="$(uptime -p 2>/dev/null | sed 's/^up //')" +load="$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)" +ipaddr="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}')" +[ -z "$ipaddr" ] && ipaddr="n/a" + +# memory + disk (root fs) +mem="$(free -h 2>/dev/null | awk '/^Mem:/ {print $3 "/" $2}')" +disk="$(df -h / 2>/dev/null | awk 'NR==2 {print $3 "/" $2 " (" $5 ")"}')" + +# pending updates (apt) + reboot flag - cheap checks, ignore errors +updates="" +if [ -r /var/lib/update-notifier/updates-available ]; then + updates="$(awk 'NR==1{print}' /var/lib/update-notifier/updates-available 2>/dev/null)" +fi +reboot_flag="" +[ -f /var/run/reboot-required ] && reboot_flag="${R} reboot required${N}" + +# load colorization +load1="${load%% *}" +lc="$G" +awk "BEGIN{exit !($load1 > 2.0)}" && lc="$Y" +awk "BEGIN{exit !($load1 > 4.0)}" && lc="$R" + +printf '\n' +printf ' %s%s%s\n' "$B$C" "$host" "$N" +printf ' %s%s%s\n' "$D" "$os · kernel $kern" "$N" +printf '\n' +printf ' %sIP%s %s\n' "$D" "$N" "$ipaddr" +printf ' %sUptime%s %s %sload%s %s%s%s\n' "$D" "$N" "${up:-n/a}" "$D" "$N" "$lc" "$load" "$N" +printf ' %sMem%s %s %sdisk /%s %s\n' "$D" "$N" "${mem:-n/a}" "$D" "$N" "${disk:-n/a}" +[ -n "$updates" ] && printf ' %sUpdates%s %s\n' "$D" "$N" "$updates" +[ -n "$reboot_flag" ] && printf ' %s\n' "$reboot_flag" +printf '\n' diff --git a/README.md b/README.md new file mode 100644 index 0000000..772ee95 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# linux-bootstrap + +## Oneliner + +```bash +curl -fsSL https://raw.githubusercontent.com/CHANGE_ME/linux-bootstrap/main/install.sh | bash +``` + +Forward flags to the bootstrapper after `--`: + +```bash +# skip hardening, set hostname +curl -fsSL https://raw.githubusercontent.com/CHANGE_ME/linux-bootstrap/main/install.sh \ + | bash -s -- --skip hardening --hostname web01 + +# everything including hardening +curl -fsSL .../install.sh | bash -s -- --only base,cli,neovim,motd,shell,hardening +``` + +Override repo/ref/dest via env: + +```bash +REF=dev DEST=/srv/bootstrap curl -fsSL .../install.sh | bash +``` + +### Pure-git alternative (if git is already present) + +```bash +git clone --depth=1 https://github.com/CHANGE_ME/linux-bootstrap.git /opt/linux-bootstrap \ + && /opt/linux-bootstrap/bootstrap.sh +``` + +## Modules +| module | default | description | +|------------|:------:|-------------| +| `base` | yes | apt update/upgrade + essentials: git, curl, tmux, htop, tree, rsync, jq, dnsutils, mtr, build-essential, … | +| `cli` | yes | modern CLI: ripgrep, fd, bat, fzf, btop | +| `neovim` | yes | neovim + the lua config in `config/nvim/` | +| `motd` | yes | dynamic login banner (host, IP, uptime, load, mem, disk, updates) | +| `shell` | yes | `fd`/`bat` symlinks + system-wide aliases in `/etc/profile.d`, `EDITOR=nvim` | +| `hardening`| **no** | opt-in: unattended-upgrades, fail2ban sshd jail | + +```bash +./bootstrap.sh --list # show modules +./bootstrap.sh --only nvim # just (re)deploy nvim config +./bootstrap.sh --skip motd # run everything except motd +``` + +## Customizing + +- nvim: edit `config/nvim/lua/core/*.lua` and `config/nvim/lua/core/plugins.lua` +- banner: edit `config/motd/01-banner.sh` +- aliases / packages: edit the `mod_*` functions in `bootstrap.sh` + +## Optional: SSH hardening (do this manually, with care) + +After confirming key-based login works: + +```bash +sudo tee /etc/ssh/sshd_config.d/99-hardening.conf >/dev/null <<'EOF' +PasswordAuthentication no +PermitRootLogin prohibit-password +KbdInteractiveAuthentication no +EOF +sudo systemctl reload ssh # or sshd, depending on distro +``` diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100644 index 0000000..ce04186 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,247 @@ +#!/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 "$@" diff --git a/common.sh b/common.sh new file mode 100644 index 0000000..d8a3d20 --- /dev/null +++ b/common.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# common.sh - shared helpers. Sourced by bootstrap.sh, not run directly. + +# Exported once so we never rely on the `$SUDO VAR=val cmd` pattern, which +# breaks when $SUDO is empty (running as root): the var assignment gets parsed +# before $SUDO expands, so `DEBIAN_FRONTEND=...` ends up treated as a command. +export DEBIAN_FRONTEND=noninteractive + +# ---- colors / logging ------------------------------------------------------- +if [[ -t 1 ]]; then + C_RESET=$'\e[0m'; C_DIM=$'\e[2m'; C_RED=$'\e[31m'; C_GRN=$'\e[32m' + C_YLW=$'\e[33m'; C_BLU=$'\e[34m'; C_BOLD=$'\e[1m' +else + C_RESET=""; C_DIM=""; C_RED=""; C_GRN=""; C_YLW=""; C_BLU=""; C_BOLD="" +fi + +log() { printf '%s[*]%s %s\n' "$C_BLU" "$C_RESET" "$*"; } +ok() { printf '%s[+]%s %s\n' "$C_GRN" "$C_RESET" "$*"; } +warn() { printf '%s[!]%s %s\n' "$C_YLW" "$C_RESET" "$*" >&2; } +err() { printf '%s[x]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; } +die() { err "$*"; exit 1; } +step() { printf '\n%s== %s ==%s\n' "$C_BOLD" "$*" "$C_RESET"; } + +# ---- privilege -------------------------------------------------------------- +# Sets $SUDO to "sudo" if not root, "" if root. Fails if neither works. +detect_sudo() { + if [[ $EUID -eq 0 ]]; then + SUDO="" + elif command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + # prime the credential cache early so prompts don't appear mid-run + $SUDO -v || die "sudo authentication failed" + else + die "not root and sudo not available" + fi +} + +# ---- distro detection ------------------------------------------------------- +# Sets DISTRO_ID (debian/ubuntu/...), DISTRO_LIKE, DISTRO_CODENAME, PKG_MGR. +detect_distro() { + [[ -r /etc/os-release ]] || die "/etc/os-release missing - unsupported system" + # shellcheck disable=SC1091 + . /etc/os-release + DISTRO_ID="${ID:-unknown}" + # shellcheck disable=SC2034 # used in bootstrap.sh main() + DISTRO_LIKE="${ID_LIKE:-}" + DISTRO_CODENAME="${VERSION_CODENAME:-}" + + if command -v apt-get >/dev/null 2>&1; then + PKG_MGR="apt" + elif command -v dnf >/dev/null 2>&1; then + PKG_MGR="dnf" + elif command -v pacman >/dev/null 2>&1; then + PKG_MGR="pacman" + else + die "no supported package manager found (apt/dnf/pacman)" + fi + ok "detected ${DISTRO_ID} ${DISTRO_CODENAME:-} (pkg: ${PKG_MGR})" +} + +# ---- package helpers (apt-focused, dnf/pacman best-effort) ------------------ +APT_UPDATED=0 +pkg_refresh() { + case "$PKG_MGR" in + apt) + if [[ $APT_UPDATED -eq 0 ]]; then + log "apt-get update" + # a single broken third-party repo shouldn't abort the whole bootstrap; + # downstream installs will fail loudly if a needed pkg is truly missing. + $SUDO apt-get update -qq || warn "apt-get update reported errors (broken repo?) - continuing" + APT_UPDATED=1 + fi + ;; + dnf) $SUDO dnf -q makecache ;; + pacman) $SUDO pacman -Sy --noconfirm >/dev/null ;; + esac +} + +# is a binary already on PATH? +have() { command -v "$1" >/dev/null 2>&1; } + +# install one or more packages, skipping any already-present *packages* +pkg_install() { + pkg_refresh + case "$PKG_MGR" in + apt) + $SUDO apt-get install -y -qq \ + --no-install-recommends "$@" ;; + dnf) $SUDO dnf install -y "$@" ;; + pacman) $SUDO pacman -S --needed --noconfirm "$@" ;; + esac +} + +# install a package only if a given command is missing. usage: ensure_cmd +ensure_cmd() { + local cmd="$1"; shift + if have "$cmd"; then + printf '%s - %s already present%s\n' "$C_DIM" "$cmd" "$C_RESET" + return 0 + fi + log "installing ${cmd} (pkg: $*)" + pkg_install "$@" +} + +# write file from stdin only if content differs (idempotent). usage: write_if_changed +write_if_changed() { + local target="$1" tmp + tmp="$(mktemp)" + cat > "$tmp" + if [[ -f "$target" ]] && cmp -s "$tmp" "$target"; then + rm -f "$tmp" + return 1 # unchanged + fi + $SUDO install -D -m "${2:-0644}" "$tmp" "$target" + rm -f "$tmp" + return 0 # changed +} diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..51178d2 --- /dev/null +++ b/init.lua @@ -0,0 +1,16 @@ +-- init.lua - standardized neovim config for managed hosts. +-- Design goals: fast, works offline (pure-lua core), plugins are optional. + +-- core: zero external dependencies, always loads +require("core.options") +require("core.keymaps") +require("core.autocmds") + +-- plugins: best-effort. if bootstrap fails (no network on a fresh box), +-- the editor still works perfectly with the core config above. +local ok, err = pcall(require, "core.plugins") +if not ok then + vim.schedule(function() + vim.notify("plugins not loaded (offline?): " .. tostring(err), vim.log.levels.WARN) + end) +end diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..7e48004 --- /dev/null +++ b/install.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# install.sh - tiny entrypoint for the curl|bash oneliner. +# Ensures git, clones the repo, hands off to bootstrap.sh. +# +# Configurable via env: +# REPO_URL git url (default: this public repo) +# REF branch/tag/commit to check out (default: main) +# DEST clone destination (default: /opt/linux-bootstrap) +# Any extra args are forwarded to bootstrap.sh, e.g.: +# curl -fsSL .../install.sh | bash -s -- --skip hardening +# +set -Eeuo pipefail +export DEBIAN_FRONTEND=noninteractive + +REPO_URL="${REPO_URL:-https://github.com/CHANGE_ME/linux-bootstrap.git}" +REF="${REF:-main}" +DEST="${DEST:-/opt/linux-bootstrap}" + +say() { printf '\e[34m[install]\e[0m %s\n' "$*"; } + +if [[ $EUID -ne 0 ]]; then + command -v sudo >/dev/null 2>&1 || { echo "need root or sudo"; exit 1; } + SUDO="sudo" +else + SUDO="" +fi + +# 1. ensure git +if ! command -v git >/dev/null 2>&1; then + say "installing git" + if command -v apt-get >/dev/null 2>&1; then + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq git + elif command -v dnf >/dev/null 2>&1; then + $SUDO dnf install -y git + elif command -v pacman >/dev/null 2>&1; then + $SUDO pacman -Sy --noconfirm git + else + echo "no supported package manager to install git"; exit 1 + fi +fi + +# 2. clone or update +if [[ -d "$DEST/.git" ]]; then + say "updating existing clone at $DEST" + $SUDO git -C "$DEST" fetch --depth=1 origin "$REF" + $SUDO git -C "$DEST" checkout -f "$REF" + $SUDO git -C "$DEST" reset --hard "origin/$REF" 2>/dev/null || true +else + say "cloning $REPO_URL -> $DEST" + $SUDO git clone --depth=1 --branch "$REF" "$REPO_URL" "$DEST" 2>/dev/null \ + || $SUDO git clone --depth=1 "$REPO_URL" "$DEST" +fi + +# 3. run bootstrap, forwarding any extra args +say "running bootstrap.sh $*" +$SUDO chmod +x "$DEST/bootstrap.sh" "$DEST/config/motd/01-banner.sh" +exec "$DEST/bootstrap.sh" "$@"