From 9d924863f8ba466bb56404272560139aa3da3cf7 Mon Sep 17 00:00:00 2001 From: Andreas Glashauser Date: Sun, 30 Mar 2025 15:12:42 +0200 Subject: [PATCH 1/1] Initial commit --- .gitignore | 2 + LICENSE | 9 ++ README.md | 143 ++++++++++++++++++++ sslwatcher.py | 269 +++++++++++++++++++++++++++++++++++++ sslwatcher.service.example | 12 ++ sslwatcher.timer.example | 9 ++ 6 files changed, 444 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 sslwatcher.py create mode 100644 sslwatcher.service.example create mode 100644 sslwatcher.timer.example diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0220a5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.log \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b45608a --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +# Released under MIT License + +Copyright (c) 2025 Andreas Glashauser. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..39a679b --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +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 diff --git a/sslwatcher.py b/sslwatcher.py new file mode 100644 index 0000000..dff6183 --- /dev/null +++ b/sslwatcher.py @@ -0,0 +1,269 @@ +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 diff --git a/sslwatcher.service.example b/sslwatcher.service.example new file mode 100644 index 0000000..b1b6739 --- /dev/null +++ b/sslwatcher.service.example @@ -0,0 +1,12 @@ +[Unit] +Description=SSL Certificate Expiration Monitor +After=network.target + +[Service] +Type=oneshot +User=root +WorkingDirectory=/opt/sslwatcher +ExecStart=/usr/bin/python3 /opt/sslwatcher/sslwatcher.py --domains mail.example.com example.com --warning-days 30 --email-from sslwatcher@example.com --email-to admin@example.com + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/sslwatcher.timer.example b/sslwatcher.timer.example new file mode 100644 index 0000000..7dc7d7f --- /dev/null +++ b/sslwatcher.timer.example @@ -0,0 +1,9 @@ +[Unit] +Description=Daily SSL Certificate Expiration Check + +[Timer] +OnCalendar=daily +Persistent=true + +[Install] +WantedBy=timers.target \ No newline at end of file -- 2.39.5