#!/bin/zsh # FileWave Let's Encrypt installer (macOS) # Supports: # - HTTP-01 validation (standalone, requires inbound TCP/80) # - DNS-01 validation via Cloudflare plugin (no TCP/80 requirement) # # UX-focused macOS script with modular challenge handlers. set -euo pipefail LOG_FILE="/var/log/filewave-letsencrypt.log" RENEW_HOOK="/etc/letsencrypt/renewal-hooks/deploy/filewave-server-cert.sh" CF_SECRETS_DIR="/etc/letsencrypt/secrets" CF_CREDENTIALS_FILE="${CF_SECRETS_DIR}/cloudflare.ini" LEGACY_CRON_FILE="/etc/cron.daily/letsencrypt-filewave" LAUNCHD_LABEL="com.filewave.letsencrypt.renew" LAUNCHD_PLIST="/Library/LaunchDaemons/${LAUNCHD_LABEL}.plist" RENEW_RUNNER="/usr/local/filewave/sbin/filewave-letsencrypt-renew.zsh" RENEW_LOG_FILE="/var/log/filewave-letsencrypt-renew.log" METHOD="" HOSTNAME="" EMAIL="" CLOUDFLARE_TOKEN="" OS_VERSION="unknown" STEP_INDEX=0 TOTAL_STEPS=10 DNS_TOOL="" CERTBOT_BIN="" # ---------- Output formatting (no dependencies) ---------- if [ -t 1 ] && [ "${NO_COLOR:-}" = "" ]; then C_RESET='\033[0m' C_BOLD='\033[1m' C_DIM='\033[2m' C_BLUE='\033[34m' C_GREEN='\033[32m' C_YELLOW='\033[33m' C_RED='\033[31m' else C_RESET='' C_BOLD='' C_DIM='' C_BLUE='' C_GREEN='' C_YELLOW='' C_RED='' fi banner() { echo echo -e "${C_BOLD}==============================================================${C_RESET}" echo -e "${C_BOLD} FileWave Let's Encrypt Installer (macOS)${C_RESET}" echo -e "${C_BOLD}==============================================================${C_RESET}" echo } log() { # Always log plain text (no ANSI codes) to file. echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" } info() { echo -e "${C_BLUE}[INFO]${C_RESET} $*"; } ok() { echo -e "${C_GREEN}[OK]${C_RESET} $*"; } warn() { echo -e "${C_YELLOW}[WARN]${C_RESET} $*"; } err() { echo -e "${C_RED}[ERR]${C_RESET} $*"; } step() { STEP_INDEX=$((STEP_INDEX + 1)) echo echo -e "${C_BOLD}[${STEP_INDEX}/${TOTAL_STEPS}]${C_RESET} $*" } die() { err "$*" log "ERROR: $*" exit 1 } usage() { cat </dev/null || echo unknown) local major major="${OS_VERSION%%.*}" [[ "$major" =~ ^[0-9]+$ ]] || die "Unable to parse macOS version: $OS_VERSION" if [ "$major" -lt 14 ]; then die "Unsupported macOS version: $OS_VERSION. This script supports macOS 14 and newer." fi ok "macOS $OS_VERSION detected (supported: 14+)" } ensure_filewave_server() { [ -x /usr/local/bin/fwcontrol ] || die "FileWave control binary not found: /usr/local/bin/fwcontrol" [ -d /usr/local/filewave/certs ] || die "FileWave cert directory not found: /usr/local/filewave/certs" if [ ! -x /usr/local/filewave/python/bin/python ]; then warn "FileWave python not found at /usr/local/filewave/python/bin/python (update_dep_profile_certs may not run)" fi } validate_hostname() { local hostname="$1" [[ "$hostname" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$ ]] || return 1 [ ${#hostname} -le 253 ] || return 1 return 0 } validate_email() { local email="$1" [[ "$email" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] || return 1 return 0 } prompt_yes_no() { local prompt="$1" local answer while true; do read -r "answer?$prompt [yes/no]: " case "${answer:l}" in yes|y) return 0 ;; no|n) return 1 ;; *) warn "Please answer yes or no." ;; esac done } # ---------- Package/tool setup ---------- ensure_dns_tool() { if command -v nslookup >/dev/null 2>&1; then DNS_TOOL="nslookup" return 0 fi if command -v dig >/dev/null 2>&1; then DNS_TOOL="dig" return 0 fi die "Neither nslookup nor dig is available; install DNS tools and retry" } check_public_dns_resolution() { local hostname="$1" ensure_dns_tool if [ "$DNS_TOOL" = "nslookup" ]; then if nslookup "$hostname" 8.8.8.8 >/dev/null 2>&1; then ok "Public DNS resolution check passed for $hostname via 8.8.8.8" return 0 fi warn "$hostname did not resolve via 8.8.8.8; trying system resolver" nslookup "$hostname" >/dev/null 2>&1 || die "$hostname did not resolve via 8.8.8.8 or system resolver" else if [ -n "$(dig +short @8.8.8.8 "$hostname" 2>/dev/null)" ]; then ok "Public DNS resolution check passed for $hostname via 8.8.8.8" return 0 fi warn "$hostname did not resolve via 8.8.8.8; trying system resolver" [ -n "$(dig +short "$hostname" 2>/dev/null)" ] || die "$hostname did not resolve via 8.8.8.8 or system resolver" fi ok "DNS resolution check passed for $hostname via system resolver" } update_system_packages() { info "macOS detected; skipping apt-based package update" warn "No apt on macOS. Ensure system updates are applied via Software Update." } homebrew_install_hint() { echo warn "Homebrew is required on macOS for this install path." echo "Install Homebrew from: https://brew.sh" echo "Use the exact install command shown there, then re-run this script." echo } brew_as_invoking_user() { local invoking_user="${SUDO_USER:-}" if [ -z "$invoking_user" ] || [ "$invoking_user" = "root" ]; then homebrew_install_hint die "This script was launched as root directly. Re-run with sudo from a regular macOS admin account so Homebrew can run as that user." fi sudo -u "$invoking_user" "$@" } resolve_certbot_bin() { if command -v certbot >/dev/null 2>&1; then CERTBOT_BIN="$(command -v certbot)" return 0 fi for p in /opt/homebrew/bin/certbot /usr/local/bin/certbot; do if [ -x "$p" ]; then CERTBOT_BIN="$p" return 0 fi done CERTBOT_BIN="" return 1 } ensure_certbot_base() { if resolve_certbot_bin; then ok "certbot already present at $CERTBOT_BIN" return 0 fi if command -v brew >/dev/null 2>&1; then info "Installing certbot via Homebrew (non-root user context)..." brew_as_invoking_user brew install certbot || die "Homebrew certbot install failed" else homebrew_install_hint die "certbot not found and Homebrew is not installed" fi resolve_certbot_bin || die "Certbot installation failed" ok "certbot installed at $CERTBOT_BIN" } ensure_cloudflare_plugin() { info "Ensuring certbot-dns-cloudflare plugin is available..." "$CERTBOT_BIN" plugins 2>/dev/null | grep -qi 'dns-cloudflare' && { ok "Cloudflare DNS plugin already available" return 0 } if command -v brew >/dev/null 2>&1; then local brew_prefix certbot_python brew_prefix="$(brew_as_invoking_user brew --prefix certbot 2>/dev/null || true)" certbot_python="${brew_prefix}/libexec/bin/python3" if [ -x "$certbot_python" ]; then info "Installing plugin with Certbot's bundled Python (Let's Encrypt recommended for macOS Homebrew installs)..." brew_as_invoking_user "$certbot_python" -m pip install --upgrade certbot-dns-cloudflare || die "Failed to install certbot-dns-cloudflare via Homebrew certbot Python" fi fi if ! "$CERTBOT_BIN" plugins 2>/dev/null | grep -qi 'dns-cloudflare'; then if command -v pipx >/dev/null 2>&1 && pipx list 2>/dev/null | grep -qi 'package certbot'; then info "Detected pipx-managed certbot; trying pipx inject certbot-dns-cloudflare..." pipx inject certbot certbot-dns-cloudflare || true fi fi "$CERTBOT_BIN" plugins 2>/dev/null | grep -qi 'dns-cloudflare' || { if command -v brew >/dev/null 2>&1; then die "Cloudflare DNS plugin not detected. On macOS with Homebrew Certbot, run: $(brew --prefix certbot)/libexec/bin/python3 -m pip install certbot-dns-cloudflare" fi homebrew_install_hint die "Cloudflare DNS plugin not detected and Homebrew-based plugin install path is unavailable" } ok "Cloudflare DNS plugin available" } # ---------- Cert + FileWave tasks ---------- setup_cloudflare_credentials() { mkdir -p "$CF_SECRETS_DIR" chmod 700 "$CF_SECRETS_DIR" cat > "$CF_CREDENTIALS_FILE" </dev/null; then ok "Existing certificates backed up to $backup_dir" else warn "Existing certificates not found for backup (continuing)" fi } request_certificate_http() { info "Requesting certificate via HTTP-01 (standalone)..." "$CERTBOT_BIN" -n --agree-tos --standalone certonly -d "$HOSTNAME" -m "$EMAIL" || { echo err "Certificate request failed (HTTP-01)." echo "Likely causes:" echo " - TCP/80 not reachable from internet" echo " - DNS record not pointing to this server" echo echo "Manual retry:" echo "sudo certbot -n --agree-tos --standalone certonly -d \"$HOSTNAME\" -m \"$EMAIL\"" die "Stopping after HTTP-01 failure" } ok "Certificate issued (HTTP-01)" } request_certificate_dns_cloudflare() { verify_cloudflare_credentials_exists info "Requesting certificate via DNS-01 (Cloudflare)..." "$CERTBOT_BIN" -n --agree-tos --dns-cloudflare --dns-cloudflare-credentials "$CF_CREDENTIALS_FILE" certonly -d "$HOSTNAME" -m "$EMAIL" || { echo err "Certificate request failed (DNS-01 Cloudflare)." echo "Likely causes:" echo " - invalid Cloudflare token" echo " - token lacks DNS edit permission on this zone" echo " - FQDN not in a zone accessible by that token" echo echo "Manual retry:" echo "sudo certbot -n --agree-tos --dns-cloudflare --dns-cloudflare-credentials $CF_CREDENTIALS_FILE certonly -d \"$HOSTNAME\" -m \"$EMAIL\"" die "Stopping after DNS-01 failure" } ok "Certificate issued (DNS-01 Cloudflare)" } set_mdm_cert_trusted() { info "Updating iOS preferences in PostgreSQL (mdm_cert_trusted=true)..." local psql_bin="/usr/local/filewave/postgresql/bin/psql" if [ ! -x "$psql_bin" ]; then warn "PostgreSQL binary not found at $psql_bin (skipping mdm_cert_trusted update)" return 0 fi if "$psql_bin" -d mdm -U django -c \ "INSERT INTO ios_preferences (key, value) VALUES ('mdm_cert_trusted', TRUE) ON CONFLICT (key) DO NOTHING; UPDATE ios_preferences SET value='true' WHERE key='mdm_cert_trusted';" \ 2>/dev/null; then ok "iOS preferences updated" else warn "Failed to update iOS preferences (continuing)" fi } create_renew_hook() { mkdir -p "$(dirname "$RENEW_HOOK")" rm -f "$RENEW_HOOK" cat > "$RENEW_HOOK" </dev/null || echo root) CERT_GROUP=\$(stat -f '%Sg' /usr/local/filewave/certs/server.crt 2>/dev/null || echo wheel) else CERT_OWNER=\$(stat -f '%Su' /usr/local/filewave/certs 2>/dev/null || echo root) CERT_GROUP=\$(stat -f '%Sg' /usr/local/filewave/certs 2>/dev/null || echo wheel) fi cp /etc/letsencrypt/live/"\$HOSTNAME"/fullchain.pem /usr/local/filewave/certs/server.crt cp /etc/letsencrypt/live/"\$HOSTNAME"/privkey.pem /usr/local/filewave/certs/server.key chown "\$CERT_OWNER":"\$CERT_GROUP" /usr/local/filewave/certs/server.* /usr/local/bin/fwcontrol server restart (yes 2>/dev/null) | /usr/local/filewave/python/bin/python /usr/local/filewave/django/manage.pyc update_dep_profile_certs 2>/dev/null || true HOOK chmod +x "$RENEW_HOOK" zsh -n "$RENEW_HOOK" || die "Renewal hook syntax check failed" ok "Renewal hook created: $RENEW_HOOK" } inject_certificate_now() { info "Injecting certificate into FileWave now..." "$RENEW_HOOK" || die "Initial certificate injection failed" ok "Certificate injected into FileWave" } create_renew_runner() { mkdir -p "$(dirname "$RENEW_RUNNER")" cat > "$RENEW_RUNNER" < "$LAUNCHD_PLIST" < Label ${LAUNCHD_LABEL} ProgramArguments ${RENEW_RUNNER} StartCalendarInterval Hour 0 Minute 0 RunAtLoad StandardOutPath ${RENEW_LOG_FILE} StandardErrorPath ${RENEW_LOG_FILE} PLIST chmod 644 "$LAUNCHD_PLIST" # Replace existing loaded job if present. launchctl bootout "system/${LAUNCHD_LABEL}" 2>/dev/null || true launchctl bootout system "$LAUNCHD_PLIST" 2>/dev/null || true launchctl bootstrap system "$LAUNCHD_PLIST" || die "Failed to load launchd job" launchctl print "system/${LAUNCHD_LABEL}" >/dev/null 2>&1 || die "launchd job did not register correctly" ok "LaunchDaemon installed and loaded: $LAUNCHD_PLIST" } remove_renew_launchd() { launchctl bootout "system/${LAUNCHD_LABEL}" 2>/dev/null || true launchctl bootout system "$LAUNCHD_PLIST" 2>/dev/null || true rm -f "$LAUNCHD_PLIST" rm -f "$RENEW_RUNNER" ok "Removed LaunchDaemon and renewal runner" } uninstall_files() { step "Removing FileWave Let's Encrypt integration files" log "Starting uninstall flow" rm -f "$RENEW_HOOK" rm -f "$LEGACY_CRON_FILE" remove_renew_launchd if [ -f "$CF_CREDENTIALS_FILE" ]; then if command -v shred >/dev/null 2>&1; then shred -vfz -n 3 "$CF_CREDENTIALS_FILE" || rm -f "$CF_CREDENTIALS_FILE" else rm -f "$CF_CREDENTIALS_FILE" fi ok "Removed Cloudflare credential file" else info "No Cloudflare credential file found (nothing to remove)" fi ok "Uninstall complete" echo echo "certbot package was intentionally left installed." } # ---------- Prompting ---------- prompt_hostname() { while true; do read -r "HOSTNAME?Enter fully qualified domain name (FQDN): " if validate_hostname "$HOSTNAME"; then return 0 fi warn "Invalid hostname format. Example: fw.example.com" done } prompt_email() { while true; do read -r "EMAIL?Enter email address for Let's Encrypt notifications: " if validate_email "$EMAIL"; then return 0 fi warn "Invalid email format. Example: admin@example.com" done } prompt_method() { while true; do echo echo "Select Let's Encrypt validation method:" echo " 1) HTTP-01 (standalone certbot)" echo " - Requires inbound TCP 80 from internet" echo " - Simpler setup, no DNS API token" echo echo " 2) DNS-01 (Cloudflare)" echo " - Does NOT require inbound TCP 80" echo " - Requires Cloudflare API token with Zone.DNS edit rights" echo read -r "method_choice?Choice [1/2]: " case "$method_choice" in 1) METHOD="http"; return 0 ;; 2) METHOD="dns-cloudflare"; return 0 ;; *) warn "Invalid choice. Enter 1 or 2." ;; esac done } prompt_cloudflare_token_if_needed() { if [ "$METHOD" = "dns-cloudflare" ]; then while true; do read -rs "CLOUDFLARE_TOKEN?Enter Cloudflare API token: " echo if [ -n "$CLOUDFLARE_TOKEN" ]; then return 0 fi warn "Cloudflare token cannot be empty." done fi } collect_install_inputs() { step "Collecting install inputs" prompt_hostname prompt_email prompt_method prompt_cloudflare_token_if_needed echo echo -e "${C_BOLD}Review before continuing:${C_RESET}" echo " Hostname: $HOSTNAME" echo " Email: $EMAIL" if [ "$METHOD" = "http" ]; then echo " Method: HTTP-01 (standalone)" echo " Note: TCP 80 must be publicly reachable" else echo " Method: DNS-01 (Cloudflare)" echo " Note: Cloudflare token will be stored at $CF_CREDENTIALS_FILE (mode 600)" fi if ! prompt_yes_no "Proceed with installation"; then die "Aborted by user" fi } print_install_summary() { echo echo "--------------------------------------------------------------" echo -e "${C_BOLD}Install completed successfully${C_RESET}" echo "--------------------------------------------------------------" echo "Files created/updated:" echo "- $RENEW_HOOK" echo "- $LAUNCHD_PLIST" echo "- $RENEW_RUNNER" if [ "$METHOD" = "dns-cloudflare" ]; then echo "- $CF_CREDENTIALS_FILE" fi echo "- $LOG_FILE" echo "- $RENEW_LOG_FILE" echo if [ "$METHOD" = "http" ]; then echo "Manual retry (HTTP-01):" echo "sudo certbot -n --agree-tos --standalone certonly -d \"$HOSTNAME\" -m \"$EMAIL\"" else echo "Manual retry (DNS-01 Cloudflare):" echo "sudo certbot -n --agree-tos --dns-cloudflare --dns-cloudflare-credentials $CF_CREDENTIALS_FILE certonly -d \"$HOSTNAME\" -m \"$EMAIL\"" fi echo echo "Optional renewal test:" echo "sudo certbot renew --force-renewal" echo } install_flow() { log "Starting install flow" collect_install_inputs step "Validating FileWave server prerequisites" ensure_filewave_server step "Validating DNS resolution" check_public_dns_resolution "$HOSTNAME" step "Backing up existing FileWave certs" backup_existing_certs step "Updating system packages" update_system_packages step "Ensuring certbot is installed" ensure_certbot_base if [ "$METHOD" = "dns-cloudflare" ]; then step "Preparing DNS-01 (Cloudflare) prerequisites" ensure_cloudflare_plugin setup_cloudflare_credentials step "Requesting certificate" request_certificate_dns_cloudflare else step "Requesting certificate" warn "HTTP-01 requires public reachability on TCP 80 during validation" request_certificate_http fi step "Applying certificate to FileWave" set_mdm_cert_trusted create_renew_hook inject_certificate_now step "Configuring auto-renewal" create_renew_runner create_renew_launchd print_install_summary } main() { local action="${1:-}" if [ "$action" != "--install" ] && [ "$action" != "--uninstall" ]; then usage exit 1 fi require_root banner require_macos case "$action" in --install) install_flow ;; --uninstall) uninstall_files ;; esac } main "$@"