#!/usr/bin/env python3
"""Install, inspect, snapshot, and remove MYKOS from explicit maintained sources."""
import argparse
import datetime
import hashlib
import io
import ipaddress
import json
import os
from pathlib import Path
import pwd
import re
import grp
import shutil
import sqlite3
import stat
import subprocess
import sys
import tarfile
import tempfile
from urllib.parse import urlsplit

SOURCE = Path(__file__).resolve().parents[1]
STATE = '/var/lib/mykos-installer/installed.json'
BACKUPS = '/var/backups/mykos-installer'
UNITS = ['mykos-idle-bases.timer','mykos-snapshot-preview-gc.timer','mykos-host-history.service','mykos-web.service','mykos-container-admin.socket','mykos-terminal.socket',
         'mykos-speedtest-collector.timer','mykos-collector.timer','mykos-details.timer','mykos-extended.timer','mykos-command-collector.timer']
PREFIXES = ('/opt/mykos-web/', '/usr/local/share/mykos/', '/etc/mykos/',
            '/etc/systemd/system/mykos-', '/usr/local/sbin/mykos-')
# Keep the legacy path allowed for old recovery snapshots and upgrade cleanup.
OPTIONAL_UNITS = ['mykos-speedtest-collector.service','mykos-repo-sync.service','mykosreposync.slice','mykos-idle-bases.service','mykos-repository-inventory.service','mykos-repository-inventory.timer','mykos-snapshot-preview-gc.service','mykos-legacy-cgroups.service','mykos-vpn-gateway-guard.service','mykos-vpn-gateway.service','mykos-vpn-gateway.timer']
EXACT = {'/etc/systemd/system/mykosreposync.slice','/usr/local/sbin/mykos_peer_vpn.py','/usr/local/sbin/mykos_idle_bases.py','/usr/local/sbin/mykos_snapshot_preview.py','/etc/sysctl.d/90-mykos-inotify.conf','/usr/local/sbin/mykos_criu_binary.py','/usr/local/sbin/mykos_criu_cgroups.py','/usr/local/sbin/mykos_criu_images.py','/usr/local/sbin/mykos_cgroups.py','/usr/local/sbin/mykos-podman-nm.conf','/usr/local/sbin/mykos_vpn_host.py','/usr/local/sbin/mykos_vpn_gateway.py','/usr/local/sbin/mykos_vpn_topology.py','/usr/local/sbin/mykos_backup_restore.py','/usr/local/sbin/mykos_backup_identity.py','/usr/local/sbin/mykos_watch_paths.py','/usr/local/sbin/mykos_base_session.py','/usr/local/sbin/mykos_image_admin.py','/usr/local/sbin/mykos_refresh.py','/usr/local/sbin/mykos_image_build.py','/usr/local/sbin/mykos_ubuntu_vpn.py','/usr/local/sbin/mykos_ssh.py', '/usr/local/sbin/mykos_powershell.py', '/usr/local/sbin/mykos_serial.py', '/usr/local/sbin/mykos_network.py', '/usr/local/sbin/mykos_host_monitoring.py', '/usr/local/sbin/mykos_profiles.py', '/usr/local/sbin/mykos_criu.py', STATE, '/usr/bin/mykos', '/usr/local/bin/mykos', '/usr/local/sbin/mykos_runtime.py', '/usr/local/sbin/mykos_admin.py', '/usr/local/sbin/mykos_vm.py', '/usr/local/sbin/mykos_linux.py', '/usr/local/sbin/mykos_snapshots.py', '/usr/local/sbin/mykos_commands.py', '/usr/local/sbin/mykos_containers.py', '/usr/local/sbin/mykos_prune.py',
         '/usr/local/bin/monthname.sh','/usr/local/bin/strip.sh','/usr/local/bin/recon.sh',
         '/usr/local/bin/cloud_pricing','/usr/local/bin/backup_perf.sh'}
PACKAGES = ['dmidecode','podman','httpd','python3','python3-pip','openssh-server','openssh-clients','rsync',
            'iproute','net-tools','netavark','containernetworking-plugins','libreswan','curl','wget','jq','util-linux','procps-ng','tar','gzip',
            'cronie','chrony','firewalld','policycoreutils-python-utils','ksh','unzip',
            'expect','nmap','time','ipset','iptables','git','criu','pykickstart']

