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) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-06-06 16:35:10 +01:00
parent 99a3598c68
commit 036e1f4010
3 changed files with 182 additions and 138 deletions

21
LICENSE Normal file
View file

@ -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.

42
README.md Normal file
View file

@ -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 <name>` 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 <deploy-user>`, 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

View file

@ -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 <alias> (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 <name>). Asked ONCE, used everywhere.
echo ""
echo " This name becomes the hostname and your local SSH alias"
echo " (ssh <name>)."
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}"