Last active
December 28, 2025 17:38
-
-
Save Sly777/d07b30d92f7d515ee8b50227fafa01ed to your computer and use it in GitHub Desktop.
Deploy SSH Configuration to All Debian LXC Containers with security audit, config comparison, user management, and logging
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # ============================================================================= | |
| # Deploy SSH Configuration to All Debian LXC Containers | |
| # With security audit, config comparison, user management, and logging | |
| # Usage: ./deploy-ssh-config.sh [CT_ID] | |
| # No argument: Process all containers | |
| # With CT_ID: Process only specified container | |
| # ============================================================================= | |
| # ============================================================================= | |
| # CONFIGURATION - Edit these variables | |
| # ============================================================================= | |
| SSH_USER="USERNAME_ON_DEBIAN" | |
| SSHD_CONFIG="/root/sshd_config.template" | |
| PUBKEY_FILE="/root/${SSH_USER}_authorized_keys" | |
| LOG_DIR="/var/log/ssh-deploy" | |
| DATE=$(date +%Y%m%d-%H%M%S) | |
| # Secure groups for user | |
| SECURE_GROUPS="sudo systemd-journal" | |
| # Groups to REMOVE if found (security risks) | |
| RISKY_GROUPS="disk www-data" | |
| # Colors for terminal output | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| BLUE='\033[0;34m' | |
| NC='\033[0m' | |
| # ============================================================================= | |
| # PARSE COMMAND LINE ARGUMENTS | |
| # ============================================================================= | |
| TARGET_CT="" | |
| if [ $# -eq 1 ]; then | |
| TARGET_CT="$1" | |
| # Validate container ID is numeric | |
| if ! [[ "$TARGET_CT" =~ ^[0-9]+$ ]]; then | |
| echo -e "${RED}Error: Container ID must be numeric${NC}" | |
| echo "Usage: $0 [CT_ID]" | |
| exit 1 | |
| fi | |
| # Verify container exists | |
| if ! pct list | awk '{print $1}' | grep -q "^${TARGET_CT}$"; then | |
| echo -e "${RED}Error: Container $TARGET_CT does not exist${NC}" | |
| echo "" | |
| echo "Available containers:" | |
| pct list | |
| exit 1 | |
| fi | |
| echo -e "${BLUE}Target mode: Processing only CT ${TARGET_CT}${NC}" | |
| elif [ $# -gt 1 ]; then | |
| echo -e "${RED}Error: Too many arguments${NC}" | |
| echo "Usage: $0 [CT_ID]" | |
| echo " No argument: Process all containers" | |
| echo " With CT_ID: Process only specified container (e.g., $0 112)" | |
| exit 1 | |
| fi | |
| # ============================================================================= | |
| # LOGGING SETUP | |
| # ============================================================================= | |
| mkdir -p "$LOG_DIR" | |
| LOG_FILE="${LOG_DIR}/ssh-deploy-$(date +%Y%m%d).log" | |
| # Function to log with timestamp | |
| log() { | |
| local level="$1" | |
| local message="$2" | |
| local timestamp="[$(date '+%Y-%m-%d %H:%M:%S')]" | |
| # Write to log file (plain text, no colors) | |
| echo "${timestamp} [${level}] ${message}" >> "$LOG_FILE" | |
| # Write to terminal with colors | |
| case "$level" in | |
| "INFO") echo -e "${timestamp} ${GREEN}[${level}]${NC} ${message}" ;; | |
| "WARN") echo -e "${timestamp} ${YELLOW}[${level}]${NC} ${message}" ;; | |
| "ERROR") echo -e "${timestamp} ${RED}[${level}]${NC} ${message}" ;; | |
| "AUDIT") echo -e "${timestamp} ${BLUE}[${level}]${NC} ${message}" ;; | |
| "SUCCESS") echo -e "${timestamp} ${GREEN}[${level}]${NC} ${message}" ;; | |
| *) echo -e "${timestamp} [${level}] ${message}" ;; | |
| esac | |
| } | |
| # Log section headers | |
| log_section() { | |
| local title="$1" | |
| echo "" >> "$LOG_FILE" | |
| echo "========================================" >> "$LOG_FILE" | |
| echo "$title" >> "$LOG_FILE" | |
| echo "========================================" >> "$LOG_FILE" | |
| echo "" | |
| echo -e "${BLUE}========================================${NC}" | |
| echo -e "${BLUE}${title}${NC}" | |
| echo -e "${BLUE}========================================${NC}" | |
| } | |
| # Log container headers | |
| log_container() { | |
| local ct_id="$1" | |
| local ct_name="$2" | |
| local ct_status="$3" | |
| echo "" >> "$LOG_FILE" | |
| echo "----------------------------------------" >> "$LOG_FILE" | |
| echo "CT ${ct_id} (${ct_name}) - Status: ${ct_status}" >> "$LOG_FILE" | |
| echo "----------------------------------------" >> "$LOG_FILE" | |
| echo "" | |
| echo "----------------------------------------" | |
| echo -e "CT ${ct_id} (${YELLOW}${ct_name}${NC}) - Status: ${ct_status}" | |
| echo "----------------------------------------" | |
| } | |
| # ============================================================================= | |
| # HELPER FUNCTIONS | |
| # ============================================================================= | |
| # Check if user is member of a group in container | |
| user_in_group() { | |
| local ct_id=$1 | |
| local user=$2 | |
| local group=$3 | |
| pct exec "$ct_id" -- id -nG "$user" 2>/dev/null | grep -qw "$group" | |
| } | |
| # Get public key hash from container | |
| get_remote_pubkey_hash() { | |
| local ct_id=$1 | |
| local user=$2 | |
| pct exec "$ct_id" -- cat "/home/$user/.ssh/authorized_keys" 2>/dev/null | md5sum | awk '{print $1}' | |
| } | |
| # ============================================================================= | |
| # MAIN SCRIPT START | |
| # ============================================================================= | |
| if [ -n "$TARGET_CT" ]; then | |
| log_section "SSH Config Deployment (Target: CT $TARGET_CT)" | |
| else | |
| log_section "SSH Config Deployment & Security Audit (All Containers)" | |
| fi | |
| log "INFO" "Date: $DATE" | |
| log "INFO" "User: $SSH_USER" | |
| log "INFO" "Log file: $LOG_FILE" | |
| # Log to system journal | |
| if [ -n "$TARGET_CT" ]; then | |
| logger -t ssh-deploy "Starting SSH deployment for CT $TARGET_CT (user: $SSH_USER)" | |
| else | |
| logger -t ssh-deploy "Starting SSH deployment for all containers (user: $SSH_USER)" | |
| fi | |
| # ============================================================================= | |
| # PRE-FLIGHT CHECKS | |
| # ============================================================================= | |
| if [ ! -f "$SSHD_CONFIG" ]; then | |
| log "ERROR" "$SSHD_CONFIG not found!" | |
| logger -p user.err -t ssh-deploy "SSHD config template not found" | |
| exit 1 | |
| fi | |
| if [ ! -f "$PUBKEY_FILE" ]; then | |
| log "ERROR" "$PUBKEY_FILE not found!" | |
| log "INFO" "Create it with: micro $PUBKEY_FILE" | |
| log "INFO" "Then paste your SSH public key from Bitwarden" | |
| logger -p user.err -t ssh-deploy "Public key file not found" | |
| exit 1 | |
| fi | |
| log "SUCCESS" "Pre-flight checks passed" | |
| # Get template hashes for comparison | |
| TEMPLATE_HASH=$(md5sum "$SSHD_CONFIG" | awk '{print $1}') | |
| LOCAL_PUBKEY_HASH=$(md5sum "$PUBKEY_FILE" | awk '{print $1}') | |
| # Get container list based on mode | |
| if [ -n "$TARGET_CT" ]; then | |
| containers="$TARGET_CT" | |
| else | |
| containers=$(pct list | tail -n +2 | awk '{print $1}') | |
| fi | |
| # Counters for summary | |
| TOTAL_CONTAINERS=0 | |
| PROCESSED_CONTAINERS=0 | |
| SKIPPED_CONTAINERS=0 | |
| USERS_CREATED=0 | |
| KEYS_DEPLOYED=0 | |
| CONFIGS_DEPLOYED=0 | |
| ERRORS=0 | |
| for CT_ID in $containers; do | |
| TOTAL_CONTAINERS=$((TOTAL_CONTAINERS + 1)) | |
| CT_STATUS=$(pct status "$CT_ID" | awk '{print $2}') | |
| CT_NAME=$(pct list | awk -v id="$CT_ID" '$1==id {print $3}') | |
| log_container "$CT_ID" "$CT_NAME" "$CT_STATUS" | |
| # Skip stopped containers | |
| if [ "$CT_STATUS" != "running" ]; then | |
| log "WARN" "Container not running, skipping" | |
| SKIPPED_CONTAINERS=$((SKIPPED_CONTAINERS + 1)) | |
| continue | |
| fi | |
| # Improved Debian check - verify dpkg exists | |
| if ! pct exec "$CT_ID" -- test -f /usr/bin/dpkg 2>/dev/null; then | |
| log "WARN" "Not Debian-based, skipping" | |
| SKIPPED_CONTAINERS=$((SKIPPED_CONTAINERS + 1)) | |
| continue | |
| fi | |
| # Check if SSH server is installed | |
| if ! pct exec "$CT_ID" -- test -f /etc/ssh/sshd_config 2>/dev/null; then | |
| log "WARN" "SSH server not installed, skipping" | |
| SKIPPED_CONTAINERS=$((SKIPPED_CONTAINERS + 1)) | |
| continue | |
| fi | |
| log "SUCCESS" "Debian container with SSH detected" | |
| PROCESSED_CONTAINERS=$((PROCESSED_CONTAINERS + 1)) | |
| # ========================================================================= | |
| # SECURITY AUDIT: Check and fix user groups | |
| # ========================================================================= | |
| log "AUDIT" "Starting security audit for $SSH_USER" | |
| if pct exec "$CT_ID" -- id "$SSH_USER" > /dev/null 2>&1; then | |
| # User exists - audit groups | |
| CURRENT_GROUPS=$(pct exec "$CT_ID" -- groups "$SSH_USER" 2>/dev/null) | |
| log "AUDIT" "Current groups: $CURRENT_GROUPS" | |
| # Remove risky groups | |
| for RISKY_GROUP in $RISKY_GROUPS; do | |
| if user_in_group "$CT_ID" "$SSH_USER" "$RISKY_GROUP"; then | |
| log "WARN" "Removing risky group: $RISKY_GROUP" | |
| pct exec "$CT_ID" -- gpasswd -d "$SSH_USER" "$RISKY_GROUP" 2>/dev/null | |
| fi | |
| done | |
| # Ensure secure groups are present (only if not already member) | |
| GROUPS_CHANGED=false | |
| for SECURE_GROUP in $SECURE_GROUPS; do | |
| if ! pct exec "$CT_ID" -- getent group "$SECURE_GROUP" > /dev/null 2>&1; then | |
| continue | |
| fi | |
| if user_in_group "$CT_ID" "$SSH_USER" "$SECURE_GROUP"; then | |
| log "INFO" "Already in group: $SECURE_GROUP" | |
| else | |
| pct exec "$CT_ID" -- usermod -aG "$SECURE_GROUP" "$SSH_USER" 2>/dev/null | |
| log "SUCCESS" "Added to group: $SECURE_GROUP" | |
| GROUPS_CHANGED=true | |
| fi | |
| done | |
| if [ "$GROUPS_CHANGED" = true ]; then | |
| UPDATED_GROUPS=$(pct exec "$CT_ID" -- groups "$SSH_USER" 2>/dev/null) | |
| log "SUCCESS" "Updated groups: $UPDATED_GROUPS" | |
| fi | |
| else | |
| # User doesn't exist - create with secure groups | |
| log "INFO" "Creating $SSH_USER user..." | |
| pct exec "$CT_ID" -- useradd -m -s /bin/bash "$SSH_USER" | |
| for SECURE_GROUP in $SECURE_GROUPS; do | |
| if pct exec "$CT_ID" -- getent group "$SECURE_GROUP" > /dev/null 2>&1; then | |
| pct exec "$CT_ID" -- usermod -aG "$SECURE_GROUP" "$SSH_USER" | |
| log "SUCCESS" "Added to group: $SECURE_GROUP" | |
| fi | |
| done | |
| pct exec "$CT_ID" -- mkdir -p "/home/$SSH_USER/.ssh" | |
| pct exec "$CT_ID" -- chmod 700 "/home/$SSH_USER/.ssh" | |
| pct exec "$CT_ID" -- chown -R "$SSH_USER:$SSH_USER" "/home/$SSH_USER" | |
| log "SUCCESS" "User created with secure groups" | |
| USERS_CREATED=$((USERS_CREATED + 1)) | |
| fi | |
| # ========================================================================= | |
| # SSH PUBLIC KEY DEPLOYMENT (with comparison) | |
| # ========================================================================= | |
| if [ -f "$PUBKEY_FILE" ]; then | |
| REMOTE_PUBKEY_HASH=$(get_remote_pubkey_hash "$CT_ID" "$SSH_USER") | |
| if [ "$LOCAL_PUBKEY_HASH" == "$REMOTE_PUBKEY_HASH" ]; then | |
| log "INFO" "Public key already matches, skipping" | |
| else | |
| log "INFO" "Deploying SSH public key..." | |
| pct push "$CT_ID" "$PUBKEY_FILE" "/home/$SSH_USER/.ssh/authorized_keys" | |
| pct exec "$CT_ID" -- chmod 600 "/home/$SSH_USER/.ssh/authorized_keys" | |
| pct exec "$CT_ID" -- chown "$SSH_USER:$SSH_USER" "/home/$SSH_USER/.ssh/authorized_keys" | |
| log "SUCCESS" "Public key deployed" | |
| KEYS_DEPLOYED=$((KEYS_DEPLOYED + 1)) | |
| fi | |
| else | |
| log "WARN" "No public key file found at $PUBKEY_FILE" | |
| fi | |
| # ========================================================================= | |
| # SSH CONFIG DEPLOYMENT | |
| # ========================================================================= | |
| CURRENT_HASH=$(pct exec "$CT_ID" -- md5sum /etc/ssh/sshd_config 2>/dev/null | awk '{print $1}') | |
| if [ "$TEMPLATE_HASH" == "$CURRENT_HASH" ]; then | |
| log "INFO" "SSH config already matches template, skipping" | |
| continue | |
| fi | |
| log "INFO" "Backing up existing config..." | |
| pct exec "$CT_ID" -- cp /etc/ssh/sshd_config "/etc/ssh/sshd_config.backup.$DATE" | |
| log "INFO" "Pushing new sshd_config..." | |
| pct push "$CT_ID" "$SSHD_CONFIG" /etc/ssh/sshd_config | |
| pct exec "$CT_ID" -- chmod 644 /etc/ssh/sshd_config | |
| pct exec "$CT_ID" -- chown root:root /etc/ssh/sshd_config | |
| log "INFO" "Validating config syntax..." | |
| if pct exec "$CT_ID" -- sshd -t 2>&1; then | |
| log "SUCCESS" "Config syntax valid" | |
| log "INFO" "Restarting SSH service..." | |
| pct exec "$CT_ID" -- systemctl restart sshd 2>/dev/null || \ | |
| pct exec "$CT_ID" -- systemctl restart ssh 2>/dev/null | |
| if pct exec "$CT_ID" -- systemctl is-active sshd > /dev/null 2>&1 || \ | |
| pct exec "$CT_ID" -- systemctl is-active ssh > /dev/null 2>&1; then | |
| log "SUCCESS" "SSH service running" | |
| CONFIGS_DEPLOYED=$((CONFIGS_DEPLOYED + 1)) | |
| CT_IP=$(pct exec "$CT_ID" -- hostname -I 2>/dev/null | awk '{print $1}') | |
| if [ -n "$CT_IP" ]; then | |
| log "INFO" "Test: ssh $SSH_USER@$CT_IP" | |
| fi | |
| else | |
| log "ERROR" "SSH service failed!" | |
| ERRORS=$((ERRORS + 1)) | |
| fi | |
| else | |
| log "ERROR" "Config validation FAILED! Restoring backup..." | |
| pct exec "$CT_ID" -- cp "/etc/ssh/sshd_config.backup.$DATE" /etc/ssh/sshd_config | |
| pct exec "$CT_ID" -- systemctl restart sshd 2>/dev/null || \ | |
| pct exec "$CT_ID" -- systemctl restart ssh 2>/dev/null | |
| ERRORS=$((ERRORS + 1)) | |
| fi | |
| done | |
| # ============================================================================= | |
| # DEPLOYMENT SUMMARY | |
| # ============================================================================= | |
| log_section "Deployment Summary" | |
| if [ -n "$TARGET_CT" ]; then | |
| log "INFO" "Target mode: CT $TARGET_CT" | |
| fi | |
| log "INFO" "Total containers: $TOTAL_CONTAINERS" | |
| log "INFO" "Processed: $PROCESSED_CONTAINERS" | |
| log "INFO" "Skipped: $SKIPPED_CONTAINERS" | |
| log "INFO" "Users created: $USERS_CREATED" | |
| log "INFO" "Keys deployed: $KEYS_DEPLOYED" | |
| log "INFO" "Configs deployed: $CONFIGS_DEPLOYED" | |
| if [ "$ERRORS" -gt 0 ]; then | |
| log "ERROR" "Errors encountered: $ERRORS" | |
| else | |
| log "SUCCESS" "Errors encountered: $ERRORS" | |
| fi | |
| log "INFO" "" | |
| log "INFO" "User managed: $SSH_USER" | |
| log "INFO" "Secure groups: $SECURE_GROUPS" | |
| log "INFO" "Risky groups removed: $RISKY_GROUPS" | |
| log "INFO" "" | |
| log "INFO" "Log saved to: $LOG_FILE" | |
| log "INFO" "Safety reminder: If SSH fails, use: pct enter <CT_ID>" | |
| # Log completion to system journal | |
| if [ -n "$TARGET_CT" ]; then | |
| logger -t ssh-deploy "Completed CT $TARGET_CT: $PROCESSED_CONTAINERS processed, $ERRORS errors" | |
| else | |
| logger -t ssh-deploy "Completed: $PROCESSED_CONTAINERS processed, $ERRORS errors" | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment