Files
platform/scripts/setup/machine-setup/machine-setup.sh
T
pastilhasandClaude Opus 5 7622239949 split the upstream binaries out of the package section
lazydocker, lazygit, starship and fastfetch were buried inside "System Update &
Essentials", after the package install and with no announcement — so a run
appeared to be installing system packages and then started pulling tarballs and
printing a five-shell starship tutorial. They are a different thing: upstream
binaries on their own release cadence, not anything the distribution ships. Now
their own step, announced in the same shape as the package section.

Each is checked before it is fetched. The original re-ran every installer on
every run, which is why a machine that already had starship got it reinstalled
along with its "add this to your ~/.zshrc" instructions — advice this script
does not want followed, since it writes the shell config itself. Its output is
now dropped; errors still surface.

Two real bugs fixed on the way:

  lazygit's asset name was hardcoded to x86_64, so on arm64 the download 404s
  and tar fails partway through the run. It now maps ARCH, and spells the
  architectures the way lazygit does rather than the way we do.

  The version was extracted with `tr -d 'v'`, which deletes every v in the
  string rather than the leading one. `${version#v}` instead.

fastfetch stays a package but stops assuming the PPA is needed: Ubuntu picked
it up in 24.10, so the repository is now checked first and the PPA added only
where the archive has nothing. Verified on this host — noble genuinely has no
candidate, so the PPA is still the only source here.

Verified both branches of tools_install by stubbing the presence check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:28:46 +00:00

1150 lines
40 KiB
Bash
Executable File

