456 lines
19 KiB
Python
456 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""Update installed CUPS project files without reinstalling or changing config."""
|
|
|
|
import argparse
|
|
import ast
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import shutil
|
|
import signal
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
import zipfile
|
|
|
|
|
|
ARCHIVE_URL = 'https://git.workyai.cn/237899745/S905L3A/archive/master.zip'
|
|
MAX_DOWNLOAD = 20 * 1024 * 1024
|
|
MAX_EXTRACTED = 100 * 1024 * 1024
|
|
TIMERS = ('cups-network-watchdog.timer', 'cups-print-watchdog.timer')
|
|
WATCHDOGS = ('cups-network-watchdog.service', 'cups-print-watchdog.service')
|
|
MANAGER = 'cups-driver-manager.service'
|
|
ACTIVE = {'active', 'activating', 'reloading', 'deactivating'}
|
|
PROGRAMS = {
|
|
'cups-driver-manager/driver_manager.py': 'opt/cups-driver-manager/driver_manager.py',
|
|
'watchdog/network-watchdog.sh': 'opt/cups-watchdog/network-watchdog.sh',
|
|
'watchdog/print-watchdog.sh': 'opt/cups-watchdog/print-watchdog.sh',
|
|
}
|
|
|
|
|
|
class UpdateError(Exception):
|
|
pass
|
|
|
|
|
|
def digest(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def safe_path(root, relative):
|
|
relative = PurePosixPath(relative)
|
|
if relative.is_absolute() or '..' in relative.parts or not relative.parts:
|
|
raise UpdateError(f'Unsafe relative path: {relative}')
|
|
root = root.resolve()
|
|
candidate = root
|
|
for part in relative.parts:
|
|
candidate = candidate / part
|
|
if candidate.is_symlink():
|
|
raise UpdateError(f'Refusing symbolic link: {candidate}')
|
|
if not candidate.resolve().is_relative_to(root):
|
|
raise UpdateError(f'Path escaped update root: {candidate}')
|
|
return candidate
|
|
|
|
|
|
def fetch_archive(url, output):
|
|
if urllib.parse.urlsplit(url).scheme != 'https':
|
|
raise UpdateError('The update archive URL must use HTTPS')
|
|
started = time.monotonic()
|
|
size = 0
|
|
with urllib.request.urlopen(url, timeout=30) as response:
|
|
if urllib.parse.urlsplit(response.geturl()).scheme != 'https':
|
|
raise UpdateError('Refusing an HTTPS-to-HTTP redirect')
|
|
with output.open('wb') as destination:
|
|
while True:
|
|
chunk = response.read(65536)
|
|
if not chunk:
|
|
break
|
|
size += len(chunk)
|
|
if size > MAX_DOWNLOAD or time.monotonic() - started > 180:
|
|
raise UpdateError('Update download exceeded the size/time limit')
|
|
destination.write(chunk)
|
|
if not size:
|
|
raise UpdateError('Downloaded archive is empty')
|
|
|
|
|
|
def extract_archive(archive_path, destination):
|
|
with zipfile.ZipFile(archive_path) as archive:
|
|
entries = archive.infolist()
|
|
if len(entries) > 5000 or sum(entry.file_size for entry in entries) > MAX_EXTRACTED:
|
|
raise UpdateError('Update archive is too large')
|
|
seen = set()
|
|
for entry in entries:
|
|
if '\\' in entry.filename or ':' in entry.filename:
|
|
raise UpdateError(f'Invalid archive path: {entry.filename}')
|
|
target = safe_path(destination, entry.filename)
|
|
if target in seen:
|
|
raise UpdateError(f'Duplicate archive path: {entry.filename}')
|
|
seen.add(target)
|
|
mode = entry.external_attr >> 16
|
|
if stat.S_IFMT(mode) not in (0, stat.S_IFREG, stat.S_IFDIR):
|
|
raise UpdateError(f'Unsupported archive entry: {entry.filename}')
|
|
archive.extractall(destination)
|
|
candidates = [destination] + [path for path in destination.iterdir() if path.is_dir()]
|
|
candidates = [path for path in candidates if (path / 'setup_cups.sh').is_file()]
|
|
if len(candidates) != 1:
|
|
raise UpdateError('Archive must contain exactly one project root')
|
|
return candidates[0]
|
|
|
|
|
|
@dataclass
|
|
class Change:
|
|
relative: str
|
|
content: bytes
|
|
previous: bytes
|
|
metadata: object
|
|
|
|
|
|
def build_plan(source, root, bash='bash'):
|
|
# Validate the release before inspecting or changing any installed file.
|
|
for relative in ('setup_cups.sh', *PROGRAMS):
|
|
path = safe_path(source, relative)
|
|
if not path.is_file():
|
|
raise UpdateError(f'Incomplete release: {relative}')
|
|
if relative.endswith('.sh'):
|
|
result = subprocess.run([bash, '-n', str(path)], capture_output=True, timeout=30)
|
|
if result.returncode:
|
|
raise UpdateError(f'Invalid shell syntax: {relative}')
|
|
else:
|
|
ast.parse(path.read_bytes(), filename=relative)
|
|
|
|
if not any(safe_path(root, relative).is_file() for relative in PROGRAMS.values()) and not safe_path(root, 'usr/share/cups/templates-zh_CN').is_dir():
|
|
raise UpdateError('No existing installation of this CUPS project was found; use the installer first')
|
|
|
|
mapping = dict(PROGRAMS)
|
|
groups = (
|
|
('cups-templates-zh_CN', 'usr/share/cups/templates', ('.tmpl',)),
|
|
('cups-templates-zh_CN', 'usr/share/cups/templates-zh_CN', ('.tmpl',)),
|
|
('cups-docroot-zh_CN', 'usr/share/cups/doc-root', ('.html', '.js')),
|
|
)
|
|
# Each source may be installed at more than one destination.
|
|
pairs = list(mapping.items())
|
|
for source_dir, installed_dir, suffixes in groups:
|
|
directory = safe_path(source, source_dir)
|
|
if not directory.is_dir():
|
|
raise UpdateError(f'Incomplete release: {source_dir}')
|
|
installed = safe_path(root, installed_dir)
|
|
if installed.is_dir():
|
|
for path in sorted(directory.iterdir()):
|
|
if path.suffix in suffixes:
|
|
safe_path(source, f'{source_dir}/{path.name}')
|
|
pairs.append((f'{source_dir}/{path.name}', f'{installed_dir}/{path.name}'))
|
|
|
|
css = safe_path(root, 'usr/share/cups/doc-root/cups.css')
|
|
modern_css = css.is_file() and b'.cups-header' in css.read_bytes()
|
|
changes = []
|
|
for origin, relative in pairs:
|
|
target = safe_path(root, relative)
|
|
if not target.exists():
|
|
continue
|
|
if not target.is_file():
|
|
raise UpdateError(f'Installed target is not a regular file: {target}')
|
|
content = safe_path(source, origin).read_bytes()
|
|
if modern_css and origin in ('cups-templates-zh_CN/header.tmpl', 'cups-templates-zh_CN/trailer.tmpl'):
|
|
for old, new in ((b'header', b'cups-header'), (b'body', b'cups-body'), (b'footer', b'cups-footer')):
|
|
content = content.replace(b'class="' + old + b'"', b'class="' + new + b'"')
|
|
previous = target.read_bytes()
|
|
if previous != content:
|
|
changes.append(Change(relative, content, previous, target.stat()))
|
|
return changes
|
|
|
|
|
|
class Services:
|
|
def __init__(self, runner=subprocess.run, sleeper=time.sleep, proc=Path('/proc')):
|
|
self.runner = runner
|
|
self.sleeper = sleeper
|
|
self.proc = proc
|
|
self.states = {}
|
|
self.stopped = []
|
|
|
|
def command(self, *args):
|
|
result = self.runner(['systemctl', *args], capture_output=True, text=True, timeout=120)
|
|
if result.returncode:
|
|
raise UpdateError(f'systemctl {" ".join(args)} failed: {result.stderr.strip()}')
|
|
return result.stdout
|
|
|
|
def state(self, unit):
|
|
result = self.runner(['systemctl', 'show', unit, '--property=LoadState,ActiveState,MainPID,UnitFileState'],
|
|
capture_output=True, text=True, timeout=120)
|
|
state = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
|
|
if state.get('LoadState') == 'not-found':
|
|
return state
|
|
if result.returncode or 'ActiveState' not in state:
|
|
raise UpdateError(f'Cannot inspect {unit}: {result.stderr.strip()}')
|
|
return state
|
|
|
|
def snapshot(self, manager_changed):
|
|
units = list(TIMERS) + list(WATCHDOGS)
|
|
if manager_changed:
|
|
units.append(MANAGER)
|
|
for unit in units:
|
|
state = self.state(unit)
|
|
if state.get('LoadState') == 'not-found':
|
|
continue
|
|
if state.get('LoadState') not in ('loaded', 'masked'):
|
|
raise UpdateError(f'Cannot inspect unit: {unit}')
|
|
if state.get('ActiveState') in ACTIVE and state.get('UnitFileState', '').startswith('masked'):
|
|
raise UpdateError(f'Cannot safely restore a masked active unit: {unit}')
|
|
self.states[unit] = state
|
|
|
|
def busy_processes(self, manager_pid=0):
|
|
# Read only process names/parents, never environment variables or credentials.
|
|
parents = {}
|
|
watchdog_pids = []
|
|
for directory in self.proc.glob('[0-9]*'):
|
|
try:
|
|
pid = int(directory.name)
|
|
fields = dict(line.split(':', 1) for line in (directory / 'status').read_text().splitlines() if ':' in line)
|
|
parents[pid] = int(fields.get('PPid', '0').strip())
|
|
args = (directory / 'cmdline').read_bytes().split(b'\0')
|
|
if any(Path(os.fsdecode(arg)).name in ('network-watchdog.sh', 'print-watchdog.sh') for arg in args if arg):
|
|
watchdog_pids.append(pid)
|
|
except (OSError, ValueError):
|
|
continue
|
|
descendants = {manager_pid} if manager_pid else set()
|
|
while True:
|
|
children = {pid for pid, parent in parents.items() if parent in descendants}
|
|
expanded = descendants | children
|
|
if expanded == descendants:
|
|
break
|
|
descendants = expanded
|
|
return watchdog_pids, descendants - {manager_pid}
|
|
|
|
def pause(self):
|
|
for timer in TIMERS:
|
|
if self.states.get(timer, {}).get('ActiveState') in ACTIVE:
|
|
self.stopped.append(timer)
|
|
self.command('stop', timer)
|
|
# Never kill a watchdog halfway through changing network configuration.
|
|
for unit in WATCHDOGS:
|
|
if unit not in self.states:
|
|
continue
|
|
for _ in range(60):
|
|
if self.state(unit).get('ActiveState') not in ACTIVE:
|
|
break
|
|
self.sleeper(2)
|
|
else:
|
|
raise UpdateError(f'Watchdog is still running; update cancelled: {unit}')
|
|
manager = self.states.get(MANAGER, {})
|
|
watchdogs, children = self.busy_processes(int(manager.get('MainPID', '0')))
|
|
if watchdogs or children:
|
|
raise UpdateError('A manual watchdog or driver installation is running; retry when idle')
|
|
if manager.get('ActiveState') in ACTIVE:
|
|
self.stopped.append(MANAGER)
|
|
self.command('stop', MANAGER)
|
|
|
|
def restore(self):
|
|
# Keep watchdog timers paused until the manager has passed startup checks.
|
|
order = ([MANAGER] if MANAGER in self.stopped else []) + [unit for unit in TIMERS if unit in self.stopped]
|
|
errors = []
|
|
for unit in order:
|
|
try:
|
|
self.command('start', unit)
|
|
for _ in range(3):
|
|
self.sleeper(1)
|
|
if self.state(unit).get('ActiveState') != 'active':
|
|
raise UpdateError(f'Startup health check failed: {unit}')
|
|
except Exception as error:
|
|
errors.append(str(error))
|
|
if unit == MANAGER:
|
|
break
|
|
if errors:
|
|
raise UpdateError('; '.join(errors))
|
|
|
|
def stop_for_rollback(self):
|
|
errors = []
|
|
for unit in self.stopped:
|
|
try:
|
|
self.command('stop', unit)
|
|
except Exception as error:
|
|
errors.append(str(error))
|
|
if errors:
|
|
raise UpdateError('; '.join(errors))
|
|
|
|
|
|
def atomic_write(target, data, metadata):
|
|
fd, temporary = tempfile.mkstemp(prefix=f'.{target.name}.update-', dir=target.parent)
|
|
temporary = Path(temporary)
|
|
try:
|
|
with os.fdopen(fd, 'wb') as handle:
|
|
handle.write(data)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
shutil.copystat(target, temporary)
|
|
os.chmod(temporary, stat.S_IMODE(metadata.st_mode))
|
|
if os.name == 'posix':
|
|
os.chown(temporary, metadata.st_uid, metadata.st_gid)
|
|
os.replace(temporary, target)
|
|
finally:
|
|
if temporary.exists():
|
|
temporary.unlink()
|
|
|
|
|
|
def clear_old_locks(root):
|
|
for kind in ('network', 'print'):
|
|
lock = safe_path(root, f'run/cups-watchdog/{kind}.lock')
|
|
if lock.exists():
|
|
if not lock.is_dir() or any(lock.iterdir()):
|
|
raise UpdateError(f'Refusing to remove an unexpected watchdog lock: {lock}')
|
|
lock.rmdir()
|
|
|
|
|
|
def apply_update(changes, root, services, release):
|
|
backup_root = safe_path(root, 'var/backups/cups-project')
|
|
backup_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
backup = backup_root / (datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ-') + uuid.uuid4().hex[:8])
|
|
backup.mkdir(mode=0o700)
|
|
manifest = {'release': release, 'status': 'preparing', 'files': []}
|
|
|
|
def save_status(status):
|
|
manifest['status'] = status
|
|
(backup / 'manifest.json').write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding='utf-8')
|
|
|
|
for change in changes:
|
|
original = safe_path(root, change.relative)
|
|
copy = safe_path(backup / 'files', change.relative)
|
|
copy.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(original, copy)
|
|
if copy.read_bytes() != change.previous:
|
|
raise UpdateError(f'File changed during backup; retry: {original}')
|
|
manifest['files'].append({'path': change.relative, 'before': digest(change.previous), 'after': digest(change.content)})
|
|
save_status('prepared')
|
|
print(f'Backup: {backup}', flush=True)
|
|
|
|
attempted = []
|
|
try:
|
|
services.snapshot(any(change.relative == PROGRAMS['cups-driver-manager/driver_manager.py'] for change in changes))
|
|
manifest['services_before'] = getattr(services, 'states', {})
|
|
save_status('prepared')
|
|
services.pause()
|
|
clear_old_locks(root)
|
|
for change in changes:
|
|
if safe_path(root, change.relative).read_bytes() != change.previous:
|
|
raise UpdateError(f'File changed after preflight: {change.relative}')
|
|
save_status('applying')
|
|
for change in changes:
|
|
attempted.append(change)
|
|
target = safe_path(root, change.relative)
|
|
atomic_write(target, change.content, change.metadata)
|
|
if target.read_bytes() != change.content:
|
|
raise UpdateError(f'Written file verification failed: {change.relative}')
|
|
services.restore()
|
|
save_status('complete')
|
|
except (Exception, KeyboardInterrupt) as error:
|
|
rollback_errors = []
|
|
if attempted:
|
|
try:
|
|
services.stop_for_rollback()
|
|
except Exception as rollback_error:
|
|
rollback_errors.append(str(rollback_error))
|
|
for change in reversed(attempted):
|
|
try:
|
|
previous = safe_path(backup / 'files', change.relative).read_bytes()
|
|
if digest(previous) != digest(change.previous):
|
|
raise UpdateError(f'Backup checksum mismatch: {change.relative}')
|
|
atomic_write(safe_path(root, change.relative), previous, change.metadata)
|
|
except Exception as rollback_error:
|
|
rollback_errors.append(str(rollback_error))
|
|
try:
|
|
services.restore()
|
|
except Exception as rollback_error:
|
|
rollback_errors.append(str(rollback_error))
|
|
try:
|
|
save_status('rollback_failed' if rollback_errors else 'rolled_back')
|
|
except Exception as rollback_error:
|
|
rollback_errors.append(str(rollback_error))
|
|
detail = '; '.join(rollback_errors) if rollback_errors else 'Original files/service states restored'
|
|
raise UpdateError(f'Update failed: {error}. {detail}. Backup: {backup}') from error
|
|
return backup
|
|
|
|
|
|
@contextmanager
|
|
def update_lock(path):
|
|
import fcntl
|
|
with path.open('a') as handle:
|
|
try:
|
|
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError as error:
|
|
raise UpdateError('Another update is already running') from error
|
|
yield
|
|
|
|
|
|
def confirm():
|
|
try:
|
|
with open('/dev/tty', 'r+') as terminal:
|
|
terminal.write('Apply this update? [y/N]: ')
|
|
terminal.flush()
|
|
return terminal.readline().strip().lower() == 'y'
|
|
except OSError as error:
|
|
raise UpdateError('No interactive terminal. Review with --dry-run, then use --yes to confirm') from error
|
|
|
|
|
|
def check_prerequisites():
|
|
if sys.version_info < (3, 9):
|
|
raise UpdateError('Python 3.9 or later is required')
|
|
if os.name != 'posix' or os.geteuid() != 0:
|
|
raise UpdateError('Run this updater as root on the Linux print server')
|
|
if not shutil.which('systemctl') or not shutil.which('bash'):
|
|
raise UpdateError('systemctl and Bash are required; no packages will be installed automatically')
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--archive-url', default=ARCHIVE_URL, help='HTTPS archive URL (default: project master)')
|
|
parser.add_argument('--source-dir', type=Path, help='Use a trusted local release instead of downloading')
|
|
parser.add_argument('--dry-run', action='store_true', help='Validate and list changes without applying them')
|
|
parser.add_argument('--yes', action='store_true', help='Confirm the displayed file update without prompting')
|
|
args = parser.parse_args(argv)
|
|
check_prerequisites()
|
|
root = Path('/')
|
|
with update_lock(safe_path(root, 'run/cups-project-update.lock')), tempfile.TemporaryDirectory(prefix='cups-project-update-') as temporary:
|
|
if args.source_dir:
|
|
source = args.source_dir.resolve()
|
|
release = {'source': str(source)}
|
|
else:
|
|
archive = Path(temporary) / 'release.zip'
|
|
print('Downloading the current project release...', flush=True)
|
|
fetch_archive(args.archive_url, archive)
|
|
source = extract_archive(archive, Path(temporary) / 'release')
|
|
release = {'source': args.archive_url, 'archive_sha256': digest(archive.read_bytes())}
|
|
changes = build_plan(source, root)
|
|
if not changes:
|
|
print('No installed project files need updating. No services were changed.')
|
|
return 0
|
|
print('Only the following installed files will be replaced:')
|
|
for change in changes:
|
|
print(f' /{change.relative}')
|
|
print('CUPS queues/config, network settings, credentials, systemd units and packages are preserved.')
|
|
print('The driver-manager web service may be briefly unavailable; CUPS will not be restarted.')
|
|
if args.dry_run or not (args.yes or confirm()):
|
|
print('No installed files or services were changed.')
|
|
return 0
|
|
backup = apply_update(changes, root, Services(), release)
|
|
print(f'Update completed. Backup retained at: {backup}')
|
|
return 0
|
|
|
|
|
|
def interrupted(signum, frame):
|
|
raise UpdateError(f'Update interrupted by signal {signum}')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
signal.signal(signal.SIGTERM, interrupted)
|
|
try:
|
|
sys.exit(main())
|
|
except (Exception, KeyboardInterrupt) as error:
|
|
print(f'ERROR: {error}', file=sys.stderr)
|
|
sys.exit(1)
|