#!/bin/sh
# Download this complete script over HTTPS. Run only on a destination server.
# Wrapped to avoid executing a partially downloaded script.
main() {
    set -eu
    yes=0; dry=0
    for arg in "$@"; do
        case "$arg" in
            --yes) yes=1;;
            --dry-run) dry=1;;
            --legacy) :;;
            --help) printf '%s\n' 'Usage: sh install.sh [--yes] [--dry-run] [--legacy]'; return;;
            *) printf 'Unknown argument: %s\n' "$arg" >&2; return 2;;
        esac
    done
    elevate=''
    if [ "$(id -u)" != 0 ]; then
        if command -v sudo >/dev/null 2>&1; then
            elevate=sudo
        elif command -v su >/dev/null 2>&1; then
            elevate=su
        else
            printf '%s\n' 'Root access required: neither sudo nor su is available. Open a root shell (for example, su - where available), then rerun with sh.' >&2
            return 1
        fi
        if ! ( : </dev/tty ) 2>/dev/null; then
            printf '%s\n' 'An interactive terminal is required for authentication. Open a root shell (for example, su -), then rerun with sh. --yes does not bypass authentication.' >&2
            return 1
        fi
    fi
    . /etc/os-release
    [ "$ID" = debian ] && [ "$(dpkg --print-architecture)" = amd64 ] || { printf '%s\n' 'Debian amd64 required.' >&2; return 1; }
    case "$VERSION_CODENAME" in bookworm|trixie) :;; *) printf '%s\n' 'Debian 12/13 required.' >&2; return 1;; esac
    missing=''
    for package in python3 ca-certificates gnupg; do
        if [ "$(dpkg-query -W -f='${db:Status-Status}' "$package" 2>/dev/null || true)" != installed ]; then missing="$missing $package"; fi
    done
    if [ -n "$missing" ]; then
        printf 'Missing prerequisites:%s\n' "$missing" >&2
        printf '%s\n' 'Install them manually as root, then rerun this script:' >&2
        printf '  apt-get update && apt-get install --no-install-recommends%s\n' "$missing" >&2
        return 1
    fi
    umask 077
    work=$(mktemp -d)
    trap 'rm -rf "$work"' EXIT HUP INT TERM
    cat >"$work/nginx-geoip2-install" <<'IP_SIGNAL_HELPER_EOF'
#!/usr/bin/python3
"""Explicit, fail-closed installer for an official nginx / GeoIP2 pair."""
import argparse
import contextlib
import functools
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request


class Error(RuntimeError):
    pass


