--- /dev/null
+sslwatcher monitors SSL certificates for specified domains and sends email notifications when they are close to expiration.
+
+Let's Encrypt has [announced the discontinuation](https://letsencrypt.org/2025/01/22/ending-expiration-emails/) of their free certificate expiration notification email service, effective **June 4, 2025**.
+This leaves users who relied on these notifications needing an alternative solution to monitor their SSL/TLS certificate validity and prevent outages caused by expired certificates.
+
+sslwatcher was created to provide a simple, self-hosted alternative for monitoring certificate expiration and receiving timely email warnings, ensuring you have ample time to renew your certificates.
+
+## Features
+
+- Checks SSL certificate expiration dates using cert.sh API
+- Configurable warning threshold (default: 30 days)
+- Email notifications via local MTA
+
+## Requirements
+
+- Python 3.x
+- requests Python package
+- Local MTA (mail transfer agent) installed and configured (postfix, sendmail, or exim4) - only required for email notifications
+
+## Installation
+
+1. Install a Mail Transfer Agent (MTA) (optional if using dry-run mode):
+```bash
+# For Debian/Ubuntu:
+sudo apt-get install postfix
+
+# For Fedora/RHEL:
+sudo dnf install postfix
+```
+
+2. Clone the repository to `/opt/sslwatcher`:
+```bash
+sudo git clone https://github.com/andreasglashauser/sslwatcher.git /opt/sslwatcher
+```
+
+3. Create and set permissions for the log directory:
+```bash
+sudo mkdir -p /var/log/sslwatcher
+sudo chown $USER:$USER /var/log/sslwatcher
+```
+
+## Running the Script
+
+You can run the script in several ways:
+
+### 1. Using systemd (systemd-based systems)
+
+1. Copy the example systemd files:
+```bash
+sudo cp sslwatcher.service.example /etc/systemd/system/sslwatcher.service
+sudo cp sslwatcher.timer.example /etc/systemd/system/sslwatcher.timer
+```
+
+2. Edit the service file to configure your domains and email addresses:
+```bash
+sudo nano /etc/systemd/system/sslwatcher.service
+```
+
+3. Enable and start the timer:
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable sslwatcher.timer
+sudo systemctl start sslwatcher.timer
+```
+
+### 2. Using cron (any Unix-like system)
+
+1. Create a cron script:
+```bash
+sudo nano /etc/cron.daily/sslwatcher
+```
+
+2. Add the following content (adjust paths and arguments as needed):
+```bash
+#!/bin/bash
+/usr/bin/python3 /opt/sslwatcher/sslwatcher.py \
+ --domains example.com another.com \
+ --from-email alerts \
+ --to-email admin \
+ --warning-days 30
+```
+
+3. Make the script executable:
+```bash
+sudo chmod +x /etc/cron.daily/sslwatcher
+```
+
+The script will now run daily at the system's default cron.daily time (usually around 6:25 AM).
+
+### 3. Running Manually
+
+You can also run the script manually using any of the command-line options described below.
+
+## Usage
+
+The script can be run manually with the following parameters:
+
+```bash
+# Using --domain (can be specified multiple times)
+python3 sslwatcher.py --domain example.com --domain another.com --warning-days 30 --from-email alerts@example.com --to-email admin
+
+# Or using --domains (space-separated list)
+python3 sslwatcher.py --domains example.com another.com --warning-days 30 --from-email alerts@example.com --to-email admin
+```
+
+Parameters:
+- `--domain`: Domain to monitor (can be specified multiple times)
+- `--domains`: List of domains to monitor (space-separated)
+- `--warning-days`: Number of days before expiration to send warning (default: 30)
+- `--from-email`: Sender email address (domain optional, will use hostname; required unless using --dry-run)
+- `--to-email`: Recipient email address (domain optional, will use hostname; required unless using --dry-run)
+- `-v`, `-vv`, `-vvv`: Increase verbosity level (optional)
+- `--dry-run`: Print notifications to terminal instead of sending emails
+
+#### Dry Run Mode
+
+You can test the script without having an MTA installed by using the `--dry-run` option. This will print the notifications to the terminal instead of sending emails. When using `--dry-run`, the `--from-email` and `--to-email` arguments are optional - if not provided, default values will be used:
+
+```bash
+# Using --domain (email arguments optional)
+python3 sslwatcher.py --domain example.com --dry-run
+
+# Or using --domains (email arguments optional)
+python3 sslwatcher.py --domains example.com anotherexample.com --dry-run
+
+# With custom email addresses (optional)
+python3 sslwatcher.py --domain example.com --from-email alerts --to-email admin --dry-run
+```
+
+Example output in dry-run mode:
+
+```
+================================================================================
+SSL Certificate Expiration Warning for example.com
+================================================================================
+From: sslwatcher@your-hostname
+To: admin@your-hostname
+Subject: SSL Certificate Expiration Warning for example.com
+--------------------------------------------------------------------------------
+Warning: The SSL certificate for example.com will expire in 25 days.
+Please renew the certificate before it expires.
+================================================================================
+```
\ No newline at end of file
--- /dev/null
+import argparse
+import smtplib
+import logging
+import os
+import sys
+import socket
+import subprocess
+import json
+import platform
+from datetime import datetime
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+
+def check_mta():
+ """Check if a Mail Transfer Agent is installed and running"""
+ mta_services = ['postfix', 'sendmail', 'exim4']
+ for service in mta_services:
+ try:
+ with smtplib.SMTP('localhost', timeout=1) as server:
+ server.quit()
+ return True
+ except:
+ continue
+
+ print("WARNING: No Mail Transfer Agent (MTA) is installed or running.")
+ print("This script requires a working MTA to send email notifications.")
+ print("Please install and configure an MTA (e.g., postfix, sendmail, or exim4).")
+ return False
+
+def get_hostname():
+ """Get the system's hostname"""
+ return socket.gethostname()
+
+def format_email(email, hostname):
+ """Format email address, adding hostname as domain if none specified"""
+ if '@' not in email:
+ return f"{email}@{hostname}"
+ return email
+
+def get_log_dir():
+ """Get the appropriate log directory based on the operating system"""
+ system = platform.system().lower()
+ if system == 'windows':
+ log_dir = os.path.join(os.getenv('LOCALAPPDATA', ''), 'sslwatcher', 'logs')
+ else:
+ log_dir = '/var/log/sslwatcher'
+ return log_dir
+
+def setup_logging(verbose_level):
+ """Configure logging to write to platform-specific log directory"""
+ log_dir = get_log_dir()
+ try:
+ if not os.path.exists(log_dir):
+ try:
+ os.makedirs(log_dir)
+ except PermissionError:
+ print(f"Error: Permission denied creating log directory {log_dir}")
+ print("Please run the script with sudo or create the directory manually with:")
+ if platform.system().lower() == 'windows':
+ print(f"mkdir {log_dir}")
+ else:
+ print(f"sudo mkdir -p {log_dir}")
+ print(f"sudo chown {os.getuid()}:{os.getgid()} {log_dir}")
+ sys.exit(1)
+ except Exception as e:
+ print(f"Error creating log directory: {str(e)}")
+ sys.exit(1)
+
+ log_file = os.path.join(log_dir, 'sslwatcher.log')
+ try:
+ with open(log_file, 'a') as f:
+ f.write('')
+ os.truncate(log_file, 0)
+ except PermissionError:
+ print(f"Error: Permission denied writing to log file {log_file}")
+ print("Please run the script with sudo or fix permissions with:")
+ if platform.system().lower() == 'windows':
+ print(f"icacls {log_file} /grant Users:F")
+ else:
+ print(f"sudo touch {log_file}")
+ print(f"sudo chown {os.getuid()}:{os.getgid()} {log_file}")
+ sys.exit(1)
+ except Exception as e:
+ print(f"Error accessing log file: {str(e)}")
+ sys.exit(1)
+
+ if verbose_level >= 3:
+ log_level = logging.DEBUG
+ elif verbose_level == 2:
+ log_level = logging.INFO
+ elif verbose_level == 1:
+ log_level = logging.WARNING
+ else:
+ log_level = logging.ERROR
+
+ logging.basicConfig(
+ level=log_level,
+ format='%(asctime)s - %(levelname)s - %(message)s',
+ handlers=[
+ logging.FileHandler(log_file),
+ logging.StreamHandler() if verbose_level > 0 else logging.NullHandler()
+ ]
+ )
+
+ if verbose_level > 0:
+ logging.info(f'SSLWatcher started with verbosity level {verbose_level}')
+ else:
+ logging.error('SSLWatcher started (no console output)')
+
+class SSLWatcher:
+ def __init__(self, domains, warning_days, email_from, email_to, dry_run=False):
+ self.domains = domains
+ self.warning_days = warning_days
+ self.hostname = get_hostname()
+ self.email_from = format_email(email_from, self.hostname)
+ self.email_to = format_email(email_to, self.hostname)
+ self.dry_run = dry_run
+ logging.info(f'Initialized SSLWatcher with domains: {", ".join(domains)}')
+ logging.info(f'Warning threshold: {warning_days} days')
+ if not dry_run:
+ logging.info(f'Email notifications: from {self.email_from} to {self.email_to}')
+ else:
+ logging.info('Running in dry-run mode - notifications will be printed to terminal')
+
+ def check_certificate(self, domain):
+ """Check certificate expiration using cert.sh API via curl"""
+ logging.info(f'Checking certificate for domain: {domain}')
+
+ def make_curl_request(url):
+ try:
+ logging.debug(f'Making request to: {url}')
+ result = subprocess.run(['curl', '-s', url],
+ capture_output=True,
+ text=True,
+ timeout=10)
+ if result.returncode != 0:
+ logging.error(f'Curl request failed with return code {result.returncode}')
+ return None
+ return json.loads(result.stdout)
+ except subprocess.TimeoutExpired:
+ logging.error(f'Curl request timed out for {url}')
+ return None
+ except json.JSONDecodeError as e:
+ logging.error(f'Failed to parse JSON response: {str(e)}')
+ return None
+ except Exception as e:
+ logging.error(f'Unexpected error during curl request: {str(e)}')
+ return None
+
+ url = f"https://crt.sh/json?q={domain}&exclude=expired"
+ cert_data = make_curl_request(url)
+
+ if cert_data:
+ not_after = datetime.fromisoformat(cert_data[0]['not_after'].replace('Z', '+00:00'))
+ logging.info(f'Certificate for {domain} expires on: {not_after}')
+ return not_after
+
+ logging.info(f'No active certificate found for {domain}, checking for any certificate')
+ url = f"https://crt.sh/json?q={domain}"
+ cert_data = make_curl_request(url)
+
+ if not cert_data:
+ logging.warning(f'No certificate found for {domain}')
+ return None
+
+ not_after = datetime.fromisoformat(cert_data[0]['not_after'].replace('Z', '+00:00'))
+ logging.warning(f'Only expired certificate found for {domain}, expired on: {not_after}')
+ return not_after
+
+ def send_notification(self, domain, days_remaining):
+ """Send email notification about certificate expiration"""
+ logging.info(f'Preparing notification for {domain} ({days_remaining} days remaining)')
+
+ if self.dry_run:
+ print("\n" + "="*80)
+ print(f"SSL Certificate Expiration Warning for {domain}")
+ print("="*80)
+ print(f"From: {self.email_from}")
+ print(f"To: {self.email_to}")
+ print(f"Subject: SSL Certificate Expiration Warning for {domain}")
+ print("-"*80)
+ print(f"Warning: The SSL certificate for {domain} will expire in {days_remaining} days.")
+ print("Please renew the certificate before it expires.")
+ print("="*80 + "\n")
+ return
+
+ msg = MIMEMultipart()
+ msg['From'] = self.email_from
+ msg['To'] = self.email_to
+ msg['Subject'] = f"SSL Certificate Expiration Warning for {domain}"
+
+ body = f"""
+ Warning: The SSL certificate for {domain} will expire in {days_remaining} days.
+ Please renew the certificate before it expires.
+ """
+
+ msg.attach(MIMEText(body, 'plain'))
+
+ try:
+ logging.debug('Attempting to send email via local SMTP')
+ with smtplib.SMTP('localhost') as server:
+ server.send_message(msg)
+ logging.info(f'Successfully sent notification for {domain}')
+ except Exception as e:
+ logging.error(f'Failed to send notification for {domain}: {str(e)}')
+
+ def run(self):
+ """Main execution method"""
+ logging.info('Starting certificate check for all domains')
+ for domain in self.domains:
+ logging.info(f'Processing domain: {domain}')
+ not_after = self.check_certificate(domain)
+ if not_after:
+ days_remaining = (not_after - datetime.now()).days
+ logging.info(f'{domain}: {days_remaining} days remaining until expiration')
+ if days_remaining <= self.warning_days:
+ logging.warning(f'{domain}: Certificate expires in {days_remaining} days, sending notification')
+ self.send_notification(domain, days_remaining)
+ else:
+ logging.info(f'{domain}: Certificate valid for {days_remaining} days, no notification needed')
+ logging.info('Finished checking all domains')
+
+def main():
+ parser = argparse.ArgumentParser(description='SSL Certificate Expiration Monitor')
+ domain_group = parser.add_mutually_exclusive_group(required=True)
+ domain_group.add_argument('--domain', action='append',
+ help='Domain to monitor (can be specified multiple times)')
+ domain_group.add_argument('--domains', nargs='+',
+ help='List of domains to monitor (space-separated)')
+ parser.add_argument('--warning-days', type=int, default=30,
+ help='Number of days before expiration to send warning (default: 30)')
+ parser.add_argument('--from-email',
+ help='Sender email address (domain optional, will use hostname)')
+ parser.add_argument('--to-email',
+ help='Recipient email address (domain optional, will use hostname)')
+ parser.add_argument('-v', '--verbose', action='count', default=0,
+ help='Increase verbosity level (-v, -vv, -vvv)')
+ parser.add_argument('--dry-run', action='store_true',
+ help='Print notifications to terminal instead of sending emails')
+ args = parser.parse_args()
+
+ domains = args.domain if args.domain else args.domains
+
+ if not args.dry_run:
+ if not args.from_email or not args.to_email:
+ parser.error("--from-email and --to-email are required unless using --dry-run")
+
+ if not args.dry_run and not check_mta():
+ sys.exit(1)
+
+ setup_logging(args.verbose)
+
+ try:
+ watcher = SSLWatcher(
+ domains=domains,
+ warning_days=args.warning_days,
+ email_from=args.from_email or 'sslwatcher',
+ email_to=args.to_email or 'admin',
+ dry_run=args.dry_run
+ )
+ watcher.run()
+ except Exception as e:
+ logging.error(f'Fatal error in main execution: {str(e)}')
+ raise
+ finally:
+ logging.info('SSLWatcher finished')
+
+if __name__ == '__main__':
+ main()
\ No newline at end of file