#!/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 <user>
# rm -f /etc/sudoers.d/ssh-onboard-<user>
# rm -f /etc/ssh/sshd_config.d/60-ssh-onboard-<user>.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 <<EOF
Usage: sudo bash ssh-onboard.sh [options]
Options:
--user NAME account to create/use (default: $DEFAULT_USER)
--key "KEY" public key to install (inline, one authorized_keys line)
--key-file FILE public key to install (read from FILE)
--from LIST restrict key to source IPs/CIDRs, e.g. "203.0.113.7,10.0.0.0/8"
--sudo grant passwordless sudo (off by default)
--no-match skip the per-user key-only Match block
--no-restrict install the key without restrict,pty options
--apply actually make changes (default is a dry run)
--yes skip the interactive fingerprint confirmation
--help this text
Version: $VERSION
EOF
}
die() { printf 'ERROR: %s\n' "$*" >&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 <<EOF
# ssh-onboard: key-only policy for the dedicated account '${ONBOARD_USER}'.
# Applies ONLY to this user. Remove this file and reload sshd to undo.
Match User ${ONBOARD_USER}
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
EOF
)"
if [[ -f "$MATCH_FILE" ]] && [[ "$(cat "$MATCH_FILE")" == "$MATCH_CONTENT" ]]; then
note "Match block ${MATCH_FILE} already up to date."
else
printf '%s\n' "$MATCH_CONTENT" > "$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