From 036e1f40107841cea88158bd97a7b805c605e769 Mon Sep 17 00:00:00 2001 From: 28allday Date: Sat, 6 Jun 2026 16:35:10 +0100 Subject: [PATCH] Refocus as pure hardening base: drop docker + omaterm prep, add README/LICENSE - arch-setup.sh now takes a fresh Arch box to a locked-down base, nothing else (omaterm one-shot now lives in the sibling omaserver project) - Port proven UX from omaserver's install-server.sh: machine name asked once (hostname + ssh alias), answers persist across the kernel-upgrade reboot via /root/.arch-setup.conf, reboot offered in-script, public IP detected early, ufw tailscale0 pre-allow (dormant), default-yes prompts - MIT license, README with usage + Arch-specific notes Co-Authored-By: Claude Opus 4.8 (1M context) --- LICENSE | 21 +++++ README.md | 42 +++++++++ arch-setup.sh | 257 +++++++++++++++++++++++--------------------------- 3 files changed, 182 insertions(+), 138 deletions(-) create mode 100644 LICENSE create mode 100644 README.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5acd172 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Gavin Nugent + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..81acb2b --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# arch-boot-strap + +Setup & hardening script for a fresh Arch Linux VPS. Takes a bare box to a clean, locked-down base — **nothing else**. No app stack, no opinions beyond security and maintenance basics. + +## Usage + +Run as root on a fresh Arch VPS: + +```bash +ssh -t root@YOUR_SERVER 'bash <(curl -fsSL https://raw.githubusercontent.com/28allday/arch-boot-strap/main/arch-setup.sh)' +``` + +or scp it over and `ssh -t root@YOUR_SERVER bash arch-setup.sh`. + +Two questions up front — machine name (becomes the hostname and your local `ssh ` alias) and a deploy username. Answers survive a mid-run reboot: if the first `pacman -Syu` upgrades the kernel, the script offers to reboot, and the re-run picks up where it left off without re-asking. + +## What it does + +- **Deploy user** — wheel group, passwordless sudo, root's `authorized_keys` copied over +- **SSH hardening** — root login disabled, password auth disabled, key-only, `AllowUsers `, config validated with `sshd -t` before restart (auto-reverts on failure) +- **Firewall** — UFW deny incoming, allow 22/tcp; `tailscale0` pre-allowed (dormant unless you install Tailscale later) +- **fail2ban** — sshd jail, 3 strikes → 1h ban, systemd journal backend (Arch has no auth.log) +- **Auto-updates** — weekly `pacman -Syu` timer (Arch has no unattended-upgrades) +- **Housekeeping** — 2GB swap (btrfs-aware, skipped if the image ships its own), journald capped at 50MB, weekly package-cache pruning, ModemManager/udisks2/multipathd disabled +- **Hostname, timezone, locale** set; prints a ready-to-paste `~/.ssh/config` snippet when done + +## Arch-specific details baked in + +- Kernel-upgrade guard: if `-Syu` replaces the running kernel's modules, the script stops and offers a reboot instead of letting module loads fail mysteriously +- fail2ban `backend = systemd`, service name `sshd` (not `ssh`), `wheel` (not `sudo`) +- Some provider images strip the `sshd_config.d` Include or ship cloud-init SSH overrides — both handled +- `kitty-terminfo` installed so SSHing in from kitty doesn't break interactive tools +- btrfs swapfiles need `btrfs filesystem mkswapfile`, not fallocate + +## Notes + +- Safe to re-run; completed steps skip themselves. After the first run root SSH login is disabled — re-run as the deploy user with `sudo`. +- Want hardening **+ OMATERM** in one shot? That's the sibling project: [omaserver](https://github.com/28allday/omaserver). + +## License + +MIT diff --git a/arch-setup.sh b/arch-setup.sh index 06da992..0544f18 100755 --- a/arch-setup.sh +++ b/arch-setup.sh @@ -1,8 +1,20 @@ #!/bin/bash # ============================================================================= # VPS Setup & Hardening Script for Arch Linux -# Prepares a fresh box for OMATERM (docker-only) + Docker deployments -# Run as root on a fresh Arch Linux VPS +# Takes a fresh Arch box to a clean, locked-down base — nothing else. +# +# Run as root on a fresh Arch Linux VPS: +# +# ssh -t root@YOUR_SERVER 'bash <(curl -fsSL https://raw.githubusercontent.com/28allday/arch-boot-strap/main/arch-setup.sh)' +# +# or scp it over and: ssh -t root@YOUR_SERVER bash arch-setup.sh +# +# Safe to re-run — completed steps skip themselves, and your answers survive +# a mid-run reboot. After the first run root SSH login is disabled, so +# re-run as the deploy user with sudo. +# +# Want hardening + OMATERM in one shot? That's the sibling project: +# https://github.com/28allday/omaserver # ============================================================================= set -euo pipefail @@ -11,8 +23,9 @@ set -euo pipefail # DEFAULTS # ============================================================================= -TIMEZONE="Europe/London" -LOCALE="en_GB.UTF-8" +TIMEZONE="${TIMEZONE:-Europe/London}" +LOCALE="${LOCALE:-en_GB.UTF-8}" +SELF_URL="https://raw.githubusercontent.com/28allday/arch-boot-strap/main/arch-setup.sh" # ============================================================================= # COLOURS @@ -35,7 +48,7 @@ step() { echo -e "\n${CYAN}━━━ $1 ━━━${NC}\n"; } if [ "$EUID" -ne 0 ]; then err "This script must be run as root." - err "Usage: sudo bash arch-setup.sh" + err "Usage: ssh -t root@YOUR_SERVER 'bash <(curl -fsSL $SELF_URL)'" exit 1 fi @@ -49,26 +62,47 @@ fi echo -e "${CYAN}" echo "╔══════════════════════════════════════════════════════════════╗" -echo "║ VPS Setup & Hardening Script ║" -echo "║ Arch Linux · Docker · OMATERM ║" +echo "║ VPS Setup & Hardening Script ║" +echo "║ Arch Linux · clean locked-down base ║" echo "╚══════════════════════════════════════════════════════════════╝" echo -e "${NC}" -# SSH alias -echo "" -echo " This name is used in your local SSH config so you can" -echo " connect with: ssh (e.g. ssh titan, ssh nebula)" -read -rp "SSH alias for this server [vps]: " input_alias -SSH_ALIAS="${input_alias:-vps}" +# Public IP — used in re-run instructions and the SSH config snippet +SERVER_IP=$(curl -4 -s --max-time 5 ifconfig.me 2>/dev/null || \ + curl -4 -s --max-time 5 icanhazip.com 2>/dev/null || \ + echo "YOUR_SERVER_IP") -# Username -echo "" -read -rp "Choose a username for the deploy user [deploy]: " input_user -DEPLOY_USER="${input_user:-deploy}" +# Answers survive the kernel-upgrade reboot — a re-run finds them here and +# doesn't ask again +STATE_FILE="/root/.arch-setup.conf" +SSH_ALIAS="" +DEPLOY_USER="" +if [ -f "$STATE_FILE" ]; then + # shellcheck source=/dev/null + . "$STATE_FILE" +fi + +if [ -n "$SSH_ALIAS" ] && [ -n "$DEPLOY_USER" ]; then + log "Using saved settings from the previous run" +else + # Machine name — used as the hostname AND the alias in your local SSH + # config (ssh ). Asked ONCE, used everywhere. + echo "" + echo " This name becomes the hostname and your local SSH alias" + echo " (ssh )." + read -rp "Machine name for this server [vps]: " input_alias + SSH_ALIAS="${input_alias:-vps}" + + # Username + echo "" + read -rp "Choose a username for the deploy user [deploy]: " input_user + DEPLOY_USER="${input_user:-deploy}" +fi # Validate username if ! [[ "$DEPLOY_USER" =~ ^[a-z_][a-z0-9_-]*$ ]]; then err "Invalid username. Use lowercase letters, numbers, hyphens, underscores." + rm -f "$STATE_FILE" exit 1 fi @@ -76,17 +110,22 @@ fi echo "" echo -e "${CYAN}━━━ Confirm settings ━━━${NC}" echo "" -echo " SSH alias: $SSH_ALIAS" +echo " Machine name: $SSH_ALIAS (hostname + ssh alias)" echo " Deploy user: $DEPLOY_USER" echo " Timezone: $TIMEZONE" echo "" -read -p "Continue? (y/n) " -n 1 -r +read -p "Continue? (Y/n) " -n 1 -r echo "" -if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Aborted." +if [[ $REPLY =~ ^[Nn]$ ]]; then + rm -f "$STATE_FILE" + echo "Aborted — saved settings cleared. Re-run to start over." exit 0 fi +# Persist for re-runs (cleared on abort above) +printf 'SSH_ALIAS=%q\nDEPLOY_USER=%q\n' "$SSH_ALIAS" "$DEPLOY_USER" > "$STATE_FILE" +chmod 600 "$STATE_FILE" + # ============================================================================= # 1. SYSTEM UPDATES & ESSENTIALS # ============================================================================= @@ -96,38 +135,51 @@ step "1/8 · System updates & essential packages" pacman -Syu --noconfirm # If -Syu upgraded the kernel, the running kernel's modules are gone and -# Docker can't load overlay/netfilter — reboot and re-run (script is -# safe to re-run; completed steps skip themselves) +# anything that loads modules (netfilter for ufw, tun, etc.) will fail — +# reboot and re-run (script is safe to re-run; completed steps skip +# themselves). This runs BEFORE SSH hardening, so root login still works +# for the re-run. if [ ! -d "/usr/lib/modules/$(uname -r)" ]; then warn "Kernel was upgraded — running kernel $(uname -r) has no modules on disk." - warn "Reboot, then re-run this script:" - warn " reboot" - warn " bash arch-setup.sh" + warn "The box must reboot before setup can continue." + echo "" + echo " After the reboot, reconnect and re-run — it picks up where it left off:" + echo "" + if [ -f "$0" ] && [[ "$0" != /dev/fd/* ]]; then + echo -e " ${CYAN}ssh -t root@$SERVER_IP bash $(basename "$0")${NC}" + else + echo -e " ${CYAN}ssh -t root@$SERVER_IP 'bash <(curl -fsSL $SELF_URL)'${NC}" + fi + echo "" + read -p "Reboot now? (Y/n) " -n 1 -r + echo "" + if [[ ! $REPLY =~ ^[Nn]$ ]]; then + systemctl reboot + fi exit 1 fi -# Minimal host package set — omaterm installs everything else it needs -# (git, tmux, agents, tailscale all live inside its container). +# Hardening essentials only — anything app-shaped (docker, etc.) is the +# consuming project's job. # kitty-terminfo: without it, SSHing in from kitty gives -# "'xterm-kitty': unknown terminal type" and the omaterm installer dies +# "'xterm-kitty': unknown terminal type" and interactive tools die pacman -S --noconfirm --needed \ ufw \ fail2ban \ curl \ kitty-terminfo \ - docker + pacman-contrib -# Docker must be running before omaterm installs — the new omaterm is -# docker-only (everything runs in the ghcr.io/omacom-io/omaterm container) -systemctl enable --now docker.service - -log "System updated, packages installed, Docker running" +log "System updated, hardening packages installed" # ============================================================================= -# 2. TIMEZONE & LOCALE +# 2. HOSTNAME, TIMEZONE & LOCALE # ============================================================================= -step "2/8 · Timezone & locale" +step "2/8 · Hostname, timezone & locale" + +hostnamectl set-hostname "$SSH_ALIAS" +log "Hostname set to $SSH_ALIAS" timedatectl set-timezone "$TIMEZONE" log "Timezone set to $TIMEZONE" @@ -155,21 +207,21 @@ else fi # Allow sudo without password for deploy user -echo "$DEPLOY_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/$DEPLOY_USER -chmod 440 /etc/sudoers.d/$DEPLOY_USER +echo "$DEPLOY_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/"$DEPLOY_USER" +chmod 440 /etc/sudoers.d/"$DEPLOY_USER" log "Passwordless sudo enabled for $DEPLOY_USER" # Copy root's authorized_keys to deploy user if they exist if [ -f /root/.ssh/authorized_keys ]; then - mkdir -p /home/$DEPLOY_USER/.ssh - cp /root/.ssh/authorized_keys /home/$DEPLOY_USER/.ssh/authorized_keys - chown -R $DEPLOY_USER:$DEPLOY_USER /home/$DEPLOY_USER/.ssh - chmod 700 /home/$DEPLOY_USER/.ssh - chmod 600 /home/$DEPLOY_USER/.ssh/authorized_keys + mkdir -p /home/"$DEPLOY_USER"/.ssh + cp /root/.ssh/authorized_keys /home/"$DEPLOY_USER"/.ssh/authorized_keys + chown -R "$DEPLOY_USER":"$DEPLOY_USER" /home/"$DEPLOY_USER"/.ssh + chmod 700 /home/"$DEPLOY_USER"/.ssh + chmod 600 /home/"$DEPLOY_USER"/.ssh/authorized_keys log "SSH keys copied from root to $DEPLOY_USER" else warn "No SSH keys found for root — you'll need to add keys manually:" - warn " ssh-copy-id $DEPLOY_USER@YOUR_SERVER_IP" + warn " ssh-copy-id $DEPLOY_USER@$SERVER_IP" fi # ============================================================================= @@ -236,11 +288,15 @@ ufw default deny incoming ufw default allow outgoing ufw allow 22/tcp comment 'SSH' +# Dormant unless tailscale is installed later — without it, a future +# `tailscale up` silently drops all incoming tailnet traffic +ufw allow in on tailscale0 comment 'Tailscale' + # Enable without prompt, and make it survive reboots echo "y" | ufw enable systemctl enable ufw -log "UFW enabled — port 22 only" +log "UFW enabled — 22/tcp + tailscale0 only" ufw status verbose # ============================================================================= @@ -310,51 +366,12 @@ log "Weekly auto-update timer enabled (pacman-update.timer)" warn "Rolling release: check 'journalctl -u pacman-update' if anything misbehaves after an update" # ============================================================================= -# 8. OMATERM PREP (Docker already installed via pacman in step 1) +# 8. HOUSEKEEPING — swap, services, journal, pacman cache # ============================================================================= -step "8/8 · Omaterm prep" - -# Omaterm itself is installed interactively after first login as the deploy -# user — here we just make sure the box is ready for it: - -# Deploy user needs docker group membership to run the omaterm container -if getent group docker &>/dev/null; then - usermod -aG docker $DEPLOY_USER - log "$DEPLOY_USER added to docker group" -fi - -# Pre-pull the omaterm image so the installer doesn't have to -log "Pre-pulling omaterm image (this may take a while)..." -if docker pull ghcr.io/omacom-io/omaterm:latest; then - log "Omaterm image pre-pulled" -else - warn "Image pre-pull failed — the omaterm installer will pull it instead" -fi - -# Installer wrapper: `curl | bash` makes stdin the pipe, so the installer's -# final `docker run -it` attach fails with "stdin is not a terminal". -# Download-then-run keeps stdin on the real TTY. -cat > /home/$DEPLOY_USER/install-omaterm << 'OMAEOF' -#!/bin/bash -# Fetch and run the omaterm installer with a real TTY on stdin -set -euo pipefail -tmp=$(mktemp) -trap 'rm -f "$tmp"' EXIT -curl -fsSL https://omaterm.org/install -o "$tmp" -bash "$tmp" -OMAEOF - -chown $DEPLOY_USER:$DEPLOY_USER /home/$DEPLOY_USER/install-omaterm -chmod +x /home/$DEPLOY_USER/install-omaterm -log "Installer wrapper created at ~/install-omaterm" - -# ============================================================================= -# SWAP (2GB) -# ============================================================================= - -step "Swap file" +step "8/8 · Housekeeping" +# --- Swap (2GB) --- if swapon --show --noheadings 2>/dev/null | grep -q .; then warn "Swap already active — skipping:" swapon --show @@ -374,12 +391,7 @@ else log "2GB swap created and enabled" fi -# ============================================================================= -# DISABLE UNNECESSARY SERVICES -# ============================================================================= - -step "Disable unnecessary services" - +# --- Disable unnecessary services --- for svc in ModemManager udisks2 multipathd; do if systemctl is-enabled "$svc" &>/dev/null; then systemctl stop "$svc" @@ -388,50 +400,34 @@ for svc in ModemManager udisks2 multipathd; do fi done -# ============================================================================= -# CAP JOURNAL LOGS (50MB) -# ============================================================================= - -step "Cap journal logs" - +# --- Cap journal logs (50MB) --- mkdir -p /etc/systemd/journald.conf.d cat > /etc/systemd/journald.conf.d/size.conf << EOF [Journal] SystemMaxUse=50M EOF - systemctl restart systemd-journald log "Journal capped at 50MB" -# ============================================================================= -# CLEAN PACMAN CACHE (keep last 2 versions of each package) -# ============================================================================= - -step "Pacman cache hygiene" - -pacman -S --noconfirm --needed pacman-contrib +# --- Pacman cache hygiene (prune old package versions weekly) --- systemctl enable --now paccache.timer log "paccache.timer enabled — old package versions pruned weekly" # ============================================================================= -# GENERATE LOCAL SSH CONFIG FILE +# LOCAL SSH CONFIG SNIPPET # ============================================================================= -step "SSH config for your local machine" - -SERVER_IP=$(curl -4 -s --max-time 5 ifconfig.me 2>/dev/null || \ - curl -4 -s --max-time 5 icanhazip.com 2>/dev/null || \ - echo "YOUR_SERVER_IP") - -cat > /home/$DEPLOY_USER/ssh-config-snippet.txt << SSHEOF +cat > /home/"$DEPLOY_USER"/ssh-config-snippet.txt << SSHEOF Host $SSH_ALIAS HostName $SERVER_IP User $DEPLOY_USER IdentityFile ~/.ssh/id_ed25519 SSHEOF -chown $DEPLOY_USER:$DEPLOY_USER /home/$DEPLOY_USER/ssh-config-snippet.txt -log "SSH config snippet saved to ~/ssh-config-snippet.txt" +chown "$DEPLOY_USER":"$DEPLOY_USER" /home/"$DEPLOY_USER"/ssh-config-snippet.txt + +# Hardening complete — saved answers no longer needed +rm -f "$STATE_FILE" # ============================================================================= # SUMMARY @@ -440,7 +436,7 @@ log "SSH config snippet saved to ~/ssh-config-snippet.txt" echo "" echo -e "${CYAN}" echo "╔══════════════════════════════════════════════════════════════╗" -echo "║ SETUP COMPLETE ║" +echo "║ SETUP COMPLETE ║" echo "╚══════════════════════════════════════════════════════════════╝" echo -e "${NC}" echo "" @@ -450,20 +446,14 @@ echo " ├─ Root login: disabled" echo " └─ Auth: key-only" echo "" echo -e " ${GREEN}Firewall${NC}" -echo " └─ 22/tcp SSH (only open port)" +echo " ├─ 22/tcp SSH" +echo " └─ tailscale0 allowed (dormant unless tailscale is installed)" echo "" echo -e " ${GREEN}Services${NC}" -echo " ├─ Docker: $(docker --version 2>/dev/null || echo 'installed')" -echo " ├─ Omaterm: image pre-pulled — install after first login (see below)" echo " ├─ Fail2ban: active (systemd backend)" echo " └─ Auto-updates: weekly pacman-update.timer" echo "" -echo -e " ${GREEN}Paths${NC}" -echo " └─ SSH config: ~/ssh-config-snippet.txt" -echo "" -echo -e " ${YELLOW}NEXT STEPS${NC}" -echo "" -echo " 1. On your local machine, run this to add the SSH config:" +echo " On your local machine, add the SSH config:" echo "" echo -e " ${CYAN}────── COPY BELOW THIS LINE ──────${NC}" echo "" @@ -477,16 +467,7 @@ echo "EOF" echo "" echo -e " ${CYAN}────── COPY ABOVE THIS LINE ──────${NC}" echo "" -echo " 2. Test SSH: ssh $SSH_ALIAS" -echo " 3. Install omaterm (from an interactive SSH session — do NOT" -echo " curl|bash it, the container attach step needs a real TTY):" -echo "" -echo -e " ${CYAN}ssh $SSH_ALIAS${NC}" -echo -e " ${CYAN}./install-omaterm${NC}" -echo "" -echo " If it complains about the terminal: TERM=xterm-256color ./install-omaterm" -echo "" -echo " Once step 1 is done, Claude Code can control this server." +echo " Then test: ssh $SSH_ALIAS" echo "" echo -e " ${YELLOW}A reboot is recommended to apply kernel updates:${NC}" echo -e " ${CYAN} reboot${NC}"