#!/usr/bin/env python3 """Build private UTF-8 backup reports; never read executable config/credentials. Sizes are captured before staging cleanup. Delivery uses only the local MTA and has a timeout; a reporting failure never turns a failed backup into a success. """ import argparse from collections import deque import datetime as dt from email.message import EmailMessage from email.policy import SMTP import json import os from pathlib import Path import socket import subprocess import sys import time def allocated(path): """Report disk allocation, like du in the old report, without following links.""" result = subprocess.run(['du', '-s', '-B1', '--', str(path)], capture_output=True, text=True, timeout=30, check=True) return int(result.stdout.split()[0]) def snapshot(output, staging, archives): data = {'staged': {}, 'archives': {}} if Path(output).is_file(): data.update(json.loads(Path(output).read_text())) captured = 'staging_total' in data if not captured and staging and Path(staging).is_dir(): root = Path(staging) # Measure components independently; the total accounts for shared links. paths = sorted((root/'files').glob('*')) + [root/'db', root/'meta'] for path in paths: if path.exists(): data['staged'][path.name] = allocated(path) data['staging_total'] = allocated(root) if archives and Path(archives).is_dir(): for path in sorted(Path(archives).glob('*.tar.*')): if path.is_file() and not path.is_symlink(): data['archives'][path.name] = path.stat().st_size Path(output).write_text(json.dumps(data), encoding='utf-8') def human(value): for unit in ('B', 'KiB', 'MiB', 'GiB', 'TiB'): if value < 1024 or unit == 'TiB': return f'{value:.1f} {unit}' value /= 1024 def deliver(args): fqdn = socket.getfqdn() host = args.hostname or fqdn now = dt.datetime.now().astimezone() state = 'SUCCESS' if args.exit_code == 0 else 'FAIL' try: sizes = json.loads(Path(args.sizes).read_text()) except (OSError, ValueError): sizes = {} # Bounded memory even for long-running rclone logs. Tail length is configurable. with open(args.log, encoding='utf-8', errors='replace') as stream: tail = ''.join(deque(stream, maxlen=args.tail_lines)) upload = {'NOT_STARTED': 'Nicht gestartet', 'DISABLED': 'Deaktiviert (nur lokal)', 'COPY': 'Nicht abgeschlossen: Upload', 'CHECK': 'Nicht abgeschlossen: Rückleseprüfung', 'MARKER': 'Nicht abgeschlossen: Abschlussmarker', 'VERIFIED': 'OK (Inhalt und Abschlussmarker zurückgelesen)'}[args.upload_status] body = [ 'Backup Report', '=============', f'Zeit: {now.isoformat(timespec="seconds")}', f'Host: {host}', f'Status: {state}', f'Exit code: {args.exit_code}', f'Dauer: {max(0, int(time.time()) - args.started)}s', f'Lauf: {args.run}', f'Letzte Phase: {args.phase}', '', f'Config: {args.config}', f'Log: {args.log}', f'Workdir: {args.workdir}', f'Archive dir: {args.archive_dir}', f'Kompression: {args.compression}', '', 'Remote', '------', f'Upload: {str(args.upload_status != "DISABLED").lower()}', f'Remote base: {args.remote or "nicht konfiguriert"}', f'Upload Status: {upload}', 'Die separate Update-Abnahme steht in result.txt; dies ist der Backup-Bericht.', '', 'Größen', '------', 'Staging: belegter Speicher vor Bereinigung; Archive: Dateigrößen.', ] for label, value in sizes.get('staged', {}).items(): body.append(f'{label} staged: {human(value)}') body.append('Staging total: '+(human(sizes['staging_total']) if 'staging_total' in sizes else 'nicht verfügbar')) body.extend(['', 'Archive', '-------']) for name, value in sizes.get('archives', {}).items(): body.append(f'{name}: {human(value)} ({value} Bytes)') if not sizes.get('archives'): body.append('Keine Archive erfasst.') else: body.append('Archive total: '+human(sum(sizes['archives'].values()))) body.extend(['', f'Log-Auszug (letzte {args.tail_lines} Zeilen)', '-----------------', tail]) message = EmailMessage(policy=SMTP) # EmailMessage rejects CR/LF in headers and encodes non-ASCII safely. sender = args.sender or f'app-backup@{fqdn}' if not args.to.strip() or not sender.strip(): raise ValueError('Mail recipient and sender must not be empty') message['From'] = sender message['To'] = args.to message['Subject'] = f'{args.subject_prefix} {state} {host} {args.run}' message['Date'] = now message.set_content('\n'.join(body), charset='utf-8') raw = message.as_bytes() Path(args.output).write_bytes(raw) # -oi prevents a single dot in a log line from terminating the message. # Headers supply recipients; no shell or SMTP credentials are involved. subprocess.run([args.sendmail, '-oi', '-t'], input=raw, check=True, timeout=args.timeout) print(f'Mail report handed to local MTA: {args.output}') def main(): parser = argparse.ArgumentParser() commands = parser.add_subparsers(dest='command', required=True) snap = commands.add_parser('snapshot') for name in ('output', 'staging', 'archives'): snap.add_argument(name) mail = commands.add_parser('deliver') mail.add_argument('--hostname', default='') for name in ('sizes', 'log', 'output', 'to', 'sender', 'subject-prefix', 'run', 'phase', 'upload-status', 'config', 'workdir', 'archive-dir', 'compression', 'remote', 'sendmail'): mail.add_argument('--'+name, required=True) for name in ('exit-code', 'started', 'tail-lines', 'timeout'): mail.add_argument('--'+name, type=int, required=True) args = parser.parse_args() if args.command == 'snapshot': snapshot(args.output, args.staging, args.archives) else: if not 0 <= args.tail_lines <= 10000 or not 1 <= args.timeout <= 300: raise ValueError('Invalid mail tail length or timeout') deliver(args) if __name__ == '__main__': os.umask(0o077) try: main() except Exception as exc: # Do not echo header values or MTA arguments which might include addresses. print(f'WARNING: mail report failed ({type(exc).__name__}); inspect local report/MTA.', file=sys.stderr) sys.exit(1)