omaserver v0.1.0: one-shot hardened Arch VPS + native OMATERM

Single SSH session takes a fresh Arch box to a locked-down server running
native OMATERM (28allday/omaterm@native-frozen, fetched read-only at runtime).

- Phase A: hardening (deploy user, key-only SSH, ufw 22+tailscale0, fail2ban,
  weekly updates, swap, journal cap) — proven on tyler + durden 2026-06-06
- Phase B: hands off to install-native.sh as the deploy user with a real TTY;
  recovery net resumes first-run setup and lands in a shell if the chain
  exits early instead of dropping the SSH session
- Every answer asked once: machine name = hostname + tailnet name + ssh
  alias; git identity pre-seeds ~/.gitconfig so omaterm setup skips it;
  answers survive the kernel-upgrade reboot
- Kernel-upgrade guard offers the reboot in-script with exact resume command

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-06-06 16:35:23 +01:00
commit 3b2707a8bd
3 changed files with 665 additions and 0 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.

54
README.md Normal file
View file

@ -0,0 +1,54 @@
# omaserver
One-shot bootstrap for a fresh Arch Linux VPS: **hardened server + native [OMATERM](https://omaterm.org)**, in a single SSH session.
Part of the oma-family: [omalocal](https://github.com/28allday/omalocal) installs OMATERM on local hardware via a custom ISO — **omaserver** does the same job for a remote box you can only reach over SSH.
## Usage
Run as root on a fresh Arch VPS, **with a TTY** (the install ends in an interactive omaterm session):
```bash
ssh -t root@YOUR_SERVER 'bash <(curl -fsSL https://raw.githubusercontent.com/28allday/omaserver/main/install-server.sh)'
```
Answer four questions up front (machine name, deploy user, optional git identity) — each is asked **once** and reused everywhere:
| Answer | Used for |
|---|---|
| Machine name | hostname, Tailscale machine name, your local `ssh <name>` alias |
| Deploy user | the locked-down account that replaces root |
| Git identity | pre-seeds `~/.gitconfig` so omaterm's setup skips its git prompts |
If the first `pacman -Syu` upgrades the kernel, the script offers to reboot and your answers survive — reconnect, re-run the same command, and it picks up where it left off.
## What it does
**Phase A — hardening (as root)**
- Deploy user with key-only SSH (root's `authorized_keys` copied over), passwordless sudo
- Root SSH login disabled, password auth disabled, `AllowUsers <deploy-user>`
- UFW: deny incoming, allow 22/tcp + the `tailscale0` interface only
- fail2ban (systemd journal backend, ufw banaction)
- Weekly `pacman -Syu` timer, weekly package-cache pruning, 50MB journal cap
- 2GB swap (btrfs-aware; skipped if the image ships its own)
- Hostname, timezone, locale
**Phase B — OMATERM (as the deploy user)**
Hands off to the native OMATERM installer — packages, dotfiles, AI agents (claude, codex, gemini, opencode via mise), tmux workspace — then runs OMATERM's interactive first-run setup (GitHub, Tailscale, 1Password). The Tailscale hostname comes pre-filled with your machine name.
You end up inside a tmux session on a hardened box. If any part of the interactive setup bails early, the installer wrapper catches it, resumes setup, and lands you in a shell — the session is never just dropped.
To re-run or resume the OMATERM part later: `ssh -t <name> ./install-omaterm`
## Notes
- **Arch only** — refuses to run elsewhere.
- OMATERM is installed **natively** (no container) from the pinned fork [`28allday/omaterm@native-frozen`](https://github.com/28allday/omaterm/tree/native-frozen), which preserves the last upstream native install path. The fork is read-only at runtime; nothing is ever pushed to it.
- Want hardening without OMATERM? That's [arch-boot-strap](https://github.com/28allday/arch-boot-strap).
- Safe to re-run; completed steps skip themselves. After the first run root SSH login is disabled, so re-run as the deploy user with `sudo`.
## License
MIT

590
install-server.sh Executable file
View file

@ -0,0 +1,590 @@
#!/usr/bin/env bash
set -euo pipefail
# ─────────────────────────────────────────────────────────────────────────────
# One-shot server bootstrap: fresh Arch VPS → hardened box → native OMATERM.
#
# Run as root over SSH **with a TTY** (the install ends in an interactive
# omaterm session, so a plain `ssh host cmd` won't do):
#
# ssh -t root@YOUR_SERVER 'bash <(curl -fsSL https://raw.githubusercontent.com/28allday/omaserver/main/install-server.sh)'
#
# Phase A (as root): hardening — deploy user, key-only SSH, ufw, fail2ban,
# weekly updates, swap, journald cap, cache hygiene
# Phase B (as user): hands off to install-native.sh from the frozen fork
# (28allday/omaterm @ native-frozen — the branch omalocal
# also pins; fetched at runtime, never modified) which
# installs packages, omadots, AI agents, and runs the
# interactive first-time setup
#
# Safe to re-run — completed steps skip themselves. After the first run root
# SSH login is disabled, so re-run as the deploy user with sudo.
# ─────────────────────────────────────────────────────────────────────────────
# =============================================================================
# DEFAULTS
# =============================================================================
TIMEZONE="${TIMEZONE:-Europe/London}"
LOCALE="${LOCALE:-en_GB.UTF-8}"
# Where install-native.sh is fetched from at runtime — the frozen branch that
# omalocal also pins. Read-only consumer: nothing is ever pushed there.
OMATERM_REPO_RAW="https://raw.githubusercontent.com/28allday/omaterm/native-frozen"
# This script's own home, for re-run instructions
SELF_URL="https://raw.githubusercontent.com/28allday/omaserver/main/install-server.sh"
# =============================================================================
# COLOURS
# =============================================================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
log() { echo -e "${GREEN}[✓]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
err() { echo -e "${RED}[✗]${NC} $1"; }
step() { echo -e "\n${CYAN}━━━ $1 ━━━${NC}\n"; }
# =============================================================================
# PRE-FLIGHT CHECKS
# =============================================================================
if [ "$EUID" -ne 0 ]; then
err "This script must be run as root."
err "Usage: ssh -t root@YOUR_SERVER 'bash <(curl -fsSL $SELF_URL)'"
exit 1
fi
if [ ! -f /etc/arch-release ]; then
err "This script requires Arch Linux."
exit 1
fi
if [ ! -t 0 ]; then
err "No TTY on stdin — the omaterm installer needs an interactive terminal."
err "Connect with: ssh -t root@YOUR_SERVER"
err "Then run: bash <(curl -fsSL $SELF_URL)"
exit 1
fi
# =============================================================================
# INTERACTIVE SETUP
# =============================================================================
echo -e "${CYAN}"
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ OMATERM Server Bootstrap ║"
echo "║ Arch Linux · Hardening · Native OMATERM ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo -e "${NC}"
# 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")
# Answers survive the kernel-upgrade reboot — a re-run finds them here and
# doesn't ask again
STATE_FILE="/root/.install-server.conf"
SSH_ALIAS=""
DEPLOY_USER=""
GIT_NAME=""
GIT_EMAIL=""
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, the tailnet name, AND the alias in
# your local SSH config (ssh <name>). Asked ONCE, used everywhere.
echo ""
echo " This name becomes the hostname, the Tailscale machine name,"
echo " and your local SSH alias (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}"
# Git identity — pre-seeds the deploy user's ~/.gitconfig so omaterm's
# first-run setup skips its git prompts. Leave blank to be asked there
# instead.
echo ""
read -rp "Git name (blank to skip): " GIT_NAME
if [ -n "$GIT_NAME" ]; then
read -rp "Git email: " GIT_EMAIL
fi
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
# Confirm
echo ""
echo -e "${CYAN}━━━ Confirm settings ━━━${NC}"
echo ""
echo " Machine name: $SSH_ALIAS (hostname + tailnet + ssh alias)"
echo " Deploy user: $DEPLOY_USER"
echo " Git identity: ${GIT_NAME:-} ${GIT_EMAIL:+<$GIT_EMAIL>}"
echo " Timezone: $TIMEZONE"
echo " Omaterm: native (28allday/omaterm @ native-frozen)"
echo ""
read -p "Continue? (Y/n) " -n 1 -r
echo ""
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\nGIT_NAME=%q\nGIT_EMAIL=%q\n' \
"$SSH_ALIAS" "$DEPLOY_USER" "$GIT_NAME" "$GIT_EMAIL" > "$STATE_FILE"
chmod 600 "$STATE_FILE"
# =============================================================================
# 1. SYSTEM UPDATES & ESSENTIALS
# =============================================================================
step "1/9 · System updates & essential packages"
pacman -Syu --noconfirm
# If -Syu upgraded the kernel, the running kernel's modules are gone and
# things like tun (tailscale) and netfilter (docker/ufw) can't load —
# 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 "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
# Hardening-phase packages only — install-native.sh brings everything else
# (docker, tailscale, git, tmux, agents) via arch.packages.
# kitty-terminfo: without it, SSHing in from kitty gives
# "'xterm-kitty': unknown terminal type" and interactive tools die
pacman -S --noconfirm --needed \
ufw \
fail2ban \
curl \
kitty-terminfo \
pacman-contrib
log "System updated, hardening packages installed"
# =============================================================================
# 2. TIMEZONE & LOCALE
# =============================================================================
step "2/9 · Hostname, timezone & locale"
# Machine name becomes the hostname — omaterm-setup's tailnet prompt
# defaults to it, so the name is only ever typed once
hostnamectl set-hostname "$SSH_ALIAS"
log "Hostname set to $SSH_ALIAS"
timedatectl set-timezone "$TIMEZONE"
log "Timezone set to $TIMEZONE"
# Generate and set locale (minimal Arch cloud images often ship C.UTF-8 only)
if ! locale -a 2>/dev/null | grep -qi "^${LOCALE/UTF-8/utf8}$"; then
sed -i "s/^#${LOCALE}/${LOCALE}/" /etc/locale.gen
locale-gen
fi
echo "LANG=$LOCALE" > /etc/locale.conf
log "Locale set to $LOCALE"
# =============================================================================
# 3. CREATE DEPLOY USER
# =============================================================================
step "3/9 · Deploy user"
if id "$DEPLOY_USER" &>/dev/null; then
warn "User '$DEPLOY_USER' already exists — skipping creation"
else
useradd -m -s /bin/bash "$DEPLOY_USER"
usermod -aG wheel "$DEPLOY_USER"
log "User '$DEPLOY_USER' created and added to wheel group"
fi
# Passwordless sudo — install-native.sh and omaterm-setup sudo freely,
# and the user has no password to type
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"
# Pre-seed git identity — omaterm-setup skips its git prompts when
# user.name and user.email are already configured
if [ -n "$GIT_NAME" ] && [ ! -f /home/"$DEPLOY_USER"/.gitconfig ]; then
cat > /home/"$DEPLOY_USER"/.gitconfig << GITEOF
[user]
name = $GIT_NAME
email = $GIT_EMAIL
GITEOF
chown "$DEPLOY_USER":"$DEPLOY_USER" /home/"$DEPLOY_USER"/.gitconfig
log "Git identity pre-seeded for $DEPLOY_USER"
fi
# 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
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"
fi
# =============================================================================
# 4. HARDEN SSH
# =============================================================================
step "4/9 · SSH hardening"
# Backup original config
cp /etc/ssh/sshd_config "/etc/ssh/sshd_config.backup.$(date +%Y%m%d)"
# Neutralise conflicting directives in the main config so our drop-in wins
sed -i 's/^\s*PermitRootLogin\b/#&/' /etc/ssh/sshd_config
sed -i 's/^\s*PasswordAuthentication\b/#&/' /etc/ssh/sshd_config
sed -i 's/^\s*PubkeyAuthentication\b/#&/' /etc/ssh/sshd_config
sed -i 's/^\s*X11Forwarding\b/#&/' /etc/ssh/sshd_config
sed -i 's/^\s*PermitEmptyPasswords\b/#&/' /etc/ssh/sshd_config
log "Commented out conflicting directives in sshd_config"
# Remove cloud-init SSH overrides that can conflict (some VPS images use them)
rm -f /etc/ssh/sshd_config.d/50-cloud-init.conf
# Ensure the drop-in directory is included (Arch's stock sshd_config has this,
# but some provider images strip it)
mkdir -p /etc/ssh/sshd_config.d
if ! grep -q '^Include /etc/ssh/sshd_config.d/\*\.conf' /etc/ssh/sshd_config; then
sed -i '1i Include /etc/ssh/sshd_config.d/*.conf' /etc/ssh/sshd_config
warn "Added missing Include for sshd_config.d drop-ins"
fi
cat > /etc/ssh/sshd_config.d/hardened.conf << EOF
# Custom SSH hardening
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
MaxSessions 5
AllowUsers $DEPLOY_USER
ClientAliveInterval 300
ClientAliveCountMax 2
X11Forwarding no
PermitEmptyPasswords no
EOF
log "SSH hardened — root disabled, key-only auth"
# Test config before restarting (service is 'sshd' on Arch, not 'ssh')
if sshd -t; then
systemctl restart sshd
log "SSH restarted successfully"
else
err "SSH config test failed — reverting"
rm /etc/ssh/sshd_config.d/hardened.conf
exit 1
fi
# =============================================================================
# 5. FIREWALL (UFW)
# =============================================================================
step "5/9 · Firewall (UFW)"
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp comment 'SSH'
# Native omaterm runs tailscaled on the host (kernel networking, tun
# device) — without this, incoming tailnet traffic is silently dropped
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 — 22/tcp + tailscale0 only"
ufw status verbose
# =============================================================================
# 6. FAIL2BAN
# =============================================================================
step "6/9 · Fail2ban"
# Arch logs auth to the systemd journal — there is no /var/log/auth.log,
# so use the systemd backend instead of a logpath
cat > /etc/fail2ban/jail.local << EOF
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 3
banaction = ufw
backend = systemd
[sshd]
enabled = true
port = 22
filter = sshd
maxretry = 3
bantime = 3600
EOF
systemctl enable fail2ban
systemctl restart fail2ban
log "Fail2ban configured and running (systemd journal backend)"
# =============================================================================
# 7. AUTOMATIC UPDATES (weekly pacman -Syu timer)
# =============================================================================
step "7/9 · Automatic updates"
# Arch has no unattended-upgrades equivalent — use a weekly full update timer.
# NOTE: rolling release means an update can very occasionally need manual
# intervention (check https://archlinux.org/news/ if something breaks).
cat > /etc/systemd/system/pacman-update.service << EOF
[Unit]
Description=Weekly pacman system update
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/pacman -Syu --noconfirm
EOF
cat > /etc/systemd/system/pacman-update.timer << EOF
[Unit]
Description=Weekly pacman system update
[Timer]
OnCalendar=weekly
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now pacman-update.timer
log "Weekly auto-update timer enabled (pacman-update.timer)"
warn "Rolling release: check 'journalctl -u pacman-update' if anything misbehaves after an update"
# =============================================================================
# 8. HOUSEKEEPING — swap, services, journal, pacman cache
# =============================================================================
step "8/9 · Housekeeping"
# --- Swap (2GB) ---
if swapon --show --noheadings 2>/dev/null | grep -q .; then
warn "Swap already active — skipping:"
swapon --show
elif [ -f /swapfile ]; then
warn "Swap file already exists — skipping"
else
# fallocate swapfiles fail on btrfs (needs NOCOW) — use the proper tool
if [ "$(findmnt -n -o FSTYPE /)" = "btrfs" ]; then
btrfs filesystem mkswapfile --size 2g /swapfile
else
dd if=/dev/zero of=/swapfile bs=1M count=2048 status=none
chmod 600 /swapfile
mkswap /swapfile
fi
swapon /swapfile
grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
log "2GB swap created and enabled"
fi
# --- Disable unnecessary services ---
for svc in ModemManager udisks2 multipathd; do
if systemctl is-enabled "$svc" &>/dev/null; then
systemctl stop "$svc"
systemctl disable "$svc"
log "Disabled $svc"
fi
done
# --- 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"
# --- Pacman cache hygiene (keep last versions, prune weekly) ---
systemctl enable --now paccache.timer
log "paccache.timer enabled — old package versions pruned weekly"
# =============================================================================
# 9. OMATERM HANDOFF
# =============================================================================
step "9/9 · Omaterm handoff"
# Pre-seed the tailnet machine name — omaterm-setup's hostname prompt
# defaults to this file's contents, so Enter accepts the machine name
echo "$SSH_ALIAS" > /etc/omaterm-tailscale-hostname
# Re-runnable installer wrapper in the deploy user's home. Download-then-run
# (not curl | bash) so stdin stays on the real TTY — the installer's
# interactive first-run setup (gum) and final shell handoff need it.
cat > /home/"$DEPLOY_USER"/install-omaterm << OMAEOF
#!/bin/bash
# Fetch and run the omaterm native installer (28allday fork, native-frozen)
# with a real TTY on stdin. Re-run this if the install is interrupted
# (e.g. after a kernel-upgrade reboot).
set -uo pipefail
tmp=\$(mktemp)
trap 'rm -f "\$tmp"' EXIT
curl -fsSL $OMATERM_REPO_RAW/install-native.sh -o "\$tmp" || exit 1
OMATERM_REF=native-frozen bash "\$tmp"
# On success install-native.sh execs into a login shell and never returns —
# reaching this point means the chain bailed early (a first-run setup
# section erroring out, a stray Ctrl+C). Don't strand the user by dropping
# the SSH session: resume setup if it didn't finish, then land in a shell.
echo ""
echo "[!] The installer exited early — recovering."
if [ ! -f "\$HOME/.omaterm-setup-done" ] && [ -x "\$HOME/.local/bin/omaterm-setup" ]; then
echo "[!] Resuming first-run setup (already-completed sections are skipped)..."
"\$HOME/.local/bin/omaterm-setup" || true
fi
exec bash -l
OMAEOF
chown "$DEPLOY_USER":"$DEPLOY_USER" /home/"$DEPLOY_USER"/install-omaterm
chmod +x /home/"$DEPLOY_USER"/install-omaterm
log "Installer wrapper created at ~$DEPLOY_USER/install-omaterm"
# =============================================================================
# LOCAL SSH CONFIG SNIPPET
# =============================================================================
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
# Hardening phase complete — saved answers no longer needed
rm -f "$STATE_FILE"
# =============================================================================
# SUMMARY
# =============================================================================
echo ""
echo -e "${CYAN}"
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ HARDENING COMPLETE ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo -e "${NC}"
echo ""
echo -e " ${GREEN}SSH${NC}"
echo " ├─ User: $DEPLOY_USER"
echo " ├─ Root login: disabled"
echo " └─ Auth: key-only"
echo ""
echo -e " ${GREEN}Firewall${NC}"
echo " ├─ 22/tcp SSH"
echo " └─ tailscale0 allowed (for omaterm's tailscale up --ssh)"
echo ""
echo -e " ${GREEN}Services${NC}"
echo " ├─ Fail2ban: active (systemd backend)"
echo " └─ Auto-updates: weekly pacman-update.timer"
echo ""
echo " 1. On your local machine, add the SSH config:"
echo ""
echo -e " ${CYAN}────── COPY BELOW THIS LINE ──────${NC}"
echo ""
echo "cat >> ~/.ssh/config << 'EOF'"
echo ""
echo "Host $SSH_ALIAS"
echo " HostName $SERVER_IP"
echo " User $DEPLOY_USER"
echo " IdentityFile ~/.ssh/id_ed25519"
echo "EOF"
echo ""
echo -e " ${CYAN}────── COPY ABOVE THIS LINE ──────${NC}"
echo ""
echo " 2. The omaterm install runs next, as $DEPLOY_USER. If it's"
echo " interrupted (e.g. a kernel-upgrade reboot), resume with:"
echo ""
echo -e " ${CYAN}ssh -t $SSH_ALIAS ./install-omaterm${NC}"
echo ""
echo -e " ${YELLOW}Heads-up — after the packages install, first-run setup asks about:${NC}"
echo ""
if [ -n "$GIT_NAME" ]; then
echo " · Git identity skipped — already configured"
else
echo " · Git identity your name + email"
fi
echo " · GitHub optional, wants a classic token"
echo " · Tailscale recommended — browser login link;"
echo " hostname pre-filled ($SSH_ALIAS), Enter accepts"
echo -e " · SSH public key ${YELLOW}answer No${NC} — this box is already key-only"
echo " · 1Password optional"
echo ""
echo -e " ${YELLOW}A reboot is recommended once you're done, to apply kernel updates.${NC}"
echo ""
read -p "Launch the omaterm installer now as $DEPLOY_USER? (Y/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
# su keeps the controlling TTY, so gum prompts and the final
# `exec bash -l` in install-native.sh all work
exec su - "$DEPLOY_USER" -c ./install-omaterm
else
echo ""
echo " When you're ready:"
echo ""
echo -e " ${CYAN}ssh -t $SSH_ALIAS ./install-omaterm${NC}"
echo ""
fi