From 1e19399862cbf9ad2f521b0f28cb1e39682152f1 Mon Sep 17 00:00:00 2001 From: gandc Date: Mon, 5 May 2025 00:47:57 +0300 Subject: [PATCH 1/8] next release --- .gitignore | 1 + main.py | 82 +++++++++++++++++++-------------------------- modules/checker.py | 18 ++++++++++ modules/config.py | 20 +++++++++++ modules/domains.py | 19 +++++++++++ modules/notifier.py | 19 +++++++++++ requirements.txt | 1 + 7 files changed, 113 insertions(+), 47 deletions(-) create mode 100644 .gitignore create mode 100644 modules/checker.py create mode 100644 modules/config.py create mode 100644 modules/domains.py create mode 100644 modules/notifier.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcc54c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +conf/*.conf diff --git a/main.py b/main.py index 8993ea7..c2cbdee 100644 --- a/main.py +++ b/main.py @@ -1,56 +1,44 @@ -import ssl -import socket +from pathlib import Path from datetime import datetime from zoneinfo import ZoneInfo -import smtplib -from email.message import EmailMessage -MOSCOW_TZ = ZoneInfo("Europe/Moscow") +from modules.config import load_mail_config +from modules.domains import load_domains +from modules.checker import get_cert_expiry_utc +from modules.notifier import send_email -def get_cert_expiry_utc(host: str, port: int = 443) -> datetime: - ctx = ssl.create_default_context() - with ctx.wrap_socket(socket.socket(), server_hostname=host) as sock: - sock.settimeout(5) - sock.connect((host, port)) - cert = sock.getpeercert() - exp_str = cert['notAfter'] - dt_utc = datetime.strptime(exp_str, '%b %d %H:%M:%S %Y %Z') - return dt_utc.replace(tzinfo=ZoneInfo("UTC")) +# Пути к файлам +BASE_DIR = Path(__file__).parent +CONF_DIR = BASE_DIR / 'conf' +MAIL_CONF = CONF_DIR / 'mail.conf' +DOMAINS_FILE = CONF_DIR / 'domains.txt' -def send_email(subject: str, body: str, to_addr: str) -> None: - msg = EmailMessage() - msg['Subject'] = subject - msg['From'] = 'monitor@example.com' - msg['To'] = to_addr - msg.set_content(body) - with smtplib.SMTP('smtp.example.com') as smtp: - smtp.login('user', 'pass') - smtp.send_message(msg) +# Настройки +MOSCOW_TZ = ZoneInfo('Europe/Moscow') +WARNING_DAYS = 30 -if __name__ == '__main__': - domain = 'example.com' - - # 1. Берём время истечения в UTC - expiry_utc = get_cert_expiry_utc(domain) - - # 2. Конвертируем expiry в московское время для вывода - expiry_moscow = expiry_utc.astimezone(MOSCOW_TZ) - - # 3. Получаем текущее время в Москве +def main(): + # Загружаем настройки и список доменов + mail_cfg = load_mail_config(MAIL_CONF) + domains = load_domains(DOMAINS_FILE) now_moscow = datetime.now(MOSCOW_TZ) - # 4. Считаем дни до окончания - days_left = (expiry_moscow.date() - now_moscow.date()).days + for host, port in domains: + expiry_utc = get_cert_expiry_utc(host, port) + expiry_local = expiry_utc.astimezone(MOSCOW_TZ) + days_left = (expiry_local.date() - now_moscow.date()).days - if days_left < 30: - subject = f'Сертификат {domain} истекает через {days_left} дней' - body = ( - f'Сертификат домена {domain} истечёт {expiry_moscow:%Y-%m-%d %H:%M:%S %Z}.\n' - f'Осталось {days_left} дней (по московскому времени).' - ) - #send_email(subject, body, 'admin@example.com') - body = ( - f'Сертификат домена {domain} истечёт {expiry_moscow:%Y-%m-%d %H:%M:%S %Z}.\n' - f'Осталось {days_left} дней (по московскому времени).' - ) - print(body) \ No newline at end of file + print(f"{host}:{port} — истекает {expiry_local:%Y-%m-%d %H:%M:%S %Z}, осталось {days_left} дн.") + + if days_left < WARNING_DAYS: + subject = f"[WARNING] SSL {host} истекает через {days_left} дн." + body = ( + f"Домен: {host}\n" + f"Порт: {port}\n" + f"Истекает: {expiry_local:%Y-%m-%d %H:%M:%S %Z}\n" + f"Осталось дней: {days_left}\n" + ) + send_email(mail_cfg, subject, body) + +if __name__ == '__main__': + main() diff --git a/modules/checker.py b/modules/checker.py new file mode 100644 index 0000000..61d00c2 --- /dev/null +++ b/modules/checker.py @@ -0,0 +1,18 @@ +import ssl +import socket +from datetime import datetime, timezone +from typing import Tuple + +def get_cert_expiry_utc(host: str, port: int = 443) -> datetime: + """ + Устанавливает TLS-соединение и возвращает дату окончания сертификата + в UTC (timezone-aware). + """ + ctx = ssl.create_default_context() + with ctx.wrap_socket(socket.socket(), server_hostname=host) as sock: + sock.settimeout(5) + sock.connect((host, port)) + cert = sock.getpeercert() + exp_str = cert['notAfter'] # 'Jun 15 12:00:00 2025 GMT' + dt = datetime.strptime(exp_str, '%b %d %H:%M:%S %Y %Z') + return dt.replace(tzinfo=timezone.utc) diff --git a/modules/config.py b/modules/config.py new file mode 100644 index 0000000..d6f96bf --- /dev/null +++ b/modules/config.py @@ -0,0 +1,20 @@ +import configparser +from pathlib import Path +from typing import Dict + +def load_mail_config(path: Path) -> Dict[str, str]: + """ + Читает mail.conf и возвращает словарь с настройками SMTP: + host, port, username, password, from, to + """ + cfg = configparser.ConfigParser() + cfg.read(path, encoding='utf-8') + smtp = cfg['smtp'] + return { + 'host': smtp.get('host'), + 'port': smtp.getint('port', fallback=587), + 'username': smtp.get('username'), + 'password': smtp.get('password'), + 'from': smtp.get('from'), + 'to': smtp.get('to'), + } diff --git a/modules/domains.py b/modules/domains.py new file mode 100644 index 0000000..5087827 --- /dev/null +++ b/modules/domains.py @@ -0,0 +1,19 @@ +from pathlib import Path +from typing import List, Tuple + +def load_domains(path: Path) -> List[Tuple[str, int]]: + """ + Возвращает список кортежей (host, port). + Если порт не указан — 443. + """ + result: List[Tuple[str, int]] = [] + for line in path.read_text(encoding='utf-8').splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + if ':' in line: + host, port = line.split(':', 1) + result.append((host.strip(), int(port))) + else: + result.append((line, 443)) + return result diff --git a/modules/notifier.py b/modules/notifier.py new file mode 100644 index 0000000..0189f4d --- /dev/null +++ b/modules/notifier.py @@ -0,0 +1,19 @@ +import smtplib +from email.message import EmailMessage +from typing import Dict + +def send_email(cfg: Dict[str, str], subject: str, body: str) -> None: + """ + Отправляет письмо через SMTP по настройкам из cfg. + Ожидает ключи: host, port, username, password, from, to. + """ + msg = EmailMessage() + msg['Subject'] = subject + msg['From'] = cfg['from'] + msg['To'] = cfg['to'] + msg.set_content(body) + + with smtplib.SMTP(cfg['host'], cfg['port']) as smtp: + smtp.starttls() + smtp.login(cfg['username'], cfg['password']) + smtp.send_message(msg) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0883ff0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +tzdata From f3eb4283f58bbdd404633d20d6d9003c5507216d Mon Sep 17 00:00:00 2001 From: gandc Date: Mon, 5 May 2025 00:49:34 +0300 Subject: [PATCH 2/8] update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bcc54c4..c2293e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ conf/*.conf +.idea From 6a413f433492b794d705bb64bb585b8441e87d92 Mon Sep 17 00:00:00 2001 From: gandc Date: Mon, 5 May 2025 00:50:55 +0300 Subject: [PATCH 3/8] add config examples --- conf/domains.conf.sample | 4 ++++ conf/mail.conf.sample | 8 ++++++++ 2 files changed, 12 insertions(+) create mode 100644 conf/domains.conf.sample create mode 100644 conf/mail.conf.sample diff --git a/conf/domains.conf.sample b/conf/domains.conf.sample new file mode 100644 index 0000000..ddf2e7d --- /dev/null +++ b/conf/domains.conf.sample @@ -0,0 +1,4 @@ +example.com:443 +google.com +expired.badssl.com:8443 + diff --git a/conf/mail.conf.sample b/conf/mail.conf.sample new file mode 100644 index 0000000..4958cff --- /dev/null +++ b/conf/mail.conf.sample @@ -0,0 +1,8 @@ +[smtp] +host = smtp.example.com +port = 587 +username = user@example.com +password = S3cr3tP@ss +from = monitor@example.com +to = admin@example.com + From 3835490d72948453a381294948a2df1856cf1d29 Mon Sep 17 00:00:00 2001 From: gandc Date: Mon, 5 May 2025 00:57:03 +0300 Subject: [PATCH 4/8] update gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index c2293e6..8dc5f01 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ conf/*.conf .idea +__pycache__/ +venv/ +.env From d0be5048e42dd970afb33df4535f5e07ee12ab37 Mon Sep 17 00:00:00 2001 From: gandc Date: Mon, 5 May 2025 00:57:40 +0300 Subject: [PATCH 5/8] adjust WARNING_DAYS --- main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index c2cbdee..9659f0c 100644 --- a/main.py +++ b/main.py @@ -11,11 +11,11 @@ from modules.notifier import send_email BASE_DIR = Path(__file__).parent CONF_DIR = BASE_DIR / 'conf' MAIL_CONF = CONF_DIR / 'mail.conf' -DOMAINS_FILE = CONF_DIR / 'domains.txt' +DOMAINS_FILE = CONF_DIR / 'domains.conf' # Настройки MOSCOW_TZ = ZoneInfo('Europe/Moscow') -WARNING_DAYS = 30 +WARNING_DAYS = 10 def main(): # Загружаем настройки и список доменов From d25321e26be04e337a985e111fc36c0562cf0838 Mon Sep 17 00:00:00 2001 From: gandc Date: Mon, 5 May 2025 01:10:30 +0300 Subject: [PATCH 6/8] add Makefile --- Makefile | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8500c0d --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +PROJECT := certificate-expire-check +USER := $(PROJECT) +INSTALL_DIR := /etc/$(PROJECT) +CONF_DIR := $(INSTALL_DIR)/conf +SCRIPT := $(INSTALL_DIR)/main.py +CRON_SCHEDULE:= 0 0 */10 * * + +.PHONY: all user install cron clean + +all: user install cron + +user: + @id -u $(USER) &>/dev/null || \ + sudo useradd --system --no-create-home --shell /usr/sbin/nologin $(USER) && \ + echo "User '$(USER)' has been created." + @echo "User '$(USER)' already exists - skip it." + +# Copy the project to /etc and set permissions +install: user + sudo mkdir -p $(INSTALL_DIR) + sudo cp -r ./* $(INSTALL_DIR) + sudo chown -R $(USER):$(USER) $(INSTALL_DIR) + @echo "The project is installed in $(INSTALL_DIR) and permissions are assigned to user '$(USER)'." + +# Add a cron job as a user +cron: install + @CRON_LINE="$(CRON_SCHEDULE) $(USER) /usr/bin/python3 $(SCRIPT) >> /var/log/$(PROJECT).log 2>&1" + # add CRON_LINE in user crontab if not exists + @sudo crontab -u $(USER) -l 2>/dev/null | grep -F "$$CRON_LINE" >/dev/null || \ + ( sudo crontab -u $(USER) -l 2>/dev/null; echo "$$CRON_LINE" ) | sudo crontab -u $(USER) - + @echo "Cron entry for user '$(USER)' is set: $$CRON_LINE" + +clean: + -sudo crontab -u $(USER) -r + -sudo userdel $(USER) + -sudo rm -rf $(INSTALL_DIR) + @echo The cleanup is complete." From e48941efc411e78c2953d9572ac7e43048723be6 Mon Sep 17 00:00:00 2001 From: gandc Date: Mon, 5 May 2025 01:18:19 +0300 Subject: [PATCH 7/8] fix cron line --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8500c0d..5b25107 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ install: user # Add a cron job as a user cron: install - @CRON_LINE="$(CRON_SCHEDULE) $(USER) /usr/bin/python3 $(SCRIPT) >> /var/log/$(PROJECT).log 2>&1" + @CRON_LINE="$(CRON_SCHEDULE) /usr/bin/python3 $(SCRIPT) >> /var/log/$(PROJECT).log 2>&1" # add CRON_LINE in user crontab if not exists @sudo crontab -u $(USER) -l 2>/dev/null | grep -F "$$CRON_LINE" >/dev/null || \ ( sudo crontab -u $(USER) -l 2>/dev/null; echo "$$CRON_LINE" ) | sudo crontab -u $(USER) - From ac500d0f5ba9af36727e512afb800eeb745e33f9 Mon Sep 17 00:00:00 2001 From: gandc Date: Mon, 5 May 2025 01:20:05 +0300 Subject: [PATCH 8/8] add README --- README.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..43f449d --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# certificate-expire-check + +A simple Python utility to monitor SSL/TLS certificate expiration for a list of domains and send notifications via email. + +## Project Structure + +``` +project/ +├── conf/ +│ ├── mail.conf # SMTP configuration +│ └── domains.txt # List of domains and optional ports +├── main.py # Entry-point script +├── modules/ +│ ├── __init__.py +│ ├── config.py # Configuration loader +│ ├── domains.py # Domain list parser +│ ├── checker.py # Certificate expiration checker +│ └── notifier.py # SMTP notifier +├── Makefile +└── README.md +``` + +## Prerequisites + +* Python 3.9+ +* tzdata +* make(optional) +* Root or sudo privileges for auto installation and cron setup with Makefile + +## Configuration + +1. **SMTP Settings**: Edit `conf/mail.conf`: + +2. **Domain List**: Edit `conf/domains.txt`, one domain per line. Append `:port` to specify a custom port (default is 443): + + ``` + example.com:443 + google.com + expired.badssl.com:8443 + ``` + +## Installation + +From the project root, run: + +```bash +sudo make all +``` + +This will: + +1. Create a system user `certificate-expire-check` if it does not exist +2. Copy project files to `/etc/certificate-expire-check` +3. Set ownership to the `certificate-expire-check` user +4. Install a cron job that runs every 10 days at midnight + +## Makefile Targets + +* **all**: Runs `user`, `install`, and `cron` in sequence +* **user**: Creates the system user +* **install**: Copies files and sets permissions +* **cron**: Adds the following cron entry for the user: + + ```cron + 0 0 */10 * * /usr/bin/python3 /etc/certificate-expire-check/main.py >> /var/log/certificate-expire-check.log 2>&1 + ``` +* **clean**: Removes the cron job, deletes the system user, and removes the installation directory + +## Usage + +* Logs are written to `/var/log/certificate-expire-check.log`. +* Manually trigger the check with: + + ```bash + sudo -u certificate-expire-check /usr/bin/python3 /etc/certificate-expire-check/main.py + ```