def run(*args, cwd=None, env=None):
    result = subprocess.run(args, cwd=cwd, env=env or {**os.environ, 'LC_ALL': 'C'},
                            text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    if result.returncode:
        raise Error(f"Command failed: {' '.join(map(str, args))}\n{result.stderr}{result.stdout}")
    return result.stdout


def compare(a, b):
    if a == b:
        return 0
    return -1 if subprocess.run(['dpkg', '--compare-versions', a, 'lt', b],
                               check=False).returncode == 0 else 1


def release(html, legacy=False):
    title = 'Legacy versions' if legacy else 'Stable version'
    section = re.search(r'<h4>\s*' + title + r'\s*</h4>(.*?)(?=<h4>|$)', html, re.S)
    versions = set(re.findall(r'/download/nginx-(\d+\.\d+\.\d+)\.tar\.gz["\']',
                             section.group(1) if section else ''))
    if not versions:
        raise Error(f'Cannot identify {title} on nginx.org; refusing fallback.')
    return max(versions, key=functools.cmp_to_key(compare))


NGINX = 'https://nginx.org/packages/debian'
OURS = 'https://mmdb.netcube.bg/apt'
NGINX_FPR = '8540A6F18833A80E9C1653A42FD21310B49F6B46'
OUR_FPR = '3B432A371C6C86A8D1B8C3CC4539E8A767F07A71'


def primary_fingerprints(text):
    result, primary = set(), False
    for line in text.splitlines():
        fields = line.split(':')
        if fields[0] in ('pub', 'sub'):
            primary = fields[0] == 'pub'
        elif fields[0] == 'fpr' and primary:
            result.add(fields[9])
            primary = False
    return result


def validate_fingerprints(text, expected):
    if primary_fingerprints(text) != {expected}:
        raise Error('Exported keyring does not contain ONLY the pinned primary fingerprint.')


def verify_origin(policy, version, origin, suite):
    active, sources = False, []
    for line in policy.splitlines():
        header = re.match(r'^\s+(?:\*\*\*\s+)?(\S+)\s+(-?\d+)\s*$', line)
        if header:
            active = header[1] == version
        elif active and re.search(r'\bPackages\s*$', line):
            sources.append(line.split())
    if not sources or any(len(s) != 5 or s[1].rstrip('/') != origin or
                          s[2].split('/')[0] != suite or s[3] != 'amd64'
                          for s in sources):
        raise Error(f'{version}: absent or ambiguous package origin; expected {origin} {suite}.')


def records(text):
    result = []
    for paragraph in re.split(r'\n\s*\n', text.strip()):
        record, previous = {}, None
        for line in paragraph.splitlines():
            if line.startswith(' ') and previous:
                record[previous] += ' ' + line.strip()
            elif ': ' in line:
                previous, value = line.split(': ', 1)
                record[previous] = value
        if record:
            result.append(record)
    return result


def package_record(text, package, version):
    found = records(text)
    if not found or any(r.get('Package') != package or r.get('Version') != version or
                        r.get('Architecture') != 'amd64' or
                        not re.fullmatch('[0-9a-f]{64}', r.get('SHA256', '')) for r in found):
        raise Error(f'Invalid exact package metadata: {package}={version}')
    if len({r['SHA256'] for r in found}) != 1:
        raise Error('Conflicting package hashes for the same version.')
    return found[0]


def module_record(text, version, nginx_version):
    record = package_record(text, 'nginx-module-geoip2', version)
    dependencies = [d.strip() for d in record.get('Depends', '').split(',')]
    nginx_deps = [d for d in dependencies if re.search(r'\bnginx\b', d)]
    if nginx_deps != [f'nginx (= {nginx_version})']:
        raise Error('Module lacks the exact official nginx dependency.')
    return record


MARKER = '# Managed by nginx-geoip2-installer'
SOURCE_NAME = 'nginx-geoip2-installer.list'


def platform(text, arch):
    values = dict(line.split('=', 1) for line in text.splitlines() if '=' in line)
    values = {k: v.strip('"\'') for k, v in values.items()}
    if values.get('ID') != 'debian' or values.get('VERSION_CODENAME') not in ('bookworm', 'trixie') or arch != 'amd64':
        raise Error('Only Debian bookworm/trixie amd64 is supported.')
    return values['VERSION_CODENAME']


def migration(installed):
    return any(p == 'nginx-common' or p.startswith(('nginx-full', 'nginx-light', 'nginx-core', 'nginx-extras', 'libnginx-mod-')) for p in installed) or ('nginx' in installed and not re.fullmatch(r'\d+\.\d+\.\d+-\d+~(?:bookworm|trixie)', installed['nginx']))


def authorize_migration(needed, allowed, yes):
    if not needed:
        return
    if not allowed:
        raise Error('Debian/unknown nginx detected. Back up configuration; --allow-migrate is required (even with --yes). No packages have been removed.')
    if not yes and input('Migrate nginx vendor? Type MIGRATE to confirm: ') != 'MIGRATE':
        raise Error('Migration not confirmed.')


def validate_plan(text, versions, installed):
    if re.search(r'^Remv\s', text, re.M):
        raise Error('APT proposes removals. Prepare migration manually; no implicit removals are allowed.')
    for package, version in versions.items():
        if package in installed and compare(version, installed[package]) < 0:
            raise Error(f'Downgrade refused: {package}')
    for line in text.splitlines():
        if not line.startswith('Inst '):
            continue
        match = re.match(r'Inst (\S+)(?: \[[^]]+\])? \((\S+)', line)
        if not match:
            raise Error('Unrecognized APT simulation output.')
        package, version = match.groups()
        package = package.removesuffix(':amd64')
        if package in versions:
            if version != versions[package]:
                raise Error('APT selected a different nginx/module version.')
        elif package in installed or package.startswith(('nginx', 'libnginx')):
            raise Error(f'Unrelated installed-package change refused: {package}')


def check_sources(root=Path('/')):
    source_dir = root / 'etc/apt/sources.list.d'
    paths = [root / 'etc/apt/sources.list', *source_dir.glob('*.list'), *source_dir.glob('*.sources')]
    for path in paths:
        if not path.exists():
            continue
        text = path.read_text()
        if path.name == SOURCE_NAME:
            if path.is_symlink() or not text.startswith(MARKER + '\n'):
                raise Error(f'Unmanaged source would be overwritten: {path}')
            continue
        active = '\n'.join(line for line in text.splitlines() if not line.lstrip().startswith('#'))
        if re.search(r'(nginx\.org|mmdb\.netcube\.bg)', active, re.I):
            raise Error(f'Conflicting unmanaged source: {path}. Archive/disable it manually, then retry.')


def managed_write(path, data):
    import time
    path.parent.mkdir(parents=True, exist_ok=True)
    if path.is_symlink():
        raise Error(f'Refusing symlink: {path}')
    if path.exists():
        if path.read_bytes() == data:
            return
        shutil.copy2(path, str(path) + f'.backup-{time.time_ns()}')
    with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as f:
        temporary = Path(f.name)
        f.write(data)
    temporary.chmod(0o644)
    temporary.replace(path)


@contextlib.contextmanager
def no_service_actions(root=Path('/')):
    # Debian package service helpers honor policy-rc.d. Never alter nginx config.
    path = root / 'usr/sbin/policy-rc.d'
    backup = path.with_name('policy-rc.d.nginx-geoip2-installer-backup')
    if backup.exists() or backup.is_symlink():
        raise Error(f'Stale service-policy backup: {backup}; restore it manually before retrying.')
    existed = path.exists() or path.is_symlink()
    if existed:
        path.rename(backup)
    try:
        path.write_text('#!/bin/sh\nexit 101\n')
        path.chmod(0o755)
        yield
    finally:
        path.unlink(missing_ok=True)
        if existed:
            backup.rename(path)


STRICT = ['-o', 'APT::Get::AllowUnauthenticated=false',
          '-o', 'Acquire::AllowInsecureRepositories=false',
          '-o', 'Acquire::AllowDowngradeToInsecureRepositories=false',
          '-o', 'Acquire::https::Verify-Peer=true',
          '-o', 'Acquire::https::Verify-Host=true',
          '-o', 'APT::Update::Error-Mode=any']


def fetch(url):
    with urllib.request.urlopen(url, timeout=60) as response:
        if not response.url.startswith('https://'):
            raise Error('HTTPS downgrade refused.')
        data = response.read(8 * 1024 * 1024 + 1)
        if len(data) > 8 * 1024 * 1024:
            raise Error('Unexpectedly large key/release document.')
        return data


def verified_key(work, name, url, fingerprint):
    home = work / (name + '-gnupg')
    home.mkdir(mode=0o700)
    raw = work / (name + '.download')
    raw.write_bytes(fetch(url))
    gpg = ['gpg', '--batch', '--no-options', '--homedir', str(home)]
    shown = run(*gpg, '--with-colons', '--show-keys', str(raw))
    if fingerprint not in primary_fingerprints(shown):
        raise Error(f'{name}: pinned primary key missing from downloaded bundle.')
    run(*gpg, '--import', str(raw))
    dest = work / (name + '.gpg')
    run(*gpg, '--output', str(dest), '--export', fingerprint)
    validate_fingerprints(run(*gpg, '--with-colons', '--show-keys', str(dest)), fingerprint)
    dest.chmod(0o644)
    return dest


def source_text(suite, nginx_key, our_key):
    return (MARKER + '\n' +
            f'deb [arch=amd64 signed-by={nginx_key}] {NGINX} {suite} nginx\n' +
            f'deb [arch=amd64 signed-by={our_key}] {OURS} {suite} main\n')


def select_module(text, upstream, full, suite):
    revision = re.fullmatch(re.escape(upstream) + r'-([0-9]+)~' + suite, full)
    if not revision: raise Error('Unsupported official Debian revision.')
    allowed = {f'{upstream}+nginx{revision[1]}+gitcc3f1cb1534e-1~{suite}',
               f'{upstream}+gitcc3f1cb1534e-1~{suite}'}
    valid=[]
    for version in allowed:
        matching=[r for r in records(text) if r.get('Version')==version]
        paragraphs='\n\n'.join('\n'.join(f'{k}: {v}' for k,v in r.items()) for r in matching)
        try: module_record(paragraphs,version,full)
        except Error: continue
        valid.append(version)
    if not valid: raise Error('No verified module matches the exact official nginx Debian revision.')
    return max(valid,key=functools.cmp_to_key(compare))


def prepare_pair(work, suite, upstream):
    keys = {
        'nginx-geoip2-nginx.gpg': verified_key(work, 'nginx', 'https://nginx.org/keys/nginx_signing.key', NGINX_FPR),
        'nginx-geoip2-ip-signal.gpg': verified_key(work, 'ip-signal', 'https://mmdb.netcube.bg/apt/ip-signal-archive-keyring.gpg', OUR_FPR),
    }
    sources = work / 'pair.list'
    sources.write_text(source_text(suite, *keys.values()))
    for name in ('lists/partial', 'archives/partial', 'empty'):
        (work / name).mkdir(parents=True)
    # APT_CONFIG is read before system apt.conf.d: disable host hooks for this
    # isolated, temporary metadata/download operation, including in dry-run.
    config = work / 'apt.conf'
    config.write_text('Dir::Etc::parts "-";\nDir::Etc::main "-";\n')
    env = {**os.environ, 'LC_ALL': 'C', 'APT_CONFIG': str(config)}
    opts = STRICT + ['-o', f'Dir::Etc::sourcelist={sources}',
                    '-o', f'Dir::Etc::sourceparts={work / "empty"}',
                    '-o', f'Dir::State::lists={work / "lists"}',
                    '-o', f'Dir::Cache::archives={work / "archives"}',
                    '-o', 'Dir::Cache::pkgcache=', '-o', 'Dir::Cache::srcpkgcache=',
                    '-o', 'APT::Architecture=amd64', '-o', 'APT::Architectures::=amd64']
    run('apt-get', *opts, 'update', env=env)
    # Select full Debian revision using dpkg ordering, never lexical sorting.
    nginx_policy = run('apt-cache', *opts, 'policy', 'nginx', env=env)
    candidates = re.findall(r'^\s+(?:\*\*\*\s+)?(' + re.escape(upstream) + r'-[^\s]+~' + suite + r')\s+-?\d+\s*$', nginx_policy, re.M)
    if not candidates:
        raise Error(f'Official nginx {upstream} for {suite} is not available.')
    nginx_version = max(candidates, key=functools.cmp_to_key(compare))
    module_text = run('apt-cache', *opts, 'show', 'nginx-module-geoip2', env=env)
    versions = {'nginx': nginx_version, 'nginx-module-geoip2': select_module(module_text, upstream, nginx_version, suite)}
    metadata = {}
    for package, version in versions.items():
        policy = nginx_policy if package == 'nginx' else run('apt-cache', *opts, 'policy', package, env=env)
        verify_origin(policy, version, NGINX if package == 'nginx' else OURS, suite)
        text = run('apt-cache', *opts, 'show', '--no-all-versions', f'{package}={version}', env=env)
        metadata[package] = package_record(text, package, version) if package == 'nginx' else module_record(text, version, nginx_version)
    # Do not download/install nginx unless BOTH records and dependency verified.
    downloads = work / 'downloads'
    downloads.mkdir()
    run('apt-get', *opts, 'download', *[f'{p}={v}' for p, v in versions.items()], cwd=downloads, env=env)
    files = list(downloads.glob('*.deb'))
    if len(files) != 2:
        raise Error('Expected exactly two downloaded packages.')
    seen = set()
    for path in files:
        package = run('dpkg-deb', '-f', str(path), 'Package').strip()
        version = run('dpkg-deb', '-f', str(path), 'Version').strip()
        arch = run('dpkg-deb', '-f', str(path), 'Architecture').strip()
        if package not in versions or package in seen or version != versions[package] or arch != 'amd64' or hashlib.sha256(path.read_bytes()).hexdigest() != metadata[package]['SHA256']:
            raise Error('Downloaded package does not match authenticated APT metadata.')
        seen.add(package)
    return versions, files, keys


def installed_packages():
    text = run('dpkg-query', '-W', '-f=${Package}\t${Version}\t${db:Status-Status}\n')
    return {p: v for line in text.splitlines() if len(parts := line.split('\t')) == 3
            for p, v, status in [parts] if status == 'installed'}


def configure_repositories(suite, keys, root=Path('/')):
    check_sources(root)
    target = root / 'usr/share/keyrings'
    for name, source in keys.items():
        managed_write(target / name, source.read_bytes())
    managed_write(root / 'etc/apt/sources.list.d' / SOURCE_NAME,
                  source_text(suite, '/usr/share/keyrings/nginx-geoip2-nginx.gpg',
                              '/usr/share/keyrings/nginx-geoip2-ip-signal.gpg').encode())


def verify_system_pair(versions, files, suite):
    # APT can prefer a repository copy over a local .deb of the same version.
    # Verify global origins and hashes again after persistent apt update.
    for path in files:
        package = run('dpkg-deb', '-f', str(path), 'Package').strip()
        version = versions[package]
        verify_origin(run('apt-cache', 'policy', package), version,
                      NGINX if package == 'nginx' else OURS, suite)
        record = package_record(run('apt-cache', 'show', '--no-all-versions',
                                    f'{package}={version}'), package, version)
        if record['SHA256'] != hashlib.sha256(path.read_bytes()).hexdigest():
            raise Error('Repository package changed after verification; refusing install.')


def execute(args, root=Path('/')):
    suite = platform((root / 'etc/os-release').read_text(), run('dpkg', '--print-architecture').strip())
    installed = installed_packages()
    authorize_migration(migration(installed), args.allow_migrate, args.yes)
    check_sources(root)
    upstream = release(fetch('https://nginx.org/en/download.html').decode(), args.legacy)
    with tempfile.TemporaryDirectory(prefix='nginx-geoip2-') as temporary:
        work = Path(temporary)
        work.chmod(0o755)
        versions, files, keys = prepare_pair(work, suite, upstream)
        # Transaction-local freeze of every already installed unrelated package.
        # No persistent/global APT pin and no inadvertent dependency upgrades.
        (work / 'empty-preferences').mkdir()
        preferences = work / 'freeze.pref'
        preferences.write_text(''.join(f'Package: {p}\nPin: version {v}\nPin-Priority: 1001\n\n'
                                       for p, v in installed.items() if p not in versions))
        opts = STRICT + ['-o', f'Dir::Etc::preferences={preferences}',
                         '-o', f'Dir::Etc::preferencesparts={work / "empty-preferences"}',
                         '-o', 'Dpkg::Options::=--force-confold',
                         '--no-remove', '--no-install-recommends']
        command = ['apt-get', *opts, 'install', *map(str, files)]
        plan = run(command[0], '--simulate', *command[1:])
        validate_plan(plan, versions, installed)
        print(json.dumps({'suite': suite, 'channel': 'legacy' if args.legacy else 'stable', 'packages': versions}, indent=2))
        print(plan)
        if args.dry_run:
            print('DRY RUN: verified pair; no persistent repository/package/service changes. Temporary downloads are removed.')
            return
        if not args.yes and input('Configure signed repositories and install this exact pair? Type INSTALL: ') != 'INSTALL':
            raise Error('Installation not confirmed.')
        configure_repositories(suite, keys, root)
        run('apt-get', *STRICT, 'update')
        verify_system_pair(versions, files, suite)
        # Refresh plan after index update and reject concurrent dpkg changes.
        if installed_packages() != installed:
            raise Error('Installed packages changed during planning; retry.')
        validate_plan(run(command[0], '--simulate', *command[1:]), versions, installed)
        with no_service_actions(root):
            print(run(command[0], '--yes', *command[1:], env={**os.environ, 'LC_ALL': 'C', 'DEBIAN_FRONTEND': 'noninteractive', 'NEEDRESTART_MODE': 'l'}))
        final = installed_packages()
        if any(final.get(p) != v for p, v in versions.items()):
            raise Error('Post-install exact-version verification failed.')
        print(run('/usr/sbin/nginx', '-t'))
        print('Exact pair installed. No service start/reload/restart requested; existing workers may still run the old binary. Review module load_module configuration manually, run nginx -t, then schedule your own restart. No module-load configuration was changed.')


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--legacy', action='store_true', help='Latest legacy release instead of latest stable on nginx.org')
    parser.add_argument('--allow-migrate', action='store_true', help='Acknowledge Debian/unknown nginx vendor migration; never permits removals/downgrades')
    parser.add_argument('--yes', action='store_true', help='Confirm prompts (migration still requires --allow-migrate)')
    parser.add_argument('--dry-run', action='store_true', help='Verify signed metadata, download exact pair and simulate; no persistent changes')
    args = parser.parse_args(argv)
    if os.geteuid() != 0 and not args.dry_run:
        raise Error('Run as root (or use --dry-run/--help).')
    execute(args)


if __name__ == '__main__':
    try:
        main()
    except (Error, OSError, ValueError, EOFError, KeyboardInterrupt) as exc:
        print(f'nginx-geoip2-install: {exc}', file=sys.stderr)
        sys.exit(1)

IP_SIGNAL_HELPER_EOF
    cat >"$work/easy-install.py" <<'IP_SIGNAL_EASY_EOF'
#!/usr/bin/env python3
"""Interactive setup using the existing verified exact-pair resolver."""
import argparse
import importlib.machinery
import importlib.util
import os
from pathlib import Path
import re
import shutil
import sys
import tempfile
import time

loader = importlib.machinery.SourceFileLoader('helper', str(Path(__file__).with_name('nginx-geoip2-install')))
spec = importlib.util.spec_from_loader(loader.name, loader)
helper = importlib.util.module_from_spec(spec)
loader.exec_module(helper)


def validate_plan(plan, versions, installed, migrating):
    removals = re.findall(r'^Remv (\S+)', plan, re.M)
    if removals and (not migrating or set(removals) != {'nginx-common'}):
        raise helper.Error('Unsafe package removals refused: ' + ', '.join(removals))
    helper.validate_plan('\n'.join(line for line in plan.splitlines() if not line.startswith('Remv ')), versions, installed)


def conflicting_sources(root=Path('/')):
    """Only standalone legacy source files may be backed up and disabled."""
    directory = root/'etc/apt/sources.list.d'
    host = re.escape(helper.OURS.split('/')[2])
    matches = []
    for path in [root/'etc/apt/sources.list', *directory.glob('*.list'), *directory.glob('*.sources')]:
        if not path.exists() or path.name == helper.SOURCE_NAME:
            continue
        active = '\n'.join(s for s in path.read_text().splitlines() if s.strip() and not s.lstrip().startswith('#'))
        if not re.search(r'nginx\.org|' + host, active):
            continue
        urls = re.findall(r'https?://([^/\s]+)', active)
        if path.name == 'sources.list' or not urls or any(u not in ('nginx.org', helper.OURS.split('/')[2]) for u in urls):
            raise helper.Error(f'Mixed repository file requires manual review: {path}')
        matches.append(path)
    return matches


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--yes', action='store_true', help='Explicitly approve the displayed safe install/migration plan')
    parser.add_argument('--dry-run', action='store_true')
    parser.add_argument('--legacy', action='store_true')
    args = parser.parse_args()
    if os.geteuid() != 0:
        raise helper.Error('Run in a root shell (for example, su -), or use install.sh for automatic authentication.')
    suite = helper.platform(Path('/etc/os-release').read_text(), helper.run('dpkg', '--print-architecture').strip())
    installed = helper.installed_packages()
    migrating = helper.migration(installed)
    extras = [p for p in installed if p.startswith(('libnginx-mod-', 'nginx-full', 'nginx-extras', 'nginx-light', 'nginx-core'))]
    if extras:
        raise helper.Error('Custom/extra Debian nginx modules need manual migration: ' + ', '.join(extras))
    conflicts = conflicting_sources()
    upstream = helper.release(helper.fetch('https://nginx.org/en/download.html').decode(), args.legacy)
    with tempfile.TemporaryDirectory(prefix='nginx-easy-') as tmp:
        work = Path(tmp)
        work.chmod(0o755)
        versions, files, keys = helper.prepare_pair(work, suite, upstream)
        prefs = work/'freeze.pref'
        prefs.write_text(''.join(f'Package: {p}\nPin: version {v}\nPin-Priority: 1001\n\n' for p,v in installed.items() if p not in versions and p != 'nginx-common'))
        opts = helper.STRICT + ['-o', f'Dir::Etc::preferences={prefs}', '-o', f'Dir::Etc::preferencesparts={work / "empty"}', '-o', 'Dpkg::Options::=--force-confold', '--no-install-recommends']
        # Load dependency indexes privately, including when the client has no
        # cached Debian lists. Preview/cancellation must not change system lists.
        parts = work/'dependency-sources'
        parts.mkdir()
        for source in Path('/etc/apt/sources.list.d').iterdir():
            if source.suffix in ('.list', '.sources') and source not in conflicts and source.name != helper.SOURCE_NAME:
                shutil.copyfile(source, parts/source.name)
        main_sources = Path('/etc/apt/sources.list')
        sources = work/'all-sources.list'
        sources.write_text((main_sources.read_text() if main_sources.exists() else '') + '\n' + helper.source_text(suite, *keys.values()))
        lists = work/'dependency-lists'
        (lists/'partial').mkdir(parents=True)
        index_opts = ['-o', f'Dir::Etc::sourcelist={sources}', '-o', f'Dir::Etc::sourceparts={parts}', '-o', f'Dir::State::lists={lists}']
        helper.run('apt-get', *helper.STRICT, *index_opts, 'update', env={**os.environ, 'LC_ALL':'C', 'APT_CONFIG':str(work/'apt.conf')})
        opts += index_opts
        command = ['apt-get', *opts, 'install', *map(str, files)]
        plan = helper.run('apt-get', '--simulate', *command[1:])
        validate_plan(plan, versions, installed, migrating)
        print(f"nginx: {installed.get('nginx', 'not installed')} -> {versions['nginx']}", flush=True)
        print(f"Module: {versions['nginx-module-geoip2']}\nRepositories: {helper.OURS} and {helper.NGINX}", flush=True)
        print(plan, flush=True)
        if migrating:
            print('Vendor change: nginx-common may be removed. /etc/nginx will be backed up and existing nginx.conf preserved. No automatic service restart.', flush=True)
        if conflicts:
            print('Existing source files to back up and replace: ' + ', '.join(map(str, conflicts)), flush=True)
        if args.dry_run:
            print('DRY RUN: no persistent changes.')
            return
        if not args.yes and input('Add repositories and install this nginx/module pair? [y/N] ').strip().lower() not in ('y', 'yes'):
            print('Cancelled. No changes made.')
            return
        if helper.installed_packages() != installed:
            raise helper.Error('Package state changed; retry.')
        backup = Path('/var/backups')/f'nginx-geoip2-{time.time_ns()}'
        backup.mkdir(parents=True, mode=0o700)
        nginx = Path('/etc/nginx')
        if nginx.exists():
            shutil.copytree(nginx, backup/'nginx', symlinks=True)
        for path in conflicts:
            shutil.copy2(path, backup/path.name)
            # Keep backups outside APT's source directory; only remove after copy succeeds.
            path.unlink()
        print('Backup: ' + str(backup), flush=True)
        helper.configure_repositories(suite, keys)
        helper.run('apt-get', *helper.STRICT, 'update')
        helper.verify_system_pair(versions, files, suite)
        validate_plan(helper.run('apt-get', '--simulate', *command[1:]), versions, installed, migrating)
        if helper.installed_packages() != installed:
            raise helper.Error('Package state changed during repository setup; retry.')
        saved = backup/'nginx/nginx.conf'
        try:
            with helper.no_service_actions():
                print(helper.run('apt-get', '--yes', *command[1:], env={**os.environ, 'LC_ALL':'C', 'DEBIAN_FRONTEND':'noninteractive', 'NEEDRESTART_MODE':'l'}))
        finally:
            # Even a failed dpkg run must not silently discard sites-enabled.
            if saved.is_file():
                nginx.mkdir(parents=True, exist_ok=True)
                shutil.copy2(saved, nginx/'nginx.conf')
        final = helper.installed_packages()
        if any(final.get(p) != v for p,v in versions.items()):
            raise helper.Error('Installed pair does not match verified versions.')
        print(helper.run('/usr/sbin/nginx', '-t'))
        print('Installed and verified. Existing nginx.conf preserved. No service restart or client updater. Enable GeoIP2 and schedule a restart when ready. Updates: apt update && apt upgrade')


if __name__ == '__main__':
    try:
        main()
    except (helper.Error, OSError, ValueError, EOFError, KeyboardInterrupt) as error:
        print('Setup failed: ' + str(error), file=sys.stderr)
        sys.exit(1)

IP_SIGNAL_EASY_EOF
    if [ "$elevate" = sudo ]; then
        sudo -- python3 "$work/easy-install.py" "$@" </dev/tty
    elif [ "$elevate" = su ]; then
        # Keep paths and flags out of shell source, including untrusted TMPDIR.
        su -s /bin/sh root -c 'exec python3 "$@"' -- sh "$work/easy-install.py" "$@" </dev/tty
    elif [ "$yes" = 1 ] || [ "$dry" = 1 ]; then
        python3 "$work/easy-install.py" "$@"
    else
        python3 "$work/easy-install.py" "$@" </dev/tty
    fi
}
main "$@"
