#!/usr/bin/env python3 """Deny desktop-user power actions on Ubuntu/Kylin; run as root in the guest. Writes account-scoped polkit and D-Bus rules. Never sends a valid power request. Root/PVE maintenance remains available. Query uses login1 Can* methods only. """ import argparse import json import os from pathlib import Path import pwd import re import shutil import subprocess import time from xml.sax.saxutils import quoteattr def run(command): return subprocess.run(command, capture_output=True, text=True, timeout=20, env={**os.environ, 'LC_ALL': 'C', 'SYSTEMD_COLORS': '0'}) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('action', choices=['apply', 'query', 'restore']) parser.add_argument('--user', required=True) parser.add_argument('--backup', default='/var/lib/vdi/power-policy') args = parser.parse_args() if os.geteuid() != 0: parser.error('Run as root in the guest, not on the terminal or PVE node') if not re.fullmatch(r'[a-z_][a-z0-9_-]*', args.user): parser.error('Invalid desktop user') account = pwd.getpwnam(args.user) if account.pw_uid == 0: parser.error('Refusing to restrict root') backend = 'pkla' if run(['pkaction', '--version']).stdout.strip().endswith('0.105') else 'rules' if backend == 'pkla': target = Path('/etc/polkit-1/localauthority/90-mandatory.d/99-vdi-power.pkla') content = ('[VDI desktop power restriction]\nIdentity=unix-user:' + args.user + '\n' 'Action=org.freedesktop.login1.power-off*;org.freedesktop.login1.reboot*;' 'org.freedesktop.login1.halt*;org.freedesktop.login1.suspend*;' 'org.freedesktop.login1.hibernate*;org.freedesktop.login1.hybrid-sleep*;' 'org.freedesktop.consolekit.system.stop*;org.freedesktop.consolekit.system.restart*;' 'org.freedesktop.upower.suspend;org.freedesktop.upower.hibernate\n' 'ResultAny=no\nResultInactive=no\nResultActive=no\n') else: target = Path('/etc/polkit-1/rules.d/00-vdi-power.rules') content = '''polkit.addRule(function(action, subject) { if (subject.user === %s && /^org\\.freedesktop\\.login1\\.(power-off|reboot|halt|suspend|hibernate|hybrid-sleep)(-|$)/.test(action.id)) { return polkit.Result.NO; } }); ''' % json.dumps(args.user) backup = Path(args.backup) manifest = backup / 'manifest.json' dbus_path = Path('/etc/dbus-1/system.d/zz-vdi-desktop-power.conf') methods = ('PowerOff', 'Reboot', 'Halt', 'Suspend', 'Hibernate', 'HybridSleep', 'SuspendThenHibernate', 'ScheduleShutdown') capability_methods = ('CanPowerOff', 'CanReboot', 'CanHalt', 'CanSuspend', 'CanHibernate', 'CanHybridSleep', 'CanSuspendThenHibernate') members = [m for name in methods for m in (name, name + 'WithFlags')] + list(capability_methods) dbus_content = ('\n' '\n \n' + ''.join( ' \n' for m in members) + ' \n\n') dbus_manifest = backup / 'dbus-manifest.json' if args.action == 'apply': backup.mkdir(parents=True, exist_ok=True, mode=0o700) if not manifest.exists(): exists = target.exists() if exists: shutil.copy2(target, backup / 'original') manifest.write_text(json.dumps({'user': args.user, 'path': str(target), 'existed': exists})) saved = json.loads(manifest.read_text()) if saved['user'] != args.user or saved['path'] != str(target): raise RuntimeError('Backup belongs to a different target') target.parent.mkdir(parents=True, exist_ok=True) temporary = target.with_suffix(target.suffix + '.tmp') temporary.write_text(content) temporary.chmod(0o644) temporary.replace(target) # Active-session authorization can still grant Reboot. Enforce the # boundary at the bus, including the menu's capability queries. if not dbus_manifest.exists(): if dbus_path.exists(): shutil.copy2(dbus_path, backup / 'dbus-original') dbus_manifest.write_text(json.dumps({'user': args.user, 'existed': dbus_path.exists()})) if json.loads(dbus_manifest.read_text())['user'] != args.user: raise RuntimeError('D-Bus backup belongs to a different user') dbus_tmp = dbus_path.with_suffix('.conf.tmp') dbus_tmp.write_text(dbus_content) dbus_tmp.chmod(0o644) dbus_tmp.replace(dbus_path) reload_result = run(['busctl', '--system', 'call', 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus', 'ReloadConfig']) if reload_result.returncode: raise RuntimeError('D-Bus reload failed: ' + reload_result.stderr) time.sleep(2) # Both polkit backends monitor their policy directories. elif args.action == 'restore': saved = json.loads(manifest.read_text()) if saved['user'] != args.user or saved['path'] != str(target): raise RuntimeError('Backup belongs to a different target') dbus_saved = json.loads(dbus_manifest.read_text()) if dbus_saved['user'] != args.user: raise RuntimeError('D-Bus backup belongs to another user') if saved['existed']: shutil.copy2(backup / 'original', target) elif target.exists(): target.unlink() if dbus_saved['existed']: shutil.copy2(backup / 'dbus-original', dbus_path) elif dbus_path.exists(): dbus_path.unlink() reload_result = run(['busctl', '--system', 'call', 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus', 'ReloadConfig']) if reload_result.returncode: raise RuntimeError('D-Bus reload failed: ' + reload_result.stderr) time.sleep(2) capabilities = {} for method in capability_methods: result = run(['runuser', '-u', args.user, '--', 'busctl', '--system', 'call', 'org.freedesktop.login1', '/org/freedesktop/login1', 'org.freedesktop.login1.Manager', method]) capabilities[method] = {'code': result.returncode, 'value': result.stdout.strip(), 'error': result.stderr.strip()} active = target.exists() and target.read_text() == content dbus_active = dbus_path.exists() and dbus_path.read_text() == dbus_content bus_denials = {} if args.action == 'apply': # Deliberately use a STRING argument. Every real power method here # requires a boolean, flags, or a different signature, so even if the # bus rule failed, logind rejects this before any power action runs. # AccessDenied (rather than InvalidArgs) proves the bus blocks delivery. for method in methods: result = run(['runuser', '-u', args.user, '--', 'busctl', '--system', 'call', 'org.freedesktop.login1', '/org/freedesktop/login1', 'org.freedesktop.login1.Manager', method, 's', 'VDI_INVALID_SIGNATURE']) bus_denials[method] = {'code': result.returncode, 'error': result.stderr.strip()} print(json.dumps({'action': args.action, 'user': args.user, 'backend': backend, 'path': str(target), 'backup': str(backup), 'policy_matches': active, 'dbus_policy_matches': dbus_active, 'dbus_denials': bus_denials, 'boot_id': Path('/proc/sys/kernel/random/boot_id').read_text().strip(), 'capabilities': capabilities})) if args.action == 'apply' and (not active or not dbus_active or any( not (entry['code'] and 'Access denied' in entry['error']) for entry in capabilities.values()) or any( not entry['code'] or not any(message in entry['error'] for message in ('Rejected send message', 'Access denied')) for entry in bus_denials.values())): raise SystemExit('Policy installed but denial verification failed; inspect capabilities') if __name__ == '__main__': main()