#!/usr/bin/env python3 """TAPM notification policy UI and scheduled Proxmox VE version checks.""" import argparse import fcntl import json import os from pathlib import Path import re import subprocess import sys import uuid HERE = Path(__file__).resolve().parent UNIT_DIR = Path('/etc/systemd/system') TEMPLATE_DIR = Path('/etc/pve/notification-templates/default') STATE_DIR = Path('/var/lib/ta-proxmenu/notifications') SERVICE = 'tapm-version-notifications' LABELS = { 'all': 'All available package updates', 'version': 'Proxmox VE version updates only', 'disabled': 'Disable update notifications', } NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,62}\Z') VERSION = re.compile(r'[0-9][A-Za-z0-9.+:~\-]{0,100}\Z') class NotificationError(Exception): pass def command(args, payload=None, timeout=30): try: result = subprocess.run(args, input=payload, capture_output=True, text=True, timeout=timeout) except (OSError, subprocess.TimeoutExpired) as exc: raise NotificationError('The requested notification operation could not be completed.') from exc if result.returncode: raise NotificationError('The requested notification operation failed. Check Proxmox configuration and connectivity.') return result.stdout def api(method, path, **params): args = ['pvesh', method, path, '--output-format', 'json'] for key, value in params.items(): args += ['--' + key.replace('_', '-'), str(value)] raw = command(args) try: return json.loads(raw) if raw.strip() else None except ValueError as exc: raise NotificationError('Proxmox returned an unreadable response.') from exc def bridge(action, **params): raw = command(['perl', str(HERE / 'notifications-bridge.pl')], json.dumps(dict(action=action, **params)), timeout=75) try: return json.loads(raw) except ValueError as exc: raise NotificationError('Notification configuration could not be read.') from exc def local_node(): name = command(['hostname', '-s']).strip() if not NAME.fullmatch(name): raise NotificationError('The host name is not valid.') return name def effective_mode(snapshot): policy = snapshot.get('policy', {}) native = snapshot.get('notify', {}).get('package-updates', 'auto') if policy.get('mode') == 'version' and native == 'never': return 'version' return {'always': 'all', 'never': 'disabled'}.get(native, 'auto') def current_status(): snapshot = bridge('read') mode = effective_mode(snapshot) print('Update notification policy: ' + LABELS.get(mode, 'Proxmox default (subscription-dependent)')) print('Scope: this Proxmox datacenter (all nodes when clustered).') if mode == 'version': owner = snapshot['policy'].get('owner', 'unknown') print('Hourly version checker host: ' + owner) if owner == local_node(): try: command(['systemctl', 'is-enabled', SERVICE + '.timer']) command(['systemctl', 'is-active', SERVICE + '.timer']) print('Version checker timer: enabled and active') except NotificationError: print('Version checker timer: needs attention; reapply the version-only policy.') else: print('Timer status must be checked on that host; checks pause while it is offline.') return snapshot def atomic_write(path, content): path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(path.name + '.' + uuid.uuid4().hex + '.tmp') try: with temporary.open('x') as stream: stream.write(content) os.replace(temporary, path) finally: if temporary.exists(): temporary.unlink() def prepare_checker(): # Separate unit: never disable or replace Proxmox's daily update service. service = f'''[Unit] Description=TAPM Proxmox VE version notifications After=pve-cluster.service network-online.target Wants=network-online.target [Service] Type=oneshot ExecStart=/usr/bin/python3 {HERE}/notifications.py run TimeoutStartSec=15min UMask=0077 ''' timer = '''[Unit] Description=Check for Proxmox VE version updates hourly [Timer] OnCalendar=hourly RandomizedDelaySec=5min Persistent=true [Install] WantedBy=timers.target ''' atomic_write(UNIT_DIR / (SERVICE + '.service'), service) atomic_write(UNIT_DIR / (SERVICE + '.timer'), timer) templates = { 'subject.txt': 'Proxmox VE version update available on {{hostname}}\n', 'body.txt': 'Host: {{hostname}}\nInstalled Proxmox VE version: {{installed}}\nAvailable Proxmox VE version: {{available}}\n\nA newer version is available from this host\'s configured repositories.\n', 'body.html': '

Host: {{hostname}}

Installed Proxmox VE version: {{installed}}
Available Proxmox VE version: {{available}}

A newer version is available from this host's configured repositories.

