avail-mon: small enh., use apprise for notif, add .service

Signed-off-by: Martin Matous <m@matous.dev>
This commit is contained in:
Martin Matous 2025-03-17 23:25:42 +01:00
parent 85a34dc79a
commit 02e59ce7e2
Signed by: mmatous
GPG key ID: 8BED4CD352953224
3 changed files with 101 additions and 62 deletions

View file

@ -8,9 +8,9 @@ Low-budget homemade handmade monitoring for homeserver.
Status: active use Status: active use
Dependencies: python, dnspython, Matrix account Dependencies: python3, apprise, cryptography, dnspython, requests, Matrix account
Usage: Run as a daemon, `/usr/local/bin/availability-monitor.py <interval-sec>` Usage: Use provided availability-monitor.service as user unit.
## dnf-search-install.py ## dnf-search-install.py
Wrapper, marks already installed packages for `dnf search`. Wrapper, marks already installed packages for `dnf search`.

View file

@ -1,73 +1,77 @@
#!/usr/bin/env python #!/usr/bin/env python3
# nonstdlib requirements: dnspython # nonstdlib deps: apprise, cryptography, dnspython, requests
from cryptography import x509
from cryptography.hazmat.primitives import serialization, hashes
from typing import Dict, List
import dns.resolver
import imaplib import imaplib
import os import os
import requests
import smtplib import smtplib
import ssl
import subprocess
import sys import sys
import time import time
from datetime import UTC, datetime
from enum import Enum
MATRIX_ACCESS_TOKEN = os.environ['MATRIX_ACCESS_TOKEN'] import apprise
MATRIX_NOTIFICATION_ROOM = os.environ['MATRIX_NOTIFICATION_ROOM'] import dns.resolver
CHECK_INTERVAL = int(sys.argv[1]) import requests
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
MATRIX_USER = os.environ['MATRIX_USER']
MATRIX_PASSWORD = os.environ['MATRIX_PASSWORD']
MATRIX_ROOM = os.environ['MATRIX_ROOM']
CHECK_INTERVAL_SEC = int(sys.argv[1])
MONITORED_URLS = [ MONITORED_URLS = [
'https://git.matous.dev',
'https://nextcloud.matous.dev',
'https://mta-sts.matous.dev/.well-known/mta-sts.txt',
] ]
MONITORED_MAIL = [ MONITORED_MAIL = ['mx1.matous.dev']
]
HTTPS_PORT = 443 HTTPS_PORT = 443
SMTP_PORT = 25 SMTP_PORT = 25
SMTPS_PORT = 465 SMTPS_PORT = 465
def check_tlsa(url: str, port: int, dercert: bytes) -> Dict[str, bytes]:
cert = x509.load_der_x509_certificate(dercert) class SmtpStatus(Enum):
OK = 250
def check_tlsa(url: str, port: int, cert: x509.Certificate) -> dict[str, bytes]:
pubkey = cert.public_key() pubkey = cert.public_key()
pubkey = pubkey.public_bytes( pubkey_bytes = pubkey.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo
)
digest = hashes.Hash(hashes.SHA256()) digest = hashes.Hash(hashes.SHA256())
digest.update(pubkey) digest.update(pubkey_bytes)
keyhash = digest.finalize() keyhash = digest.finalize()
port = str(port)
tlsa = dns.resolver.resolve(f'_{port}._tcp.{url}', 'TLSA') tlsa = dns.resolver.resolve(f'_{port}._tcp.{url}', 'TLSA')
return {'cert': tlsa[0].cert, 'dns': keyhash} return {'local': tlsa[0].cert, 'dns': keyhash}
# check that websites are alive # check that websites are alive
def web_check() -> List[str]: def web_check() -> list[str]:
errors = [] errors = []
for url in MONITORED_URLS: for url in MONITORED_URLS:
try: try:
res = requests.head(url) res = requests.head(url, timeout=10)
if not res.ok: if not res.ok:
errors.append(f'{url} HEAD returned {res.status_code}: {res.reason}') errors.append(f'{url} HEAD returned {res.status_code}: {res.reason}')
except Exception as e: except Exception as e:
errors.append(f'{url} check failed: {e}') errors.append(f'{url} check failed: {e}')
return errors return errors
def mail_check() -> List[str]:
def mail_check() -> list[str]:
errors = [] errors = []
for url in MONITORED_MAIL: for url in MONITORED_MAIL:
# check that SMTP(S) is alive # check that SMTP(S) is alive
try: try:
with smtplib.SMTP_SSL(url, port=SMTPS_PORT) as smtp: with smtplib.SMTP_SSL(url, port=SMTPS_PORT) as smtp:
res = smtp.noop() res = smtp.noop()
if res != (250, b'2.0.0 I have sucessfully done nothing'): if res != (SmtpStatus.OK, b'2.0.0 I have successfully done nothing'):
errors.append(f'{url}:{SMTPS_PORT} check returned {res}') errors.append(f'{url}:{SMTPS_PORT} check returned {res}')
except Exception as e: except Exception as e:
errors.append(f'{url} SMTPS check failed: {e}') errors.append(f'{url} SMTPS check failed: {e}')
@ -75,7 +79,7 @@ def mail_check() -> List[str]:
with smtplib.SMTP(url, port=SMTP_PORT) as smtp: with smtplib.SMTP(url, port=SMTP_PORT) as smtp:
smtp.starttls() smtp.starttls()
res = smtp.noop() res = smtp.noop()
if res != (250, b'2.0.0 I have sucessfully done nothing'): if res != (SmtpStatus.OK, b'2.0.0 I have successfully done nothing'):
errors.append(f'{url}:{SMTP_PORT} check returned {res}') errors.append(f'{url}:{SMTP_PORT} check returned {res}')
except Exception as e: except Exception as e:
errors.append(f'{url}:{SMTP_PORT} SMTP check failed: {e}') errors.append(f'{url}:{SMTP_PORT} SMTP check failed: {e}')
@ -94,42 +98,33 @@ def mail_check() -> List[str]:
with smtplib.SMTP(url, port=SMTP_PORT) as smtp: with smtplib.SMTP(url, port=SMTP_PORT) as smtp:
smtp.starttls() smtp.starttls()
dercert = smtp.sock.getpeercert(binary_form=True) dercert = smtp.sock.getpeercert(binary_form=True)
tlsa_hash = check_tlsa(url, SMTP_PORT, dercert) cert = x509.load_der_x509_certificate(dercert)
if tlsa_hash['cert'] != tlsa_hash['dns']: if cert.not_valid_after_utc < datetime.now(UTC):
errors.append( errors.append(f'{url} certificate expired {cert.not_valid_after}')
f'{url}:{SMTP_PORT} TLSA record \ tlsa_hash = check_tlsa(url, SMTP_PORT, cert)
{str(tlsa_hash["cert"])} != {str(tlsa_hash["dns"])}' if tlsa_hash['local'] != tlsa_hash['dns']:
) errors.append(f'{url}:{SMTP_PORT} TLSA record {tlsa_hash["local"]!s} != {tlsa_hash["dns"]!s}')
except Exception as e: except Exception as e:
errors.append(f'{url}:{SMTP_PORT} TLSA check failed: {e}') errors.append(f'{url}:{SMTP_PORT} TLSA check failed: {e}')
return errors return errors
def report_results(errors: List[str]) -> None:
def report_results(errors: list[str]) -> None:
if not errors: if not errors:
errors = 'All systems nominal' errors = ['All systems nominal']
errors: str = '\n\n'.join(errors)
print(errors) print(errors)
txn_id_nonce = str(int(time.time())) apobj = apprise.Apprise()
url = f'https://conduit.koesters.xyz/_matrix/client/r0/rooms/{MATRIX_NOTIFICATION_ROOM}/send/m.room.message/{txn_id_nonce}' with apprise.LogCapture(level=apprise.logging.INFO) as output:
header_dict = { apobj.add(f'matrixs://{MATRIX_USER}@nitro.chat/{MATRIX_ROOM}?pass={MATRIX_PASSWORD}')
'Accept': 'application/json', apobj.add('glib://')
'Authorization' : f'Bearer {MATRIX_ACCESS_TOKEN}', apobj.notify(title='Server status', body=errors)
'Content-Type': 'application/json' print(output.getvalue())
}
body = f'"msgtype":"m.text", "body":"{errors}"'
body = '{' + body + '}'
try:
res = requests.put(url, data=body, headers=header_dict)
if res.status_code != 200:
print(res.json())
subprocess.run(['notify-send', f'Sending error report failed.\nPlease run {sys.argv[0]}\nError {res.json()}'])
except Exception as e:
print(e)
subprocess.run(['notify-send', f'Sending error report failed.\nPlease run {sys.argv[0]}\nError {e}'])
errors = [] errors = []
prev_errors = [] prev_errors = None
print('Monitoring...') print('Monitoring...')
while True: while True:
errors += web_check() errors += web_check()
@ -139,5 +134,4 @@ while True:
prev_errors = errors.copy() prev_errors = errors.copy()
errors = [] errors = []
sys.stdout.flush() sys.stdout.flush()
time.sleep(CHECK_INTERVAL) time.sleep(CHECK_INTERVAL_SEC)

View file

@ -0,0 +1,45 @@
[Unit]
Description=Server services monitoring
After=network-online.target graphical-session.target
[Service]
Type=simple
EnvironmentFile=%h/.config/private-env/availability-monitor.env
ExecStart=/usr/local/bin/availability-monitor.py 3600
AmbientCapabilities=
CapabilityBoundingSet=
InaccessiblePaths=/home /root
KeyringMode=private
LockPersonality=true
MemoryDenyWriteExecute=true
NoNewPrivileges=true
PrivateDevices=true
PrivateIPC=true
PrivateMounts=true
PrivateTmp=true
PrivateUsers=true
ProcSubset=pid
ProtectClock=true
ProtectControlGroups=true
# can't override rw for %t/bus if "true" (completely inaccessible /run)
# rw necessary for notif
ProtectHome=read-only
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectProc=noaccess
ProtectSystem=strict
ReadWritePaths=%t/bus
# AF_UNIX for dbus (notifications), net for checking (duh)
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
UMask=0277
[Install]
WantedBy=multi-user.target