Compare commits

...

9 Commits

Author SHA1 Message Date
d2cc85bef2
merge from dev into main 2025-05-05 01:22:43 +03:00
ac500d0f5b
add README 2025-05-05 01:20:05 +03:00
e48941efc4
fix cron line 2025-05-05 01:18:19 +03:00
d25321e26b
add Makefile 2025-05-05 01:10:30 +03:00
d0be5048e4
adjust WARNING_DAYS 2025-05-05 00:57:40 +03:00
3835490d72
update gitignore 2025-05-05 00:57:03 +03:00
6a413f4334
add config examples 2025-05-05 00:50:55 +03:00
f3eb4283f5
update gitignore 2025-05-05 00:49:34 +03:00
1e19399862
next release 2025-05-05 00:47:57 +03:00
11 changed files with 242 additions and 47 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
conf/*.conf
.idea
__pycache__/
venv/
.env

37
Makefile Normal file
View File

@ -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) /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."

76
README.md Normal file
View File

@ -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
```

4
conf/domains.conf.sample Normal file
View File

@ -0,0 +1,4 @@
example.com:443
google.com
expired.badssl.com:8443

8
conf/mail.conf.sample Normal file
View File

@ -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

82
main.py
View File

@ -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.conf'
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 = 10
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)
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()

18
modules/checker.py Normal file
View File

@ -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)

20
modules/config.py Normal file
View File

@ -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'),
}

19
modules/domains.py Normal file
View File

@ -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

19
modules/notifier.py Normal file
View File

@ -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)

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
tzdata