\n', } for suffix, content in templates.items(): atomic_write(TEMPLATE_DIR / ('tapm-pve-version-' + suffix + '.hbs'), content) command(['systemctl', 'daemon-reload']) command(['systemctl', 'enable', '--now', SERVICE + '.timer']) command(['systemctl', 'is-active', SERVICE + '.timer']) def apply_policy(mode): expected = current_status() owner = local_node() print('\nProposed policy: ' + LABELS[mode]) print('This changes update alerts across this datacenter. Other notification types retain their settings.') print('Normal Proxmox repository checks remain enabled. No packages will be upgraded.') if mode == 'version': print(f'An hourly checker on {owner} will inspect online nodes using their existing repository indexes.') print('Checks pause while this host is offline; reapply here to repair, or on another host to move the checker.') print('Only new Proxmox VE versions trigger alerts, including patch releases. Kernel-only updates do not.') print('Existing update notification routing and destinations are used. No post-upgrade alerts are sent.') elif mode == 'all': print('The native Proxmox daily update job sends alerts through existing notification routing.') if input('Apply this policy (type yes to continue)? ').strip().lower() != 'yes': print('No changes made.') return if mode == 'version': prepare_checker() policy = dict(mode=mode, owner=owner, generation=uuid.uuid4().hex) bridge('apply', expected=expected, policy=policy) print('Update notification policy applied: ' + LABELS[mode]) if mode != 'version' and (UNIT_DIR / (SERVICE + '.timer')).exists(): try: command(['systemctl', 'disable', '--now', SERVICE + '.timer']) except NotificationError: print('The local timer could not be stopped. It will remain inactive in this notification mode.') print('Previous policy saved in /etc/pve/priv/tapm-update-policy.previous.json.') def candidate_update(rows): if not isinstance(rows, list): raise NotificationError('The update list could not be read.') for row in rows: if not isinstance(row, dict) or row.get('Package') != 'pve-manager': continue installed, available = row.get('OldVersion', ''), row.get('Version', '') if not isinstance(installed, str) or not isinstance(available, str) or not VERSION.fullmatch(installed) or not VERSION.fullmatch(available): raise NotificationError('The Proxmox VE versions could not be read.') # Use Debian version ordering, including epochs, revisions and tildes. comparison = subprocess.run(['dpkg', '--compare-versions', available, 'gt', installed], capture_output=True, timeout=10) if comparison.returncode == 0: return installed, available if comparison.returncode != 1: raise NotificationError('The Proxmox VE versions could not be compared.') return None def check_versions(): snapshot = bridge('read') if effective_mode(snapshot) != 'version' or snapshot['policy'].get('owner') != local_node(): return 0 nodes = api('get', '/nodes') if not isinstance(nodes, list) or not nodes: raise NotificationError('The Proxmox node list could not be read.') failures = 0 for node in nodes: name = node.get('node', '') if isinstance(node, dict) else '' if not NAME.fullmatch(name): failures += 1 continue if node.get('status') != 'online': print(f'{name}: offline; version check deferred.') failures += 1 continue try: versions = candidate_update(api('get', f'/nodes/{name}/apt/update')) if not versions: continue installed, available = versions result = bridge('send', generation=snapshot['policy']['generation'], node=name, installed=installed, available=available) if result.get('sent'): print(f'{name}: Proxmox VE {installed} -> {available}; notification submitted to configured routing.') except (NotificationError, OSError, subprocess.TimeoutExpired): print(f'{name}: version check or notification failed; will retry on the next check.') failures += 1 return 1 if failures else 0 def delivery_status(): current_status() targets = api('get', '/cluster/notifications/targets') matchers = api('get', '/cluster/notifications/matchers') print('\nNotification destinations:') for target in targets: state = 'disabled' if target.get('disable') else 'enabled' print(f" {target['name']} ({target['type']}, {state})") if not targets: print(' None configured.') print('\nRouting rules:') for matcher in matchers: state = 'disabled' if matcher.get('disable') else 'enabled' print(f" {matcher['name']} ({state})") for key in ('target', 'match-field', 'match-severity', 'match-calendar', 'mode', 'invert-match'): if key in matcher: print(f" {key}: {matcher[key]}") print('\nConfigure recipients, delivery credentials and routing in the Proxmox Web UI: Datacenter > Notifications.') print('Version-only alerts use the existing system-update routing rules.') def test_delivery(): targets = [t for t in api('get', '/cluster/notifications/targets') if not t.get('disable')] if not targets: print('No enabled notification destinations. Configure one in Datacenter > Notifications first.') return for index, target in enumerate(targets, 1): print(f" {index}. {target['name']} ({target['type']})") choice = input('Destination number (Enter to cancel): ').strip() if not choice: return if not choice.isdigit() or not 1 <= int(choice) <= len(targets): raise NotificationError('Choose a destination from the list.') target = targets[int(choice) - 1]['name'] if not NAME.fullmatch(target): raise NotificationError('The destination name is not valid.') if input(f'Send a test notification to {target} (type yes to continue)? ').strip().lower() != 'yes': return api('create', f'/cluster/notifications/targets/{target}/test') print('Test notification submitted. Confirm receipt at the destination; this tests delivery, not routing rules.') def main(): parser = argparse.ArgumentParser(description='Proxmox notification settings') parser.add_argument('action', choices=['status', 'delivery', 'test', 'apply', 'run']) parser.add_argument('mode', nargs='?', choices=list(LABELS)) args = parser.parse_args() if os.geteuid() != 0: print('Run this action as root on a Proxmox host.', file=sys.stderr) return 1 try: if args.action == 'status': current_status() elif args.action == 'delivery': delivery_status() elif args.action == 'test': test_delivery() elif args.action == 'apply': if args.mode is None: raise NotificationError('Select an update notification policy.') apply_policy(args.mode) else: STATE_DIR.mkdir(mode=0o700, parents=True, exist_ok=True) with (STATE_DIR / 'check.lock').open('a') as lock: try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: return 0 return check_versions() except (EOFError, KeyboardInterrupt): print('\nCancelled.') return 1 except (NotificationError, OSError, ValueError, KeyError, TypeError) as exc: print(str(exc) if isinstance(exc, NotificationError) else 'Notification operation failed; verify configuration and permissions.', file=sys.stderr) return 1 return 0 if __name__ == '__main__': sys.exit(main())