#!/bin/bash
set -e
# =============================================================================
# machine-setup — provisioning for a fresh machine
#
# Brings a blank box up to a usable state: users, SSH, networking, firewall,
# Docker, shell and editor tooling, language runtimes.
#
# Run as root: sudo scripts/setup/machine-setup/machine-setup.sh
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROGRESS_FILE="$SCRIPT_DIR/.setup-progress"
# Shared state, output helpers, the step/resume machine and OS detection. Kept in
# lib/ so a step can eventually be read — or run — on its own without dragging the
# whole script in. Definitions only; nothing in there acts.
# shellcheck source=lib/base.sh
source "$SCRIPT_DIR/lib/base.sh"
# shellcheck source=lib/packages.sh
source "$SCRIPT_DIR/lib/packages.sh"
# shellcheck source=lib/tools.sh
source "$SCRIPT_DIR/lib/tools.sh"
# Trap errors with context. Installed here rather than in lib/base.sh, because
# that file is definitions only and a trap is a side effect on whoever sources it.
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
# =============================================================================
# 1. Pre-flight
# =============================================================================
echo ""
echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ Machine Setup ║${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
detect_os
echo ""
info "Machine: ${OS_NAME} (${ARCH})"
info "Packages: ${PM:-none detected}"
[[ "$IS_WSL" == true ]] && warn "WSL detected — the suspend, logind and boot-hang steps do not apply here"
# Everything below this line is written against apt and systemd. Detection above
# recognises pacman, dnf and brew so the branches have somewhere to hang, but
# nothing implements them yet — and running the apt path on Arch would half-build
# a machine and stop somewhere unhelpful. Refuse clearly instead, and relax this
# list one entry at a time as each package manager grows a real path.
case "$PM" in
apt) ;;
"") fail "Could not find a package manager for '${OS_NAME}'." ;;
*) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt-based systems only, so far." ;;
esac
ask_machine_role
info "Role: ${MACHINE_ROLE}"
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 ./machine-setup.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 "Refreshing the package index..."
pkg_refresh
# The one place that deliberately moves versions of things already installed.
# Everything else in this script only ever adds what is absent.
info "Upgrading installed packages..."
pkg_upgrade_all
# shellcheck disable=SC2046 # word splitting is how the list is passed
pkg_install "Core packages" $(pkgs_core)
ok "System updated and core packages in place"
SUMMARY+=("System packages updated, core packages installed")
step_ok
fi
# =============================================================================
# 2b. Command-line tools
# =============================================================================
#
# Separate from the packages above because they are a different thing: upstream
# binaries on their own release cadence, not anything the distribution ships.
# Lumping them in made a run look like it was installing system packages and
# then start downloading tarballs unannounced.
step "Command-line tools"
if ! skip; then
# shellcheck disable=SC2046 # word splitting is how the list is passed
tools_install "Command-line tools" $(tools_default)
SUMMARY+=("Command-line tools: $(tools_default)")
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
# =============================================================================
#
# 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 <<EOF
[Resolve]
DNS=${PRIMARY_DNS[*]}
FallbackDNS=${FALLBACK_DNS[*]}
EOF
# Ensure resolv.conf points to the stub
if [[ -L /etc/resolv.conf ]]; then
TARGET=$(readlink -f /etc/resolv.conf || true)
if [[ "$TARGET" != "/run/systemd/resolve/stub-resolv.conf" && "$TARGET" != "/run/systemd/resolve/resolv.conf" ]]; then
ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
fi
else
cp /etc/resolv.conf "/etc/resolv.conf.bak.$(date +%s)"
ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
fi
systemctl restart systemd-resolved
ok "DNS configured (systemd-resolved)"
else
if [[ -e /etc/resolv.conf ]] || [[ -L /etc/resolv.conf ]]; then
cp -a /etc/resolv.conf "/etc/resolv.conf.bak.$(date +%s)" 2>/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" <<EOF
network:
version: 2
ethernets:
$IFACE:
dhcp4: false
addresses:
- $CURRENT_IP
routes:
- to: default
via: $GATEWAY
nameservers:
addresses: [1.1.1.1, 8.8.8.8]
EOF
chmod 600 "$NETPLAN_FILE"
netplan apply
ok "Static IP set: $CURRENT_IP on $IFACE (gateway: $GATEWAY)"
SUMMARY+=("Static IP: $CURRENT_IP on $IFACE")
fi
fi
fi
step_ok
fi
# =============================================================================
# 12. Fail2ban
# =============================================================================
step "Fail2ban"
if ! skip; then
info "Installing fail2ban..."
apt-get install -y fail2ban
systemctl enable fail2ban
systemctl start fail2ban
ok "Fail2ban active"
SUMMARY+=("Fail2ban: installed and active")
step_ok
fi
# =============================================================================
# 13. Unattended Upgrades
# =============================================================================
step "Unattended Upgrades"
if ! skip; then
info "Configuring unattended upgrades..."
apt-get install -y unattended-upgrades
cat > /etc/apt/apt.conf.d/20auto-upgrades <<EOF
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOF
ok "Unattended upgrades enabled"
SUMMARY+=("Unattended upgrades: enabled")
step_ok
fi
# =============================================================================
# 14. Git Config
# =============================================================================
step "Git Config"
if ! skip; then
info "Git configuration for '$USERNAME'..."
prompt_value GIT_NAME "Git user.name" ""
prompt_value GIT_EMAIL "Git user.email" ""
as_user "git config --global user.name $(printf '%q' "$GIT_NAME")"
as_user "git config --global user.email $(printf '%q' "$GIT_EMAIL")"
as_user "git config --global init.defaultBranch main"
as_user "git config --global core.editor nvim"
ok "Git configured"
SUMMARY+=("Git: $GIT_NAME <$GIT_EMAIL>")
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 <<EOF
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
sysctl --system > /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" <<EOF
# Neovim
export PATH="\$PATH:/opt/nvim-linux-${NVIM_ARCH}/bin"
EOF
info "Choose Neovim configuration:"
echo " [1] Default LazyVim starter"
echo " [2] Custom repo"
prompt_value NVIM_CHOICE "Pick an option" "1"
NVIM_CONFIG="$USER_HOME/.config/nvim"
if [[ "$NVIM_CHOICE" == "2" ]]; then
prompt_value NVIM_REPO "Git repo for nvim config" "git@gogs:andrepadez/nvim-config.git"
else
NVIM_REPO="https://github.com/LazyVim/starter"
fi
if [[ -d "$NVIM_CONFIG" ]]; then
warn "$NVIM_CONFIG already exists, backing up to ${NVIM_CONFIG}.bak"
mv "$NVIM_CONFIG" "${NVIM_CONFIG}.bak"
fi
sudo -u "$USERNAME" -H git clone "$NVIM_REPO" "$NVIM_CONFIG"
sudo -u "$USERNAME" rm -rf "$NVIM_CONFIG/.git" 2>/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" <<EOF
# Emergency disk ballast — delete the ballast file if free space drops below 10%
# Installed by setup-ubuntu.sh. Check status: $CHECK_SCRIPT --status
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
*/10 * * * * root $CHECK_SCRIPT
EOF
chmod 644 "$CRON_FILE"
ok "Cron installed: every 10 minutes ($CRON_FILE)"
SUMMARY+=("Disk watchdog: $CHECK_SCRIPT via cron every 10m")
step_ok
fi
# =============================================================================
# 23. Summary
# =============================================================================
echo ""
echo ""
if [[ ${#ERRORS[@]} -gt 0 ]]; then
echo -e "${YELLOW}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${YELLOW}║ Setup Complete (with warnings) ║${NC}"
echo -e "${YELLOW}╚══════════════════════════════════════════════════╝${NC}"
else
echo -e "${GREEN}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Setup Complete ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════╝${NC}"
fi
echo ""
echo -e "${BOLD} What was done:${NC}"
for item in "${SUMMARY[@]}"; do
echo -e " ${GREEN}+${NC} $item"
done
if [[ ${#ERRORS[@]} -gt 0 ]]; then
echo ""
echo -e "${BOLD} Non-critical issues:${NC}"
for err in "${ERRORS[@]}"; do
echo -e " ${YELLOW}!${NC} $err"
done
fi
echo ""
echo -e "${BOLD} Server info:${NC}"
echo " User: $USERNAME"
echo " Home: $USER_HOME"
[[ -n "${TS_IP:-}" && "$TS_IP" != "unknown" ]] && echo " Tailscale: $TS_IP"
echo " Shell: zsh (Oh My Zsh + Starship)"
echo ""
echo -e "${BOLD} Next steps:${NC}"
echo " 1. Reconnect as the new user:"
echo -e " ${CYAN}ssh $USERNAME@<server-ip>${NC}"
echo " 2. Docker group requires a new login session"
echo ""
# Clean up progress file on success
rm -f "$PROGRESS_FILE"