diff --git a/README.md b/README.md index 7f33d3e..edf2012 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,22 @@ sudo BACKUP_BUCKET=my-mediawiki-backups \ --- +## Step 5b — Set up SSL + +Port 80 is immediately redirected to HTTPS. The AMI ships with the `mod_ssl` +self-signed cert as a placeholder, so browsers will show a security warning +until certbot replaces it. Once DNS is pointed at the new instance and port 443 +is reachable, obtain a real certificate: + +```bash +sudo bash /opt/mediawiki-ami/setup/setup-ssl.sh +``` + +This runs `certbot --apache`, obtains a Let's Encrypt cert for `wiki.asmbly.org`, +updates the `:443` vhost in `mediawiki.conf`, and installs the renewal timer. + +--- + ## Step 6 — Validate & cut over ```bash @@ -139,7 +155,7 @@ When satisfied: ## Automated backups (new server) -The new server runs `scripts/backup/backup-with-retention.sh` nightly at 02:00 UTC +The new server runs `scripts/backup/backup-with-retention.sh` nightly at 08:00 UTC via `/etc/cron.d/mediawiki-backup`. It uploads to S3 with GFS rotation: | Tier | When | S3 prefix | Expires | @@ -154,27 +170,53 @@ via `/etc/cron.d/mediawiki-backup`. It uploads to S3 with GFS rotation: ``` config/ - mediawiki/LocalSettings.php ← Config-as-code; secrets injected by envsubst at build time - httpd/mediawiki.conf ← Apache vhost template - php/mediawiki.ini ← PHP tuning + cloudwatch/mediawiki-cwa.json ← CloudWatch agent config + cron/mediawiki-backup ← Cron job definitions + cron/mediawiki-jobs ← MediaWiki job runner cron + httpd/mediawiki.conf ← Apache vhost template + httpd/security.conf ← Apache security headers + logrotate/httpd-mediawiki ← Log rotation for Apache + logrotate/mediawiki-backup ← Log rotation for backups + mariadb/mariadb.repo ← MariaDB 10.11 yum repo + mariadb/mediawiki.cnf ← MariaDB tuning + mediawiki/LocalSettings.php ← Config-as-code; secrets injected by envsubst at build time + mediawiki/composer.local.json ← Composer local overrides + mediawiki/robots.txt ← robots.txt for the wiki + mediawiki/assets/ ← Static assets copied to DocumentRoot (logos, favicons) + php/mediawiki.ini ← PHP tuning + system/limits.conf ← OS limits + system/mediawiki-backup.sysconfig ← /etc/sysconfig for backup cron + system/sysctl.conf ← Kernel tuning docs/ - lifecycle.json ← S3 Lifecycle Rules (apply once to bucket) - s3-backup-setup.md ← Bucket setup walkthrough + lifecycle.json ← S3 Lifecycle Rules (apply once to bucket) + s3-backup-setup.md ← Bucket setup walkthrough + iam/README.md ← IAM setup guide + iam/oidc-trust-policy.json ← OIDC trust policy for GitHub Actions + iam/packer-policy.json ← Minimal IAM policy for Packer + iam/setup-oidc-role.sh ← Script to create OIDC role + iam/setup-vpc.sh ← Script to create VPC for Packer packer/ - mediawiki.pkr.hcl ← Packer HCL2 build template - variables.pkr.hcl ← Variable declarations - variables.pkrvars.hcl.example ← Copy → my.auto.pkrvars.hcl and fill in + mediawiki.pkr.hcl ← Packer HCL2 build template + variables.pkr.hcl ← Variable declarations + variables.pkrvars.example ← Copy → my.auto.pkrvars.hcl and fill in scripts/ - 00-system.sh AL2025 base packages (lua, cronie, gettext, …) - 01-php.sh PHP 8.3 + extensions - 02-mariadb.sh MariaDB 10.11 LTS - 03-httpd.sh Apache httpd + vhost - 04-mediawiki.sh MW 1.43 core + envsubst → LocalSettings.php - 05-extensions.sh REL1_43 extensions (Gerrit + GitHub) - 06-finalize.sh Harden, clean, enable services - 07-backup-setup.sh Install backup cron + /etc/sysconfig/mediawiki-backup + 00-system.sh AL2023 base packages (lua, cronie, gettext, …) + 01-php.sh PHP 8.3 + extensions + 02-mariadb.sh MariaDB 10.11 LTS + 03-httpd.sh Apache httpd + vhost + 04-mediawiki.sh MW 1.43 core + envsubst → LocalSettings.php + 05-extensions.sh REL1_43 extensions (Gerrit + GitHub) + 06-finalize.sh Harden, clean, enable services + 07-backup-setup.sh Install backup cron + /etc/sysconfig/mediawiki-backup + +packer-test/ + Dockerfile.test ← Container image for local packer script testing + test-local.sh ← Run packer scripts locally in Docker + mock-aws ← Stub AWS CLI for offline testing + container-systemctl ← systemctl shim for containers + test.env.example ← Copy → test.env and fill in scripts/ inventory/gather-info.sh Server inventory report @@ -184,12 +226,12 @@ scripts/ backup/config-export.sh Redacted config export → S3 restore/restore.sh Restore DB + images from S3 (reusable) restore/upgrade-1.35-to-1.43.sh One-time: run update.php + post-upgrade maintenance + setup/setup-ssl.sh Post-launch: obtain Let's Encrypt cert via certbot .github/workflows/ build-ami.yml Packer build on tag push / manual dispatch + packer-validate.yml Validate packer template on pull requests -output/ - info.txt Inventory captured from the existing 1.35 server ``` --- diff --git a/config/httpd/mediawiki.conf b/config/httpd/mediawiki.conf index cc01dd1..cd0c478 100644 --- a/config/httpd/mediawiki.conf +++ b/config/httpd/mediawiki.conf @@ -1,18 +1,36 @@ # mediawiki.conf — Apache vhost for MediaWiki -# Uploaded to /tmp/ by Packer; installed by 03-httpd.sh. -# SSL is handled by Let's Encrypt (certbot) post-launch. +# +# HTTP redirects to HTTPS immediately. The HTTPS vhost uses the self-signed +# cert created by mod_ssl on first install; certbot replaces it post-launch: +# sudo bash /opt/mediawiki-ami/setup/setup-ssl.sh +# ── HTTP: redirect to HTTPS ─────────────────────────────────────────────────── + ServerName wiki.asmbly.org + RewriteEngine On + RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] + + +# ── HTTPS: MediaWiki application ────────────────────────────────────────────── + ServerName wiki.asmbly.org DocumentRoot /var/www/mediawiki - # Short URLs: /wiki/Article_Name → index.php + SSLEngine on + # Self-signed cert (installed by mod_ssl) — replaced by certbot post-launch + SSLCertificateFile /etc/pki/tls/certs/localhost.crt + SSLCertificateKeyFile /etc/pki/tls/private/localhost.key + + Header always set Strict-Transport-Security "max-age=63072000" + + # ── Short URLs: /wiki/Article_Name → index.php ──────────────────────────── RewriteEngine On RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-f RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-d RewriteRule ^/wiki/(.*)$ /index.php [L] RedirectMatch ^/$ /wiki/Main_Page + # ── DocumentRoot ────────────────────────────────────────────────────────── Options -Indexes +FollowSymLinks AllowOverride All @@ -20,23 +38,59 @@ DirectoryIndex index.php - # Protect sensitive files - + # ── Block directories that contain PHP source / tools / installer ───────── + # None of these should ever be reachable via HTTP. + Require all denied - - + + + # ── Block root-level non-web files ──────────────────────────────────────── + + # PHP: autoload.php is a Composer autoloader — not a web entry point. + # The 8 real entry points (index, api, load, rest, img_auth, thumb, + # thumb_handler, opensearch_desc) are intentionally left open. + Require all denied - # Uploads: no PHP execution + # Composer/build config files + + Require all denied + + + # Documentation and data files with no web purpose + + Require all denied + + + # ALLCAPS no-extension docs: COPYING, CREDITS, FAQ, HISTORY, INSTALL, + # RELEASE-NOTES-*, SECURITY, UPGRADE + + Require all denied + + + # ── Images: accessible but no PHP execution, no .htaccess override ─────── + # AllowOverride None prevents user-uploaded .htaccess files from + # changing security settings in this directory. Options -Indexes + AllowOverride None Require all denied - + # ── Extensions / skins: static assets only, no PHP execution ───────────── + # PHP files in these trees are loaded via include() by MW core, never + # via direct HTTP requests. + + Options -Indexes + + Require all denied + + + + Options -Indexes Require all denied @@ -46,7 +100,3 @@ ErrorLog /var/log/httpd/mediawiki-error.log CustomLog /var/log/httpd/mediawiki-access.log combined - -# HTTPS vhost — configured by certbot (Let's Encrypt) post-launch: -# sudo dnf install -y certbot python3-certbot-apache -# sudo certbot --apache -d wiki.asmbly.org diff --git a/config/mediawiki/LocalSettings.php b/config/mediawiki/LocalSettings.php index ae5b60f..633b22e 100644 --- a/config/mediawiki/LocalSettings.php +++ b/config/mediawiki/LocalSettings.php @@ -12,9 +12,7 @@ # MW_SECRET_KEY — generate: openssl rand -hex 64 # MW_UPGRADE_KEY — generate: openssl rand -hex 16 # MW_SMTP_PASSWORD — Gmail app password for notification@asmbly.org -# ⚠ ROTATE THIS — old value was exposed in info.txt # MW_DISCOURSE_SECRET — Discourse SSO shared secret -# ⚠ ROTATE THIS — old value was exposed in info.txt # # Derived from the MediaWiki 1.35.8 LocalSettings.php captured in output/info.txt # and translated to 1.43 conventions. Changes from 1.35: @@ -47,7 +45,7 @@ # ============================================================================= $wgLogo = "$wgScriptPath/ASMBLY_Avatar_135x135.png"; -$wgFavicon = "$wgScriptPath/ASMBLY_avatar-1-150x150.png"; +$wgFavicon = "$wgScriptPath/ASMBLY_Avatar-150x150.png"; $wgLogos = [ '1x' => "$wgScriptPath/ASMBLY_Avatar_135x135.png", @@ -130,7 +128,6 @@ $wgEmailAuthentication = false; # Gmail SMTP relay -# ⚠ MW_SMTP_PASSWORD must be rotated — old value was exposed in repo history $wgSMTP = [ 'host' => 'ssl://smtp.gmail.com', 'IDHost' => 'gmail.com', @@ -211,7 +208,6 @@ wfLoadExtension( 'ParserFunctions' ); wfLoadExtension( 'PdfHandler' ); wfLoadExtension( 'Poem' ); -wfLoadExtension( 'RenameUser' ); wfLoadExtension( 'ReplaceText' ); wfLoadExtension( 'SecureLinkFixer' ); wfLoadExtension( 'SpamBlacklist' ); @@ -284,40 +280,34 @@ # ── PluggableAuth v7 + DiscourseSsoConsumer ─────────────────────────────────── # -# PluggableAuth v7 (required for MW 1.39+) changed from flat global variables -# to a $wgPluggableAuth_Config array. The old 1.35 config used: -# $wgPluggableAuth_ButtonLabelMessage = "Log In With Discourse"; -# which is removed in v7. -# -# The button label is now the array key in $wgPluggableAuth_Config. -# See: https://www.mediawiki.org/wiki/Extension:PluggableAuth#Configuration -# -# ⚠ MW_DISCOURSE_SECRET must be rotated — old value was exposed in repo history wfLoadExtension( 'PluggableAuth' ); wfLoadExtension( 'DiscourseSsoConsumer' ); $wgPluggableAuth_Config = [ - 'Log In With Discourse' => [ - 'plugin' => 'DiscourseSsoConsumer', - 'data' => [], - ], + 'Log In With Discourse' => [ 'plugin' => 'DiscourseSsoConsumer' ], ]; # Uncomment to allow local admin login alongside Discourse SSO: # $wgPluggableAuth_EnableLocalLogin = true; -$wgDiscourseSsoConsumer_DiscourseUrl = "https://yo.asmbly.org"; -$wgDiscourseSsoConsumer_SsoSharedSecret = "${MW_DISCOURSE_SECRET}"; -$wgDiscourseSsoConsumer_ExposeName = true; -$wgDiscourseSsoConsumer_ExposeEmail = false; -$wgDiscourseSsoConsumer_EnableAutoRelogin = true; - -# Discourse group → MediaWiki group mapping -# Discourse groups 'makers' and 'community' → MW 'editor' -# Discourse groups 'sysops' and 'leadership' → MW 'sysop' and 'bureaucrat' -$wgDiscourseSsoConsumer_GroupMaps = [ - 'editor' => [ 'makers', 'community' ], - 'sysop' => [ 'sysops', 'leadership' ], - 'bureaucrat' => [ 'sysops', 'leadership' ], -]; +# DiscourseSsoConsumer uses a hook-based config (flat globals removed in 6.x). +# See: https://codeberg.org/centertap/DiscourseSsoConsumer#configure-discoursessoconsumer-using-a-hook-function +$wgHooks['DiscourseSsoConsumer_Configure'][] = + function ( array &$config ) { + $config['DiscourseUrl'] = 'https://yo.asmbly.org'; + $config['Sso']['Enable'] = true; + $config['Sso']['SharedSecret'] = '${MW_DISCOURSE_SECRET}'; + $config['Sso']['EnableAutoRelogin'] = true; + $config['User']['ExposeName'] = true; + $config['User']['ExposeEmail'] = false; + # Discourse group → MediaWiki group mapping + # 'makers' and 'community' → editor + # 'sysops' and 'leadership' → sysop + bureaucrat + $config['User']['GroupMaps'] = [ + 'editor' => [ 'makers', 'community' ], + 'sysop' => [ 'sysops', 'leadership' ], + 'bureaucrat' => [ 'sysops', 'leadership' ], + ]; + return true; + }; diff --git a/config/mediawiki/assets/.gitkeep b/config/mediawiki/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config/mediawiki/assets/ASMBLY_Avatar_100x100.png b/config/mediawiki/assets/ASMBLY_Avatar_100x100.png new file mode 100644 index 0000000..843076a Binary files /dev/null and b/config/mediawiki/assets/ASMBLY_Avatar_100x100.png differ diff --git a/config/mediawiki/assets/ASMBLY_Avatar_135x135.png b/config/mediawiki/assets/ASMBLY_Avatar_135x135.png new file mode 100644 index 0000000..4f52dfc Binary files /dev/null and b/config/mediawiki/assets/ASMBLY_Avatar_135x135.png differ diff --git a/config/mediawiki/assets/ASMBLY_Avatar_150x150.png b/config/mediawiki/assets/ASMBLY_Avatar_150x150.png new file mode 100644 index 0000000..f6b1c63 Binary files /dev/null and b/config/mediawiki/assets/ASMBLY_Avatar_150x150.png differ diff --git a/config/mediawiki/assets/ASMBLY_Avatar_202x202.png b/config/mediawiki/assets/ASMBLY_Avatar_202x202.png new file mode 100644 index 0000000..0cf69fd Binary files /dev/null and b/config/mediawiki/assets/ASMBLY_Avatar_202x202.png differ diff --git a/config/mediawiki/assets/ASMBLY_Avatar_270x270.png b/config/mediawiki/assets/ASMBLY_Avatar_270x270.png new file mode 100644 index 0000000..283202e Binary files /dev/null and b/config/mediawiki/assets/ASMBLY_Avatar_270x270.png differ diff --git a/config/mediawiki/assets/ASMBLY_WordMark_135x40.png b/config/mediawiki/assets/ASMBLY_WordMark_135x40.png new file mode 100644 index 0000000..9a1b391 Binary files /dev/null and b/config/mediawiki/assets/ASMBLY_WordMark_135x40.png differ diff --git a/config/mediawiki/robots.txt b/config/mediawiki/robots.txt new file mode 100644 index 0000000..e0e3c6d --- /dev/null +++ b/config/mediawiki/robots.txt @@ -0,0 +1,48 @@ +# robots.txt for wiki.asmbly.org +# +# All article content is open to crawlers via BOTH URL forms: +# /wiki/Article_Title (short URL, canonical) +# /index.php?title=Article (legacy URL — must remain crawlable for SEO) +# +# We block only the dynamic/action URL variants that generate high server load +# without producing indexable content. +# +# References: +# https://www.mediawiki.org/wiki/Manual:Robots.txt +# https://en.wikipedia.org/robots.txt + +User-agent: * + +# ── Special pages ───────────────────────────────────────────────────────────── +# Dynamically generated; no indexable article content. +Disallow: /wiki/Special: +Disallow: /wiki/Special%3A +Disallow: /index.php?title=Special: +Disallow: /index.php?title=Special%3A + +# ── Action pages (edit, history, info, raw, submit, etc.) ───────────────────── +# Per-request DB queries with no index value. +Disallow: /*?action= +Disallow: /index.php?action= + +# ── Diff and revision-comparison pages ─────────────────────────────────────── +Disallow: /*?diff= +Disallow: /index.php?diff= +Disallow: /index.php?oldid= + +# ── Particular dynamic pages ─────────────────────────────────────── +Disallow: /index.php?title=Help +Disallow: /index.php?title=Image +Disallow: /index.php?title=MediaWiki +Disallow: /index.php?title=Special: +Disallow: /index.php?title=Template + +# ── Printable versions (duplicate content) ──────────────────────────────────── +Disallow: /*?printable=yes + +# ── Allow the Internet Archiver to index action=raw and thereby store the raw wikitext of pages ──────────────────────────────────── +User-agent: ia_archiver +Allow: /*&action=raw + +# ── Crawl-delay: limit load on a small-instance server ─────────────────────── +Crawl-delay: 1 diff --git a/packer-test/container-systemctl b/packer-test/container-systemctl index 1fa1bcc..65ea37f 100644 --- a/packer-test/container-systemctl +++ b/packer-test/container-systemctl @@ -63,7 +63,12 @@ _mariadb_stop() { _httpd_start() { echo "[systemctl-mock] Starting httpd directly (no systemd)..." - httpd -k start 2>/dev/null || apachectl start 2>/dev/null || true + httpd -t || { echo "[systemctl-mock] ERROR: httpd config check failed" >&2; return 1; } + httpd -k start 2>/dev/null || apachectl start 2>/dev/null || { + echo "[systemctl-mock] ERROR: httpd failed to start — check /var/log/httpd/error_log" >&2 + cat /var/log/httpd/error_log 2>/dev/null | tail -20 || true + return 1 + } } _httpd_stop() { diff --git a/packer/mediawiki.pkr.hcl b/packer/mediawiki.pkr.hcl index c3054d8..63f89e6 100644 --- a/packer/mediawiki.pkr.hcl +++ b/packer/mediawiki.pkr.hcl @@ -54,7 +54,7 @@ source "amazon-ebs" "mediawiki" { launch_block_device_mappings { device_name = "/dev/xvda" - volume_size = 30 + volume_size = 16 volume_type = "gp3" delete_on_termination = true encrypted = false diff --git a/packer/scripts/03-httpd.sh b/packer/scripts/03-httpd.sh index 6d39e5e..745ad87 100644 --- a/packer/scripts/03-httpd.sh +++ b/packer/scripts/03-httpd.sh @@ -3,13 +3,40 @@ # Phase 3: Install Apache httpd + mod_php, configure a MediaWiki vhost. set -euxo pipefail -# ── Install Apache ──────────────────────────────────────────────────────────── -dnf install -y httpd mod_ssl +# ── Install Apache + certbot ────────────────────────────────────────────────── +dnf install -y httpd mod_ssl certbot python3-certbot-apache # ── Apache global configuration tweaks ──────────────────────────────────────── cp /tmp/config/httpd/security.conf /etc/httpd/conf.d/security.conf +# Remove default conf files that are unnecessary or conflict with our vhost: +# autoindex.conf — directory listing (we set -Indexes everywhere) +# userdir.conf — ~/public_html user directories (~username URLs) +# welcome.conf — Apache test page; our vhost replaces it +# +# ssl.conf is intentionally kept: it provides the required global SSL directives +# (Listen 443 https, SSLSessionCache, SSLProtocol, etc.) that mod_ssl needs to +# initialise. Its _default_:443 catch-all vhost coexists harmlessly with our +# named *:443 vhost because SNI routing takes precedence. +rm -f /etc/httpd/conf.d/autoindex.conf \ + /etc/httpd/conf.d/userdir.conf \ + /etc/httpd/conf.d/welcome.conf + +# ── Ensure the self-signed SSL certificate exists ───────────────────────────── +# mod_ssl's post-install scriptlet should generate this, but on AL2023 it may +# not run in all environments. Generate it explicitly if absent. +if [[ ! -f /etc/pki/tls/certs/localhost.crt ]]; then + echo "Generating self-signed SSL certificate..." + mkdir -p /etc/pki/tls/certs /etc/pki/tls/private + openssl req -newkey rsa:2048 -nodes \ + -keyout /etc/pki/tls/private/localhost.key \ + -x509 -days 3650 \ + -out /etc/pki/tls/certs/localhost.crt \ + -subj "/C=US/ST=Texas/L=Austin/O=Asmbly/CN=wiki.asmbly.org" + chmod 600 /etc/pki/tls/private/localhost.key +fi + # ── MediaWiki vhost ─────────────────────────────────────────────────────────── mkdir -p /var/www/mediawiki mkdir -p /var/log/httpd @@ -20,6 +47,7 @@ cp /tmp/config/httpd/mediawiki.conf /etc/httpd/conf.d/mediawiki.conf cp /tmp/config/logrotate/httpd-mediawiki /etc/logrotate.d/httpd-mediawiki # ── Enable and start ────────────────────────────────────────────────────────── +httpd -t # fail fast with a readable error if config is broken systemctl enable httpd systemctl start httpd diff --git a/packer/scripts/04-mediawiki.sh b/packer/scripts/04-mediawiki.sh index 8046501..607e78d 100644 --- a/packer/scripts/04-mediawiki.sh +++ b/packer/scripts/04-mediawiki.sh @@ -83,4 +83,22 @@ mkdir -p "${MW_ROOT}/cache" chown apache:apache "${MW_ROOT}/cache" chmod 775 "${MW_ROOT}/cache" +# ── robots.txt ─────────────────────────────────────────────────────────────── +cp /tmp/config/mediawiki/robots.txt "${MW_ROOT}/robots.txt" +chown root:apache "${MW_ROOT}/robots.txt" +chmod 644 "${MW_ROOT}/robots.txt" + +# ── Static assets (logos, favicons) ────────────────────────────────────────── +# Files committed to config/mediawiki/assets/ are served from the DocumentRoot +# because $wgScriptPath = "". Copy any non-hidden files that exist. +ASSETS_SRC="/tmp/config/mediawiki/assets" +if compgen -G "${ASSETS_SRC}/*" > /dev/null 2>&1; then + cp "${ASSETS_SRC}"/* "${MW_ROOT}/" + chown root:apache "${MW_ROOT}"/*.png "${MW_ROOT}"/*.ico 2>/dev/null || true + chmod 644 "${MW_ROOT}"/*.png "${MW_ROOT}"/*.ico 2>/dev/null || true + echo "Installed static assets from ${ASSETS_SRC}" +else + echo "WARNING: no static assets found in ${ASSETS_SRC} — logos will be missing until added" +fi + echo "04-mediawiki.sh complete — MediaWiki ${MW_VERSION} installed at ${MW_ROOT}" diff --git a/scripts/setup/setup-ssl.sh b/scripts/setup/setup-ssl.sh new file mode 100644 index 0000000..cb5b3f7 --- /dev/null +++ b/scripts/setup/setup-ssl.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# scripts/setup/setup-ssl.sh +# Post-launch: obtain a Let's Encrypt certificate and reconfigure Apache. +# +# Run once after the instance has its final IP/DNS and port 80/443 are +# reachable from the internet: +# +# sudo bash /opt/mediawiki-ami/setup/setup-ssl.sh +# +# After this script completes: +# - Apache serves the wiki over HTTPS with a valid cert +# - HTTP redirects to HTTPS (certbot preserves the redirect in mediawiki.conf) +# - Certbot installs a systemd timer for automatic renewal +# +# Optional env vars: +# DOMAIN — defaults to wiki.asmbly.org +# EMAIL — defaults to admin@asmbly.org + +set -euo pipefail + +DOMAIN="${DOMAIN:-wiki.asmbly.org}" +EMAIL="${EMAIL:-admin@asmbly.org}" + +log() { echo "[$(date -u +"%Y-%m-%d %H:%M:%S")] $*"; } + +log "Obtaining Let's Encrypt certificate for ${DOMAIN}..." +certbot --apache \ + --non-interactive \ + --agree-tos \ + --email "${EMAIL}" \ + -d "${DOMAIN}" + +systemctl reload httpd + +log "SSL configured for ${DOMAIN}" +log "Auto-renewal: $(systemctl is-enabled certbot-renew.timer 2>/dev/null || echo 'check /etc/cron.d/certbot')" +