Upload files to "scripts"
This commit is contained in:
@@ -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 <<EOF
|
||||
# Generated by harden-jumphost.sh -- $(date -u +%FT%TZ)
|
||||
# Original config preserved at /etc/ssh/sshd_config.orig
|
||||
|
||||
Port ${SSH_PORT}
|
||||
AddressFamily any
|
||||
ListenAddress 0.0.0.0
|
||||
ListenAddress ::
|
||||
|
||||
# --- Host key: Ed25519 only ---
|
||||
HostKey /etc/ssh/ssh_host_ed25519_key
|
||||
|
||||
# --- Post-quantum hybrid KEX only ---
|
||||
KexAlgorithms ${KEX_LIST}
|
||||
|
||||
# --- Modern ciphers and MACs ---
|
||||
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
|
||||
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com
|
||||
|
||||
# --- Algorithms for host and client signatures ---
|
||||
HostKeyAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com
|
||||
PubkeyAcceptedAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,sk-ssh-ed25519@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256,rsa-sha2-256-cert-v01@openssh.com
|
||||
RequiredRSASize 4096
|
||||
|
||||
# --- Authentication ---
|
||||
PermitRootLogin prohibit-password
|
||||
PubkeyAuthentication yes
|
||||
PasswordAuthentication no
|
||||
PermitEmptyPasswords no
|
||||
ChallengeResponseAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
UsePAM yes
|
||||
AuthenticationMethods publickey
|
||||
MaxAuthTries 3
|
||||
MaxSessions 10
|
||||
LoginGraceTime 30s
|
||||
|
||||
# --- Default posture: deny everything ---
|
||||
# Anyone not in ssh-admins or ssh-jumpers gets nothing. Matches below
|
||||
# selectively re-enable what each group needs.
|
||||
DisableForwarding yes
|
||||
PermitTTY no
|
||||
ForceCommand /sbin/nologin
|
||||
X11Forwarding no
|
||||
GatewayPorts no
|
||||
PermitTunnel no
|
||||
PermitUserRC no
|
||||
PermitListen none
|
||||
PermitOpen none
|
||||
AllowAgentForwarding no
|
||||
AllowTcpForwarding no
|
||||
AllowStreamLocalForwarding no
|
||||
PrintMotd no
|
||||
TCPKeepAlive no
|
||||
ClientAliveInterval 300
|
||||
ClientAliveCountMax 2
|
||||
|
||||
# --- Misc ---
|
||||
StrictModes yes
|
||||
IgnoreRhosts yes
|
||||
HostbasedAuthentication no
|
||||
Compression no
|
||||
Banner none
|
||||
|
||||
# --- Subsystems ---
|
||||
# SFTP off by default. Admins who need it can be added to a separate
|
||||
# Match block; jumpers must never have it.
|
||||
# Subsystem sftp internal-sftp
|
||||
|
||||
# --- Allowlist ---
|
||||
# Only members of these groups can authenticate at all. AllowGroups
|
||||
# enforces this BEFORE the per-group Match blocks run.
|
||||
AllowGroups ssh-admins ssh-jumpers
|
||||
|
||||
# ============================================================================
|
||||
# Match blocks
|
||||
# ============================================================================
|
||||
|
||||
# --- Admins: full shell, no forwarding, no SFTP ---
|
||||
Match Group ssh-admins
|
||||
PermitTTY yes
|
||||
ForceCommand none
|
||||
DisableForwarding yes
|
||||
AllowTcpForwarding no
|
||||
AllowAgentForwarding no
|
||||
AllowStreamLocalForwarding no
|
||||
X11Forwarding no
|
||||
PermitOpen none
|
||||
|
||||
# --- Jumpers: ProxyJump only, no shell, no SFTP, whitelisted destinations ---
|
||||
# direct-tcpip channels are controlled by AllowTcpForwarding + PermitOpen.
|
||||
# Session channels (shell/exec/subsystem) hit ForceCommand=/sbin/nologin
|
||||
# and PermitTTY=no, so any interactive attempt fails immediately.
|
||||
Match Group ssh-jumpers
|
||||
PermitTTY no
|
||||
ForceCommand /sbin/nologin
|
||||
AllowTcpForwarding yes
|
||||
PermitOpen ${PERMIT_OPEN_LINE}
|
||||
AllowAgentForwarding no
|
||||
AllowStreamLocalForwarding no
|
||||
DisableForwarding no
|
||||
X11Forwarding no
|
||||
GatewayPorts no
|
||||
PermitTunnel no
|
||||
EOF
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 8. Validate config
|
||||
# ----------------------------------------------------------------------------
|
||||
log "Validating sshd config..."
|
||||
if ! sshd -t 2>/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 <<EOF
|
||||
BACKEND="/usr/libexec/sshg-fw-iptables"
|
||||
LOGREADER="LANG=C journalctl -afb -p info -n1 -u sshd -o cat"
|
||||
THRESHOLD=30
|
||||
BLOCK_TIME=300
|
||||
DETECTION_TIME=1800
|
||||
PID_FILE=/run/sshguard.pid
|
||||
WHITELIST_FILE=${WHITELIST}
|
||||
EOF
|
||||
|
||||
cat > /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 <<EOF
|
||||
|
||||
================================================================
|
||||
JUMP HOST SETUP COMPLETE
|
||||
|
||||
Groups created:
|
||||
ssh-admins -- full shell on the jump host (root added)
|
||||
ssh-jumpers -- ProxyJump only, no shell
|
||||
|
||||
Add a jumper user:
|
||||
adduser -D -s /sbin/nologin alice
|
||||
addgroup alice ssh-jumpers
|
||||
mkdir -p /home/alice/.ssh && chmod 700 /home/alice/.ssh
|
||||
echo 'ssh-ed25519 AAA...' > /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@<host>
|
||||
|
||||
----- 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@<jumphost>
|
||||
|
||||
# ProxyJump through to an internal target:
|
||||
ssh -J root@<jumphost>:${SSH_PORT} -i ~/.ssh/id_ed25519_target user@<target>
|
||||
|
||||
================================================================
|
||||
EOF
|
||||
|
||||
shred -u "$TMP_KEY" "${TMP_KEY}.pub" 2>/dev/null || rm -f "$TMP_KEY" "${TMP_KEY}.pub"
|
||||
|
||||
if [[ "$FORCE" != "1" ]]; then
|
||||
cat <<EOF
|
||||
sshd config has passed validation. Ready to reload sshd.
|
||||
|
||||
Open a SECOND terminal and verify the new key/port/KEX work BEFORE
|
||||
answering yes:
|
||||
ssh -i ~/.ssh/<saved key> -p ${SSH_PORT} root@<host>
|
||||
|
||||
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."
|
||||
@@ -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 <<EOF
|
||||
# Generated by harden-ssh.sh -- $(date -u +%FT%TZ)
|
||||
# Original config preserved at /etc/ssh/sshd_config.orig
|
||||
|
||||
Port ${SSH_PORT}
|
||||
AddressFamily any
|
||||
ListenAddress 0.0.0.0
|
||||
ListenAddress ::
|
||||
|
||||
# --- Host key: Ed25519 only ---
|
||||
HostKey /etc/ssh/ssh_host_ed25519_key
|
||||
|
||||
# --- Post-quantum hybrid KEX only ---
|
||||
# Anything not in this list (every classical-only KEX) is rejected. This
|
||||
# protects against "store now, decrypt later" because the session key is
|
||||
# derived from a PQ KEM hybrid.
|
||||
KexAlgorithms ${KEX_LIST}
|
||||
|
||||
# --- Modern ciphers and MACs ---
|
||||
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
|
||||
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com
|
||||
|
||||
# --- Host key signature algorithms (for the host proving itself) ---
|
||||
HostKeyAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com
|
||||
|
||||
# --- Public key algorithms accepted from clients ---
|
||||
# Ed25519 + RSA-4096 (RSA kept for older YubiKey firmware that lacks Ed25519
|
||||
# in the PIV applet -- pre-5.7). Plain ssh-rsa (SHA-1) is NOT included.
|
||||
PubkeyAcceptedAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,sk-ssh-ed25519@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256,rsa-sha2-256-cert-v01@openssh.com
|
||||
RequiredRSASize 4096
|
||||
|
||||
# --- Authentication ---
|
||||
PermitRootLogin prohibit-password
|
||||
PubkeyAuthentication yes
|
||||
PasswordAuthentication no
|
||||
PermitEmptyPasswords no
|
||||
ChallengeResponseAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
# PAM is enabled so the session stack runs on every login (used by
|
||||
# pam_exec.so for post-login notification hooks). Auth still requires a
|
||||
# pubkey -- PAM only handles account/session, not auth, because
|
||||
# PasswordAuthentication and KbdInteractiveAuthentication are off.
|
||||
UsePAM yes
|
||||
AuthenticationMethods publickey
|
||||
MaxAuthTries 3
|
||||
MaxSessions 4
|
||||
LoginGraceTime 30s
|
||||
|
||||
# --- Session restrictions: terminal access only, no forwarding/tunneling ---
|
||||
# This server runs SimpleX relays. The only legitimate SSH use is an admin
|
||||
# terminal session. Every forwarding feature below is therefore disabled --
|
||||
# they're just attack surface and lateral-movement aids if the host is ever
|
||||
# compromised.
|
||||
X11Forwarding no
|
||||
X11UseLocalhost yes
|
||||
AllowAgentForwarding no
|
||||
AllowTcpForwarding no
|
||||
AllowStreamLocalForwarding no
|
||||
DisableForwarding yes # belt-and-braces: kills *all* forwarding types
|
||||
GatewayPorts no
|
||||
PermitTunnel no
|
||||
PermitUserRC no # don't run ~/.ssh/rc on login
|
||||
PermitListen none
|
||||
PermitOpen none
|
||||
PrintMotd no
|
||||
TCPKeepAlive no
|
||||
ClientAliveInterval 300
|
||||
ClientAliveCountMax 2
|
||||
|
||||
# --- Misc ---
|
||||
StrictModes yes
|
||||
IgnoreRhosts yes
|
||||
HostbasedAuthentication no
|
||||
Compression no
|
||||
Banner none
|
||||
|
||||
# SFTP subsystem: enabled for backup retrieval only.
|
||||
# Note: this allows sftp and scp to work. Only the forwarding/tunneling
|
||||
# features above are disabled -- file transfer via SFTP is legitimate.
|
||||
Subsystem sftp internal-sftp
|
||||
|
||||
# Restrict who can SSH in. Add other users here if you create them.
|
||||
AllowUsers root
|
||||
EOF
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 6. Validate config
|
||||
# ----------------------------------------------------------------------------
|
||||
log "Validating sshd config..."
|
||||
if ! sshd -t 2>/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 <<EOF
|
||||
BACKEND="/usr/libexec/sshg-fw-iptables"
|
||||
LOGREADER="LANG=C journalctl -afb -p info -n1 -u sshd -o cat"
|
||||
THRESHOLD=30
|
||||
BLOCK_TIME=300
|
||||
DETECTION_TIME=1800
|
||||
PID_FILE=/run/sshguard.pid
|
||||
WHITELIST_FILE=${WHITELIST}
|
||||
EOF
|
||||
|
||||
# sshguard inserts rules into chain "sshguard"; we need a jump from INPUT.
|
||||
# awall doesn't manage this chain, so we add it once and make it persist via
|
||||
# a small startup hook.
|
||||
cat > /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 <<EOF
|
||||
|
||||
================================================================
|
||||
COPY THIS PRIVATE KEY TO YOUR CLIENT *NOW*
|
||||
|
||||
Save it as e.g. ~/.ssh/id_ed25519_simplex on your local machine,
|
||||
chmod 600, and connect with:
|
||||
|
||||
ssh -i ~/.ssh/id_ed25519_simplex -p ${SSH_PORT} root@<host>
|
||||
|
||||
----- 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 <<EOF
|
||||
sshd config has passed validation. Ready to reload sshd.
|
||||
|
||||
If you are connected via SSH RIGHT NOW, opening a SECOND ssh session in
|
||||
another terminal -- before answering yes -- to verify the new keys, port,
|
||||
and PQ KEX work is the safest path. If your new key/port/KEX is wrong,
|
||||
this reload will end your current session.
|
||||
|
||||
Test in another terminal first:
|
||||
ssh -i ~/.ssh/<your saved key> -p ${SSH_PORT} \\
|
||||
-o KexAlgorithms=${KEX_LIST} root@<host>
|
||||
|
||||
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."
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user