INOTIFY_INSTANCES = Path('/proc/sys/fs/inotify/max_user_instances')

def ensure_container_limits():
    # Raise the shared quota only when required; retain larger operator limits.
    if int(INOTIFY_INSTANCES.read_text()) < 4096:
        command(['/usr/sbin/sysctl','-w','fs.inotify.max_user_instances=4096'])

def fail(message): raise ValueError(message)
def sha(data): return hashlib.sha256(data).hexdigest()
def allowed(path):
    return isinstance(path,str) and path.startswith('/') and '..' not in Path(path).parts and '\\' not in path and (path in EXACT or any(path.startswith(p) for p in PREFIXES))

def command(args):
    print('+', ' '.join(map(str,args)), flush=True)
    return subprocess.run(list(map(str,args)), check=True, text=True)


def activate_apache(python):
    # RPM owns runtime files, so it may replace the template before setup runs.
    # Always activate managed proxy routes when an existing Apache is running.
    if subprocess.run(['systemctl','is-active','--quiet','httpd']).returncode == 0:
        command([python,str(SOURCE/'bin/mykos-console-proxy'),'--apply'])
        command(['apachectl','configtest'])
        command(['systemctl','reload','httpd'])


def readable_venv(root,gid):
    # firstboot runs with umask 0077. Give only the service group access to
    # its root-owned environment; never chmod/chown linked system interpreters.
    for path in [root,*root.rglob('*')]:
        if path.is_symlink():continue
        mode=0o750 if path.is_dir() or path.stat().st_mode&0o111 else 0o640
        os.chmod(path,mode);os.chown(path,0,gid)

def provision_venv(venv, python, requirements, install=False, runner=None):
    """Keep repeated installs stable; retain the old runtime on failed migration."""
    runner=runner or command
    venv=Path(venv)
    if venv.is_symlink():fail('Preserving custom symlink Python environment')
    def version(executable):
        return json.loads(subprocess.check_output([str(executable),'-c','import json,sys; print(json.dumps(list(sys.version_info[:2])))'],text=True))
    wanted=version(python);current=None;backup=None
    if (venv/'bin/python').is_file():
        try:current=version(venv/'bin/python')
        except (OSError,subprocess.CalledProcessError,ValueError):pass
    if venv.exists() and current!=wanted:
        backup=Path(tempfile.mkdtemp(prefix='.venv-before-runtime-',dir=str(venv.parent)))
        os.replace(venv,backup)
    created=not (venv/'bin/python').is_file()
    try:
        if created:runner([python,'-m','venv',venv])
        if install or created:runner([venv/'bin/python','-m','pip','install','-r',requirements])
        runner([venv/'bin/python','-c','import fastapi,pydantic,uvicorn'])
    except BaseException:
        if backup:
            if venv.exists():shutil.rmtree(venv)
            os.replace(backup,venv)
        raise
    return backup


