From 5cb243eed9c9df2aaeb6cde5c3a1872aa123432a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 12 Aug 2026 17:30:29 +0000 Subject: [PATCH] port into a clean script instead of editing the original in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Your call, and the right one. Editing in place let mis-grouped code sit unnoticed until it scrolled past in a live run — which is exactly how the four upstream binaries buried in "System Update & Essentials" were found. Porting forces the question of where each thing belongs before it runs, not after. machine-setup.sh now contains only what has actually been worked through: pre-flight, system update and core packages, command-line tools, and the summary. 1149 lines down to 172. The sections still to come are listed in a NOT PORTED YET block, in order, and each arrives as its own commit. The original is beside the other superseded scripts as scripts/setup-old/setup-ubuntu.sh — verified byte-identical to the live /root/ubuntu-setup copy — so porting reads from a file in the repo rather than from root's home. Two claims trimmed from the ported summary, because they were true of the old script and not of this one yet: it reported the shell as "zsh (Oh My Zsh + Starship)" unconditionally, and told you to reconnect as a user it had not created. Replaced with what pre-flight actually knows — system, role, user. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/setup-old/setup-ubuntu.sh | 1179 ++++++++++++++++++ scripts/setup/machine-setup/machine-setup.sh | 997 +-------------- 2 files changed, 1189 insertions(+), 987 deletions(-) create mode 100755 scripts/setup-old/setup-ubuntu.sh diff --git a/scripts/setup-old/setup-ubuntu.sh b/scripts/setup-old/setup-ubuntu.sh new file mode 100755 index 00000000..a9c28036 --- /dev/null +++ b/scripts/setup-old/setup-ubuntu.sh @@ -0,0 +1,1179 @@ +#!/bin/bash +set -e + +# ============================================================================= +# Ubuntu Server Setup Script +# Single-pass, non-interactive (install-everything) provisioning for a fresh +# Ubuntu server. Run as root: sudo ./setup-ubuntu.sh +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROGRESS_FILE="$SCRIPT_DIR/.setup-progress" +SUMMARY=() +ERRORS=() +CURRENT_STEP="" +SKIP_STEP=false + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${CYAN}::${NC} $*"; } +ok() { echo -e " ${GREEN}OK${NC}: $*"; } +warn() { echo -e " ${YELLOW}WARN${NC}: $*"; } +fail() { echo -e " ${RED}FAIL${NC}: $*"; exit 1; } + +step() { + CURRENT_STEP="$1" + if grep -qxF "$1" "$PROGRESS_FILE" 2>/dev/null; then + echo -e " ${GREEN}SKIP${NC}: $1 (already done)" + SKIP_STEP=true + return + fi + SKIP_STEP=false + echo "" + echo -e "${BOLD}── $1 ──${NC}" +} + +skip() { [[ "$SKIP_STEP" == true ]]; } + +step_ok() { + echo "$CURRENT_STEP" >> "$PROGRESS_FILE" +} + +# Trap errors with context +trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR + +# Try a command, log error but don't exit +try() { + local label="$1" + shift + if "$@" 2>&1; then + ok "$label" + else + warn "$label — failed (non-critical, continuing)" + ERRORS+=("$label") + fi +} + +prompt_value() { + local varname="$1" message="$2" default="$3" + # If env var already set, use it silently + if [[ -n "${!varname:-}" ]]; then + return + fi + local input + if [[ -n "$default" ]]; then + read -rp "$message [$default]: " input + eval "$varname=\"\${input:-$default}\"" + else + read -rp "$message: " input + eval "$varname=\"\$input\"" + fi +} + +# Run a block as the created user (login shell, inherits HOME) +as_user() { + sudo -u "$USERNAME" -i bash -c "$1" +} + +# ============================================================================= +# 1. Pre-flight +# ============================================================================= + +echo "" +echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ Ubuntu Server Setup ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}" + +if [[ -f "$PROGRESS_FILE" ]]; then + DONE_COUNT=$(wc -l < "$PROGRESS_FILE") + echo -e "${YELLOW} Resuming — $DONE_COUNT step(s) already completed${NC}" + echo -e "${YELLOW} Progress file: $PROGRESS_FILE${NC}" + echo -e "${YELLOW} To start fresh: rm $PROGRESS_FILE${NC}" +fi + +if [[ "$EUID" -ne 0 ]]; then + fail "Please run as root: sudo ./setup-ubuntu.sh" +fi + +prompt_value USERNAME "New admin username (or existing)" "" +if [[ -z "$USERNAME" ]]; then + fail "Username cannot be empty" +fi + +USER_HOME="/home/$USERNAME" + +# ============================================================================= +# 2. System Update & Essentials +# ============================================================================= + +step "System Update & Essentials" +if ! skip; then + info "Updating system packages..." + apt-get update -y && apt-get upgrade -y + + info "Installing essential packages..." + apt-get install -y \ + curl wget git zip unzip build-essential btop net-tools \ + software-properties-common jq htop tree ripgrep fd-find tmux \ + apt-transport-https ca-certificates gnupg lsb-release + + ok "System updated and essentials installed" + + # lazydocker + info "Installing lazydocker..." + curl -fsSL https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh | DIR=/usr/local/bin bash + ok "lazydocker installed" + + # lazygit + info "Installing lazygit..." + LAZYGIT_VERSION=$(curl -fsSL "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | jq -r '.tag_name' | tr -d 'v') + curl -fsSLo /tmp/lazygit.tar.gz "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz" + tar -C /usr/local/bin -xzf /tmp/lazygit.tar.gz lazygit + rm -f /tmp/lazygit.tar.gz + ok "lazygit installed" + + # starship + info "Installing starship..." + curl -fsSL https://starship.rs/install.sh | sh -s -- -y + ok "starship installed" + + # fastfetch + info "Installing fastfetch..." + add-apt-repository -y ppa:zhangsongcui3371/fastfetch > /dev/null 2>&1 || true + apt-get update -y > /dev/null 2>&1 + apt-get install -y fastfetch + ok "fastfetch installed" + + SUMMARY+=("System packages updated and essentials installed") + step_ok +fi + +# ============================================================================= +# 3. Locale +# ============================================================================= + +step "Locale" +if ! skip; then + info "Setting locale to en_US.UTF-8..." + locale-gen en_US.UTF-8 || true + update-locale LANG=en_US.UTF-8 + + ok "Locale set" + SUMMARY+=("Locale: en_US.UTF-8") + step_ok +fi + +# ============================================================================= +# 4. Timezone +# ============================================================================= + +step "Timezone" +if ! skip; then + info "Select timezone:" + TZ_OPTIONS=("UTC" "Europe/Lisbon" "Europe/Stockholm" "Europe/London" "US/Eastern" "US/Pacific" "Asia/Tokyo") + for i in "${!TZ_OPTIONS[@]}"; do + echo " [$((i+1))] ${TZ_OPTIONS[$i]}" + done + + prompt_value TZ_CHOICE "Pick a number or type a timezone" "1" + + # If the input is a number, map to the array; otherwise use as-is + if [[ "$TZ_CHOICE" =~ ^[0-9]+$ ]] && (( TZ_CHOICE >= 1 && TZ_CHOICE <= ${#TZ_OPTIONS[@]} )); then + TIMEZONE="${TZ_OPTIONS[$((TZ_CHOICE-1))]}" + else + TIMEZONE="$TZ_CHOICE" + fi + + timedatectl set-timezone "$TIMEZONE" + ok "Timezone: $TIMEZONE" + SUMMARY+=("Timezone: $TIMEZONE") + step_ok +fi + +# ============================================================================= +# 5. Swap +# ============================================================================= + +step "Swap" +if ! skip; then + if ! swapon --show | grep -q '/'; then + info "Creating swap file..." + RAM_KB=$(grep MemTotal /proc/meminfo | awk '{print $2}') + if (( RAM_KB <= 2097152 )); then + SWAP_SIZE="2G" + elif (( RAM_KB <= 8388608 )); then + SWAP_SIZE="4G" + else + SWAP_SIZE="8G" + fi + + fallocate -l "$SWAP_SIZE" /swapfile + chmod 600 /swapfile + mkswap /swapfile + swapon /swapfile + + if ! grep -q '/swapfile' /etc/fstab; then + echo '/swapfile none swap sw 0 0' >> /etc/fstab + fi + + ok "Swap: $SWAP_SIZE" + SUMMARY+=("Swap: $SWAP_SIZE created") + else + ok "Swap already exists, skipping" + SUMMARY+=("Swap: already present") + fi + + # Set swappiness + sysctl vm.swappiness=10 + if ! grep -q 'vm.swappiness' /etc/sysctl.conf; then + echo 'vm.swappiness=10' >> /etc/sysctl.conf + else + sed -i 's/^vm.swappiness=.*/vm.swappiness=10/' /etc/sysctl.conf + fi + step_ok +fi + +# ============================================================================= +# 6. Auto-Suspend (disable for servers) +# ============================================================================= + +step "Auto-Suspend (disable for servers)" +if ! skip; then + info "Disabling auto-suspend and idle actions..." + + # Mask sleep/suspend targets + systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target 2>/dev/null || true + + # Configure logind — patch individual keys, don't overwrite the file + set_logind_key() { + local key="$1" val="$2" file="/etc/systemd/logind.conf" + if grep -qE "^${key}=" "$file" 2>/dev/null; then + sed -i "s|^${key}=.*|${key}=${val}|" "$file" + elif grep -qE "^#${key}=" "$file" 2>/dev/null; then + sed -i "s|^#${key}=.*|${key}=${val}|" "$file" + else + echo "${key}=${val}" >> "$file" + fi + } + set_logind_key HandleLidSwitch ignore + set_logind_key HandleLidSwitchExternalPower ignore + set_logind_key HandlePowerKey ignore + set_logind_key IdleAction none + set_logind_key RuntimeDirectorySize 10% + + systemctl restart systemd-logind + + ok "Auto-suspend disabled" + SUMMARY+=("Auto-suspend: disabled") + step_ok +fi + +# ============================================================================= +# 6.5 Boot Hang Fix (NetworkManager desktops only) — OPTIONAL, default off +# ============================================================================= +# +# On Ubuntu Desktop, NetworkManager owns the NIC and systemd-networkd runs +# nothing — so systemd-networkd-wait-online.service blocks boot for its full +# ~120s timeout. Masking it removes the hang. +# +# On Ubuntu Server / cloud VPS images, systemd-networkd IS the network manager +# (cloud-init + netplan). There the service is load-bearing and completes +# quickly, so there's nothing to fix. The guard below detects which stack is +# live and only acts on the NetworkManager case. +# +# NOTE: do NOT `systemctl disable --now systemd-networkd.{socket,service}` here. +# On a networkd-managed VPS that brings the box up with no network on the next +# boot — no SSH, provider rescue console only. Keep it as a manual, desktop-only +# step if you ever want it. + +step "Boot Hang Fix" +if ! skip; then + prompt_value FIX_BOOT_HANG "Mask systemd-networkd-wait-online on NetworkManager boxes? (y/n)" "n" + + if [[ "$FIX_BOOT_HANG" != "y" ]]; then + info "Skipping boot hang fix" + SUMMARY+=("Boot hang fix: skipped") + else + # Only mask the spurious wait-online where NetworkManager is active and + # networkd is not. Anything else (networkd stack, or unclear) is left alone. + if systemctl is-active --quiet NetworkManager.service \ + && ! systemctl is-active --quiet systemd-networkd.service; then + systemctl mask --now systemd-networkd-wait-online.service + ok "Masked systemd-networkd-wait-online (NetworkManager stack)" + SUMMARY+=("Boot hang fix: wait-online masked (NetworkManager)") + else + warn "networkd stack (or unclear) — leaving wait-online alone" + SUMMARY+=("Boot hang fix: not applied (networkd stack)") + fi + fi + step_ok +fi + +# ============================================================================= +# 7. User Creation +# ============================================================================= + +step "User Creation" +if ! skip; then + if id "$USERNAME" &>/dev/null; then + info "User '$USERNAME' already exists, skipping creation" + SUMMARY+=("User: $USERNAME (existing)") + else + info "Creating user '$USERNAME'..." + adduser --gecos "" "$USERNAME" + usermod -aG sudo "$USERNAME" + ok "User '$USERNAME' created with sudo access" + SUMMARY+=("User: $USERNAME created") + fi + + prompt_value PASSWORDLESS_SUDO "Enable passwordless sudo for $USERNAME? (y/n)" "y" + + if [[ "$PASSWORDLESS_SUDO" == "y" ]]; then + echo "$USERNAME ALL=(ALL) NOPASSWD: ALL" > "/etc/sudoers.d/99-$USERNAME-nopasswd" + chmod 440 "/etc/sudoers.d/99-$USERNAME-nopasswd" + visudo -cf "/etc/sudoers.d/99-$USERNAME-nopasswd" || rm -f "/etc/sudoers.d/99-$USERNAME-nopasswd" + ok "Passwordless sudo enabled (remove /etc/sudoers.d/99-$USERNAME-nopasswd to disable)" + SUMMARY+=("Sudo: passwordless") + fi + + # Copy .tmux.conf if present next to this script + if [[ -f "$SCRIPT_DIR/.tmux.conf" ]]; then + cp "$SCRIPT_DIR/.tmux.conf" "$USER_HOME/.tmux.conf" + chown "$USERNAME:$USERNAME" "$USER_HOME/.tmux.conf" + ok "Copied .tmux.conf to $USER_HOME" + fi + step_ok +fi + +# ============================================================================= +# 8. SSH Keys +# ============================================================================= + +step "SSH Keys" +if ! skip; then + info "SSH key setup for '$USERNAME':" + echo " [1] Extract from ssh-keys.zip (next to this script)" + echo " [2] Paste key content" + echo " [3] Generate new ed25519 key" + + prompt_value SSH_CHOICE "Pick an option" "1" + + SSH_DIR="$USER_HOME/.ssh" + mkdir -p "$SSH_DIR" + + case "$SSH_CHOICE" in + 1) + ZIP_FILE="$SCRIPT_DIR/ssh-keys.zip" + if [[ ! -f "$ZIP_FILE" ]]; then + warn "ssh-keys.zip not found at $ZIP_FILE" + warn "Skipping SSH key setup" + else + read -rsp "Zip password: " ZIP_PASS + echo + if [[ -n "$ZIP_PASS" ]]; then + unzip -o -P "$ZIP_PASS" "$ZIP_FILE" -d "$SSH_DIR" + else + unzip -o "$ZIP_FILE" -d "$SSH_DIR" + fi + ok "SSH keys extracted from zip" + SUMMARY+=("SSH keys: extracted from zip") + fi + ;; + 2) + echo "Paste your PUBLIC key (then press Enter, then Ctrl-D):" + cat > "$SSH_DIR/id_ed25519.pub" + echo "Paste your PRIVATE key (then press Enter, then Ctrl-D):" + cat > "$SSH_DIR/id_ed25519" + # Also add public key to authorized_keys + cat "$SSH_DIR/id_ed25519.pub" >> "$SSH_DIR/authorized_keys" + ok "SSH keys written from input" + SUMMARY+=("SSH keys: pasted manually") + ;; + 3) + prompt_value SSH_EMAIL "Email/comment for key" "" + sudo -u "$USERNAME" ssh-keygen -t ed25519 -C "$SSH_EMAIL" -f "$SSH_DIR/id_ed25519" -N "" + cat "$SSH_DIR/id_ed25519.pub" >> "$SSH_DIR/authorized_keys" + ok "SSH key generated" + echo " Public key:" + cat "$SSH_DIR/id_ed25519.pub" + SUMMARY+=("SSH keys: generated new ed25519") + ;; + esac + + chmod 700 "$SSH_DIR" + find "$SSH_DIR" -type f -exec chmod 600 {} \; + chown -R "$USERNAME:$USERNAME" "$SSH_DIR" + step_ok +fi + +# ============================================================================= +# 9. SSH Hardening +# ============================================================================= + +step "SSH Hardening" +if ! skip; then + info "Hardening SSH..." + sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config + sed -i 's/^#\?PermitRootLogin .*/PermitRootLogin no/' /etc/ssh/sshd_config + sed -i 's/^#\?ChallengeResponseAuthentication .*/ChallengeResponseAuthentication no/' /etc/ssh/sshd_config + systemctl restart ssh + + ok "SSH hardened (password auth disabled, root login disabled)" + SUMMARY+=("SSH: hardened") + step_ok +fi + +# ============================================================================= +# 10. DNS +# ============================================================================= + +step "DNS" +if ! skip; then + info "Configuring DNS (Cloudflare + Google)..." + PRIMARY_DNS=("1.1.1.1" "8.8.8.8") + FALLBACK_DNS=("1.0.0.1" "8.8.4.4") + + if systemctl is-active --quiet systemd-resolved; then + cp /etc/systemd/resolved.conf "/etc/systemd/resolved.conf.bak.$(date +%s)" 2>/dev/null || true + + cat > /etc/systemd/resolved.conf </dev/null || true + fi + if lsattr /etc/resolv.conf 2>/dev/null | grep -q 'i-'; then + chattr -i /etc/resolv.conf + fi + + { + echo "# Generated by setup-ubuntu.sh on $(date -u +'%Y-%m-%dT%H:%M:%SZ')" + for ns in "${PRIMARY_DNS[@]}"; do + echo "nameserver $ns" + done + } > /etc/resolv.conf + + ok "DNS configured (static resolv.conf)" + fi + + SUMMARY+=("DNS: Cloudflare + Google") + + # Test DNS + if command -v dig &>/dev/null; then + dig +short google.com > /dev/null 2>&1 && ok "DNS resolution works" || warn "DNS test failed" + else + getent hosts google.com > /dev/null 2>&1 && ok "DNS resolution works" || warn "DNS test failed" + fi + step_ok +fi + +# ============================================================================= +# 11. Static IP +# ============================================================================= + +step "Static IP" +if ! skip; then + # Detect primary interface and current IP + IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}') + CURRENT_IP=$(ip -4 addr show "$IFACE" 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}/\d+' | head -1) + GATEWAY=$(ip route | grep default | awk '{print $3}' | head -1) + + if [[ -z "$IFACE" || -z "$CURRENT_IP" || -z "$GATEWAY" ]]; then + warn "Could not detect network interface, IP, or gateway — skipping static IP" + SUMMARY+=("Static IP: skipped (detection failed)") + else + # Check if a static Netplan config already exists for this interface + EXISTING_STATIC=$(grep -rl "dhcp4: false\|dhcp4: no" /etc/netplan/ 2>/dev/null | head -1) + if [[ -n "$EXISTING_STATIC" ]]; then + warn "Static IP already configured in $EXISTING_STATIC — skipping" + SUMMARY+=("Static IP: already static (skipped)") + else + info "Detected network configuration:" + echo " Interface : $IFACE" + echo " IP : $CURRENT_IP" + echo " Gateway : $GATEWAY" + echo "" + echo -e "${RED}${BOLD}╔══════════════════════════════════════════════════╗${NC}" + echo -e "${RED}${BOLD}║ STOP — DO NOT DO THIS ON A VPS / CLOUD SERVER ║${NC}" + echo -e "${RED}${BOLD}╚══════════════════════════════════════════════════╝${NC}" + echo -e "${YELLOW} This freezes the CURRENT DHCP lease into netplan and applies" + echo -e " it immediately. On a VPS or cloud instance that can strand the box:${NC}" + echo "" + echo -e " ${RED}!${NC} Providers hand out addresses by DHCP and may reassign them —" + echo -e " the hardcoded IP then belongs to someone else." + echo -e " ${RED}!${NC} cloud-init regenerates netplan config on boot; the two configs" + echo -e " can conflict and leave the interface with no usable address." + echo -e " ${RED}!${NC} The gateway/netmask on cloud networks is often NOT what a" + echo -e " simple route lookup suggests (point-to-point, /32 routing)." + echo -e " ${RED}!${NC} If any value is wrong, ${BOLD}netplan apply kills the SSH session" + echo -e " you are on right now${NC} and you are locked out until the provider's" + echo -e " rescue console." + echo "" + echo -e "${YELLOW} Only answer 'y' on a machine you physically control (LAN box," + echo -e " homelab, bare metal) where the address is yours to pin — and even" + echo -e " then, a DHCP reservation on the router is the safer way to do it.${NC}" + echo "" + prompt_value CONFIRM_STATIC "Set this as static IP? (y/n)" "n" + + if [[ "$CONFIRM_STATIC" != "y" ]]; then + info "Skipping static IP configuration" + SUMMARY+=("Static IP: skipped (user declined)") + else + NETPLAN_FILE="/etc/netplan/99-static-ip.yaml" + cat > "$NETPLAN_FILE" < /etc/apt/apt.conf.d/20auto-upgrades <") + step_ok +fi + +# ============================================================================= +# 15. Docker +# ============================================================================= + +step "Docker" +if ! skip; then + info "Installing Docker CE..." + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ + https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \ + > /etc/apt/sources.list.d/docker.list + + apt-get update -y + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + + usermod -aG docker "$USERNAME" + + # Create shared docker network + DOCKER_NETWORK="${SETUP_DOCKER_NETWORK:-services}" + docker network create "$DOCKER_NETWORK" 2>/dev/null || true + + ok "Docker installed, '$USERNAME' added to docker group" + SUMMARY+=("Docker: installed") + step_ok +fi +# ============================================================================= +# 16. Oh My Zsh + Prompt (as user) +# ============================================================================= + +step "Oh My Zsh + Prompt" +if ! skip; then + info "Installing Zsh and Oh My Zsh..." + + apt-get install -y zsh + + as_user 'sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended' + + ZSHRC="$USER_HOME/.zshrc" + + cat >> "$ZSHRC" <<'EOF' + +# Starship prompt +eval "$(starship init zsh)" +EOF + + chsh -s /bin/zsh "$USERNAME" + + # Starship config — disable runtime/language version modules + mkdir -p "$USER_HOME/.config" + cat > "$USER_HOME/.config/starship.toml" <<'STARSHIP_EOF' +add_newline = true +command_timeout = 200 + +[bun] +disabled = true + +[nodejs] +disabled = true + +[python] +disabled = true + +[rust] +disabled = true + +[golang] +disabled = true + +[package] +disabled = true +STARSHIP_EOF + chown -R "$USERNAME:$USERNAME" "$USER_HOME/.config" + + ok "Zsh + Oh My Zsh installed, starship prompt set" + SUMMARY+=("Shell: zsh with Oh My Zsh + Starship") + step_ok +fi + +# ============================================================================= +# 17. Tailscale (as user) +# ============================================================================= + +step "Tailscale" +if ! skip; then + prompt_value INSTALL_TAILSCALE "Install Tailscale? (y/n)" "y" + + if [[ "$INSTALL_TAILSCALE" != "y" || "${SETUP_SKIP_TAILSCALE:-}" == "true" ]]; then + info "Skipping Tailscale" + SUMMARY+=("Tailscale: skipped") + else + info "Installing Tailscale..." + curl -fsSL https://tailscale.com/install.sh | sh + + # Enable IP forwarding + sed -i '/^net.ipv4.ip_forward/d' /etc/sysctl.conf + sed -i '/^net.ipv6.conf.all.forwarding/d' /etc/sysctl.conf + echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf + echo "net.ipv6.conf.all.forwarding=1" >> /etc/sysctl.conf + sysctl -p + + prompt_value TS_LOGIN_SERVER "Tailscale login server" "https://headscale.pastilhas.eu" + prompt_value TS_AUTHKEY "Tailscale auth key" "" + + prompt_value TS_EXIT_NODE "Act as exit node? (y/n)" "n" + TS_ADVERTISE="" + + if [[ "$TS_EXIT_NODE" == "y" ]]; then + TS_ADVERTISE="--advertise-exit-node" + + cat > /etc/sysctl.d/99-tailscale-exit.conf < /dev/null + + # NIC offload optimizations via networkd-dispatcher + if ! command -v networkd-dispatcher &>/dev/null; then + apt-get install -y networkd-dispatcher + fi + mkdir -p /etc/networkd-dispatcher/routable.d + + cat > /etc/networkd-dispatcher/routable.d/50-tailscale-exit <<'EXITSCRIPT' +#!/usr/bin/env bash +set -Eeuo pipefail + +IF="${IFACE:-}" +if [[ -z "${IF}" ]]; then + IF="$(ip -o route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')" +fi + +[[ -n "${IF}" ]] || exit 0 +command -v ethtool >/dev/null 2>&1 || exit 0 + +ethtool -k "${IF}" 2>/dev/null | grep -q "^generic-receive-offload: " && ethtool -K "${IF}" gro on || true +ethtool -k "${IF}" 2>/dev/null | grep -q "^rx-udp-gro-forwarding: " && ethtool -K "${IF}" rx-udp-gro-forwarding on || true +ethtool -k "${IF}" 2>/dev/null | grep -q "^large-receive-offload: " && ethtool -K "${IF}" lro off || true + +exit 0 +EXITSCRIPT + + chmod 755 /etc/networkd-dispatcher/routable.d/50-tailscale-exit + systemctl enable --now networkd-dispatcher > /dev/null 2>&1 || true + + # Run once now + CURRENT_IF="$(ip -o route get 8.8.8.8 | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')" + IFACE="$CURRENT_IF" bash /etc/networkd-dispatcher/routable.d/50-tailscale-exit || true + ok "Exit node NIC optimizations applied on: ${CURRENT_IF:-unknown}" + fi + + tailscale up --login-server "$TS_LOGIN_SERVER" --authkey "$TS_AUTHKEY" ${TS_ADVERTISE:+"$TS_ADVERTISE"} + + TS_IP=$(tailscale ip -4 2>/dev/null || echo "unknown") + ok "Tailscale connected (IP: $TS_IP)" + SUMMARY+=("Tailscale: connected ($TS_IP)") + fi + step_ok +fi + +# ============================================================================= +# 18. Neovim + LazyVim (as user) +# ============================================================================= + +step "Neovim + LazyVim" +if ! skip; then + ZSHRC="$USER_HOME/.zshrc" + + info "Installing Neovim..." + NVIM_ARCH=$(uname -m) + case $NVIM_ARCH in + aarch64) NVIM_ARCH=aarch64 ;; + *) NVIM_ARCH=x86_64 ;; + esac + curl -LO "https://github.com/neovim/neovim/releases/latest/download/nvim-linux-${NVIM_ARCH}.tar.gz" + rm -rf "/opt/nvim-linux-${NVIM_ARCH}" + tar -C /opt -xzf "nvim-linux-${NVIM_ARCH}.tar.gz" + rm -f "nvim-linux-${NVIM_ARCH}.tar.gz" + ln -sf "/opt/nvim-linux-${NVIM_ARCH}/bin/nvim" /usr/local/bin/nvim + + # Add to PATH in .zshrc + cat >> "$ZSHRC" </dev/null || true + + chown -R "$USERNAME:$USERNAME" "$USER_HOME/.config" + chown "$USERNAME:$USERNAME" "$ZSHRC" + + ok "Neovim + config installed" + SUMMARY+=("Neovim: installed with LazyVim") + step_ok +fi + +# ============================================================================= +# 19. JS/TS Runtimes (system-wide for servers, optional nvm for dev) +# ============================================================================= + +step "JS/TS Runtimes" +if ! skip; then + ZSHRC="$USER_HOME/.zshrc" + + if [[ "${SETUP_SKIP_RUNTIMES:-}" == "true" ]]; then + info "Skipping JS/TS runtimes (SETUP_SKIP_RUNTIMES=true)" + SUMMARY+=("Runtimes: skipped") + else + info "Installing JS/TS runtimes..." + + # Node 22 — always system-wide via NodeSource + info "Installing system-wide Node 22..." + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + apt-get install -y nodejs + npm config set prefix /usr/local + ok "System Node 22 installed ($(node -v))" + SUMMARY+=("Node: system-wide v22 via NodeSource") + + # Bun (user-local install) + as_user 'curl -fsSL https://bun.sh/install | bash' + cat >> "$ZSHRC" <<'EOF' + +# Bun +export PATH="$HOME/.bun/bin:$PATH" +EOF + ok "Bun installed" + + # pm2 (system-wide, via system npm) + npm install -g pm2 + ok "pm2 installed (system-wide)" + + # Deno (user-local install) + as_user 'curl -fsSL https://deno.land/install.sh | sh' + cat >> "$ZSHRC" <<'EOF' + +# Deno +export DENO_INSTALL="$HOME/.deno" +export PATH="$DENO_INSTALL/bin:$PATH" +EOF + ok "Deno installed" + + chown "$USERNAME:$USERNAME" "$ZSHRC" + fi + step_ok +fi + +# ============================================================================= +# 19.5 Dev Tools (system-wide for servers, local for dev users) +# ============================================================================= + +step "Dev Tools" +if ! skip; then + if [[ "${SETUP_SKIP_DEVTOOLS:-}" == "true" ]]; then + info "Skipping dev tools (SETUP_SKIP_DEVTOOLS=true)" + SUMMARY+=("Dev tools: skipped") + else + info "Select dev tools to install:" + echo " [1] Claude Code" + echo " [2] Opencode" + echo " [3] PI" + + prompt_value DEV_TOOLS "Enter numbers separated by spaces, or 'all'" "all" + + if [[ "$DEV_TOOLS" == "all" ]]; then + DEV_TOOLS="1 2 3" + fi + + DEV_INSTALLED=() + + for tool in $DEV_TOOLS; do + case "$tool" in + 1) + info "Installing Claude Code..." + npm install -g @anthropic-ai/claude-code + ok "Claude Code installed (system-wide)" + DEV_INSTALLED+=("Claude Code") + ;; + 2) + info "Installing Opencode..." + as_user 'curl -fsSL https://opencode.ai/install | bash' + ok "Opencode installed" + DEV_INSTALLED+=("Opencode") + ;; + 3) + info "Installing PI..." + npm install -g @mariozechner/pi-coding-agent + ok "PI installed (system-wide)" + DEV_INSTALLED+=("PI") + ;; + esac + done + + if [[ ${#DEV_INSTALLED[@]} -gt 0 ]]; then + ok "Dev tools: ${DEV_INSTALLED[*]}" + SUMMARY+=("Dev tools: ${DEV_INSTALLED[*]}") + else + SUMMARY+=("Dev tools: none selected") + fi + fi + step_ok +fi + +# ============================================================================= +# 20. UFW (last, after everything) +# ============================================================================= + +step "UFW Firewall" +if ! skip; then + info "Configuring UFW firewall..." + + ufw default deny incoming + ufw default allow outgoing + ufw allow OpenSSH + + # Allow all traffic on Tailscale interface + ufw allow in on tailscale0 + + # Docker + UFW fix + UFW_RULES="/etc/ufw/after.rules" + DOCKER_RULES="$SCRIPT_DIR/ufw-docker-rules.conf" + + if [[ -f "$DOCKER_RULES" ]]; then + if ! grep -q 'DOCKER-USER' "$UFW_RULES" 2>/dev/null; then + info "Appending Docker UFW rules..." + echo "" >> "$UFW_RULES" + cat "$DOCKER_RULES" >> "$UFW_RULES" + ok "Docker UFW rules added" + else + ok "Docker UFW rules already present" + fi + else + warn "ufw-docker-rules.conf not found, skipping Docker UFW fix" + fi + + ufw --force enable + ok "UFW enabled" + SUMMARY+=("UFW: enabled (SSH, Tailscale, Docker rules)") + step_ok +fi + +# ============================================================================= +# 21. Final .zshrc additions +# ============================================================================= + +step "Final .zshrc additions" +if ! skip; then + ZSHRC="$USER_HOME/.zshrc" + cat >> "$ZSHRC" <<'EOF' + +# Neovim aliases +alias n='nvim' +alias vim='nvim' + +# Default editor +export EDITOR='nvim' +export VISUAL='nvim' +export SUDO_EDITOR='nvim' + +# Quick reload +alias sz="source ~/.zshrc" +EOF + chown "$USERNAME:$USERNAME" "$ZSHRC" + step_ok +fi + +# ============================================================================= +# 22. Emergency Disk Ballast +# ============================================================================= +# +# Pre-allocates a junk file sized at 10% of currently-available disk. It holds +# no data — its only job is to be deleted when the filesystem is about to fill, +# buying enough headroom to SSH in and clean up properly instead of hitting a +# wedged box (Docker/journald/postgres all misbehave badly at 100% full). +# +# A root cron checks free space every 10 minutes and removes the ballast if it +# drops below 10%. This is a ONE-SHOT valve: once spent, recreate it with the +# same fallocate command below. + +step "Emergency Disk Ballast" +if ! skip; then + DOCKERS_DIR="$USER_HOME/dockers" + BALLAST_FILE="$DOCKERS_DIR/emergency_empty_file.log" + LOCAL_DIR="$USER_HOME/.local" + LOCAL_BIN="$LOCAL_DIR/bin" + CHECK_SCRIPT="$LOCAL_BIN/emergency-disk-check" + CRON_FILE="/etc/cron.d/emergency-disk-check" + + # Name $LOCAL_DIR explicitly. `install -d` applies -o/-g ONLY to the directories given as + # operands — any parent it has to invent on the way gets the caller's ownership, which here + # is root. This step is the first thing to touch ~/.local, so leaving it implicit created a + # root:root ~/.local with a user-owned bin/ inside it, and locked the user out of their own + # ~/.local: anything unpacking or writing directly there (a Go tarball, npm's --prefix, + # GOPATH) fails with a bare "Permission denied" far from this line. + install -d -o "$USERNAME" -g "$USERNAME" "$DOCKERS_DIR" "$LOCAL_DIR" "$LOCAL_BIN" + + # --- the ballast file --- + if [[ -f "$BALLAST_FILE" ]]; then + ok "Ballast already exists at $BALLAST_FILE ($(du -h "$BALLAST_FILE" | cut -f1))" + SUMMARY+=("Disk ballast: already present") + else + info "Allocating ballast file (10% of available disk)..." + AVAIL_KB=$(df -Pk "$DOCKERS_DIR" | awk 'NR==2 {print $4}') + BALLAST_KB=$(( AVAIL_KB / 10 )) + + # fallocate reserves real blocks (a sparse/truncate file would reserve + # nothing and free nothing when deleted). dd is the fallback for + # filesystems that don't support fallocate. + if ! fallocate -l "${BALLAST_KB}K" "$BALLAST_FILE" 2>/dev/null; then + warn "fallocate unsupported here — falling back to dd (slower)" + dd if=/dev/zero of="$BALLAST_FILE" bs=1K count="$BALLAST_KB" status=none + fi + + chown "$USERNAME:$USERNAME" "$BALLAST_FILE" + chmod 644 "$BALLAST_FILE" + ok "Ballast created: $BALLAST_FILE ($(du -h "$BALLAST_FILE" | cut -f1))" + SUMMARY+=("Disk ballast: $(du -h "$BALLAST_FILE" | cut -f1) at $BALLAST_FILE") + fi + + # --- the checker script --- + info "Installing disk check script..." + cat > "$CHECK_SCRIPT" <<'CHECKEOF' +#!/usr/bin/env bash +# +# Emergency disk ballast checker. +# Deletes the pre-allocated ballast file when free space falls below the +# threshold, buying headroom to log in and clean up. Installed by setup-ubuntu.sh. +# +# Run manually with --status to see current usage without changing anything. + +set -euo pipefail + +BALLAST="__BALLAST_PATH__" +THRESHOLD=10 # act when free space drops below this percentage +TAG="emergency-disk" + +MOUNT_DIR="$(dirname "$BALLAST")" +USE_PCT="$(df -P "$MOUNT_DIR" | awk 'NR==2 {gsub(/%/,"",$5); print $5}')" +FREE_PCT=$(( 100 - USE_PCT )) + +if [[ "${1:-}" == "--status" ]]; then + echo "Mount: $(df -P "$MOUNT_DIR" | awk 'NR==2 {print $6}')" + echo "Free: ${FREE_PCT}% (threshold: ${THRESHOLD}%)" + if [[ -f "$BALLAST" ]]; then + echo "Ballast: present, $(du -h "$BALLAST" | cut -f1) — $BALLAST" + else + echo "Ballast: ABSENT (already spent) — $BALLAST" + fi + exit 0 +fi + +(( FREE_PCT < THRESHOLD )) || exit 0 + +if [[ -f "$BALLAST" ]]; then + FREED="$(du -h "$BALLAST" | cut -f1)" + rm -f "$BALLAST" + logger -t "$TAG" -p user.crit \ + "Free space ${FREE_PCT}% below ${THRESHOLD}% — deleted ballast, reclaimed ${FREED}. CLEAN UP NOW: this valve is spent." +else + logger -t "$TAG" -p user.crit \ + "Free space ${FREE_PCT}% below ${THRESHOLD}% — ballast already spent, no headroom left to reclaim." +fi +CHECKEOF + + sed -i "s|__BALLAST_PATH__|$BALLAST_FILE|" "$CHECK_SCRIPT" + chown "$USERNAME:$USERNAME" "$CHECK_SCRIPT" + chmod 755 "$CHECK_SCRIPT" + ok "Check script: $CHECK_SCRIPT" + + # --- the cron entry (root, so it can always delete) --- + cat > "$CRON_FILE" <${NC}" +echo " 2. Docker group requires a new login session" +echo "" + +# Clean up progress file on success +rm -f "$PROGRESS_FILE" diff --git a/scripts/setup/machine-setup/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh index d2c0ea06..23142e1e 100755 --- a/scripts/setup/machine-setup/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -114,992 +114,20 @@ if ! skip; then step_ok fi -# ───────────────────────────────────────────────────────────────────────────── -# WIP BOUNDARY — everything below is still the original script -# ───────────────────────────────────────────────────────────────────────────── -# -# Move this block down as each section is worked through, and delete it when the -# walk reaches the end. It exists so the rewritten part can be run start to -# finish on its own without the untouched sections acting on the machine. -echo "" -info "Stopping here — sections below this point have not been rewritten yet." -info "Run again from scratch with: sudo rm -f $PROGRESS_FILE" -echo "" -exit 0 # ============================================================================= -# 3. Locale -# ============================================================================= - -step "Locale" -if ! skip; then - info "Setting locale to en_US.UTF-8..." - locale-gen en_US.UTF-8 || true - update-locale LANG=en_US.UTF-8 - - ok "Locale set" - SUMMARY+=("Locale: en_US.UTF-8") - step_ok -fi - -# ============================================================================= -# 4. Timezone -# ============================================================================= - -step "Timezone" -if ! skip; then - info "Select timezone:" - TZ_OPTIONS=("UTC" "Europe/Lisbon" "Europe/Stockholm" "Europe/London" "US/Eastern" "US/Pacific" "Asia/Tokyo") - for i in "${!TZ_OPTIONS[@]}"; do - echo " [$((i+1))] ${TZ_OPTIONS[$i]}" - done - - prompt_value TZ_CHOICE "Pick a number or type a timezone" "1" - - # If the input is a number, map to the array; otherwise use as-is - if [[ "$TZ_CHOICE" =~ ^[0-9]+$ ]] && (( TZ_CHOICE >= 1 && TZ_CHOICE <= ${#TZ_OPTIONS[@]} )); then - TIMEZONE="${TZ_OPTIONS[$((TZ_CHOICE-1))]}" - else - TIMEZONE="$TZ_CHOICE" - fi - - timedatectl set-timezone "$TIMEZONE" - ok "Timezone: $TIMEZONE" - SUMMARY+=("Timezone: $TIMEZONE") - step_ok -fi - -# ============================================================================= -# 5. Swap -# ============================================================================= - -step "Swap" -if ! skip; then - if ! swapon --show | grep -q '/'; then - info "Creating swap file..." - RAM_KB=$(grep MemTotal /proc/meminfo | awk '{print $2}') - if (( RAM_KB <= 2097152 )); then - SWAP_SIZE="2G" - elif (( RAM_KB <= 8388608 )); then - SWAP_SIZE="4G" - else - SWAP_SIZE="8G" - fi - - fallocate -l "$SWAP_SIZE" /swapfile - chmod 600 /swapfile - mkswap /swapfile - swapon /swapfile - - if ! grep -q '/swapfile' /etc/fstab; then - echo '/swapfile none swap sw 0 0' >> /etc/fstab - fi - - ok "Swap: $SWAP_SIZE" - SUMMARY+=("Swap: $SWAP_SIZE created") - else - ok "Swap already exists, skipping" - SUMMARY+=("Swap: already present") - fi - - # Set swappiness - sysctl vm.swappiness=10 - if ! grep -q 'vm.swappiness' /etc/sysctl.conf; then - echo 'vm.swappiness=10' >> /etc/sysctl.conf - else - sed -i 's/^vm.swappiness=.*/vm.swappiness=10/' /etc/sysctl.conf - fi - step_ok -fi - -# ============================================================================= -# 6. Auto-Suspend (disable for servers) -# ============================================================================= - -step "Auto-Suspend (disable for servers)" -if ! skip; then - info "Disabling auto-suspend and idle actions..." - - # Mask sleep/suspend targets - systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target 2>/dev/null || true - - # Configure logind — patch individual keys, don't overwrite the file - set_logind_key() { - local key="$1" val="$2" file="/etc/systemd/logind.conf" - if grep -qE "^${key}=" "$file" 2>/dev/null; then - sed -i "s|^${key}=.*|${key}=${val}|" "$file" - elif grep -qE "^#${key}=" "$file" 2>/dev/null; then - sed -i "s|^#${key}=.*|${key}=${val}|" "$file" - else - echo "${key}=${val}" >> "$file" - fi - } - set_logind_key HandleLidSwitch ignore - set_logind_key HandleLidSwitchExternalPower ignore - set_logind_key HandlePowerKey ignore - set_logind_key IdleAction none - set_logind_key RuntimeDirectorySize 10% - - systemctl restart systemd-logind - - ok "Auto-suspend disabled" - SUMMARY+=("Auto-suspend: disabled") - step_ok -fi - -# ============================================================================= -# 6.5 Boot Hang Fix (NetworkManager desktops only) — OPTIONAL, default off +# NOT PORTED YET # ============================================================================= # -# On Ubuntu Desktop, NetworkManager owns the NIC and systemd-networkd runs -# nothing — so systemd-networkd-wait-online.service blocks boot for its full -# ~120s timeout. Masking it removes the hang. +# Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order: # -# On Ubuntu Server / cloud VPS images, systemd-networkd IS the network manager -# (cloud-init + netplan). There the service is load-bearing and completes -# quickly, so there's nothing to fix. The guard below detects which stack is -# live and only acts on the NetworkManager case. +# locale · timezone · swap · auto-suspend · boot-hang fix · user creation · +# ssh keys · ssh hardening · dns · static ip · fail2ban · unattended-upgrades · +# git config · docker · zsh + prompt · tailscale · neovim · js runtimes · +# dev tools · ufw · zshrc · disk ballast # -# NOTE: do NOT `systemctl disable --now systemd-networkd.{socket,service}` here. -# On a networkd-managed VPS that brings the box up with no network on the next -# boot — no SSH, provider rescue console only. Keep it as a manual, desktop-only -# step if you ever want it. +# Each arrives as its own commit. Delete this block when the list is empty. -step "Boot Hang Fix" -if ! skip; then - prompt_value FIX_BOOT_HANG "Mask systemd-networkd-wait-online on NetworkManager boxes? (y/n)" "n" - - if [[ "$FIX_BOOT_HANG" != "y" ]]; then - info "Skipping boot hang fix" - SUMMARY+=("Boot hang fix: skipped") - else - # Only mask the spurious wait-online where NetworkManager is active and - # networkd is not. Anything else (networkd stack, or unclear) is left alone. - if systemctl is-active --quiet NetworkManager.service \ - && ! systemctl is-active --quiet systemd-networkd.service; then - systemctl mask --now systemd-networkd-wait-online.service - ok "Masked systemd-networkd-wait-online (NetworkManager stack)" - SUMMARY+=("Boot hang fix: wait-online masked (NetworkManager)") - else - warn "networkd stack (or unclear) — leaving wait-online alone" - SUMMARY+=("Boot hang fix: not applied (networkd stack)") - fi - fi - step_ok -fi - -# ============================================================================= -# 7. User Creation -# ============================================================================= - -step "User Creation" -if ! skip; then - if id "$USERNAME" &>/dev/null; then - info "User '$USERNAME' already exists, skipping creation" - SUMMARY+=("User: $USERNAME (existing)") - else - info "Creating user '$USERNAME'..." - adduser --gecos "" "$USERNAME" - usermod -aG sudo "$USERNAME" - ok "User '$USERNAME' created with sudo access" - SUMMARY+=("User: $USERNAME created") - fi - - prompt_value PASSWORDLESS_SUDO "Enable passwordless sudo for $USERNAME? (y/n)" "y" - - if [[ "$PASSWORDLESS_SUDO" == "y" ]]; then - echo "$USERNAME ALL=(ALL) NOPASSWD: ALL" > "/etc/sudoers.d/99-$USERNAME-nopasswd" - chmod 440 "/etc/sudoers.d/99-$USERNAME-nopasswd" - visudo -cf "/etc/sudoers.d/99-$USERNAME-nopasswd" || rm -f "/etc/sudoers.d/99-$USERNAME-nopasswd" - ok "Passwordless sudo enabled (remove /etc/sudoers.d/99-$USERNAME-nopasswd to disable)" - SUMMARY+=("Sudo: passwordless") - fi - - # Copy .tmux.conf if present next to this script - if [[ -f "$SCRIPT_DIR/.tmux.conf" ]]; then - cp "$SCRIPT_DIR/.tmux.conf" "$USER_HOME/.tmux.conf" - chown "$USERNAME:$USERNAME" "$USER_HOME/.tmux.conf" - ok "Copied .tmux.conf to $USER_HOME" - fi - step_ok -fi - -# ============================================================================= -# 8. SSH Keys -# ============================================================================= - -step "SSH Keys" -if ! skip; then - info "SSH key setup for '$USERNAME':" - echo " [1] Extract from ssh-keys.zip (next to this script)" - echo " [2] Paste key content" - echo " [3] Generate new ed25519 key" - - prompt_value SSH_CHOICE "Pick an option" "1" - - SSH_DIR="$USER_HOME/.ssh" - mkdir -p "$SSH_DIR" - - case "$SSH_CHOICE" in - 1) - ZIP_FILE="$SCRIPT_DIR/ssh-keys.zip" - if [[ ! -f "$ZIP_FILE" ]]; then - warn "ssh-keys.zip not found at $ZIP_FILE" - warn "Skipping SSH key setup" - else - read -rsp "Zip password: " ZIP_PASS - echo - if [[ -n "$ZIP_PASS" ]]; then - unzip -o -P "$ZIP_PASS" "$ZIP_FILE" -d "$SSH_DIR" - else - unzip -o "$ZIP_FILE" -d "$SSH_DIR" - fi - ok "SSH keys extracted from zip" - SUMMARY+=("SSH keys: extracted from zip") - fi - ;; - 2) - echo "Paste your PUBLIC key (then press Enter, then Ctrl-D):" - cat > "$SSH_DIR/id_ed25519.pub" - echo "Paste your PRIVATE key (then press Enter, then Ctrl-D):" - cat > "$SSH_DIR/id_ed25519" - # Also add public key to authorized_keys - cat "$SSH_DIR/id_ed25519.pub" >> "$SSH_DIR/authorized_keys" - ok "SSH keys written from input" - SUMMARY+=("SSH keys: pasted manually") - ;; - 3) - prompt_value SSH_EMAIL "Email/comment for key" "" - sudo -u "$USERNAME" ssh-keygen -t ed25519 -C "$SSH_EMAIL" -f "$SSH_DIR/id_ed25519" -N "" - cat "$SSH_DIR/id_ed25519.pub" >> "$SSH_DIR/authorized_keys" - ok "SSH key generated" - echo " Public key:" - cat "$SSH_DIR/id_ed25519.pub" - SUMMARY+=("SSH keys: generated new ed25519") - ;; - esac - - chmod 700 "$SSH_DIR" - find "$SSH_DIR" -type f -exec chmod 600 {} \; - chown -R "$USERNAME:$USERNAME" "$SSH_DIR" - step_ok -fi - -# ============================================================================= -# 9. SSH Hardening -# ============================================================================= - -step "SSH Hardening" -if ! skip; then - info "Hardening SSH..." - sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config - sed -i 's/^#\?PermitRootLogin .*/PermitRootLogin no/' /etc/ssh/sshd_config - sed -i 's/^#\?ChallengeResponseAuthentication .*/ChallengeResponseAuthentication no/' /etc/ssh/sshd_config - systemctl restart ssh - - ok "SSH hardened (password auth disabled, root login disabled)" - SUMMARY+=("SSH: hardened") - step_ok -fi - -# ============================================================================= -# 10. DNS -# ============================================================================= - -step "DNS" -if ! skip; then - info "Configuring DNS (Cloudflare + Google)..." - PRIMARY_DNS=("1.1.1.1" "8.8.8.8") - FALLBACK_DNS=("1.0.0.1" "8.8.4.4") - - if systemctl is-active --quiet systemd-resolved; then - cp /etc/systemd/resolved.conf "/etc/systemd/resolved.conf.bak.$(date +%s)" 2>/dev/null || true - - cat > /etc/systemd/resolved.conf </dev/null || true - fi - if lsattr /etc/resolv.conf 2>/dev/null | grep -q 'i-'; then - chattr -i /etc/resolv.conf - fi - - { - echo "# Generated by setup-ubuntu.sh on $(date -u +'%Y-%m-%dT%H:%M:%SZ')" - for ns in "${PRIMARY_DNS[@]}"; do - echo "nameserver $ns" - done - } > /etc/resolv.conf - - ok "DNS configured (static resolv.conf)" - fi - - SUMMARY+=("DNS: Cloudflare + Google") - - # Test DNS - if command -v dig &>/dev/null; then - dig +short google.com > /dev/null 2>&1 && ok "DNS resolution works" || warn "DNS test failed" - else - getent hosts google.com > /dev/null 2>&1 && ok "DNS resolution works" || warn "DNS test failed" - fi - step_ok -fi - -# ============================================================================= -# 11. Static IP -# ============================================================================= - -step "Static IP" -if ! skip; then - # Detect primary interface and current IP - IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}') - CURRENT_IP=$(ip -4 addr show "$IFACE" 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}/\d+' | head -1) - GATEWAY=$(ip route | grep default | awk '{print $3}' | head -1) - - if [[ -z "$IFACE" || -z "$CURRENT_IP" || -z "$GATEWAY" ]]; then - warn "Could not detect network interface, IP, or gateway — skipping static IP" - SUMMARY+=("Static IP: skipped (detection failed)") - else - # Check if a static Netplan config already exists for this interface - EXISTING_STATIC=$(grep -rl "dhcp4: false\|dhcp4: no" /etc/netplan/ 2>/dev/null | head -1) - if [[ -n "$EXISTING_STATIC" ]]; then - warn "Static IP already configured in $EXISTING_STATIC — skipping" - SUMMARY+=("Static IP: already static (skipped)") - else - info "Detected network configuration:" - echo " Interface : $IFACE" - echo " IP : $CURRENT_IP" - echo " Gateway : $GATEWAY" - echo "" - echo -e "${RED}${BOLD}╔══════════════════════════════════════════════════╗${NC}" - echo -e "${RED}${BOLD}║ STOP — DO NOT DO THIS ON A VPS / CLOUD SERVER ║${NC}" - echo -e "${RED}${BOLD}╚══════════════════════════════════════════════════╝${NC}" - echo -e "${YELLOW} This freezes the CURRENT DHCP lease into netplan and applies" - echo -e " it immediately. On a VPS or cloud instance that can strand the box:${NC}" - echo "" - echo -e " ${RED}!${NC} Providers hand out addresses by DHCP and may reassign them —" - echo -e " the hardcoded IP then belongs to someone else." - echo -e " ${RED}!${NC} cloud-init regenerates netplan config on boot; the two configs" - echo -e " can conflict and leave the interface with no usable address." - echo -e " ${RED}!${NC} The gateway/netmask on cloud networks is often NOT what a" - echo -e " simple route lookup suggests (point-to-point, /32 routing)." - echo -e " ${RED}!${NC} If any value is wrong, ${BOLD}netplan apply kills the SSH session" - echo -e " you are on right now${NC} and you are locked out until the provider's" - echo -e " rescue console." - echo "" - echo -e "${YELLOW} Only answer 'y' on a machine you physically control (LAN box," - echo -e " homelab, bare metal) where the address is yours to pin — and even" - echo -e " then, a DHCP reservation on the router is the safer way to do it.${NC}" - echo "" - prompt_value CONFIRM_STATIC "Set this as static IP? (y/n)" "n" - - if [[ "$CONFIRM_STATIC" != "y" ]]; then - info "Skipping static IP configuration" - SUMMARY+=("Static IP: skipped (user declined)") - else - NETPLAN_FILE="/etc/netplan/99-static-ip.yaml" - cat > "$NETPLAN_FILE" < /etc/apt/apt.conf.d/20auto-upgrades <") - step_ok -fi - -# ============================================================================= -# 15. Docker -# ============================================================================= - -step "Docker" -if ! skip; then - info "Installing Docker CE..." - install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg - chmod a+r /etc/apt/keyrings/docker.gpg - - echo \ - "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ - https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \ - > /etc/apt/sources.list.d/docker.list - - apt-get update -y - apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - - usermod -aG docker "$USERNAME" - - # Create shared docker network - DOCKER_NETWORK="${SETUP_DOCKER_NETWORK:-services}" - docker network create "$DOCKER_NETWORK" 2>/dev/null || true - - ok "Docker installed, '$USERNAME' added to docker group" - SUMMARY+=("Docker: installed") - step_ok -fi -# ============================================================================= -# 16. Oh My Zsh + Prompt (as user) -# ============================================================================= - -step "Oh My Zsh + Prompt" -if ! skip; then - info "Installing Zsh and Oh My Zsh..." - - apt-get install -y zsh - - as_user 'sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended' - - ZSHRC="$USER_HOME/.zshrc" - - cat >> "$ZSHRC" <<'EOF' - -# Starship prompt -eval "$(starship init zsh)" -EOF - - chsh -s /bin/zsh "$USERNAME" - - # Starship config — disable runtime/language version modules - mkdir -p "$USER_HOME/.config" - cat > "$USER_HOME/.config/starship.toml" <<'STARSHIP_EOF' -add_newline = true -command_timeout = 200 - -[bun] -disabled = true - -[nodejs] -disabled = true - -[python] -disabled = true - -[rust] -disabled = true - -[golang] -disabled = true - -[package] -disabled = true -STARSHIP_EOF - chown -R "$USERNAME:$USERNAME" "$USER_HOME/.config" - - ok "Zsh + Oh My Zsh installed, starship prompt set" - SUMMARY+=("Shell: zsh with Oh My Zsh + Starship") - step_ok -fi - -# ============================================================================= -# 17. Tailscale (as user) -# ============================================================================= - -step "Tailscale" -if ! skip; then - prompt_value INSTALL_TAILSCALE "Install Tailscale? (y/n)" "y" - - if [[ "$INSTALL_TAILSCALE" != "y" || "${SETUP_SKIP_TAILSCALE:-}" == "true" ]]; then - info "Skipping Tailscale" - SUMMARY+=("Tailscale: skipped") - else - info "Installing Tailscale..." - curl -fsSL https://tailscale.com/install.sh | sh - - # Enable IP forwarding - sed -i '/^net.ipv4.ip_forward/d' /etc/sysctl.conf - sed -i '/^net.ipv6.conf.all.forwarding/d' /etc/sysctl.conf - echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf - echo "net.ipv6.conf.all.forwarding=1" >> /etc/sysctl.conf - sysctl -p - - prompt_value TS_LOGIN_SERVER "Tailscale login server" "https://headscale.pastilhas.eu" - prompt_value TS_AUTHKEY "Tailscale auth key" "" - - prompt_value TS_EXIT_NODE "Act as exit node? (y/n)" "n" - TS_ADVERTISE="" - - if [[ "$TS_EXIT_NODE" == "y" ]]; then - TS_ADVERTISE="--advertise-exit-node" - - cat > /etc/sysctl.d/99-tailscale-exit.conf < /dev/null - - # NIC offload optimizations via networkd-dispatcher - if ! command -v networkd-dispatcher &>/dev/null; then - apt-get install -y networkd-dispatcher - fi - mkdir -p /etc/networkd-dispatcher/routable.d - - cat > /etc/networkd-dispatcher/routable.d/50-tailscale-exit <<'EXITSCRIPT' -#!/usr/bin/env bash -set -Eeuo pipefail - -IF="${IFACE:-}" -if [[ -z "${IF}" ]]; then - IF="$(ip -o route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')" -fi - -[[ -n "${IF}" ]] || exit 0 -command -v ethtool >/dev/null 2>&1 || exit 0 - -ethtool -k "${IF}" 2>/dev/null | grep -q "^generic-receive-offload: " && ethtool -K "${IF}" gro on || true -ethtool -k "${IF}" 2>/dev/null | grep -q "^rx-udp-gro-forwarding: " && ethtool -K "${IF}" rx-udp-gro-forwarding on || true -ethtool -k "${IF}" 2>/dev/null | grep -q "^large-receive-offload: " && ethtool -K "${IF}" lro off || true - -exit 0 -EXITSCRIPT - - chmod 755 /etc/networkd-dispatcher/routable.d/50-tailscale-exit - systemctl enable --now networkd-dispatcher > /dev/null 2>&1 || true - - # Run once now - CURRENT_IF="$(ip -o route get 8.8.8.8 | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')" - IFACE="$CURRENT_IF" bash /etc/networkd-dispatcher/routable.d/50-tailscale-exit || true - ok "Exit node NIC optimizations applied on: ${CURRENT_IF:-unknown}" - fi - - tailscale up --login-server "$TS_LOGIN_SERVER" --authkey "$TS_AUTHKEY" ${TS_ADVERTISE:+"$TS_ADVERTISE"} - - TS_IP=$(tailscale ip -4 2>/dev/null || echo "unknown") - ok "Tailscale connected (IP: $TS_IP)" - SUMMARY+=("Tailscale: connected ($TS_IP)") - fi - step_ok -fi - -# ============================================================================= -# 18. Neovim + LazyVim (as user) -# ============================================================================= - -step "Neovim + LazyVim" -if ! skip; then - ZSHRC="$USER_HOME/.zshrc" - - info "Installing Neovim..." - NVIM_ARCH=$(uname -m) - case $NVIM_ARCH in - aarch64) NVIM_ARCH=aarch64 ;; - *) NVIM_ARCH=x86_64 ;; - esac - curl -LO "https://github.com/neovim/neovim/releases/latest/download/nvim-linux-${NVIM_ARCH}.tar.gz" - rm -rf "/opt/nvim-linux-${NVIM_ARCH}" - tar -C /opt -xzf "nvim-linux-${NVIM_ARCH}.tar.gz" - rm -f "nvim-linux-${NVIM_ARCH}.tar.gz" - ln -sf "/opt/nvim-linux-${NVIM_ARCH}/bin/nvim" /usr/local/bin/nvim - - # Add to PATH in .zshrc - cat >> "$ZSHRC" </dev/null || true - - chown -R "$USERNAME:$USERNAME" "$USER_HOME/.config" - chown "$USERNAME:$USERNAME" "$ZSHRC" - - ok "Neovim + config installed" - SUMMARY+=("Neovim: installed with LazyVim") - step_ok -fi - -# ============================================================================= -# 19. JS/TS Runtimes (system-wide for servers, optional nvm for dev) -# ============================================================================= - -step "JS/TS Runtimes" -if ! skip; then - ZSHRC="$USER_HOME/.zshrc" - - if [[ "${SETUP_SKIP_RUNTIMES:-}" == "true" ]]; then - info "Skipping JS/TS runtimes (SETUP_SKIP_RUNTIMES=true)" - SUMMARY+=("Runtimes: skipped") - else - info "Installing JS/TS runtimes..." - - # Node 22 — always system-wide via NodeSource - info "Installing system-wide Node 22..." - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - - apt-get install -y nodejs - npm config set prefix /usr/local - ok "System Node 22 installed ($(node -v))" - SUMMARY+=("Node: system-wide v22 via NodeSource") - - # Bun (user-local install) - as_user 'curl -fsSL https://bun.sh/install | bash' - cat >> "$ZSHRC" <<'EOF' - -# Bun -export PATH="$HOME/.bun/bin:$PATH" -EOF - ok "Bun installed" - - # pm2 (system-wide, via system npm) - npm install -g pm2 - ok "pm2 installed (system-wide)" - - # Deno (user-local install) - as_user 'curl -fsSL https://deno.land/install.sh | sh' - cat >> "$ZSHRC" <<'EOF' - -# Deno -export DENO_INSTALL="$HOME/.deno" -export PATH="$DENO_INSTALL/bin:$PATH" -EOF - ok "Deno installed" - - chown "$USERNAME:$USERNAME" "$ZSHRC" - fi - step_ok -fi - -# ============================================================================= -# 19.5 Dev Tools (system-wide for servers, local for dev users) -# ============================================================================= - -step "Dev Tools" -if ! skip; then - if [[ "${SETUP_SKIP_DEVTOOLS:-}" == "true" ]]; then - info "Skipping dev tools (SETUP_SKIP_DEVTOOLS=true)" - SUMMARY+=("Dev tools: skipped") - else - info "Select dev tools to install:" - echo " [1] Claude Code" - echo " [2] Opencode" - echo " [3] PI" - - prompt_value DEV_TOOLS "Enter numbers separated by spaces, or 'all'" "all" - - if [[ "$DEV_TOOLS" == "all" ]]; then - DEV_TOOLS="1 2 3" - fi - - DEV_INSTALLED=() - - for tool in $DEV_TOOLS; do - case "$tool" in - 1) - info "Installing Claude Code..." - npm install -g @anthropic-ai/claude-code - ok "Claude Code installed (system-wide)" - DEV_INSTALLED+=("Claude Code") - ;; - 2) - info "Installing Opencode..." - as_user 'curl -fsSL https://opencode.ai/install | bash' - ok "Opencode installed" - DEV_INSTALLED+=("Opencode") - ;; - 3) - info "Installing PI..." - npm install -g @mariozechner/pi-coding-agent - ok "PI installed (system-wide)" - DEV_INSTALLED+=("PI") - ;; - esac - done - - if [[ ${#DEV_INSTALLED[@]} -gt 0 ]]; then - ok "Dev tools: ${DEV_INSTALLED[*]}" - SUMMARY+=("Dev tools: ${DEV_INSTALLED[*]}") - else - SUMMARY+=("Dev tools: none selected") - fi - fi - step_ok -fi - -# ============================================================================= -# 20. UFW (last, after everything) -# ============================================================================= - -step "UFW Firewall" -if ! skip; then - info "Configuring UFW firewall..." - - ufw default deny incoming - ufw default allow outgoing - ufw allow OpenSSH - - # Allow all traffic on Tailscale interface - ufw allow in on tailscale0 - - # Docker + UFW fix - UFW_RULES="/etc/ufw/after.rules" - DOCKER_RULES="$SCRIPT_DIR/ufw-docker-rules.conf" - - if [[ -f "$DOCKER_RULES" ]]; then - if ! grep -q 'DOCKER-USER' "$UFW_RULES" 2>/dev/null; then - info "Appending Docker UFW rules..." - echo "" >> "$UFW_RULES" - cat "$DOCKER_RULES" >> "$UFW_RULES" - ok "Docker UFW rules added" - else - ok "Docker UFW rules already present" - fi - else - warn "ufw-docker-rules.conf not found, skipping Docker UFW fix" - fi - - ufw --force enable - ok "UFW enabled" - SUMMARY+=("UFW: enabled (SSH, Tailscale, Docker rules)") - step_ok -fi - -# ============================================================================= -# 21. Final .zshrc additions -# ============================================================================= - -step "Final .zshrc additions" -if ! skip; then - ZSHRC="$USER_HOME/.zshrc" - cat >> "$ZSHRC" <<'EOF' - -# Neovim aliases -alias n='nvim' -alias vim='nvim' - -# Default editor -export EDITOR='nvim' -export VISUAL='nvim' -export SUDO_EDITOR='nvim' - -# Quick reload -alias sz="source ~/.zshrc" -EOF - chown "$USERNAME:$USERNAME" "$ZSHRC" - step_ok -fi - -# ============================================================================= -# 22. Emergency Disk Ballast -# ============================================================================= -# -# Pre-allocates a junk file sized at 10% of currently-available disk. It holds -# no data — its only job is to be deleted when the filesystem is about to fill, -# buying enough headroom to SSH in and clean up properly instead of hitting a -# wedged box (Docker/journald/postgres all misbehave badly at 100% full). -# -# A root cron checks free space every 10 minutes and removes the ballast if it -# drops below 10%. This is a ONE-SHOT valve: once spent, recreate it with the -# same fallocate command below. - -step "Emergency Disk Ballast" -if ! skip; then - DOCKERS_DIR="$USER_HOME/dockers" - BALLAST_FILE="$DOCKERS_DIR/emergency_empty_file.log" - LOCAL_DIR="$USER_HOME/.local" - LOCAL_BIN="$LOCAL_DIR/bin" - CHECK_SCRIPT="$LOCAL_BIN/emergency-disk-check" - CRON_FILE="/etc/cron.d/emergency-disk-check" - - # Name $LOCAL_DIR explicitly. `install -d` applies -o/-g ONLY to the directories given as - # operands — any parent it has to invent on the way gets the caller's ownership, which here - # is root. This step is the first thing to touch ~/.local, so leaving it implicit created a - # root:root ~/.local with a user-owned bin/ inside it, and locked the user out of their own - # ~/.local: anything unpacking or writing directly there (a Go tarball, npm's --prefix, - # GOPATH) fails with a bare "Permission denied" far from this line. - install -d -o "$USERNAME" -g "$USERNAME" "$DOCKERS_DIR" "$LOCAL_DIR" "$LOCAL_BIN" - - # --- the ballast file --- - if [[ -f "$BALLAST_FILE" ]]; then - ok "Ballast already exists at $BALLAST_FILE ($(du -h "$BALLAST_FILE" | cut -f1))" - SUMMARY+=("Disk ballast: already present") - else - info "Allocating ballast file (10% of available disk)..." - AVAIL_KB=$(df -Pk "$DOCKERS_DIR" | awk 'NR==2 {print $4}') - BALLAST_KB=$(( AVAIL_KB / 10 )) - - # fallocate reserves real blocks (a sparse/truncate file would reserve - # nothing and free nothing when deleted). dd is the fallback for - # filesystems that don't support fallocate. - if ! fallocate -l "${BALLAST_KB}K" "$BALLAST_FILE" 2>/dev/null; then - warn "fallocate unsupported here — falling back to dd (slower)" - dd if=/dev/zero of="$BALLAST_FILE" bs=1K count="$BALLAST_KB" status=none - fi - - chown "$USERNAME:$USERNAME" "$BALLAST_FILE" - chmod 644 "$BALLAST_FILE" - ok "Ballast created: $BALLAST_FILE ($(du -h "$BALLAST_FILE" | cut -f1))" - SUMMARY+=("Disk ballast: $(du -h "$BALLAST_FILE" | cut -f1) at $BALLAST_FILE") - fi - - # --- the checker script --- - info "Installing disk check script..." - cat > "$CHECK_SCRIPT" <<'CHECKEOF' -#!/usr/bin/env bash -# -# Emergency disk ballast checker. -# Deletes the pre-allocated ballast file when free space falls below the -# threshold, buying headroom to log in and clean up. Installed by setup-ubuntu.sh. -# -# Run manually with --status to see current usage without changing anything. - -set -euo pipefail - -BALLAST="__BALLAST_PATH__" -THRESHOLD=10 # act when free space drops below this percentage -TAG="emergency-disk" - -MOUNT_DIR="$(dirname "$BALLAST")" -USE_PCT="$(df -P "$MOUNT_DIR" | awk 'NR==2 {gsub(/%/,"",$5); print $5}')" -FREE_PCT=$(( 100 - USE_PCT )) - -if [[ "${1:-}" == "--status" ]]; then - echo "Mount: $(df -P "$MOUNT_DIR" | awk 'NR==2 {print $6}')" - echo "Free: ${FREE_PCT}% (threshold: ${THRESHOLD}%)" - if [[ -f "$BALLAST" ]]; then - echo "Ballast: present, $(du -h "$BALLAST" | cut -f1) — $BALLAST" - else - echo "Ballast: ABSENT (already spent) — $BALLAST" - fi - exit 0 -fi - -(( FREE_PCT < THRESHOLD )) || exit 0 - -if [[ -f "$BALLAST" ]]; then - FREED="$(du -h "$BALLAST" | cut -f1)" - rm -f "$BALLAST" - logger -t "$TAG" -p user.crit \ - "Free space ${FREE_PCT}% below ${THRESHOLD}% — deleted ballast, reclaimed ${FREED}. CLEAN UP NOW: this valve is spent." -else - logger -t "$TAG" -p user.crit \ - "Free space ${FREE_PCT}% below ${THRESHOLD}% — ballast already spent, no headroom left to reclaim." -fi -CHECKEOF - - sed -i "s|__BALLAST_PATH__|$BALLAST_FILE|" "$CHECK_SCRIPT" - chown "$USERNAME:$USERNAME" "$CHECK_SCRIPT" - chmod 755 "$CHECK_SCRIPT" - ok "Check script: $CHECK_SCRIPT" - - # --- the cron entry (root, so it can always delete) --- - cat > "$CRON_FILE" <${NC}" -echo " 2. Docker group requires a new login session" echo "" # Clean up progress file on success