commit aef47c835a8657deaaa011dc699443d8cf9ca3ad Author: 57_Wolve <57_wolve@private.email> Date: Mon May 4 17:14:17 2026 +0000 Upload files to "scripts" diff --git a/scripts/harden-jumphost.sh b/scripts/harden-jumphost.sh new file mode 100644 index 0000000..7fb79cc --- /dev/null +++ b/scripts/harden-jumphost.sh @@ -0,0 +1,400 @@ +#!/usr/bin/env bash +# +# harden-jumphost.sh +# +# Hardens an Alpine box for use as an SSH jump host (bastion). Run on a fresh +# box. Layered on the same PQ-hybrid posture as harden-ssh.sh, plus jump-host +# specifics. +# +# Two groups, two privilege levels: +# ssh-admins -- full TTY shell on the jump host (for maintenance only). +# No forwarding. This is for fixing the box, not for +# reaching anywhere else. +# ssh-jumpers -- ProxyJump ONLY. No TTY, no shell, no SFTP, no agent +# forwarding. Only direct-tcpip to whitelisted targets. +# These users cannot get a prompt on the jump host even if +# their key works. +# +# How the restriction works: +# - Global default: DisableForwarding yes, PermitTTY no, ForceCommand +# /sbin/nologin. So a user with no group membership cannot do anything. +# - Match Group ssh-admins: re-enables PermitTTY and clears ForceCommand +# so admins get a normal shell. Forwarding stays off. +# - Match Group ssh-jumpers: enables AllowTcpForwarding + PermitOpen +# whitelist. Keeps PermitTTY no and ForceCommand /sbin/nologin so any +# attempt at an interactive session fails -- but ProxyJump (direct-tcpip) +# still works because it doesn't open a session channel. +# +# A note on ProxyJump and ForceCommand: +# `ssh -J jumphost target` opens a direct-tcpip channel on the jump host; +# it is NOT a session/exec request, so ForceCommand never fires. That's +# why this pattern works: jumpers literally cannot run anything on the +# jump host, but their tunnels go through. +# +# Usage: +# bash harden-jumphost.sh +# SSH_PORT=2222 bash harden-jumphost.sh +# JUMP_TARGETS="10.0.0.5:22 10.0.0.6:22" bash harden-jumphost.sh +# ALLOWED_IP=1.2.3.4 bash harden-jumphost.sh +# FORCE=1 bash harden-jumphost.sh + +set -euo pipefail + +# ============================================================================ +# CONFIG +# ============================================================================ +: "${SSH_PORT:=22}" +: "${ALLOWED_IP:=}" +: "${FORCE:=0}" +# Space-separated host:port list jumpers can reach via ProxyJump. +# Empty means jumpers can ProxyJump nowhere (deny-all). Set this to your +# internal targets, e.g. "10.0.0.5:22 10.0.0.6:22". +: "${JUMP_TARGETS:=}" +: "${KEY_COMMENT:=root@$(hostname)-$(date +%Y%m%d)}" + +log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || die "Run as root." +[[ -f /etc/alpine-release ]] || die "This script targets Alpine Linux." + +# ---------------------------------------------------------------------------- +# 1. Packages +# ---------------------------------------------------------------------------- +log "Installing openssh + PAM + sshguard + iptables + gum..." +if apk info -e openssh-server >/dev/null 2>&1 && \ + ! apk info -e openssh-server-pam >/dev/null 2>&1; then + apk del -q openssh-server || true +fi +apk add -q openssh openssh-server-pam linux-pam sshguard iptables ip6tables openrc gum shadow + +# Install sshuser tool alongside this script if present. +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +if [[ -f "$SCRIPT_DIR/sshuser.sh" ]]; then + install -m 0755 "$SCRIPT_DIR/sshuser.sh" /usr/local/bin/sshuser + log "Installed /usr/local/bin/sshuser" +fi + +# ---------------------------------------------------------------------------- +# 2. PQ KEX detection (same as harden-ssh.sh) +# ---------------------------------------------------------------------------- +log "Checking OpenSSH version supports PQ KEX..." +SSH_VER=$(ssh -V 2>&1 | grep -oE 'OpenSSH_[0-9]+\.[0-9]+' | head -1 | sed 's/OpenSSH_//') +SSH_MAJOR=${SSH_VER%%.*} +SSH_MINOR=${SSH_VER##*.} + +HAS_MLKEM=0 +HAS_SNTRUP=0 +[[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 0 ) ]] && HAS_SNTRUP=1 +[[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 9 ) ]] && HAS_MLKEM=1 +[[ $HAS_SNTRUP -eq 1 || $HAS_MLKEM -eq 1 ]] \ + || die "OpenSSH ${SSH_VER} has no PQ KEX. Need >= 9.0." +log "OpenSSH ${SSH_VER}: ML-KEM=${HAS_MLKEM} sntrup761=${HAS_SNTRUP}" + +KEX_LIST="" +[[ $HAS_MLKEM -eq 1 ]] && KEX_LIST="mlkem768x25519-sha256" +[[ $HAS_SNTRUP -eq 1 ]] && KEX_LIST="${KEX_LIST:+$KEX_LIST,}sntrup761x25519-sha512" + +# ---------------------------------------------------------------------------- +# 3. Host keys (Ed25519 only) +# ---------------------------------------------------------------------------- +log "Regenerating host keys (Ed25519 only)..." +rm -f /etc/ssh/ssh_host_rsa_key* /etc/ssh/ssh_host_ecdsa_key* /etc/ssh/ssh_host_dsa_key* +if [[ ! -f /etc/ssh/ssh_host_ed25519_key ]]; then + ssh-keygen -q -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N "" \ + -C "host@$(hostname)-$(date +%Y%m%d)" +fi +chmod 600 /etc/ssh/ssh_host_ed25519_key +chmod 644 /etc/ssh/ssh_host_ed25519_key.pub + +log "Host key fingerprint:" +ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub | sed 's/^/ /' + +# Stop Alpine's sshd init from regenerating RSA/ECDSA keys. +if [[ -f /etc/conf.d/sshd ]]; then + if ! grep -q '^sshd_disable_keygen=' /etc/conf.d/sshd; then + echo 'sshd_disable_keygen="yes"' >> /etc/conf.d/sshd + else + sed -i 's/^sshd_disable_keygen=.*/sshd_disable_keygen="yes"/' /etc/conf.d/sshd + fi +fi + +# ---------------------------------------------------------------------------- +# 4. Groups -- create if missing +# ---------------------------------------------------------------------------- +log "Ensuring groups ssh-admins and ssh-jumpers exist..." +getent group ssh-admins >/dev/null || addgroup -S ssh-admins +getent group ssh-jumpers >/dev/null || addgroup -S ssh-jumpers + +# ---------------------------------------------------------------------------- +# 5. Root keypair (for ssh-admins maintenance access) +# ---------------------------------------------------------------------------- +log "Generating Ed25519 keypair for root..." +mkdir -p /root/.ssh +chmod 700 /root/.ssh +touch /root/.ssh/authorized_keys +chmod 600 /root/.ssh/authorized_keys + +# Add root to ssh-admins so the Match block applies. +adduser root ssh-admins 2>/dev/null || true + +TMP_KEY=$(mktemp -u /tmp/root_ed25519.XXXXXX) +ssh-keygen -q -t ed25519 -f "$TMP_KEY" -N "" -C "$KEY_COMMENT" +ROOT_PUB=$(cat "${TMP_KEY}.pub") +ROOT_PRIV=$(cat "$TMP_KEY") +if ! grep -qxF "$ROOT_PUB" /root/.ssh/authorized_keys; then + echo "$ROOT_PUB" >> /root/.ssh/authorized_keys +fi + +# ---------------------------------------------------------------------------- +# 6. Build PermitOpen line from JUMP_TARGETS +# ---------------------------------------------------------------------------- +# JUMP_TARGETS is space-separated; PermitOpen wants comma-separated. +# Empty => "none" (deny all). sshd_config doesn't accept a literal empty list. +PERMIT_OPEN_LINE="none" +if [[ -n "$JUMP_TARGETS" ]]; then + PERMIT_OPEN_LINE=$(echo "$JUMP_TARGETS" | tr -s ' ' ',' | sed 's/^,//;s/,$//') +fi + +# ---------------------------------------------------------------------------- +# 7. sshd_config +# ---------------------------------------------------------------------------- +log "Writing /etc/ssh/sshd_config..." +[[ -f /etc/ssh/sshd_config.orig ]] || cp /etc/ssh/sshd_config /etc/ssh/sshd_config.orig + +cat > /etc/ssh/sshd_config </tmp/sshd-test.err; then + cat /tmp/sshd-test.err >&2 + cp /etc/ssh/sshd_config.orig /etc/ssh/sshd_config + die "sshd config invalid; restored original. NOT reloading." +fi +rm -f /tmp/sshd-test.err + +# ---------------------------------------------------------------------------- +# 9. sshguard +# ---------------------------------------------------------------------------- +log "Configuring sshguard..." +mkdir -p /etc/sshguard +WHITELIST=/etc/sshguard/whitelist +{ + echo "127.0.0.1" + echo "::1" + [[ -n "$ALLOWED_IP" ]] && echo "$ALLOWED_IP" +} > "$WHITELIST" + +cat > /etc/sshguard/sshguard.conf < /etc/local.d/sshguard-iptables.start <<'EOF' +#!/bin/sh +SSH_PORT=$(awk '/^Port / {print $2; exit}' /etc/ssh/sshd_config) +SSH_PORT=${SSH_PORT:-22} +for ipt in iptables ip6tables; do + $ipt -N sshguard 2>/dev/null || true + $ipt -C INPUT -p tcp --dport "$SSH_PORT" -j sshguard 2>/dev/null \ + || $ipt -I INPUT -p tcp --dport "$SSH_PORT" -j sshguard +done +EOF +chmod +x /etc/local.d/sshguard-iptables.start +rc-update add local default 2>/dev/null || true +/etc/local.d/sshguard-iptables.start + +rc-update add sshguard default +rc-service sshguard restart || rc-service sshguard start + +# ---------------------------------------------------------------------------- +# 10. Enable sshd +# ---------------------------------------------------------------------------- +log "Enabling sshd at boot..." +rc-update add sshd default + +cat < /home/alice/.ssh/authorized_keys + chmod 600 /home/alice/.ssh/authorized_keys + chown -R alice:alice /home/alice/.ssh + +Note the user's shell can be /sbin/nologin -- ProxyJump still works +because it never opens a session channel. + +Allowed jump targets (PermitOpen): + ${PERMIT_OPEN_LINE} + +To change targets: edit JUMP_TARGETS and re-run, or edit the Match +block in /etc/ssh/sshd_config directly. + +COPY THIS PRIVATE KEY TO YOUR CLIENT *NOW* (admin/root key): + + ssh -i ~/.ssh/id_ed25519_jump -p ${SSH_PORT} root@ + +----- BEGIN ROOT PRIVATE KEY (Ed25519) ----- +${ROOT_PRIV} +----- END ROOT PRIVATE KEY ----- + +Public key (already in /root/.ssh/authorized_keys): +${ROOT_PUB} + +Host fingerprint: +$(ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub) + +Client usage examples: + + # Admin shell on the jump host: + ssh -i ~/.ssh/id_ed25519_jump -p ${SSH_PORT} root@ + + # ProxyJump through to an internal target: + ssh -J root@:${SSH_PORT} -i ~/.ssh/id_ed25519_target user@ + +================================================================ +EOF + +shred -u "$TMP_KEY" "${TMP_KEY}.pub" 2>/dev/null || rm -f "$TMP_KEY" "${TMP_KEY}.pub" + +if [[ "$FORCE" != "1" ]]; then + cat < -p ${SSH_PORT} root@ + +Reload sshd now? [y/N] +EOF + read -r ans + if [[ "${ans,,}" != "y" && "${ans,,}" != "yes" ]]; then + warn "Skipping reload. Run 'rc-service sshd reload' manually when ready." + exit 0 + fi +fi + +log "Reloading sshd..." +rc-service sshd reload || rc-service sshd restart +log "Done." diff --git a/scripts/harden-ssh.sh b/scripts/harden-ssh.sh new file mode 100644 index 0000000..9bdfe70 --- /dev/null +++ b/scripts/harden-ssh.sh @@ -0,0 +1,374 @@ +#!/usr/bin/env bash +# +# harden-ssh.sh +# +# SSH hardening for Alpine Linux. Run BEFORE deploy-simplex.sh on a fresh box. +# +# What this does: +# 1. Generates fresh Ed25519 host keys; removes RSA/ECDSA/DSA host keys +# 2. Generates an Ed25519 root keypair on the host, installs the public key +# into /root/.ssh/authorized_keys, and PRINTS the private key to stdout +# so you can copy it to your client. THIS IS YOUR ONLY CHANCE TO COPY IT. +# 3. Forces post-quantum hybrid KEX only: +# mlkem768x25519-sha256 (the future default, NIST ML-KEM hybrid) +# sntrup761x25519-sha512 (older PQ KEX, kept as fallback) +# Drops every classical-only KEX. Connections that don't speak PQ KEX +# will be rejected. +# 4. Modern ciphers and MACs only (chacha20-poly1305, aes256-gcm, +# hmac-sha2-512-etm) +# 5. Disables everything not needed for an interactive terminal: +# - password auth, root password login (key-only) +# - challenge-response, GSSAPI, PAM, host-based auth +# - X11 forwarding, agent forwarding +# - TCP forwarding, stream-local forwarding (UNIX sockets) +# - tunneling (PermitTunnel), gateway ports +# - SFTP subsystem (kept ON — needed for backup retrieval) +# - empty passwords, .ssh/rc execution, compression +# Result: a session can run a shell. That's it. No -L, no -R, no -D, no +# jump hosting, no sftp, no scp. +# 6. Optional non-default port (-p PORT) +# 7. Installs sshguard with iptables backend for brute-force protection +# 8. Validates config with `sshd -t` and prompts for confirmation before +# reloading sshd (so a config error or a typo doesn't lock you out) +# +# A note on "quantum-safe": +# Stock OpenSSH provides PQ KEY EXCHANGE (the session key, the thing that +# matters for "store now, decrypt later"). It does NOT yet provide PQ +# AUTHENTICATION KEYS -- there is no standardized PQ host or user key +# algorithm in mainline OpenSSH yet. So: +# - Your session is PQ-protected against SNDL: yes +# - Your auth keypair (Ed25519) is classical: yes, and that's the best +# practical choice today. PQ signature support exists only in the +# open-quantum-safe/openssh fork, which breaks compatibility with +# every standard SSH client. +# This script gives you the strongest stock-OpenSSH posture available. +# +# Usage: +# bash harden-ssh.sh # port stays 22, default +# SSH_PORT=2222 bash harden-ssh.sh # change port +# ALLOWED_IP=1.2.3.4 bash harden-ssh.sh # whitelist your client IP in sshguard +# FORCE=1 bash harden-ssh.sh # skip the "are you sure" prompt + +set -euo pipefail + +# ============================================================================ +# CONFIG +# ============================================================================ +: "${SSH_PORT:=22}" +: "${ALLOWED_IP:=}" # optional: your client IP, will be sshguard-whitelisted +: "${KEY_COMMENT:=root@$(hostname)-$(date +%Y%m%d)}" +: "${FORCE:=0}" + +log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || die "Run as root." +[[ -f /etc/alpine-release ]] || die "This script targets Alpine Linux." + +# ---------------------------------------------------------------------------- +# 1. Pre-flight checks +# ---------------------------------------------------------------------------- +log "Checking OpenSSH version supports PQ KEX..." +SSH_VER=$(ssh -V 2>&1 | grep -oE 'OpenSSH_[0-9]+\.[0-9]+' | head -1 \ + | sed 's/OpenSSH_//') +SSH_MAJOR=${SSH_VER%%.*} +SSH_MINOR=${SSH_VER##*.} + +# OpenSSH 9.0+ has sntrup761x25519-sha512. +# OpenSSH 9.9+ also has mlkem768x25519-sha256. +HAS_MLKEM=0 +HAS_SNTRUP=0 +if [[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 0 ) ]]; then + HAS_SNTRUP=1 +fi +if [[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 9 ) ]]; then + HAS_MLKEM=1 +fi +[[ $HAS_SNTRUP -eq 1 || $HAS_MLKEM -eq 1 ]] \ + || die "OpenSSH ${SSH_VER} has no PQ KEX. Need >= 9.0. Upgrade Alpine first." + +log "OpenSSH ${SSH_VER}: ML-KEM=${HAS_MLKEM} sntrup761=${HAS_SNTRUP}" + +# Build the KEX list from what's actually available. +KEX_LIST="" +[[ $HAS_MLKEM -eq 1 ]] && KEX_LIST="mlkem768x25519-sha256" +[[ $HAS_SNTRUP -eq 1 ]] && KEX_LIST="${KEX_LIST:+$KEX_LIST,}sntrup761x25519-sha512" + +# ---------------------------------------------------------------------------- +# 2. Install packages +# ---------------------------------------------------------------------------- +log "Installing openssh-server-pam, sshguard, iptables..." +# openssh-server-pam replaces openssh-server (PAM-enabled sshd). If the +# non-pam version was installed earlier, swap it out cleanly. +if apk info -e openssh-server >/dev/null 2>&1 && \ + ! apk info -e openssh-server-pam >/dev/null 2>&1; then + apk del -q openssh-server || true +fi +apk add -q openssh openssh-server-pam linux-pam sshguard iptables ip6tables openrc + +# ---------------------------------------------------------------------------- +# 3. Host keys -- regenerate with Ed25519 only +# ---------------------------------------------------------------------------- +log "Regenerating host keys (Ed25519 only)..." +rm -f /etc/ssh/ssh_host_rsa_key* \ + /etc/ssh/ssh_host_ecdsa_key* \ + /etc/ssh/ssh_host_dsa_key* + +# Keep existing ed25519 key if there is one (so the host fingerprint doesn't +# change unnecessarily on re-runs). Generate one if not. +if [[ ! -f /etc/ssh/ssh_host_ed25519_key ]]; then + ssh-keygen -q -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N "" \ + -C "host@$(hostname)-$(date +%Y%m%d)" +fi +chmod 600 /etc/ssh/ssh_host_ed25519_key +chmod 644 /etc/ssh/ssh_host_ed25519_key.pub + +log "Host key fingerprint (verify on first connect):" +ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub | sed 's/^/ /' + +# Stop Alpine's sshd init from regenerating RSA/ECDSA keys on every start. +# /etc/conf.d/sshd: pin sshd_disable_keygen=no but only generate ed25519 by +# overriding the keygen line in the init script via a drop-in. +if [[ -f /etc/conf.d/sshd ]]; then + if ! grep -q '^sshd_disable_keygen=' /etc/conf.d/sshd; then + echo 'sshd_disable_keygen="yes"' >> /etc/conf.d/sshd + else + sed -i 's/^sshd_disable_keygen=.*/sshd_disable_keygen="yes"/' /etc/conf.d/sshd + fi +fi + +# ---------------------------------------------------------------------------- +# 4. Root user keypair +# ---------------------------------------------------------------------------- +log "Generating Ed25519 keypair for root..." +mkdir -p /root/.ssh +chmod 700 /root/.ssh +touch /root/.ssh/authorized_keys +chmod 600 /root/.ssh/authorized_keys + +# Always create a brand-new key pair in a temp location so we can show the +# private key to the user and then add the public key to authorized_keys. +TMP_KEY=$(mktemp -u /tmp/root_ed25519.XXXXXX) +ssh-keygen -q -t ed25519 -f "$TMP_KEY" -N "" -C "$KEY_COMMENT" + +ROOT_PUB=$(cat "${TMP_KEY}.pub") +ROOT_PRIV=$(cat "$TMP_KEY") + +# Idempotency: don't add the same pubkey twice. +if ! grep -qxF "$ROOT_PUB" /root/.ssh/authorized_keys; then + echo "$ROOT_PUB" >> /root/.ssh/authorized_keys +fi + +# ---------------------------------------------------------------------------- +# 5. sshd_config +# ---------------------------------------------------------------------------- +log "Writing /etc/ssh/sshd_config..." +# Back up whatever was there before, once. +[[ -f /etc/ssh/sshd_config.orig ]] || cp /etc/ssh/sshd_config /etc/ssh/sshd_config.orig + +cat > /etc/ssh/sshd_config </tmp/sshd-test.err; then + cat /tmp/sshd-test.err >&2 + cp /etc/ssh/sshd_config.orig /etc/ssh/sshd_config + die "sshd config invalid; restored original. NOT reloading." +fi +rm -f /tmp/sshd-test.err + +# ---------------------------------------------------------------------------- +# 7. sshguard (brute-force protection) +# ---------------------------------------------------------------------------- +log "Configuring sshguard with iptables backend..." +mkdir -p /etc/sshguard + +# Whitelist: localhost always, plus optional caller-supplied IP. +WHITELIST=/etc/sshguard/whitelist +{ + echo "127.0.0.1" + echo "::1" + [[ -n "$ALLOWED_IP" ]] && echo "$ALLOWED_IP" +} > "$WHITELIST" + +# sshguard.conf: explicitly point it at iptables backend. +cat > /etc/sshguard/sshguard.conf < /etc/local.d/sshguard-iptables.start <<'EOF' +#!/bin/sh +# Ensure sshguard chain exists and INPUT jumps to it for tcp/22 (and PORT). +SSH_PORT=$(awk '/^Port / {print $2; exit}' /etc/ssh/sshd_config) +SSH_PORT=${SSH_PORT:-22} +for ipt in iptables ip6tables; do + $ipt -N sshguard 2>/dev/null || true + $ipt -C INPUT -p tcp --dport "$SSH_PORT" -j sshguard 2>/dev/null \ + || $ipt -I INPUT -p tcp --dport "$SSH_PORT" -j sshguard +done +EOF +chmod +x /etc/local.d/sshguard-iptables.start +rc-update add local default 2>/dev/null || true +/etc/local.d/sshguard-iptables.start + +rc-update add sshguard default +rc-service sshguard restart || rc-service sshguard start + +# ---------------------------------------------------------------------------- +# 8. SSHD enable & reload (with safety prompt) +# ---------------------------------------------------------------------------- +log "Enabling sshd at boot..." +rc-update add sshd default + +# Print the private key BEFORE reloading sshd so even if reload locks the +# user out, they have what they need to come back in via console. +cat < + +----- BEGIN ROOT PRIVATE KEY (Ed25519) ----- +${ROOT_PRIV} +----- END ROOT PRIVATE KEY ----- + +Public key (already in /root/.ssh/authorized_keys): +${ROOT_PUB} + +Host fingerprint (verify on first connect): +$(ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub) + +================================================================ +EOF + +# Wipe the temp files holding the private key. +shred -u "$TMP_KEY" "${TMP_KEY}.pub" 2>/dev/null || rm -f "$TMP_KEY" "${TMP_KEY}.pub" + +# Final guard: confirm before reloading sshd. A bad reload is recoverable from +# console; a bad reload while you assumed everything was fine is not. +if [[ "$FORCE" != "1" ]]; then + cat < -p ${SSH_PORT} \\ + -o KexAlgorithms=${KEX_LIST} root@ + +Reload sshd now? [y/N] +EOF + read -r ans + if [[ "${ans,,}" != "y" && "${ans,,}" != "yes" ]]; then + warn "Skipping reload. Run 'rc-service sshd reload' manually when ready." + exit 0 + fi +fi + +log "Reloading sshd..." +rc-service sshd reload || rc-service sshd restart + +log "Done. Your session, if any, should remain alive (reload preserves connections)." +log "Test from another machine before closing this session." diff --git a/scripts/sshuser.sh b/scripts/sshuser.sh new file mode 100644 index 0000000..9207e3d --- /dev/null +++ b/scripts/sshuser.sh @@ -0,0 +1,358 @@ +#!/usr/bin/env bash +# +# sshuser -- manage SSH users on a hardened Alpine box. +# +# Two roles, matching harden-jumphost.sh: +# admin -> group ssh-admins, shell /bin/ash (full shell) +# jumper -> group ssh-jumpers, shell /sbin/nologin (ProxyJump only) +# +# Two modes: +# - TUI (gum) : run with no command, or any command with missing args +# - CLI flags : full automation, suitable for shell scripts and CI +# +# Install: +# install -m 0755 sshuser.sh /usr/local/bin/sshuser +# apk add gum # only needed for TUI mode +# +# Usage: +# sshuser # interactive TUI +# sshuser add -u alice -r jumper -f alice.pub +# sshuser add -u bob -r admin -k "ssh-ed25519 AAA..." +# sshuser edit -u alice --add-key "ssh-ed25519 BBB..." +# sshuser edit -u alice --remove-key "comment-substring" +# sshuser edit -u alice --role admin +# sshuser remove -u alice -y +# sshuser list +# sshuser list -r jumper +# sshuser show -u alice + +set -euo pipefail + +ADMIN_GROUP="ssh-admins" +JUMPER_GROUP="ssh-jumpers" +ADMIN_SHELL="/bin/ash" +JUMPER_SHELL="/sbin/nologin" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +err() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; } +warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; } +log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; } + +have_gum() { command -v gum >/dev/null 2>&1; } + +require_root() { + [[ $EUID -eq 0 ]] || err "Run as root." +} + +require_groups() { + getent group "$ADMIN_GROUP" >/dev/null || err "Group $ADMIN_GROUP missing -- run harden-jumphost.sh first." + getent group "$JUMPER_GROUP" >/dev/null || err "Group $JUMPER_GROUP missing -- run harden-jumphost.sh first." +} + +confirm() { + # confirm "Prompt" -- returns 0 if yes, 1 if no. + local prompt="$1" + if [[ "${YES:-0}" == "1" ]]; then return 0; fi + if have_gum; then + gum confirm "$prompt" + else + read -r -p "$prompt [y/N] " ans + [[ "${ans,,}" == "y" || "${ans,,}" == "yes" ]] + fi +} + +ask() { + # ask "Prompt" [default] -- prints user input. + local prompt="$1" + local default="${2:-}" + if have_gum; then + gum input --prompt "$prompt: " --placeholder "$default" + else + local ans + read -r -p "$prompt${default:+ [$default]}: " ans + echo "${ans:-$default}" + fi +} + +choose() { + # choose "Prompt" opt1 opt2 ... -- prints chosen. + local prompt="$1"; shift + if have_gum; then + gum choose --header "$prompt" "$@" + else + printf '%s\n' "$prompt" >&2 + local i=1; for o in "$@"; do printf ' %d) %s\n' "$i" "$o" >&2; ((i++)); done + local n; read -r -p "Choice: " n + echo "${!n}" + fi +} + +valid_pubkey() { + # Quick sanity check: starts with a known algo prefix. + [[ "$1" =~ ^(ssh-ed25519|ssh-rsa|sk-ssh-ed25519@openssh\.com|ecdsa-sha2-)[[:space:]]+[A-Za-z0-9+/=]+ ]] +} + +resolve_role() { + # Map role -> group + shell. echoes "GROUP SHELL". + case "$1" in + admin) echo "$ADMIN_GROUP $ADMIN_SHELL" ;; + jumper) echo "$JUMPER_GROUP $JUMPER_SHELL" ;; + *) err "Unknown role: $1 (must be admin|jumper)" ;; + esac +} + +ssh_dir_setup() { + # Ensure ~user/.ssh exists with correct perms and ownership. + local user="$1" + local home; home=$(getent passwd "$user" | cut -d: -f6) + [[ -n "$home" ]] || err "User $user has no home directory." + install -d -m 0700 -o "$user" -g "$user" "$home/.ssh" + install -m 0600 -o "$user" -g "$user" /dev/null "$home/.ssh/authorized_keys" 2>/dev/null || \ + touch "$home/.ssh/authorized_keys" + chown "$user:$user" "$home/.ssh/authorized_keys" + chmod 600 "$home/.ssh/authorized_keys" + echo "$home/.ssh/authorized_keys" +} + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + +cmd_add() { + local user="${USER_ARG:-}" role="${ROLE_ARG:-}" key="${KEY_ARG:-}" + [[ -z "$user" ]] && user=$(ask "Username") + [[ -z "$user" ]] && err "Username required." + if id "$user" >/dev/null 2>&1; then + err "User $user already exists. Use 'sshuser edit' instead." + fi + [[ -z "$role" ]] && role=$(choose "Role" admin jumper) + read -r group shell <<<"$(resolve_role "$role")" + + if [[ -z "$key" ]]; then + if [[ -n "${KEY_FILE_ARG:-}" ]]; then + [[ -f "$KEY_FILE_ARG" ]] || err "Key file not found: $KEY_FILE_ARG" + key=$(<"$KEY_FILE_ARG") + elif have_gum; then + key=$(gum input --prompt "SSH public key (paste full line, blank to skip): " --width 200) + else + read -r -p "SSH public key (paste full line, blank to skip): " key + fi + fi + if [[ -n "$key" ]]; then + valid_pubkey "$key" || err "That doesn't look like a valid SSH public key." + fi + + confirm "Create user $user as $role (group $group, shell $shell)?" || { warn "Aborted."; exit 1; } + + log "Creating $user..." + adduser -D -s "$shell" -g "" "$user" + adduser "$user" "$group" + + if [[ -n "$key" ]]; then + local ak; ak=$(ssh_dir_setup "$user") + if grep -qxF "$key" "$ak" 2>/dev/null; then + warn "Key already present, skipping." + else + echo "$key" >> "$ak" + log "Added SSH key to $ak" + fi + else + warn "No SSH key added. User cannot log in until 'sshuser edit -u $user --add-key ...' is run." + fi + log "Done." +} + +cmd_edit() { + local user="${USER_ARG:-}" + [[ -z "$user" ]] && user=$(ask "Username to edit") + id "$user" >/dev/null 2>&1 || err "No such user: $user" + + # If no specific edit flag, prompt for action. + local has_action=0 + [[ -n "${ADD_KEY_ARG:-}" ]] && has_action=1 + [[ -n "${REMOVE_KEY_ARG:-}" ]] && has_action=1 + [[ -n "${ROLE_ARG:-}" ]] && has_action=1 + [[ -n "${SHELL_ARG:-}" ]] && has_action=1 + + if [[ $has_action -eq 0 ]]; then + local action + action=$(choose "What to do for $user?" \ + "add ssh key" "remove ssh key" "change role" "change shell" "cancel") + case "$action" in + "add ssh key") ADD_KEY_ARG=$(ask "Paste SSH public key") ;; + "remove ssh key") REMOVE_KEY_ARG=$(ask "Substring of key to remove (comment is fine)") ;; + "change role") ROLE_ARG=$(choose "New role" admin jumper) ;; + "change shell") SHELL_ARG=$(ask "New shell" "/bin/ash") ;; + *) warn "Cancelled."; return 0 ;; + esac + fi + + if [[ -n "${ROLE_ARG:-}" ]]; then + read -r new_group new_shell <<<"$(resolve_role "$ROLE_ARG")" + log "Setting $user role to $ROLE_ARG (group $new_group, shell $new_shell)" + # Remove from the other ssh group, add to the target. + local other + [[ "$new_group" == "$ADMIN_GROUP" ]] && other="$JUMPER_GROUP" || other="$ADMIN_GROUP" + deluser "$user" "$other" 2>/dev/null || true + adduser "$user" "$new_group" 2>/dev/null || true + usermod -s "$new_shell" "$user" 2>/dev/null || \ + sed -i "s|^\($user:.*:\)[^:]*$|\1$new_shell|" /etc/passwd + fi + + if [[ -n "${SHELL_ARG:-}" ]]; then + log "Setting $user shell to $SHELL_ARG" + usermod -s "$SHELL_ARG" "$user" 2>/dev/null || \ + sed -i "s|^\($user:.*:\)[^:]*$|\1$SHELL_ARG|" /etc/passwd + fi + + if [[ -n "${ADD_KEY_ARG:-}" ]]; then + valid_pubkey "$ADD_KEY_ARG" || err "Invalid pubkey." + local ak; ak=$(ssh_dir_setup "$user") + if grep -qxF "$ADD_KEY_ARG" "$ak" 2>/dev/null; then + warn "Key already present." + else + echo "$ADD_KEY_ARG" >> "$ak" + log "Added key to $ak" + fi + fi + + if [[ -n "${REMOVE_KEY_ARG:-}" ]]; then + local ak; ak=$(ssh_dir_setup "$user") + local before after + before=$(wc -l <"$ak") + # Remove any line containing the substring (safe escape for sed). + local pat; pat=$(printf '%s\n' "$REMOVE_KEY_ARG" | sed 's/[][\.*^$/]/\\&/g') + sed -i "/$pat/d" "$ak" + after=$(wc -l <"$ak") + log "Removed $((before - after)) key line(s) from $ak" + fi +} + +cmd_remove() { + local user="${USER_ARG:-}" + [[ -z "$user" ]] && user=$(ask "Username to remove") + id "$user" >/dev/null 2>&1 || err "No such user: $user" + [[ "$user" == "root" ]] && err "Refusing to remove root." + + confirm "DELETE user $user and their home directory?" || { warn "Aborted."; exit 1; } + log "Deleting $user..." + deluser --remove-home "$user" 2>/dev/null || deluser "$user" + log "Done." +} + +cmd_list() { + local role="${ROLE_ARG:-}" + local groups=() + case "$role" in + admin) groups=("$ADMIN_GROUP") ;; + jumper) groups=("$JUMPER_GROUP") ;; + "") groups=("$ADMIN_GROUP" "$JUMPER_GROUP") ;; + *) err "Unknown role: $role" ;; + esac + + printf '%-20s %-15s %-20s %s\n' "USER" "ROLE" "SHELL" "KEYS" + printf '%-20s %-15s %-20s %s\n' "----" "----" "-----" "----" + for g in "${groups[@]}"; do + local label="admin" + [[ "$g" == "$JUMPER_GROUP" ]] && label="jumper" + # getent group returns: groupname:x:gid:user1,user2,... + local members; members=$(getent group "$g" | awk -F: '{print $4}' | tr ',' ' ') + for u in $members; do + [[ -z "$u" ]] && continue + local home; home=$(getent passwd "$u" | cut -d: -f6) + local shell; shell=$(getent passwd "$u" | cut -d: -f7) + local nkeys=0 + [[ -f "$home/.ssh/authorized_keys" ]] && nkeys=$(grep -cv '^\s*$\|^\s*#' "$home/.ssh/authorized_keys" 2>/dev/null || echo 0) + printf '%-20s %-15s %-20s %s\n' "$u" "$label" "$shell" "$nkeys" + done + done +} + +cmd_show() { + local user="${USER_ARG:-}" + [[ -z "$user" ]] && user=$(ask "Username") + id "$user" >/dev/null 2>&1 || err "No such user: $user" + local home; home=$(getent passwd "$user" | cut -d: -f6) + local shell; shell=$(getent passwd "$user" | cut -d: -f7) + local groups; groups=$(id -nG "$user" | tr ' ' ',') + echo "user: $user" + echo "home: $home" + echo "shell: $shell" + echo "groups: $groups" + echo "keys:" + if [[ -f "$home/.ssh/authorized_keys" ]]; then + awk 'NF && !/^#/ { + n=split($0, p, " "); + type=p[1]; comment=(n>=3 ? p[n] : "(no comment)"); + printf " - %s %s\n", type, comment + }' "$home/.ssh/authorized_keys" + else + echo " (none)" + fi +} + +cmd_tui() { + have_gum || err "TUI mode requires gum: 'apk add gum'. Or use CLI flags (sshuser --help)." + local action + action=$(choose "What do you want to do?" \ + "add user" "edit user" "remove user" "list users" "show user" "quit") + case "$action" in + "add user") cmd_add ;; + "edit user") cmd_edit ;; + "remove user") cmd_remove ;; + "list users") cmd_list ;; + "show user") cmd_show ;; + *) ;; + esac +} + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +usage() { + sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' +} + +CMD="" +USER_ARG="" +ROLE_ARG="" +KEY_ARG="" +KEY_FILE_ARG="" +ADD_KEY_ARG="" +REMOVE_KEY_ARG="" +SHELL_ARG="" +YES=0 + +if [[ $# -gt 0 && "$1" != -* ]]; then + CMD="$1"; shift +fi + +while [[ $# -gt 0 ]]; do + case "$1" in + -u|--user) USER_ARG="$2"; shift 2 ;; + -r|--role) ROLE_ARG="$2"; shift 2 ;; + -k|--key) KEY_ARG="$2"; shift 2 ;; + -f|--key-file) KEY_FILE_ARG="$2"; shift 2 ;; + --add-key) ADD_KEY_ARG="$2"; shift 2 ;; + --remove-key) REMOVE_KEY_ARG="$2"; shift 2 ;; + --shell) SHELL_ARG="$2"; shift 2 ;; + -y|--yes) YES=1; shift ;; + -h|--help) usage; exit 0 ;; + *) err "Unknown flag: $1" ;; + esac +done + +require_root +require_groups + +case "$CMD" in + add) cmd_add ;; + edit) cmd_edit ;; + remove|rm|del) cmd_remove ;; + list|ls) cmd_list ;; + show) cmd_show ;; + "") cmd_tui ;; + *) err "Unknown command: $CMD (try: add edit remove list show)" ;; +esac