class Installer:
    def __init__(self, args):
        self.args=args
        self.root=Path(args.root).absolute()
        if any(p.is_symlink() for p in (self.root,*self.root.parents)): fail('Root must not contain symlinks')
        self.live=self.root==Path('/')
        if not self.live and not self.root.is_dir() and args.action not in ('install',): fail('Staging root does not exist')
        self.uid=self.gid=None

    def path(self, name):
        if not allowed(name) and name not in (BACKUPS,'/var/lib/mykos-installer','/var/lib/mykos/jobs','/backups','/opt/mykos-web','/etc/mykos'):
            fail('Unmanaged path: '+name)
        dest=self.root/name.lstrip('/')
        if dest.is_symlink() or any(p.is_symlink() for p in dest.parents): fail('Refusing symlink: '+str(dest))
        return dest

    def mutation(self):
        if self.live and os.geteuid()!=0: fail('Live mutations require root; use --root for staging')
        if not self.live and (getattr(self.args,'install_deps',False) or getattr(self.args,'python_deps',False) or getattr(self.args,'enable',False) or getattr(self.args,'configure_https',False) or getattr(self.args,'desktop_network',None)):
            fail('--root staging never installs dependencies or changes host services')

    def prepare_backups(self, create=False):
        # Customer storage is external to the managed installation. Preserve a
        # working /backups alias without writing through it or changing its mode.
        dest = self.root / 'backups'
        if dest.is_symlink():
            if not dest.is_dir():
                fail('/backups symlink must point to an existing directory')
            return
        dest = self.path('/backups')
        if dest.exists():
            if not dest.is_dir(): fail('/backups must be a directory')
        elif create:
            dest.mkdir(mode=0o755)

    def identity(self):
        if not self.live:return
        try: user=pwd.getpwnam('mykosweb')
        except KeyError:
            command(['useradd','--system','--user-group','--home-dir','/opt/mykos-web',
                     '--no-create-home','--shell','/sbin/nologin','mykosweb'])
            user=pwd.getpwnam('mykosweb')
        self.uid=user.pw_uid
        self.gid=grp.getgrnam('mykosweb').gr_gid

    def write(self, name, data, mode=0o644, owner='root'):
        dest=self.path(name)
        dest.parent.mkdir(parents=True, exist_ok=True)
        fd,tmp=tempfile.mkstemp(prefix='.mykos-',dir=dest.parent)
        try:
            with os.fdopen(fd,'wb') as f: f.write(data);f.flush();os.fsync(f.fileno())
            os.chmod(tmp,mode)
            if self.live:
                os.chown(tmp,self.uid if owner=='mykosweb' else 0,
                         self.gid if owner in ('mykosweb','web-read') else 0)
            os.replace(tmp,dest)
        finally:
            if os.path.exists(tmp): os.unlink(tmp)

    def entries(self):
        entries={}
        def add(dest,data,mode=0o644,owner='root',mutable=False):
            if not allowed(dest):fail('Invalid installation path '+dest)
            entries[dest]={'data':data,'mode':mode,'owner':owner,'mutable':mutable}
        for directory in ('bin','back','data','pos','repo','docs','services','packaging'):
            for src in sorted((SOURCE/directory).rglob('*')):
                rel=src.relative_to(SOURCE)
                if src.is_symlink(): fail('Source symlink rejected: '+str(src))
                if not src.is_file() or any(part.startswith('.') or part=='__pycache__' for part in rel.parts):continue
                if src.name in ('mykos-web.img','install-legacy','cloud_pricing_previous','cloud_pricing_changed') or src.suffix=='.pyc':continue
                # The docs page is served by mykosweb; keep guides root-owned but group-readable.
                docs_readable = directory == 'docs'
                add('/usr/local/share/mykos/'+str(rel),src.read_bytes(),
                    0o750 if os.access(src,os.X_OK) or directory=='bin' else 0o640,
                    'web-read' if docs_readable else 'root',
                    mutable=directory == 'pos' and rel.parent == Path('pos/ks') and src.suffix == '.ks')
        for name in ('MENU','README.md','AGENTS.md','LICENSE.txt','THIRD_PARTY_NOTICES.txt'):
            add('/usr/local/share/mykos/'+name,(SOURCE/name).read_bytes(),0o750 if name=='MENU' else 0o640)
        for src in sorted((SOURCE/'services/web').iterdir()):
            if src.is_file() and not src.name.startswith('.'):
                add('/opt/mykos-web/'+src.name,src.read_bytes(),0o750 if src.suffix=='.sh' else 0o640,'web-read')
        python=self.args.python
        if not python.startswith('/') or any(c in python for c in '\n\r \t%'):fail('--python must be an absolute path without whitespace or systemd specifiers')
        for src in sorted((SOURCE/'services/helpers').iterdir()):
            if not src.is_file() or src.name.startswith('.') or src.suffix=='.pyc':continue
            content=src.read_bytes()
            if content.startswith(b'#!/usr/bin/env python3\n'):
                content=('#!'+python+'\n').encode()+content.split(b'\n',1)[1]
            add('/usr/local/sbin/'+src.name,content,0o644 if src.suffix in ('.py','.conf') else 0o755)
        for src in sorted((SOURCE/'services/systemd').glob('*')):
            add('/etc/systemd/system/'+src.name,src.read_text().replace('@PYTHON@',python).encode())
        add('/etc/sysctl.d/90-mykos-inotify.conf',(SOURCE/'services/sysctl/90-mykos-inotify.conf').read_bytes(),0o644,'root',True)
        add('/usr/bin/mykos',b'#!/usr/bin/env bash\nset -e\nif [ -x /usr/share/mykos/MENU ]; then\n  cd /usr/share/mykos\nelse\n  cd /usr/local/share/mykos\nfi\nexec ./MENU "$@"\n',0o755)
        # Legacy scripts expect these support paths; no cron jobs or email jobs are activated.
        for name in ('monthname.sh','strip.sh','recon.sh','cloud_pricing','backup_perf.sh'):
            add('/usr/local/bin/'+name,(SOURCE/'back/bin'/name).read_bytes(),0o750)
        origin=self.args.public_origin
        parsed=urlsplit(origin)
        http_ip=False
        if parsed.scheme=='http' and parsed.hostname:
            try:http_ip=not ipaddress.ip_address(parsed.hostname).is_unspecified
            except ValueError:pass
        if (parsed.scheme!='https' and not http_ip) or not parsed.hostname or parsed.path not in ('','/') or parsed.query or parsed.fragment or parsed.username:
            fail('--public-origin must be HTTPS or an HTTP origin with a literal server IP')
        if any(c.isspace() for c in origin):fail('Invalid origin whitespace')
        add('/etc/mykos/web.env',('MYKOS_PUBLIC_ORIGIN='+origin.rstrip('/')+'\nMYKOS_SECURE_COOKIE='+('0' if http_ip else '1')+'\n').encode(),0o640,'web-read',True)
        add('/etc/mykos/config.json',(SOURCE/'services/config.example.json').read_bytes(),0o600,'root',True)
        add('/etc/mykos/pos.env',(SOURCE/'services/pos.env.example').read_bytes(),0o600,'root',True)
        add('/opt/mykos-web/inventory.json',b'{"nodes": []}\n',0o640,'web-read',True)
        return entries

    def snapshot(self, paths, output=None, include_data=False):
        files=[];absent=[]
        for name in sorted(set(paths)):
            dest=self.path(name)
            if not dest.exists(): absent.append(name);continue
            if not dest.is_file():fail('Not a regular file: '+str(dest))
            st=dest.stat()
            if name.endswith(('.db','.sqlite','.sqlite3')):
                # SQLite online backup includes committed WAL transactions consistently.
                with tempfile.TemporaryDirectory() as folder:
                    with sqlite3.connect('file:'+str(dest)+'?mode=ro',uri=True) as c, sqlite3.connect(folder+'/copy.db') as d:c.backup(d)
                    data=Path(folder+'/copy.db').read_bytes()
            else:data=dest.read_bytes()
            files.append(({'path':name,'mode':stat.S_IMODE(st.st_mode),'uid':st.st_uid,'gid':st.st_gid,'sha256':sha(data)},data))
        if output is None:
            folder=self.path(BACKUPS);folder.mkdir(parents=True,exist_ok=True,mode=0o700)
            output=folder/('mykos-'+datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f')+'.tar.gz')
        output=Path(output)
        if output.exists() or output.is_symlink():fail('Snapshot output already exists')
        output.parent.mkdir(parents=True,exist_ok=True)
        meta={'format':2,'kind':'mykos-snapshot','includes_data':include_data,'files':[x[0] for x in files],'absent':absent}
        with output.open('xb') as raw:
            os.chmod(output,0o600)
            with tarfile.open(fileobj=raw,mode='w:gz') as tar:
                values=[('manifest.json',json.dumps(meta).encode())]+[('files/'+e['path'].lstrip('/'),b) for e,b in files]
                for name,data in values:
                    info=tarfile.TarInfo(name);info.size=len(data);info.mode=0o600
                    tar.addfile(info,io.BytesIO(data))
        print('Snapshot:',output)
        return output

    def install(self):
        if self.live and not self.args.dry_run:
            print('MYKOS is supplied under its free-use license; review:',SOURCE/'LICENSE.txt')
            print('Third-party licenses remain separate:',SOURCE/'THIRD_PARTY_NOTICES.txt')
        if getattr(self.args,'configure_https',False):
            self.mutation()
            args=[self.args.python,str(SOURCE/'bin/mykos-bootstrap'),'--installed-rpm','--configure-https']
            if self.args.domain:args+=['--domain',self.args.domain]
            if self.args.email:args+=['--email',self.args.email]
            if self.args.dry_run:args+=['--plan']
            command(args);return
        entries=self.entries()
        self.prepare_backups()
        selected={p:e for p,e in entries.items() if not e['mutable'] or not self.path(p).exists()}
        if getattr(self.args,'set_public_origin',False):selected['/etc/mykos/web.env']=entries['/etc/mykos/web.env']
        print(('Live' if self.live else 'Staging')+' installation: %d files; %d preserved settings/data files' % (len(selected),len(entries)-len(selected)))
        if self.args.dry_run:
            print(json.dumps({'root':str(self.root),'write':sorted(selected),'preserve':sorted(set(entries)-set(selected)),
                'system_packages_if_requested':PACKAGES,'activate_if_requested':UNITS,
                'desktop_network_if_requested':self.args.desktop_network},indent=2));return
        self.mutation()
        self.root.mkdir(parents=True,exist_ok=True)
        if self.live:
            if self.args.install_deps:
                packages=PACKAGES
                release=Path('/etc/os-release').read_text()
                if re.search(r'^VERSION_ID=[\"\']?10(?:[.\"\']|$)',release,re.M):
                    packages=[name for name in PACKAGES if name != 'containernetworking-plugins']
                command(['dnf','install','-y',*packages])
                # Image building tools are optional because repository availability varies.
                if self.args.image_build_deps:command(['dnf','install','-y','lorax','lorax-lmc-novirt','anaconda-tui','pykickstart'])
            result=subprocess.run([self.args.python,'-c','import sys,hashlib; assert sys.version_info >= (3,11), "Python 3.11+ required"; assert hasattr(hashlib, "scrypt"), "Python needs hashlib.scrypt/OpenSSL"'],check=False)
            if result.returncode:fail('Select Python 3.11+ using --python; install that interpreter first')
        if self.live and self.args.enable and not self.path('/etc/mykos/web.env').exists() and self.args.public_origin=='https://mykos.example.com':
            fail('Supply the real --public-origin before activating a new installation')
        snap=self.snapshot([*selected,STATE,'/usr/local/bin/mykos'])
        self.identity()
        self.prepare_backups(create=True)
        for name in ('/opt/mykos-web','/opt/mykos-web/data','/etc/mykos','/var/lib/mykos/jobs'):
            dest=self.path(name)
            if dest.is_symlink():fail('Unsafe directory '+str(dest))
            dest.mkdir(parents=True,exist_ok=True)
            os.chmod(dest,0o700 if name.endswith(('data','jobs')) else 0o750)
            if self.live:os.chown(dest,self.uid if name.endswith('/data') else 0,self.gid if not name.endswith('/jobs') else 0)
        for path,entry in selected.items():self.write(path,**{k:entry[k] for k in ('data','mode','owner')})
        docs=self.path('/usr/local/share/mykos/docs')
        # A restrictive umask can leave the shared parent root-only even when
        # the documents themselves have the intended web-readable permissions.
        docs.parent.chmod(0o755)
        for path in [docs,*docs.rglob('*')]:
            if path.is_dir() and not path.is_symlink():path.chmod(0o755)
        # Notes are user data, including the notes-driven maintenance work list.
        # An application upgrade must not clear either pane or personal notes.
        for prefix in ('/usr/local/share/mykos',):
            removed=self.path(prefix+'/docs/notes.md')
            if removed.is_file() and not removed.is_symlink(): removed.unlink()
        legacy=self.path('/usr/local/bin/mykos')
        if legacy.exists():legacy.unlink()
        if self.live:
            venv=self.path('/opt/mykos-web/.venv')
            provision_venv(venv,self.args.python,SOURCE/'services/requirements.txt',
                           install=self.args.install_deps or self.args.python_deps)
            frozen=subprocess.check_output([str(venv/'bin/python'),'-m','pip','freeze'])
            self.write('/opt/mykos-web/installed-requirements.txt',frozen,0o640,'web-read')
            readable_venv(venv,self.gid)
            command([venv/'bin/python','-c','import fastapi,pydantic,uvicorn'])
            # Empty schema only; no archived accounts, passwords or API keys are imported.
            command(['runuser','-u','mykosweb','--',venv/'bin/python','/opt/mykos-web/manage.py','users','list'])
            command(['runuser','-u','mykosweb','--',venv/'bin/python','/opt/mykos-web/manage.py','keys','list'])
            ensure_container_limits()
            command(['systemctl','daemon-reload'])
            activate_apache(self.args.python)
        manifest={'format':1,'python':self.args.python,'staging':not self.live,'snapshot':str(snap),'files':[]}
        for path,entry in entries.items():
            manifest['files'].append({'path':path,'sha256':sha(self.path(path).read_bytes()),
                                      'mode':entry['mode'],'mutable':entry['mutable'],'owner':entry['owner']})
        self.write(STATE,json.dumps(manifest,indent=2).encode(),0o600)
        if self.live:
            command([self.args.python,'/usr/local/sbin/mykos-snapshot','init'])
            command([self.args.python,'/usr/local/sbin/mykos-command','init'])
        if self.live and getattr(self.args, 'native_network', False):
            command([self.args.python,'/usr/local/sbin/mykos-network-setup'])
        if self.live and self.args.desktop_network:
            command([self.args.python,'/usr/local/sbin/mykos-desktop-setup','--network',self.args.desktop_network])
        if self.live and self.args.enable:
            # Restart long-running services so an update takes effect.
            command(['systemctl','enable',*UNITS])
            # A recreated service identity can leave an already-running socket
            # owned by its former numeric group. Refresh only stale sockets.
            for unit in ('mykos-container-admin.socket','mykos-terminal.socket'):
                socket_path=Path('/run')/(unit.removesuffix('.socket')+'.sock')
                if socket_path.exists():
                    status=socket_path.stat()
                    if status.st_uid!=0 or status.st_gid!=self.gid or stat.S_IMODE(status.st_mode)!=0o660:
                        command(['systemctl','restart',unit])
            command(['systemctl','start','mykos-container-admin.socket','mykos-terminal.socket'])
            command(['systemctl','restart','mykos-web.service','mykos-host-history.service'])
            command(['systemctl','start','mykos-snapshot-preview-gc.timer','mykos-host-history.service','mykos-speedtest-collector.timer','mykos-collector.timer','mykos-details.timer','mykos-extended.timer','mykos-command-collector.timer'])
        print('Installed. Snapshot:',snap)
        print('Configure HTTPS proxy, /etc/mykos settings, users, and prepared POS images before production use.')
        if not self.live:print('Staging only: no accounts, dependency installation, chown, or service changes were performed.')


    def quiescent(self):
        if not self.live:return
        output=subprocess.check_output(['systemctl','list-units','--state=active,activating',
            '--plain','--no-legend','mykos-stage@*.service','mykos-terminal@*.service',
            'mykos-container-admin@*.service','mykos-command@*.service','mykos-image-remove-*.service', 'mykos-image-reset-*.service', 'mykos-image-build@*.service'],text=True)
        if output.strip():fail('Active privileged connections or staging jobs exist; finish/stop them before maintenance')

    def manifest(self):
        value=json.loads(self.path(STATE).read_text())
        if value.get('format')!=1:fail('Unsupported installation manifest')
        for e in value['files']:
            if not allowed(e['path']):fail('Unsafe installation manifest')
        return value

    def verify(self):
        errors=[]
        manifest=self.manifest()
        for e in manifest['files']:
            path=self.path(e['path'])
            if not path.is_file():errors.append('Missing '+e['path']);continue
            if not e['mutable'] and sha(path.read_bytes())!=e['sha256']:errors.append('Changed '+e['path'])
            if stat.S_IMODE(path.stat().st_mode)!=e['mode']:errors.append('Mode mismatch '+e['path'])
            if self.live and path.stat().st_uid!=0:errors.append('Expected root ownership '+e['path'])
        if self.live:
            user=pwd.getpwnam('mykosweb');group=grp.getgrnam('mykosweb')
            for socket_name in ('mykos-container-admin.sock','mykos-terminal.sock'):
                socket_path=Path('/run')/socket_name
                if socket_path.exists():
                    status=socket_path.stat()
                    if status.st_uid!=0 or status.st_gid!=group.gr_gid or stat.S_IMODE(status.st_mode)!=0o660:
                        errors.append('Socket ownership/mode mismatch '+str(socket_path))
            command(['runuser','-u','mykosweb','--',self.path('/opt/mykos-web/.venv')/'bin/python',
                     '-c','import sys; from pathlib import Path; Path(sys.argv[1]).read_bytes()',
                     self.path('/usr/local/share/mykos/docs/rendered/README.html')])
            for entry in manifest['files']:
                path=self.path(entry['path'])
                expected_gid=group.gr_gid if entry.get('owner')=='web-read' else 0
                if path.exists() and path.stat().st_gid!=expected_gid:errors.append('Group mismatch '+entry['path'])
            directory=self.path('/opt/mykos-web/data')
            if directory.stat().st_uid!=user.pw_uid or stat.S_IMODE(directory.stat().st_mode)!=0o700:
                errors.append('Expected mykosweb-owned 0700 data directory')
            for name in ('auth.db','api_keys.db'):
                database=self.path('/opt/mykos-web/data/'+name)
                if database.exists() and (database.stat().st_uid!=user.pw_uid or stat.S_IMODE(database.stat().st_mode)!=0o600):
                    errors.append('Expected mykosweb-owned 0600 '+str(database))
            command([self.path('/opt/mykos-web/.venv')/'bin/python','-c','import fastapi,pydantic,uvicorn'])
            command(['systemd-analyze','verify',*[str(self.path('/etc/systemd/system/'+u)) for u in UNITS+OPTIONAL_UNITS]])
        if errors:fail('\n'.join(errors))
        print('Verified %d managed files%s' % (len(manifest['files']), ' (staged layout only)' if not self.live else ''))

    def status(self):
        print(json.dumps(self.manifest(),indent=2))
        if self.live:command(['systemctl','--no-pager','status',*UNITS])

    def backup(self):
        paths=[e['path'] for e in self.manifest()['files']]+[STATE]
        if self.path('/opt/mykos-web/installed-requirements.txt').exists():paths.append('/opt/mykos-web/installed-requirements.txt')
        if self.args.include_data:
            for dest in (self.path('/opt/mykos-web/data'),):
                paths.extend('/'+str(p.relative_to(self.root)) for p in dest.rglob('*') if p.is_file() and not p.name.endswith(('-wal','-shm')))
        if self.args.dry_run:
            print('Would snapshot %d files to %s (include_data=%s)' % (len(paths),self.args.output,self.args.include_data));return
        self.mutation();self.snapshot(paths,self.args.output,self.args.include_data)

    def restore(self):
        contents={}
        with tarfile.open(self.args.snapshot,'r:gz') as tar:
            members=tar.getmembers()
            if len(members)>20000 or len({m.name for m in members})!=len(members):fail('Duplicate/excessive archive entries')
            mm=tar.getmember('manifest.json')
            if not mm.isfile() or mm.size>10*1024**2:fail('Invalid manifest member')
            meta=json.load(tar.extractfile(mm))
            if meta.get('format')!=2 or meta.get('kind')!='mykos-snapshot':fail('Use install-bundle for legacy format 1 archives')
            if meta.get('includes_data') and not self.args.include_data:fail('Data-bearing snapshot requires --include-data')
            seen=set()
            for entry in meta['files']:
                name=entry['path']
                if not allowed(name) or name in seen:fail('Unsafe/duplicate snapshot path')
                if not isinstance(entry.get('mode'),int) or entry['mode']<0 or entry['mode']>0o777:
                    fail('Invalid snapshot mode')
                if any(not isinstance(entry.get(k),int) or entry[k]<0 for k in ('uid','gid')):fail('Invalid snapshot ownership')
                seen.add(name);self.path(name)
                member=tar.getmember('files/'+name.lstrip('/'))
                if not member.isfile() or member.size>200*1024**2:fail('Invalid archive member')
                data=tar.extractfile(member).read()
                if sha(data)!=entry['sha256']:fail('Snapshot checksum mismatch')
                contents[name]=(entry,data)
            absent=meta.get('absent',[])
            if len(set(absent))!=len(absent) or seen.intersection(absent):fail('Invalid absent paths')
            for name in absent:self.path(name)
        if self.args.dry_run:print('Would restore %d files and remove %d previously absent files' % (len(contents),len(absent)));return
        self.mutation()
        self.quiescent()
        if self.live:
            # Avoid workers/collectors racing restored code and SQLite WAL files.
            command(['systemctl','stop',*UNITS,*OPTIONAL_UNITS,'mykos-collector.service','mykos-details.service','mykos-extended.service','mykos-command-collector.service'])
        self.snapshot([*contents,*absent],include_data=meta.get('includes_data',False))
        for name,(entry,data) in contents.items():
            if entry['mode'] & 0o7000:fail('Special permission bits are not accepted')
            self.write(name,data,entry['mode'])
            if self.live:os.chown(self.path(name),entry['uid'],entry['gid'])
            if name.endswith('.db'):
                for suffix in ('-wal','-shm'):
                    side=self.path(name+suffix)
                    if side.exists():side.unlink()
        for name in absent:
            path=self.path(name)
            if path.exists():path.unlink()
        if self.live:command(['systemctl','daemon-reload'])
        print('Restored. Services remain stopped; verify and deliberately re-enable them.')

    def uninstall(self):
        manifest=self.manifest()
        removable=[]
        for entry in manifest['files']:
            name=entry['path']
            # Always preserve runtime data, operator settings, backup storage, accounts and images.
            if entry['mutable']:continue
            path=self.path(name)
            if path.exists():
                if sha(path.read_bytes())!=entry['sha256']:fail('Refusing to remove modified file: '+name)
                removable.append(name)
        if self.args.dry_run:print('Would remove:\n'+'\n'.join(removable));return
        self.mutation()
        self.quiescent()
        if self.live:
            command(['systemctl','disable','--now',*UNITS,*OPTIONAL_UNITS])
            command(['systemctl','stop','mykos-collector.service','mykos-details.service','mykos-extended.service','mykos-command-collector.service'])
        self.snapshot([*removable,STATE])
        if self.live and self.path('/usr/local/sbin/mykos-snapshot').exists():
            command([self.args.python,'/usr/local/sbin/mykos-snapshot','unhook'])
        if self.live and self.path('/usr/local/sbin/mykos-vpn-gateway').exists():
            command([self.args.python,'/usr/local/sbin/mykos-vpn-gateway','deactivate'])
        for name in removable:self.path(name).unlink()
        self.path(STATE).unlink()
        if self.live:command(['systemctl','daemon-reload'])
        print('Removed managed code and unit files. Retained settings, identities, venv, customer data, images, users, and snapshots.')

def main():
    parser=argparse.ArgumentParser(description=__doc__)
    sub=parser.add_subparsers(dest='action',required=True)
    for action in ('install','verify','status','backup','restore','uninstall'):
        p=sub.add_parser(action)
        p.add_argument('--root',default='/',help='Alternate staging filesystem; never runs host operations')
        p.add_argument('--dry-run',action='store_true')
        if action=='install':
            p.add_argument('--configure-https',action='store_true',help='Opt in to Apache domain and Certbot HTTPS setup')
            p.add_argument('--domain',help='Domain for --configure-https')
            p.add_argument('--email',help='Certbot account email for --configure-https')
            p.add_argument('--set-public-origin',action='store_true',help='Explicitly replace the saved browser origin during web setup')
            p.add_argument('--public-origin',default='https://mykos.example.com')
            p.add_argument('--python',default='/usr/bin/python3',help='Target Python 3.9+ interpreter')
            p.add_argument('--install-deps',action='store_true')
            p.add_argument('--python-deps',action='store_true',help='Install Python requirements only; preserve existing system packages')
            p.add_argument('--image-build-deps',action='store_true')
            p.add_argument('--enable',action='store_true',help='Enable sockets/timers and restart web service')
            p.add_argument('--native-network',action='store_true',help='Create configured mykos-lan macvlan network and enable host DHCP proxy')
            p.add_argument('--desktop-network',help='Explicit trusted private IPv4 CIDR for desktop ports; checks KVM/storage and adds persistent LAN firewall rules')
        if action=='backup':
            p.add_argument('--output',required=True);p.add_argument('--include-data',action='store_true')
        if action=='restore':
            p.add_argument('--snapshot',required=True);p.add_argument('--include-data',action='store_true')
    args=parser.parse_args()
    if (getattr(args,'domain',None) or getattr(args,'email',None)) and not getattr(args,'configure_https',False):parser.error('--domain and --email require --configure-https')
    if getattr(args,'desktop_network',None):
        import ipaddress
        network=ipaddress.IPv4Network(args.desktop_network,strict=True)
        if not network.is_private or network.prefixlen < 8:parser.error('--desktop-network requires an explicit private IPv4 network')
        args.desktop_network=str(network)
    if getattr(args,'image_build_deps',False) and not args.install_deps:parser.error('--image-build-deps requires --install-deps')
    getattr(Installer(args),args.action)()

if __name__=='__main__':
    try:main()
    except (ValueError,OSError,KeyError,tarfile.TarError,subprocess.SubprocessError) as e:
        print('ERROR:',str(e),file=sys.stderr);sys.exit(1)
