7faa9098de
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
393 lines
14 KiB
Bash
393 lines
14 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# sshuser -- manage SSH users on a hardened box (Alpine, Debian, or Alma).
|
|
#
|
|
# Two roles, matching harden-jumphost.sh:
|
|
# 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
|
|
# - CLI flags : full automation, suitable for shell scripts and CI
|
|
#
|
|
# Install:
|
|
# install -m 0755 sshuser.sh /usr/local/bin/sshuser
|
|
# gum is only needed for TUI mode (apk add gum / apt install gum / dnf install gum)
|
|
#
|
|
# 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"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
# ---------------------------------------------------------------------------
|
|
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..."
|
|
user_create "$user" "$shell"
|
|
user_join_group "$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" "$ADMIN_SHELL") ;;
|
|
*) 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"
|
|
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"
|
|
set_user_shell "$user" "$SHELL_ARG"
|
|
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..."
|
|
user_delete "$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/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")
|
|
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
|