feat: unified launcher, multi-OS hardening, login alerts & auto-updates

Restructure around a single entry point (automations.sh) with a Gum wizard and
a self-extracting bundle for repo-less installs. Add scripts/oslib.sh so the
provisioning scripts (setup-host, harden-ssh, harden-jumphost, sshuser) run on
Alpine/Debian/Alma; seed root keys from globals/.

- ntfy SSH-login alerts (user, source IP, key, region, jump target) via pam_exec
- daily auto-updates with AUTO_REBOOT=idle (reboots only when no SSH active) and
  opt-in Alpine stable-branch upgrades
- generic + per-deployment cloud-init; Gitea release workflow on tag
- README/LICENSE/.gitignore/.gitattributes (force LF); repo URLs -> Gitea
This commit is contained in:
2026-06-12 14:56:02 -05:00
parent 85eeb79971
commit 7faa9098de
58 changed files with 6225 additions and 284 deletions
+50 -16
View File
@@ -1,10 +1,14 @@
#!/usr/bin/env bash
#
# sshuser -- manage SSH users on a hardened Alpine box.
# sshuser -- manage SSH users on a hardened box (Alpine, Debian, or Alma).
#
# Two roles, matching harden-jumphost.sh:
# admin -> group ssh-admins, shell /bin/ash (full shell)
# jumper -> group ssh-jumpers, shell /sbin/nologin (ProxyJump only)
# admin -> group ssh-admins, interactive shell (full shell)
# jumper -> group ssh-jumpers, nologin shell (ProxyJump only)
#
# The interactive shell and nologin paths, and the user-management commands,
# differ per distro -- this script detects the OS and adapts (it's installed
# standalone, so it can't share scripts/oslib.sh).
#
# Two modes:
# - TUI (gum) : run with no command, or any command with missing args
@@ -12,7 +16,7 @@
#
# Install:
# install -m 0755 sshuser.sh /usr/local/bin/sshuser
# apk add gum # only needed for TUI mode
# gum is only needed for TUI mode (apk add gum / apt install gum / dnf install gum)
#
# Usage:
# sshuser # interactive TUI
@@ -30,8 +34,40 @@ set -euo pipefail
ADMIN_GROUP="ssh-admins"
JUMPER_GROUP="ssh-jumpers"
ADMIN_SHELL="/bin/ash"
# ---------------------------------------------------------------------------
# OS detection + per-distro user-management primitives. (Standalone install,
# so we can't source oslib.sh -- this is the minimal slice we need.)
# ---------------------------------------------------------------------------
_OS_FAMILY=alpine
if [[ -r /etc/os-release ]]; then
_osid="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")"
_oslike="$(. /etc/os-release 2>/dev/null && echo "${ID_LIKE:-}")"
case " ${_osid} ${_oslike} " in
*" debian "*|*" ubuntu "*) _OS_FAMILY=debian ;;
*" rhel "*|*" fedora "*|*" centos "*|*" almalinux "*|*" rocky "*) _OS_FAMILY=rhel ;;
*" alpine "*) _OS_FAMILY=alpine ;;
esac
fi
# Admin (interactive) shell: ash on Alpine, bash elsewhere.
case "$_OS_FAMILY" in
alpine) ADMIN_SHELL="/bin/ash" ;;
*) ADMIN_SHELL="/bin/bash" ;;
esac
[[ -x "$ADMIN_SHELL" ]] || ADMIN_SHELL="/bin/sh"
# Nologin path: /usr/sbin/nologin on Debian, /sbin/nologin on Alpine/Alma.
JUMPER_SHELL="/sbin/nologin"
[[ -x "$JUMPER_SHELL" ]] || for _p in /usr/sbin/nologin /sbin/nologin; do
[[ -x "$_p" ]] && { JUMPER_SHELL="$_p"; break; }
done
user_create() { case "$_OS_FAMILY" in alpine) adduser -D -s "$2" -g "" "$1";; *) useradd -m -s "$2" "$1";; esac; }
user_join_group() { case "$_OS_FAMILY" in alpine) adduser "$1" "$2";; *) usermod -aG "$2" "$1";; esac; }
user_leave_group() { case "$_OS_FAMILY" in alpine) deluser "$1" "$2" 2>/dev/null || true;; *) gpasswd -d "$1" "$2" 2>/dev/null || true;; esac; }
user_delete() { case "$_OS_FAMILY" in alpine) deluser --remove-home "$1" 2>/dev/null || deluser "$1";; *) userdel -r "$1" 2>/dev/null || userdel "$1";; esac; }
set_user_shell() { usermod -s "$2" "$1" 2>/dev/null || chsh -s "$2" "$1" 2>/dev/null \
|| sed -i "s|^\($1:.*:\)[^:]*$|\1$2|" /etc/passwd; }
# ---------------------------------------------------------------------------
# Helpers
@@ -147,8 +183,8 @@ cmd_add() {
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"
user_create "$user" "$shell"
user_join_group "$user" "$group"
if [[ -n "$key" ]]; then
local ak; ak=$(ssh_dir_setup "$user")
@@ -184,7 +220,7 @@ cmd_edit() {
"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") ;;
"change shell") SHELL_ARG=$(ask "New shell" "$ADMIN_SHELL") ;;
*) warn "Cancelled."; return 0 ;;
esac
fi
@@ -195,16 +231,14 @@ cmd_edit() {
# 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
user_leave_group "$user" "$other"
user_join_group "$user" "$new_group"
set_user_shell "$user" "$new_shell"
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
set_user_shell "$user" "$SHELL_ARG"
fi
if [[ -n "${ADD_KEY_ARG:-}" ]]; then
@@ -238,7 +272,7 @@ cmd_remove() {
confirm "DELETE user $user and their home directory?" || { warn "Aborted."; exit 1; }
log "Deleting $user..."
deluser --remove-home "$user" 2>/dev/null || deluser "$user"
user_delete "$user"
log "Done."
}
@@ -294,7 +328,7 @@ cmd_show() {
}
cmd_tui() {
have_gum || err "TUI mode requires gum: 'apk add gum'. Or use CLI flags (sshuser --help)."
have_gum || err "TUI mode requires gum (apk/apt/dnf install 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")