diff --git a/README.md b/README.md index 2b98614..584ebb9 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ sudo ./setup.sh --unattended base # non-interactive, use defaults ## What the wizard does **First run:** -1. Installs essential CLI packages (`net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`, `glow`), Docker CE + Compose plugin, and `openssh-server` — offers to import SSH keys from GitHub/Launchpad (`ssh-import-id`), disable password login once a key is confirmed, and install NetBird +1. Installs essential CLI packages (`net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`, `glow`), Docker CE + Compose plugin, and `openssh-server` — offers to import SSH keys from GitHub/Launchpad (`ssh-import-id`), disable password login once a key is confirmed, install NetBird, and add SSH Host aliases (see [SSH Host aliases](#ssh-host-aliases)) 2. Asks **where Caddy runs** — this machine, a remote machine/VPN peer, or none — before anything else, since every later service prompt depends on the answer 3. If Caddy is local: offers to set **site defaults** — timezone, base domain, Caddy Docker network — so every service picks them up automatically instead of asking each time, then offers to install Caddy itself 4. Drops into a **category menu** — pick a group, tick services, install, repeat @@ -66,13 +66,13 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. | Group | Services | |-------|---------| -| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import and optional password-auth lockdown; optional NetBird overlay network | +| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | | `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `sunshine` | | `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam`, `sky-cam-frigate` | | `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` | -| `extras` | `kdeconnect`, `silent-send`, `sync-cc` | +| `extras` | `kdeconnect`, `silent-send`, `ssh-config`, `sync-cc` | | `backup` | `backup` — complete recovery: entire `~/docker//` for every service via Kopia (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency); `borg-backup` — same coverage via Borg (chunk dedup, SSH remote repos, Borgmatic/Vorta compatible); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) | Run `./setup.sh --list` to see descriptions. @@ -157,6 +157,7 @@ gaming extras kdeconnect silent-send + ssh-config sync-cc backup @@ -189,6 +190,31 @@ docker compose pull && docker compose up -d # update docker compose down # stop ``` +## SSH Host aliases + +`~/.ssh/config` lets you `ssh ` instead of typing `ssh user@1.2.3.4` +every time — especially handy once machines are reachable over a VPN/NetBird +overlay network where the IP is easy to forget: + +``` +Host myserver + HostName 100.x.x.x + User someuser + Port 22 +``` + +Three ways to manage these entries: + +- **During `base` install** — after SSH key import, the wizard offers to add + one or more aliases interactively +- **Any time** — `sudo ./setup.sh ssh-config` lists, adds, or removes aliases + without touching anything else +- **By hand** — edit `~/.ssh/config` directly; it's a plain OpenSSH client + config file, nothing generated or templated beyond the `Host` block itself + +Aliases are written to the invoking user's own config (not root's), since +that's whose terminal actually runs `ssh`. + ## Installing from a USB thumb drive No git required. Works for anyone with a browser. diff --git a/lib/common.sh b/lib/common.sh index a4cb588..9a2777b 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -224,6 +224,85 @@ require_docker() { log_success "Docker installed ($("$_docker_bin" --version 2>/dev/null))" } +# ── SSH client config (~/.ssh/config) Host aliases ──────────────────────────── +# Lets "ssh " connect directly to user@host without typing it out each +# time — handy for VPN/NetBird peers with unmemorable IPs. Operates on the +# ACTUAL_USER's config (not root's), since that's whose terminal runs ssh. +ssh_config_path() { echo "$ACTUAL_HOME/.ssh/config"; } + +ssh_host_alias_exists() { + local alias="$1" cfg; cfg="$(ssh_config_path)" + [ -f "$cfg" ] && grep -qiE "^Host[[:space:]]+${alias}([[:space:]]|\$)" "$cfg" +} + +add_ssh_host_alias() { + local alias="$1" hostname="$2" user="$3" port="${4:-22}" + local cfg; cfg="$(ssh_config_path)" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would add SSH alias '$alias' -> $user@$hostname:$port to $cfg" + return 0 + fi + + mkdir -p "$(dirname "$cfg")" + touch "$cfg" + chmod 700 "$(dirname "$cfg")" + chmod 600 "$cfg" + + if ssh_host_alias_exists "$alias"; then + log_warning "Host alias '$alias' already exists in $cfg — skipping (remove it first to replace)." + return 1 + fi + + { + echo "" + echo "Host $alias" + echo " HostName $hostname" + echo " User $user" + [ "$port" != "22" ] && echo " Port $port" + } >> "$cfg" + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$(dirname "$cfg")" 2>/dev/null || true + log_success "Added SSH alias: ssh $alias -> $user@$hostname:$port" +} + +list_ssh_host_aliases() { + local cfg; cfg="$(ssh_config_path)" + if [ ! -f "$cfg" ] || ! grep -qiE "^Host[[:space:]]+" "$cfg"; then + echo " (none — $cfg has no Host entries yet)" + return 0 + fi + grep -inE "^Host[[:space:]]+" "$cfg" | sed -E 's/^([0-9]+):Host[[:space:]]+/ \1) /' +} + +remove_ssh_host_alias() { + local alias="$1" cfg; cfg="$(ssh_config_path)" + if [ ! -f "$cfg" ]; then + log_warning "No SSH config file found at $cfg" + return 1 + fi + if ! ssh_host_alias_exists "$alias"; then + log_warning "Host alias '$alias' not found in $cfg" + return 1 + fi + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would remove SSH alias '$alias' from $cfg" + return 0 + fi + local tmp; tmp="$(mktemp)" + awk -v alias="$alias" ' + BEGIN { skip=0 } + tolower($1)=="host" && tolower($2)==tolower(alias) { skip=1; next } + skip==1 && /^Host[[:space:]]/ { skip=0 } + skip==1 && /^[[:space:]]*$/ { skip=0; next } + skip==1 { next } + { print } + ' "$cfg" > "$tmp" + mv "$tmp" "$cfg" + chmod 600 "$cfg" + chown "$ACTUAL_USER:$ACTUAL_USER" "$cfg" 2>/dev/null || true + log_success "Removed SSH alias: $alias" +} + # ── Command execution honoring dry-run ─────────────────────────────────────── run_cmd() { if [ "$DRY_RUN" = true ]; then diff --git a/services/base.sh b/services/base.sh index ec5814a..132b549 100644 --- a/services/base.sh +++ b/services/base.sh @@ -15,6 +15,7 @@ install_base() { echo "[DRY-RUN] Would offer SSH key import from GitHub/Launchpad" echo "[DRY-RUN] Would offer to disable SSH password auth" echo "[DRY-RUN] Would offer NetBird install with --allow-server-ssh" + echo "[DRY-RUN] Would offer to add SSH Host aliases to ~/.ssh/config" return 0 fi @@ -38,6 +39,9 @@ install_base() { # ── NetBird ────────────────────────────────────────────────────────────── _base_setup_netbird + + # ── SSH Host aliases ───────────────────────────────────────────────────── + _base_setup_ssh_aliases } _base_setup_ssh() { @@ -124,6 +128,28 @@ _base_setup_netbird() { fi } +_base_setup_ssh_aliases() { + local ADD_ALIAS="" + prompt_yn "Add an SSH Host alias now ('ssh myserver' instead of 'ssh user@1.2.3.4')? (y/n):" "n" ADD_ALIAS + [[ "$ADD_ALIAS" =~ ^[Yy]$ ]] || return 0 + + while true; do + local ALIAS_NAME="" ALIAS_HOST="" ALIAS_USER="" ALIAS_PORT="" + prompt_text " Alias name (e.g. myserver):" "" ALIAS_NAME + if [ -z "$ALIAS_NAME" ]; then + log_warning "Alias name required — skipping." + else + prompt_text " Hostname or IP to connect to (e.g. a NetBird peer IP):" "" ALIAS_HOST + prompt_text " Remote username:" "$ACTUAL_USER" ALIAS_USER + prompt_text " Port [22]:" "22" ALIAS_PORT + add_ssh_host_alias "$ALIAS_NAME" "$ALIAS_HOST" "$ALIAS_USER" "$ALIAS_PORT" + fi + local ADD_ANOTHER="" + prompt_yn " Add another alias? (y/n):" "n" ADD_ANOTHER + [[ "$ADD_ANOTHER" =~ ^[Yy]$ ]] || break + done +} + # glow is also exposed as its own module so it can be (re)installed on its own. install_glow() { if command -v glow >/dev/null 2>&1; then diff --git a/services/ssh-config.sh b/services/ssh-config.sh new file mode 100644 index 0000000..7db4b79 --- /dev/null +++ b/services/ssh-config.sh @@ -0,0 +1,165 @@ +#!/bin/bash +# services/ssh-config.sh — manage ~/.ssh/config Host aliases. +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash ssh-config.sh +# +# Not a Docker service — edits the invoking (non-root) user's ~/.ssh/config +# so "ssh myserver" connects directly instead of "ssh user@1.2.3.4". Handy +# once NetBird/VPN peers have IPs you don't want to memorize or retype. + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +# Detected when the script is executed directly rather than sourced by setup.sh. +# Sets up helpers and globals, then defers execution until after the function +# definition at the bottom of this file. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } + + _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + _COMMON="$_SELF_DIR/../lib/common.sh" + + if [[ -f "$_COMMON" ]]; then + # Full repo present — use the real helpers (picks up ~/docker/.config too) + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + # One-off copy — inline minimal stubs so the script works without the repo + log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } + log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } + log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } + log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } + + # Match common.sh's eval-based pattern so local vars in install_* are set correctly + prompt_text() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_yn() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + ssh_config_path() { echo "$ACTUAL_HOME/.ssh/config"; } + + ssh_host_alias_exists() { + local alias="$1" cfg; cfg="$(ssh_config_path)" + [ -f "$cfg" ] && grep -qiE "^Host[[:space:]]+${alias}([[:space:]]|\$)" "$cfg" + } + + add_ssh_host_alias() { + local alias="$1" hostname="$2" user="$3" port="${4:-22}" + local cfg; cfg="$(ssh_config_path)" + mkdir -p "$(dirname "$cfg")" + touch "$cfg" + chmod 700 "$(dirname "$cfg")" + chmod 600 "$cfg" + if ssh_host_alias_exists "$alias"; then + log_warning "Host alias '$alias' already exists in $cfg — skipping." + return 1 + fi + { + echo "" + echo "Host $alias" + echo " HostName $hostname" + echo " User $user" + [ "$port" != "22" ] && echo " Port $port" + } >> "$cfg" + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$(dirname "$cfg")" 2>/dev/null || true + log_success "Added SSH alias: ssh $alias -> $user@$hostname:$port" + } + + list_ssh_host_aliases() { + local cfg; cfg="$(ssh_config_path)" + if [ ! -f "$cfg" ] || ! grep -qiE "^Host[[:space:]]+" "$cfg"; then + echo " (none — $cfg has no Host entries yet)" + return 0 + fi + grep -inE "^Host[[:space:]]+" "$cfg" | sed -E 's/^([0-9]+):Host[[:space:]]+/ \1) /' + } + + remove_ssh_host_alias() { + local alias="$1" cfg; cfg="$(ssh_config_path)" + [ -f "$cfg" ] || { log_warning "No SSH config file found at $cfg"; return 1; } + if ! ssh_host_alias_exists "$alias"; then + log_warning "Host alias '$alias' not found in $cfg" + return 1 + fi + local tmp; tmp="$(mktemp)" + awk -v alias="$alias" ' + BEGIN { skip=0 } + tolower($1)=="host" && tolower($2)==tolower(alias) { skip=1; next } + skip==1 && /^Host[[:space:]]/ { skip=0 } + skip==1 && /^[[:space:]]*$/ { skip=0; next } + skip==1 { next } + { print } + ' "$cfg" > "$tmp" + mv "$tmp" "$cfg" + chmod 600 "$cfg" + chown "$ACTUAL_USER:$ACTUAL_USER" "$cfg" 2>/dev/null || true + log_success "Removed SSH alias: $alias" + } + fi + + # Globals — ACTUAL_USER/ACTUAL_HOME must come before anything else + # ($HOME under sudo is /root, not the real user's home) + ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" + ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" + DRY_RUN="${DRY_RUN:-false}" + UNATTENDED="${UNATTENDED:-false}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service ssh-config extras "Manage SSH Host aliases in ~/.ssh/config (ssh instead of ssh user@ip)" + +install_ssh-config() { + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would list/add/remove Host aliases in $(ssh_config_path)" + return 0 + fi + + echo "" + echo "Current SSH Host aliases in $(ssh_config_path):" + list_ssh_host_aliases + echo "" + + local ACTION="" + echo " [1] Add an alias" + echo " [2] Remove an alias" + echo " [3] Done" + prompt_text "Choice [3]:" "3" ACTION + + case "$ACTION" in + 1) + local ALIAS_NAME="" ALIAS_HOST="" ALIAS_USER="" ALIAS_PORT="" + prompt_text " Alias name (e.g. myserver):" "" ALIAS_NAME + if [ -z "$ALIAS_NAME" ]; then + log_warning "Alias name required." + return 1 + fi + prompt_text " Hostname or IP to connect to (e.g. a NetBird peer IP):" "" ALIAS_HOST + prompt_text " Remote username:" "$ACTUAL_USER" ALIAS_USER + prompt_text " Port [22]:" "22" ALIAS_PORT + add_ssh_host_alias "$ALIAS_NAME" "$ALIAS_HOST" "$ALIAS_USER" "$ALIAS_PORT" + ;; + 2) + local REMOVE_NAME="" + prompt_text " Alias name to remove:" "" REMOVE_NAME + [ -n "$REMOVE_NAME" ] && remove_ssh_host_alias "$REMOVE_NAME" + ;; + *) + log_info "No changes." + ;; + esac +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_ssh-config diff --git a/setup.sh b/setup.sh index 25cb949..37abfd1 100755 --- a/setup.sh +++ b/setup.sh @@ -88,6 +88,7 @@ is_installed() { sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;; sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;; sky-cam-frigate) [ -d "$ACTUAL_HOME/sky-cam/.git" ] && [ -f "$ACTUAL_HOME/sky-cam/frigate-retime.sh" ] ;; + ssh-config) false ;; # repeatable management tool, never shows [installed] *) [ -e "$DOCKER_DIR/$1" ] ;; esac }