#!/usr/bin/env bash # TOOLBOX-TILE: {"title": "SSH Onboard", "desc": "Einer externen Person gehΓ€rteten SSH-Zugang auf dem eigenen Linux- oder Windows-System einrichten β€” ein prΓΌfbares Script: eigener Key-only-Benutzer, bestehende SSH- und Firewall-Einstellungen bleiben unangetastet. Zeigt erst den Plan (Dry-Run), Γ€ndert nur mit --apply.", "icon": "πŸ”", "type": "download", "doc": "ssh-onboard.html", "os": ["linux", "windows"], "order": 25, "downloads": [{"file": "ssh-onboard.sh", "label": "Linux (.sh)"}, {"file": "ssh-onboard.ps1", "label": "Windows (.ps1)"}]} # # ssh-onboard β€” grant an external party hardened SSH access to this host. # # Run it, read the plan, run it again with --apply. Nothing changes without # --apply. Designed to be read top to bottom before you execute it as root β€” # it is deliberately one file, no downloads at runtime, no hidden steps. # # What it does: # 1. Creates a dedicated login user (password locked β€” key-only by design). # 2. Installs the external party's SSH public key with restrictive per-key options # (restrict,pty[,from="..."]). This hardening lives in authorized_keys # and does NOT touch your global sshd configuration. # 3. Optionally (--sudo) grants that user passwordless sudo via a # visudo-validated /etc/sudoers.d drop-in. # 4. If your sshd_config includes /etc/ssh/sshd_config.d: adds a per-user # Match block making the account key-only, validated with `sshd -t` # BEFORE sshd is reloaded. Skipped (with a notice) otherwise. # # What it deliberately does NOT do: # - no changes to global sshd defaults (port, PasswordAuthentication, ...) # - no firewall or fail2ban changes # - no package installations, no network access # # Undo everything (also printed after --apply): # userdel -r # rm -f /etc/sudoers.d/ssh-onboard- # rm -f /etc/ssh/sshd_config.d/60-ssh-onboard-.conf && systemctl reload ssh # set -euo pipefail VERSION="1.1.0" # ── Personalization ────────────────────────────────────────────────────────── # Distributors: put your public key and identity here, so recipients only # have to check the fingerprint you publish and run the script. # An empty DEFAULT_PUBKEY makes the script require --key or --key-file. DEFAULT_USER="ext-ops" DEFAULT_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxGfIJpDOUhggucuFBnkjpSqPz1aFBQGfhMoo0AgqOT onboard@jozapf.de" DEFAULT_KEY_OWNER="Jo Zapf β€” https://jozapf.de (fingerprint published at https://toolbox.jozapf.de/ssh-onboard.html)" # ── Defaults ───────────────────────────────────────────────────────────────── ONBOARD_USER="$DEFAULT_USER" PUBKEY="$DEFAULT_PUBKEY" KEY_OWNER="$DEFAULT_KEY_OWNER" FROM="" # optional from= source restriction (IP/CIDR list) WITH_SUDO=false WITH_MATCH=true # per-user key-only Match block (if sshd_config.d is used) WITH_RESTRICT=true # restrict,pty per-key options APPLY=false ASSUME_YES=false usage() { # Print header lines 4-29 (after shebang + toolbox marker) as help text. sed -n '4,29p' "$0" | sed 's/^# \{0,1\}//' cat <&2; exit 1; } note() { printf '%s\n' "$*"; } # ── Arguments ──────────────────────────────────────────────────────────────── while [[ $# -gt 0 ]]; do case "$1" in --user) ONBOARD_USER="${2:?--user needs a value}"; shift 2 ;; --key) PUBKEY="${2:?--key needs a value}"; KEY_OWNER="(given on the command line)"; shift 2 ;; --key-file) PUBKEY="$(cat "${2:?--key-file needs a value}")"; KEY_OWNER="(from file: $2)"; shift 2 ;; --from) FROM="${2:?--from needs a value}"; shift 2 ;; --sudo) WITH_SUDO=true; shift ;; --no-match) WITH_MATCH=false; shift ;; --no-restrict) WITH_RESTRICT=false; shift ;; --apply) APPLY=true; shift ;; --yes) ASSUME_YES=true; shift ;; --help|-h) usage; exit 0 ;; *) die "unknown option: $1 (see --help)" ;; esac done # ── Preconditions (read-only) ──────────────────────────────────────────────── [[ "$(uname -s)" == "Linux" ]] || die "Linux only." [[ $EUID -eq 0 ]] || die "run as root: sudo bash ssh-onboard.sh" command -v sshd >/dev/null || die "no sshd found β€” is an OpenSSH server installed?" command -v useradd >/dev/null || die "useradd not found." if $WITH_SUDO && { ! command -v visudo >/dev/null || [[ ! -d /etc/sudoers.d ]]; }; then die "--sudo requested, but sudo is not installed on this host (no visudo//etc/sudoers.d)." fi [[ "$ONBOARD_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \ || die "invalid user name: $ONBOARD_USER" [[ -n "$PUBKEY" ]] || die \ "this copy has no built-in key β€” pass --key \"ssh-ed25519 AAAA...\" or --key-file key.pub" # Exactly one key line, and a valid one. [[ "$(printf '%s\n' "$PUBKEY" | grep -c .)" -eq 1 ]] \ || die "expected exactly one public key line" FINGERPRINT="$(ssh-keygen -lf /dev/stdin <<<"$PUBKEY" 2>/dev/null)" \ || die "not a valid SSH public key" KEY_TYPE="$(awk '{print $1}' <<<"$PUBKEY")" KEY_BLOB="$(awk '{print $2}' <<<"$PUBKEY")" [[ "$KEY_TYPE" == "ssh-ed25519" ]] \ || note "NOTE: key type is $KEY_TYPE (Ed25519 recommended)." # Per-key options: 'restrict' disables all forwarding/tunneling/X11/user-rc, # 'pty' re-allows an interactive terminal on top of it. KEY_OPTS="" if $WITH_RESTRICT; then KEY_OPTS="restrict,pty" [[ -n "$FROM" ]] && KEY_OPTS="${KEY_OPTS},from=\"${FROM}\"" elif [[ -n "$FROM" ]]; then KEY_OPTS="from=\"${FROM}\"" fi AK_LINE="${KEY_OPTS:+${KEY_OPTS} }${PUBKEY}" HOME_DIR="/home/${ONBOARD_USER}" id "$ONBOARD_USER" &>/dev/null && HOME_DIR="$(getent passwd "$ONBOARD_USER" | cut -d: -f6)" AK_FILE="${HOME_DIR}/.ssh/authorized_keys" SUDOERS_FILE="/etc/sudoers.d/ssh-onboard-${ONBOARD_USER}" MATCH_FILE="/etc/ssh/sshd_config.d/60-ssh-onboard-${ONBOARD_USER}.conf" SSHD_CONFIG_D_ACTIVE=false if [[ -d /etc/ssh/sshd_config.d ]] \ && grep -Eq '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/' /etc/ssh/sshd_config; then SSHD_CONFIG_D_ACTIVE=true fi # ── Plan ───────────────────────────────────────────────────────────────────── note "ssh-onboard v${VERSION} β€” $($APPLY && echo 'APPLY' || echo 'DRY RUN (nothing will change)')" note "" note "Key to install:" note " ${FINGERPRINT}" note " owner: ${KEY_OWNER}" note " Make sure this fingerprint MATCHES the one published by the key owner." note "" note "Plan:" if id "$ONBOARD_USER" &>/dev/null; then note " [1] user '${ONBOARD_USER}' exists β€” reuse (only ~/.ssh and its permissions are touched)" else note " [1] create user '${ONBOARD_USER}' (locked password, shell /bin/bash, home ${HOME_DIR})" fi if [[ -f "$AK_FILE" ]] && grep -qF "$KEY_BLOB" "$AK_FILE"; then note " [2] key already present in ${AK_FILE} β€” nothing to add (permissions still verified)" else note " [2] append to ${AK_FILE}:" note " ${AK_LINE}" fi if $WITH_SUDO; then note " [3] write ${SUDOERS_FILE} (passwordless sudo, visudo-validated)" else note " [3] no sudo (pass --sudo if the external party needs it)" fi if $WITH_MATCH && $SSHD_CONFIG_D_ACTIVE; then note " [4] write ${MATCH_FILE} (key-only Match block for '${ONBOARD_USER}'," note " validated with 'sshd -t' before reload; your global policy is untouched)" elif $WITH_MATCH; then note " [4] SKIPPED: sshd_config.d is not included by your sshd_config β€”" note " not editing your main config; the per-key options still apply." else note " [4] Match block disabled (--no-match)" fi note "" if ! $APPLY; then note "Dry run only. Re-run with --apply to execute." exit 0 fi # ── Confirmation ───────────────────────────────────────────────────────────── if ! $ASSUME_YES; then [[ -t 0 ]] || die "stdin is not a terminal β€” verify the fingerprint, then re-run with --yes" read -r -p "Fingerprint matches the published one? Install now? [y/N] " answer [[ "$answer" =~ ^[Yy]$ ]] || die "aborted β€” nothing was changed." fi # ── Apply ──────────────────────────────────────────────────────────────────── if ! id "$ONBOARD_USER" &>/dev/null; then useradd -m -s /bin/bash "$ONBOARD_USER" note "created user '${ONBOARD_USER}' (password locked)." fi install -d -m 0700 -o "$ONBOARD_USER" -g "$ONBOARD_USER" "${HOME_DIR}/.ssh" touch "$AK_FILE" chmod 0600 "$AK_FILE" chown "$ONBOARD_USER:$ONBOARD_USER" "$AK_FILE" if grep -qF "$KEY_BLOB" "$AK_FILE"; then note "key already in ${AK_FILE} β€” unchanged." else printf '%s\n' "$AK_LINE" >> "$AK_FILE" note "key installed into ${AK_FILE}." fi if $WITH_SUDO; then printf '%s ALL=(ALL) NOPASSWD:ALL\n' "$ONBOARD_USER" > "${SUDOERS_FILE}.tmp" visudo -cf "${SUDOERS_FILE}.tmp" >/dev/null \ || { rm -f "${SUDOERS_FILE}.tmp"; die "sudoers validation failed β€” sudo NOT granted."; } install -m 0440 -o root -g root "${SUDOERS_FILE}.tmp" "$SUDOERS_FILE" rm -f "${SUDOERS_FILE}.tmp" note "passwordless sudo granted via ${SUDOERS_FILE}." fi if $WITH_MATCH && $SSHD_CONFIG_D_ACTIVE; then MATCH_CONTENT="$(cat < "$MATCH_FILE" chmod 0644 "$MATCH_FILE" if sshd -t 2>/dev/null; then systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null \ || service ssh reload 2>/dev/null || note "NOTE: reload sshd manually to activate the Match block." note "Match block written and sshd reloaded." else rm -f "$MATCH_FILE" die "sshd -t rejected the Match block β€” file removed, sshd untouched." fi fi fi # ── Summary ────────────────────────────────────────────────────────────────── PORT="$(sshd -T 2>/dev/null | awk '$1=="port"{print $2; exit}' || true)" HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" note "" note "SUCCESS: the dedicated account is ready." note " user: ${ONBOARD_USER} port: ${PORT:-22} local address: ${HOST_IP:-unknown}" [[ -n "$FROM" ]] && note " (access restricted to source: ${FROM})" note " NOTE: 'local address' is how this system sees itself. Whether it is" note " reachable from outside (NAT, firewall, port forwarding) depends on" note " your network β€” this script does not change any of that." note "" note "Undo everything:" note " userdel -r ${ONBOARD_USER}" if $WITH_SUDO; then note " rm -f ${SUDOERS_FILE}" fi if [[ -f "$MATCH_FILE" ]]; then note " rm -f ${MATCH_FILE} && systemctl reload ssh # service may be named 'sshd'" fi