ops-jrz1/scripts/dev-remove.sh
Dan bde2aad939 Harden dev provisioning scripts (ops-review fixes)
- Remove stderr suppression from ssh-keygen (show errors)
- Add curl timeouts (--connect-timeout 5 --max-time 30)
- Add || true to arithmetic increments for set -e safety

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 20:21:57 -08:00

326 lines
9.2 KiB
Bash
Executable file

#!/usr/bin/env bash
# dev-remove.sh - Remove a dev account
# Usage: dev-remove.sh <username> [--archive] [--dry-run]
#
# Removes:
# - Unix user account
# - Home directory (or archives if --archive flag)
# - Maubot plugin symlinks
#
# NOTE: Revokes Forgejo SSH keys automatically (prevents zombie access).
# Does NOT delete Forgejo account (too dangerous - may delete repos).
# Manual account suspension still recommended via Forgejo admin UI.
set -euo pipefail
MAUBOT_PLUGINS_DIR="/var/lib/maubot/plugins"
ARCHIVE_DIR="/var/backups/devs"
DRY_RUN=false
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
log_dry() { echo -e "${CYAN}[DRY-RUN]${NC} $1"; }
revoke_forgejo_keys() {
local username="$1"
local token_file="/run/secrets/forgejo-api-token"
local forgejo_url="http://localhost:3000"
if [[ "$DRY_RUN" == true ]]; then
log_dry "Would revoke Forgejo SSH keys for '$username'"
return 0
fi
# Check if token file exists
if [[ ! -r "$token_file" ]]; then
log_warn "Forgejo API token not found - cannot revoke keys automatically"
log_warn "MANUAL ACTION REQUIRED: Delete SSH keys for '$username' in Forgejo"
return 1
fi
local token
token=$(cat "$token_file")
log_info "Revoking Forgejo SSH keys for '$username'..."
# Get list of user's keys
local keys_json
keys_json=$(curl -s --connect-timeout 5 --max-time 30 \
-H "Authorization: token $token" \
"$forgejo_url/api/v1/admin/users/$username/keys" 2>/dev/null) || true
if [[ -z "$keys_json" || "$keys_json" == "null" ]]; then
log_info "No Forgejo keys found for '$username'"
return 0
fi
# Parse key IDs and delete each one
# Using grep/sed since jq may not be available
local key_ids
key_ids=$(echo "$keys_json" | grep -o '"id":[0-9]*' | sed 's/"id"://' || true)
if [[ -z "$key_ids" ]]; then
log_info "No Forgejo keys found for '$username'"
return 0
fi
local revoked=0
local failed=0
for key_id in $key_ids; do
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 5 --max-time 30 \
-X DELETE "$forgejo_url/api/v1/admin/users/$username/keys/$key_id" \
-H "Authorization: token $token" 2>/dev/null) || true
if [[ "$http_code" == "204" ]]; then
((revoked++)) || true
else
log_warn "Failed to revoke key $key_id (HTTP $http_code)"
((failed++)) || true
fi
done
if [[ $revoked -gt 0 ]]; then
log_info "Revoked $revoked Forgejo SSH key(s)"
fi
if [[ $failed -gt 0 ]]; then
log_warn "$failed key(s) failed to revoke - manual cleanup may be needed"
return 1
fi
return 0
}
usage() {
echo "Usage: $0 <username> [--archive] [--dry-run]"
echo ""
echo "Arguments:"
echo " username - Dev's username to remove"
echo " --archive - Archive home directory instead of deleting"
echo " --dry-run - Show what would be done without making changes"
echo ""
echo "Example:"
echo " $0 alice # Delete user and home directory"
echo " $0 alice --archive # Archive home directory before deleting"
echo " $0 alice --dry-run # Preview what would be removed"
exit 1
}
validate_username() {
local username="$1"
# Check if user exists
if ! id "$username" &>/dev/null; then
log_error "User '$username' does not exist"
exit 1
fi
# Safety check - don't delete system users
local uid
uid=$(id -u "$username")
if [[ $uid -lt 1000 ]]; then
log_error "Refusing to delete system user '$username' (UID $uid < 1000)"
exit 1
fi
# Safety check - don't delete important users
case "$username" in
root|dan|admin|maubot|postgres|nginx)
log_error "Refusing to delete protected user '$username'"
exit 1
;;
esac
}
remove_maubot_symlinks() {
local username="$1"
if [[ "$DRY_RUN" == true ]]; then
log_dry "Checking maubot plugin symlinks for '$username'..."
else
log_info "Removing maubot plugin symlinks for '$username'..."
fi
# Remove any symlinks that point to user's home directory
local count=0
for symlink in "$MAUBOT_PLUGINS_DIR"/*; do
if [[ -L "$symlink" ]]; then
local target
target=$(readlink "$symlink")
if [[ "$target" == "/home/$username/"* ]]; then
if [[ "$DRY_RUN" == true ]]; then
log_dry "Would remove symlink: $symlink -> $target"
else
rm "$symlink"
log_info "Removed symlink: $symlink"
fi
((count++)) || true
fi
fi
done
# Also remove any plugins named after user
for symlink in "$MAUBOT_PLUGINS_DIR/${username}-"*; do
if [[ -e "$symlink" || -L "$symlink" ]]; then
if [[ "$DRY_RUN" == true ]]; then
log_dry "Would remove: $symlink"
else
rm -f "$symlink"
log_info "Removed: $symlink"
fi
((count++)) || true
fi
done
if [[ $count -eq 0 ]]; then
log_info "No maubot symlinks found for '$username'"
fi
}
archive_home() {
local username="$1"
local home_dir="/home/$username"
if [[ ! -d "$home_dir" ]]; then
log_warn "Home directory does not exist: $home_dir"
return
fi
local archive_name
archive_name="${username}_$(date +%Y%m%d_%H%M%S).tar.gz"
local archive_path="$ARCHIVE_DIR/$archive_name"
if [[ "$DRY_RUN" == true ]]; then
log_dry "Would archive home directory to: $archive_path"
else
log_info "Archiving home directory..."
mkdir -p "$ARCHIVE_DIR"
tar -czf "$archive_path" -C /home "$username"
chmod 600 "$archive_path"
log_info "Archived to: $archive_path"
fi
}
remove_user() {
local username="$1"
if [[ "$DRY_RUN" == true ]]; then
log_dry "Would kill processes for user '$username'"
log_dry "Would remove user '$username' and home directory"
else
log_info "Removing user '$username'..."
# Kill any running processes
pkill -u "$username" 2>/dev/null || true
sleep 1
# Remove user and home directory
userdel -r "$username" 2>/dev/null || {
# If userdel -r fails, try without -r and manually remove home
userdel "$username" 2>/dev/null || true
rm -rf "/home/${username:?}"
}
log_info "User removed"
fi
}
main() {
if [[ $# -lt 1 ]]; then
usage
fi
local username="$1"
local archive=false
# Parse flags
shift
while [[ $# -gt 0 ]]; do
case "$1" in
--archive)
archive=true
shift
;;
--dry-run)
DRY_RUN=true
shift
;;
*)
log_error "Unknown option: $1"
usage
;;
esac
done
# Must run as root
if [[ $EUID -ne 0 ]]; then
log_error "This script must be run as root"
exit 1
fi
validate_username "$username"
if [[ "$DRY_RUN" == true ]]; then
echo ""
log_dry "Preview of removing user '$username':"
if [[ "$archive" == true ]]; then
log_dry "Home directory would be archived to $ARCHIVE_DIR"
else
log_dry "Home directory would be PERMANENTLY DELETED"
fi
echo ""
else
# Confirm deletion
echo ""
log_warn "This will remove user '$username' and all their data!"
if [[ "$archive" == true ]]; then
echo "Home directory will be archived to $ARCHIVE_DIR"
else
echo "Home directory will be PERMANENTLY DELETED"
fi
echo ""
read -rp "Are you sure? (yes/no): " confirm
if [[ "$confirm" != "yes" ]]; then
log_info "Aborted"
exit 0
fi
fi
# Revoke Forgejo SSH keys BEFORE removing user (prevents zombie access)
revoke_forgejo_keys "$username"
remove_maubot_symlinks "$username"
if [[ "$archive" == true ]]; then
archive_home "$username"
fi
remove_user "$username"
if [[ "$DRY_RUN" == true ]]; then
log_dry "Dry run complete - no changes made"
else
log_info "Dev '$username' removed successfully"
# Remind admin to handle Forgejo account (keys already revoked)
echo ""
log_warn "RECOMMENDED: Suspend Forgejo account for '$username'"
echo " SSH keys have been revoked automatically."
echo " To fully disable the account, visit: https://git.clarun.xyz/admin/users"
echo " Find user '$username' and either:"
echo " - Prohibit login (keeps repos/PRs intact)"
echo " - Delete user (WARNING: may delete their repos)"
echo ""
fi
}
main "$@"