From 79975614127235a05550f51527f502a772659a14 Mon Sep 17 00:00:00 2001 From: Phil Date: Fri, 24 Jul 2026 21:48:40 +0100 Subject: [PATCH] - Add idempotent certbot installation (skip if already installed) - Support multiple domains with separate credential files per domain - Implement optional automatic renewal via cronjob (every 2 months) - Add interactive prompt to choose between automatic or manual renewal - Create renewal script at /usr/local/bin/certbot-renewal.sh - Add comprehensive logging to /var/log/certbot-renewal.log - Add input validation for domain, email, and API token formats - Improve error handling with explicit checks on all operations - Add helper functions for better code organization and reusability - Allow script to be run multiple times for different domains - Display certificate paths and renewal configuration after setup - Check for existing certificates and prompt before overwriting - Preserve existing crontab entries when adding renewal job --- certbot-setup.sh | 460 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 409 insertions(+), 51 deletions(-) diff --git a/certbot-setup.sh b/certbot-setup.sh index daaef9c..497d81d 100755 --- a/certbot-setup.sh +++ b/certbot-setup.sh @@ -1,70 +1,428 @@ #!/bin/bash -set -e +set -euo pipefail -# Ensure script is run as root -if [[ "$EUID" -ne 0 ]]; then - echo "Please run as root" - exit 1 -fi +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color -echo "=== Let's Encrypt + Cloudflare setup ===" +# Constants +CERTBOT_HOME="/opt/certbot" +CREDS_BASE_DIR="/root/certbot-cloudflare" +CERTBOT_BIN="${CERTBOT_HOME}/bin/certbot" +RENEWAL_SCRIPT="/usr/local/bin/certbot-renewal.sh" +RENEWAL_LOG="/var/log/certbot-renewal.log" -# Ask for domain -read -p "Enter the FQDN (e.g. example.com or sub.example.com): " DOMAIN +# Helper functions +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} -if [[ -z "$DOMAIN" ]]; then - echo "Domain cannot be empty" - exit 1 -fi +log_error() { + echo -e "${RED}[ERROR]${NC} $1" >&2 +} -# Ask for email (NEW) -read -p "Enter email address for Let's Encrypt notifications: " EMAIL +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} -if [[ -z "$EMAIL" ]]; then - echo "Email cannot be empty" - exit 1 -fi +log_debug() { + echo -e "${BLUE}[DEBUG]${NC} $1" +} -# Ask for Cloudflare API token/key -read -p "Enter your Cloudflare API Token/Key: " CF_API_KEY +validate_input() { + local input="$1" + local field_name="$2" + + if [[ -z "$input" ]]; then + log_error "$field_name cannot be empty" + return 1 + fi + return 0 +} -if [[ -z "$CF_API_KEY" ]]; then - echo "API key cannot be empty" - exit 1 -fi +validate_email() { + local email="$1" + # Basic email validation + if [[ ! "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then + log_error "Invalid email format: $email" + return 1 + fi + return 0 +} -echo "Installing dependencies..." -apt update -apt install -y python3 python3-dev python3-venv libaugeas-dev gcc +validate_domain() { + local domain="$1" + # Basic domain validation + if [[ ! "$domain" =~ ^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$ ]]; then + log_error "Invalid domain format: $domain" + return 1 + fi + return 0 +} -echo "Setting up virtual environment..." -python3 -m venv /opt/certbot/ -/opt/certbot/bin/pip install --upgrade pip -/opt/certbot/bin/pip install certbot -/opt/certbot/bin/pip install certbot-dns-cloudflare +certbot_installed() { + [[ -f "$CERTBOT_BIN" ]] && [[ -d "$CERTBOT_HOME" ]] +} -# Symlink certbot -ln -sf /opt/certbot/bin/certbot /usr/bin/certbot +check_certbot_venv() { + if ! certbot_installed; then + return 1 + fi + + # Verify the venv is functional + if ! "$CERTBOT_BIN" --version &>/dev/null; then + log_warn "Certbot venv found but appears broken, will reinstall" + return 1 + fi + + return 0 +} -echo "Creating Cloudflare credentials file..." -mkdir -p /root/certbot-cloudflare/ +install_certbot() { + if check_certbot_venv; then + log_info "Certbot is already installed and configured" + return 0 + fi + + log_info "Installing certbot for the first time..." + + log_info "Updating package manager..." + apt-get update || { log_error "Failed to update package list"; return 1; } + + log_info "Installing dependencies..." + apt-get install -y --no-install-recommends \ + python3 \ + python3-dev \ + python3-venv \ + libaugeas-dev \ + gcc \ + || { log_error "Failed to install dependencies"; return 1; } + + log_info "Setting up Python virtual environment..." + python3 -m venv "$CERTBOT_HOME" || { log_error "Failed to create virtual environment"; return 1; } + + log_info "Installing certbot packages..." + "${CERTBOT_HOME}/bin/pip" install --upgrade pip setuptools wheel || { log_error "Failed to upgrade pip"; return 1; } + "${CERTBOT_HOME}/bin/pip" install certbot certbot-dns-cloudflare || { log_error "Failed to install certbot"; return 1; } + + log_info "Creating symlink..." + ln -sf "$CERTBOT_BIN" /usr/local/bin/certbot || { log_error "Failed to create symlink"; return 1; } + + log_info "Certbot installation completed" + return 0 +} -cat < /root/certbot-cloudflare/cloudflare.ini -dns_cloudflare_api_token = $CF_API_KEY +get_credentials_file() { + local domain="$1" + # Sanitize domain name for filename (replace dots with underscores) + local safe_domain="${domain//./_}" + echo "${CREDS_BASE_DIR}/cloudflare_${safe_domain}.ini" +} + +certificate_exists() { + local domain="$1" + [[ -d "/etc/letsencrypt/live/$domain" ]] +} + +create_credentials_file() { + local domain="$1" + local api_token="$2" + local creds_file + + creds_file=$(get_credentials_file "$domain") + + # Check if credentials file already exists + if [[ -f "$creds_file" ]]; then + read -p "Credentials file for $domain already exists. Overwrite? (y/N): " -n 1 -r CONFIRM + echo + if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then + log_info "Keeping existing credentials file for $domain" + return 0 + fi + fi + + log_info "Creating Cloudflare credentials directory..." + mkdir -p "$CREDS_BASE_DIR" || { log_error "Failed to create credentials directory"; return 1; } + + log_info "Writing Cloudflare credentials for $domain..." + cat > "$creds_file" < "$RENEWAL_SCRIPT" <<'RENEWAL_EOF' +#!/bin/bash -echo "Done! Certificate setup completed for $DOMAIN" +# Let's Encrypt Certificate Renewal Script +# Automatically renews all certificates managed by certbot + +CERTBOT_BIN="/opt/certbot/bin/certbot" +LOG_FILE="/var/log/certbot-renewal.log" +TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') + +# Log function +log_message() { + echo "[$TIMESTAMP] $1" >> "$LOG_FILE" +} + +# Ensure log file exists and is writable +mkdir -p "$(dirname "$LOG_FILE")" +touch "$LOG_FILE" +chmod 644 "$LOG_FILE" + +log_message "=== Certificate Renewal Started ===" + +# Run certbot renew +if "$CERTBOT_BIN" renew --quiet --non-interactive --agree-tos 2>>"$LOG_FILE"; then + log_message "✓ Certificate renewal completed successfully" + exit 0 +else + log_message "✗ Certificate renewal failed with exit code $?" + exit 1 +fi +RENEWAL_EOF + + chmod 755 "$RENEWAL_SCRIPT" || { log_error "Failed to set script permissions"; return 1; } + log_debug "Renewal script created: $RENEWAL_SCRIPT" + return 0 +} + +renewal_script_exists() { + [[ -f "$RENEWAL_SCRIPT" ]] +} + +cronjob_exists() { + crontab -l 2>/dev/null | grep -q "$RENEWAL_SCRIPT" || return 1 +} + +setup_cronjob() { + if renewal_script_exists && cronjob_exists; then + log_info "Cronjob is already configured" + return 0 + fi + + # Create renewal script if it doesn't exist + if ! renewal_script_exists; then + if ! create_renewal_script; then + log_error "Failed to create renewal script" + return 1 + fi + fi + + # Add cronjob if it doesn't exist + if cronjob_exists; then + log_info "Cronjob already configured in crontab" + return 0 + fi + + log_info "Adding renewal cronjob..." + + # Get current crontab (if exists) + local crontab_content + crontab_content=$(crontab -l 2>/dev/null || echo "") + + # Add new cron entry for every 2 months (runs on the 1st at 2 AM) + # This will run on the 1st of every other month + crontab_content+=$'\n'"# Let's Encrypt Certificate Renewal (every 2 months)"$'\n' + crontab_content+=$'\n'"0 2 1 */2 * $RENEWAL_SCRIPT >> /var/log/certbot-renewal.log 2>&1"$'\n' + + # Install new crontab + if echo "$crontab_content" | crontab -; then + log_info "✓ Cronjob configured successfully" + log_debug "Cronjob will run on the 1st of every 2 months at 2 AM" + return 0 + else + log_error "Failed to install cronjob" + return 1 + fi +} + +show_renewal_status() { + echo + echo "Renewal Configuration:" + echo " Renewal script: $RENEWAL_SCRIPT" + echo " Log file: $RENEWAL_LOG" + echo " Cronjob schedule: 1st of every 2 months at 2 AM" + echo + echo "To manually renew all certificates:" + echo " $RENEWAL_SCRIPT" + echo + echo "To view renewal logs:" + echo " tail -f $RENEWAL_LOG" + echo +} + +show_manual_renewal_info() { + echo + echo "Manual Renewal Information:" + echo " To manually renew certificates, run:" + echo " /opt/certbot/bin/certbot renew" + echo + echo " Or set up a cronjob manually in the future by running this script again" + echo +} + +cleanup_on_exit() { + local exit_code=$? + if [[ $exit_code -ne 0 ]]; then + log_error "Script failed with exit code $exit_code" + fi + return $exit_code +} + +trap cleanup_on_exit EXIT + +# Main script +main() { + # Ensure script is run as root + if [[ "$EUID" -ne 0 ]]; then + log_error "This script must be run as root (use 'sudo')" + exit 1 + fi + + log_info "=== Let's Encrypt + Cloudflare Certificate Manager ===" + + # Install certbot if needed + if ! install_certbot; then + log_error "Failed to install certbot" + exit 1 + fi + + # Prompt for inputs with validation + while true; do + read -p "Enter the FQDN (e.g., example.com or sub.example.com): " DOMAIN + validate_input "$DOMAIN" "Domain" && validate_domain "$DOMAIN" && break + done + + # Check if certificate already exists + if certificate_exists "$DOMAIN"; then + log_warn "Certificate already exists for $DOMAIN" + read -p "Do you want to renew/update it? (y/N): " -n 1 -r CONFIRM + echo + if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then + log_info "Skipping certificate setup for $DOMAIN" + show_certificate_info "$DOMAIN" + return 0 + fi + fi + + while true; do + read -p "Enter email address for Let's Encrypt notifications: " EMAIL + validate_input "$EMAIL" "Email" && validate_email "$EMAIL" && break + done + + while true; do + read -sp "Enter your Cloudflare API Token: " CF_API_KEY + echo + validate_input "$CF_API_KEY" "API token" && break + done + + # Confirm inputs + echo + log_info "Configuration summary:" + echo " Domain: $DOMAIN" + echo " Email: $EMAIL" + echo " Credentials file: $(get_credentials_file "$DOMAIN")" + read -p "Continue? (y/N): " -n 1 -r CONFIRM + echo + if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then + log_warn "Setup cancelled by user" + exit 0 + fi + + # Create credentials file + if ! create_credentials_file "$DOMAIN" "$CF_API_KEY"; then + log_error "Failed to create credentials file" + exit 1 + fi + + # Request certificate + if ! request_certificate "$DOMAIN" "$EMAIL"; then + log_error "Failed to request certificate" + exit 1 + fi + + show_certificate_info "$DOMAIN" + + # Ask about renewal setup + echo + echo "Certificate renewal options:" + echo " 1) Setup automatic renewal with cronjob (every 2 months)" + echo " 2) Manual renewal only (you'll run renewal commands yourself)" + read -p "Choose option (1 or 2): " -n 1 -r RENEWAL_CHOICE + echo + + case "$RENEWAL_CHOICE" in + 1) + if ! setup_cronjob; then + log_error "Failed to setup cronjob, but certificate was created successfully" + show_certificate_info "$DOMAIN" + show_manual_renewal_info + exit 1 + fi + show_renewal_status + ;; + 2) + log_info "Skipping automatic renewal setup" + show_manual_renewal_info + ;; + *) + log_warn "Invalid option. Skipping renewal setup" + show_manual_renewal_info + ;; + esac +} + +show_certificate_info() { + local domain="$1" + log_info "✓ Certificate setup completed for $domain" + echo + echo "Certificate Details:" + echo " Location: /etc/letsencrypt/live/$domain/" + echo " Full chain: /etc/letsencrypt/live/$domain/fullchain.pem" + echo " Private key: /etc/letsencrypt/live/$domain/privkey.pem" + echo " Credentials: $(get_credentials_file "$domain")" + echo +} + +main "$@"