From 4f1765a625e7b1c9648957fa63e810243841c8b0 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Sat, 26 Sep 2026 19:58:57 +0300 Subject: [PATCH] Add interactive on-server shell installer --- README.md | 13 +++ docs/en/README.md | 50 +++++++++++ docs/ru/README.md | 53 +++++++++++ install.sh | 57 ++++++++++++ src/megaproxy_server/bootstrap.py | 12 ++- src/megaproxy_server/cli.py | 4 + src/megaproxy_server/inventory.py | 9 +- src/megaproxy_server/local_setup.py | 52 +++++++++++ src/megaproxy_server/models.py | 1 + src/megaproxy_server/wizard.py | 18 ++-- tests/integration/lxd.sh | 41 +++++++++ tests/test_local_setup.py | 133 ++++++++++++++++++++++++++++ 12 files changed, 431 insertions(+), 12 deletions(-) create mode 100755 install.sh create mode 100644 src/megaproxy_server/local_setup.py create mode 100644 tests/test_local_setup.py diff --git a/README.md b/README.md index fb68cd4..465df09 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,19 @@ Documentation: **[English](docs/en/README.md)** · **[Русский](docs/ru/RE ## Quick start +To configure the Debian/Ubuntu server you are logged into, run the interactive installer: + +```shell +curl -fsSL https://raw.githubusercontent.com/andre487/MegaProxyServer/main/install.sh | sudo bash +``` + +It installs dependencies and starts the setup wizard. Keep your SSH session open and save the +administrative key on your computer before confirming provisioning. See the +[English](docs/en/README.md#install-on-the-server) or +[Russian](docs/ru/README.md#установка-на-самом-сервере) walkthrough. + +### Manage servers from another machine + Requirements: macOS or Linux, Python 3.12+, `uv`, OpenSSH, and a Debian or Ubuntu server reachable through a sudo-capable SSH account. diff --git a/docs/en/README.md b/docs/en/README.md index 8652228..671d008 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -22,6 +22,56 @@ Public-IP ACME certificates require Certbot 5.4+ and Let's Encrypt's short-lived ## First deployment +### Install on the server + +Connect to Debian/Ubuntu with an interactive terminal (`ssh -t`) or use the server console. +You need root or sudo, curl and internet access. Install curl if necessary: + +```shell +sudo apt-get update && sudo apt-get install -y curl ca-certificates +``` + +Start the installer (omit sudo when logged in as root): + +```shell +curl -fsSL https://raw.githubusercontent.com/andre487/MegaProxyServer/main/install.sh | sudo bash +``` + +To inspect it first, download with `curl -fsSL URL -o install.sh`, review the file and run +`sudo bash install.sh`, using the URL above. + +1. Confirm dependency installation. The checkout lives in `/opt/megaproxy-server`; Python and + project dependencies are installed automatically. +2. Enter a host name, its **public** IP or DNS name, administrator and current SSH port. Choose or + generate an administrative key, then select HTTPS and/or SSH, certificates and proxy users. + For domain ACME, point DNS at this server beforehand. Open the required provider firewall ports. +3. Set and save an Ansible Vault password; subsequent invocations will need it. +4. Copy the displayed administrative private key to your computer before applying. With existing + root access, use another local terminal: `scp root@SERVER:/PATH/TO/KEY ./megaproxy-admin`, then + `chmod 600 ./megaproxy-admin`. With a sudo account, transfer securely using sudo to read the + file; do not make it publicly readable on the server. +5. Confirm that the key is saved. Setup creates the administrator locally, checks a fresh SSH login + and sudo through loopback, disables root/password administrative login, configures services and + the host firewall, verifies provisioning and exports client configurations. +6. Keep the original session open and test login **from your computer**: + `ssh -i ./megaproxy-admin -p PORT ADMIN@SERVER`. Securely retrieve + `/opt/megaproxy-server/.generated/configs/MegaProxy.json` using sudo access. + +If you cancelled before applying or setup failed, rerun the installer or resume with: + +```shell +sudo /opt/megaproxy-server/mega-proxy setup-local +``` + +Existing inventory is reused; the installer does not automatically update an existing checkout. +For maintenance, run `sudo /opt/megaproxy-server/mega-proxy` with the desired command. +Inventory, keys and exports stay on the server; keep private backups. In this mode `local: true` +routes administrative SSH through loopback while client exports retain the public address. +Only use this inventory on that server. To move control elsewhere, remove `local: true` and update +key paths. Local setup handles one server; multi-host management is described below. + +### Manage from another machine + ```shell ./mega-proxy inventory ./mega-proxy bootstrap diff --git a/docs/ru/README.md b/docs/ru/README.md index f04ed73..6fe756e 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -22,6 +22,59 @@ ACME-сертификаты на публичный IP требуют Certbot 5. ## Первое развёртывание +### Установка на самом сервере + +Подключитесь к Debian/Ubuntu по SSH с терминалом (`ssh -t`) либо откройте консоль сервера. +Нужны root или `sudo`, установленный `curl` и доступ в интернет. Если `curl` отсутствует: + +```shell +sudo apt-get update && sudo apt-get install -y curl ca-certificates +``` + +Запустите мастер (для root уберите `sudo`): + +```shell +curl -fsSL https://raw.githubusercontent.com/andre487/MegaProxyServer/main/install.sh | sudo bash +``` + +Если хотите сначала прочитать скрипт, скачайте его через `curl -fsSL URL -o install.sh`, +просмотрите и выполните `sudo bash install.sh`, подставив URL из команды выше. + +1. Подтвердите установку зависимостей. Репозиторий сохраняется в `/opt/megaproxy-server`; + Python и зависимости проекта устанавливаются автоматически. +2. Укажите имя сервера, его **публичный** IP или DNS, административный логин и текущий SSH-порт. + Выберите или создайте административный ключ, затем HTTPS и/или SSH, сертификат и пользователей. + Для доменного ACME заранее направьте DNS на этот сервер. Откройте нужные порты у провайдера. +3. Задайте пароль Ansible Vault и сохраните его: он понадобится для следующих запусков. +4. Мастер покажет путь к административному приватному ключу. Скопируйте его на свой компьютер + через ещё открытый административный доступ. Для root-доступа можно использовать во втором + локальном терминале `scp root@SERVER:/ПУТЬ/К/КЛЮЧУ ./megaproxy-admin`, затем + `chmod 600 ./megaproxy-admin`. При входе через sudo-пользователя используйте защищённую передачу + с чтением файла через `sudo`; не делайте ключ общедоступным на сервере. +5. Только после сохранения ключа подтвердите применение. Мастер создаёт администратора локально, + проверяет независимый SSH-вход и sudo через loopback, отключает root/парольный административный + вход, настраивает сервисы и firewall, проверяет их и создаёт клиентские конфигурации. +6. Не закрывая исходную сессию, проверьте вход **со своего компьютера**: + `ssh -i ./megaproxy-admin -p PORT ADMIN@SERVER`. Заберите клиентский файл + `/opt/megaproxy-server/.generated/configs/MegaProxy.json` через защищённый канал с sudo. + +Если остановились перед применением или установка завершилась ошибкой, повторите команду +установщика либо запустите: + +```shell +sudo /opt/megaproxy-server/mega-proxy setup-local +``` + +Существующий inventory используется повторно; установщик не обновляет checkout автоматически. +Для дальнейшей работы выполняйте `sudo /opt/megaproxy-server/mega-proxy` с нужной командой. +Inventory, ключи и экспорты остаются на сервере; сохраняйте их резервные копии приватно. +В этом режиме `local: true` направляет управляющий SSH на loopback, а публичный адрес используется +в клиентских конфигурациях. Такой inventory предназначен для запуска только на этом сервере; +при переносе управления на другую машину уберите `local: true` и исправьте пути к ключам. +Мастер локальной установки настраивает один сервер; управление несколькими хостами описано ниже. + +### Управление с другой машины + ```shell ./mega-proxy inventory ./mega-proxy bootstrap diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..42b5f12 --- /dev/null +++ b/install.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Keep execution after the complete function definition for curl | bash. +main() { + set -euo pipefail + umask 077 + if [[ ${1:-} == --help ]]; then + echo 'Usage: curl -fsSL https://raw.githubusercontent.com/andre487/MegaProxyServer/main/install.sh | sudo bash' + echo 'Interactive Debian/Ubuntu installation into /opt/megaproxy-server. Requires a terminal.' + echo 'Set MEGAPROXY_REF to select a branch or tag for a fresh checkout (default: main).' + return + fi + if [[ $# -ne 0 ]]; then + echo 'Unexpected arguments; use --help.' >&2 + return 2 + fi + if [[ $EUID -ne 0 ]]; then + echo 'Run as root: curl -fsSL URL | sudo bash' >&2 + return 1 + fi + if [[ ! -r /etc/os-release ]]; then + echo 'Only Debian and Ubuntu are supported.' >&2 + return 1 + fi + . /etc/os-release + case "$ID" in + debian|ubuntu) ;; + *) echo 'Only Debian and Ubuntu are supported.' >&2; return 1 ;; + esac + # stdin contains the script when piped; all prompts must read the terminal. + if ! exec &2 + return 1 + fi + local install_dir=/opt/megaproxy-server answer + echo "Install dependencies and MegaProxyServer into $install_dir, then start the setup wizard." + read -r -p 'Continue? [y/N] ' answer + [[ "$answer" == [yY] ]] || return 0 + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl git python3 python3-venv openssh-server sudo + systemctl enable --now ssh + if [[ ! -e "$install_dir" ]]; then + git clone --depth 1 --branch "${MEGAPROXY_REF:-main}" https://github.com/andre487/MegaProxyServer.git "$install_dir" + elif [[ ! -f "$install_dir/src/megaproxy_server/local_setup.py" ]]; then + echo "$install_dir already exists without local setup support; move it aside or update it manually." >&2 + return 1 + fi + cd "$install_dir" + if ! command -v uv >/dev/null 2>&1; then + python3 -m venv .bootstrap + .bootstrap/bin/pip install 'uv==0.12.5' + ln -s "$install_dir/.bootstrap/bin/uv" /usr/local/bin/uv + fi + uv sync --frozen --python 3.12 + ./mega-proxy setup-local +} + +main "$@" diff --git a/src/megaproxy_server/bootstrap.py b/src/megaproxy_server/bootstrap.py index 9a21e14..594efcd 100644 --- a/src/megaproxy_server/bootstrap.py +++ b/src/megaproxy_server/bootstrap.py @@ -10,7 +10,7 @@ from ruamel.yaml import YAML -from .inventory import ROOT, ansible_inventory, save +from .inventory import LOCAL_SSH_ARGS, ROOT, ansible_inventory, save from .models import Host, Inventory @@ -20,10 +20,11 @@ def check_admin(host: Host, *, report_error: bool = False) -> bool: '-o', 'PreferredAuthentications=publickey', '-o', 'ControlMaster=no', '-o', 'ControlPath=none', '-o', 'ConnectTimeout=10', '-p', str(host.admin.port), '-i', str(Path(host.admin.private_key_file).expanduser()), + *(shlex.split(LOCAL_SSH_ARGS) if host.local else []), *shlex.split(os.environ.get('ANSIBLE_SSH_ARGS', '')), *shlex.split(os.environ.get('ANSIBLE_SSH_COMMON_ARGS', '')), *shlex.split(os.environ.get('ANSIBLE_SSH_EXTRA_ARGS', '')), - '-l', host.admin.user, '--', host.address, 'sudo -n true', + '-l', host.admin.user, '--', '127.0.0.1' if host.local else host.address, 'sudo -n true', ] try: result = sp.run(command, capture_output=True, text=True, timeout=30) @@ -52,6 +53,11 @@ def run_phase(inventory: Inventory, name: str, phase: str, password: str | None variables['ansible_become_password'] = password variables['ansible_ssh_common_args'] = '-o PubkeyAuthentication=no -o PreferredAuthentications=password,keyboard-interactive' variables['megaproxy_bootstrap_phase'] = phase + if host.local and phase == 'account': + if os.geteuid() != 0: + raise ValueError('Local bootstrap must run as root') + variables['ansible_connection'] = 'local' + variables['ansible_python_interpreter'] = sys.executable # Bootstrap passwords exist only in a private temporary directory for this subprocess. with tempfile.TemporaryDirectory(prefix='megaproxy-bootstrap-') as directory: path = Path(directory) / 'inventory.yml' @@ -73,7 +79,7 @@ def bootstrap(path: Path, inventory: Inventory, limit: str | None = None) -> int print(f'Bootstrapping administrative access: {name}', flush=True) if not check_admin(host): password = None - if host.admin.bootstrap_auth == 'password': + if host.admin.bootstrap_auth == 'password' and not host.local: password = getpass.getpass(f'Initial SSH password for {host.admin.bootstrap_user}@{host.address}: ') if not password: raise ValueError('Initial SSH password must not be empty') diff --git a/src/megaproxy_server/cli.py b/src/megaproxy_server/cli.py index 5853c75..6e1da8a 100644 --- a/src/megaproxy_server/cli.py +++ b/src/megaproxy_server/cli.py @@ -32,6 +32,7 @@ def parser() -> argparse.ArgumentParser: sub = result.add_subparsers(dest="command") commands = [] commands.append(sub.add_parser("inventory", help="Create a new inventory")) + commands.append(sub.add_parser("setup-local", help="Interactively provision this server (root)")) commands.append(sub.add_parser("add-host", help="Add hosts to the existing inventory")) initial = sub.add_parser("bootstrap", help="Create and verify permanent administrative access") initial.add_argument("--limit", action=ExtendLimit, help="Exact host names, comma-separated or repeated") @@ -134,6 +135,9 @@ def main() -> None: try: if args.command == "check": raise SystemExit(run_checks()) + if args.command == "setup-local": + from .local_setup import setup_local + raise SystemExit(setup_local(discover(args.inventory) or DEFAULT_INVENTORY)) path = choose_inventory(args.inventory, args.command == "inventory") if args.command == "inventory": print(f"Created {path}") diff --git a/src/megaproxy_server/inventory.py b/src/megaproxy_server/inventory.py index e46bc49..7513e3a 100644 --- a/src/megaproxy_server/inventory.py +++ b/src/megaproxy_server/inventory.py @@ -5,6 +5,7 @@ import ipaddress import os import re +import shlex from pathlib import Path from typing import Any @@ -15,6 +16,10 @@ from .models import Inventory ROOT = Path(__file__).resolve().parents[2] +LOCAL_SSH_ARGS = ( + "-o StrictHostKeyChecking=yes -o UserKnownHostsFile=" + + shlex.quote(str(ROOT / ".secrets/local-known-hosts")) +) DEFAULT_INVENTORY = Path.cwd() / "inventory.yml" POINTER_FILE = Path.cwd() / "inventory-path" _vault_secret: VaultSecret | None = None @@ -215,13 +220,15 @@ def ansible_inventory(inventory: Inventory) -> dict[str, Any]: } ) variables: dict[str, Any] = { - "ansible_host": host.address, + "ansible_host": "127.0.0.1" if host.local else host.address, "ansible_user": host.admin.bootstrap_user or host.admin.user, "ansible_port": host.admin.port, "megaproxy_admin": host.admin.model_dump(mode="json", exclude_none=True), "megaproxy_settings": inventory.settings.model_dump(mode="json"), "megaproxy_services": services, } + if host.local: + variables["ansible_ssh_common_args"] = LOCAL_SSH_ARGS if https and https.enabled: variables["megaproxy_https_public_routes"] = variables_public_routes variables["ansible_ssh_private_key_file"] = ( diff --git a/src/megaproxy_server/local_setup.py b/src/megaproxy_server/local_setup.py new file mode 100644 index 0000000..3d678bf --- /dev/null +++ b/src/megaproxy_server/local_setup.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import questionary + +from .bootstrap import bootstrap +from .generate import generate +from .inventory import ROOT, load +from .wizard import create_inventory + + +def setup_local(path: Path) -> int: + from .cli import run_ansible + + if os.geteuid() != 0: + raise ValueError('Local setup must run as root; use sudo') + os.umask(0o077) + inventory = load(path) if path.exists() else create_inventory(path, local=True) + if len(inventory.hosts) != 1 or not next(iter(inventory.hosts.values())).local: + raise ValueError('Local setup requires an inventory with exactly one local host') + host = next(iter(inventory.hosts.values())) + # Trust the host's own keys, without a network scan or disabling SSH verification. + keys = list(Path('/etc/ssh').glob('ssh_host_*_key.pub')) + if not keys: + raise ValueError('No SSH host keys found in /etc/ssh; start openssh-server first') + known_hosts = ROOT / '.secrets/local-known-hosts' + known_hosts.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + known_hosts.write_text(''.join( + f'127.0.0.1,[127.0.0.1]:{host.admin.port} {key.read_text().strip()}\n' + for key in keys + )) + known_hosts.chmod(0o600) + print(f'Inventory: {path}\nAdministrative private key: {host.admin.private_key_file}') + print('Copy this private key securely to your computer before continuing. Keep this session open.') + print(f'Future SSH login: {host.admin.user}@{host.address}, port {host.admin.port}') + print('Setup disables root/password administrative SSH login and configures the host firewall.') + if not questionary.confirm('Key saved outside this server; apply configuration now?', default=False).ask(): + print('Configuration saved. Run setup-local again to continue.') + return 0 + code = bootstrap(path, inventory) + if code: + return code + for playbook in ('site.yml', 'verify.yml'): + code = run_ansible(path, playbook) + if code: + return code + output = ROOT / '.generated/configs' + generate(path, output) + print(f'Setup verified. Client configurations (contain secrets): {output}') + return 0 diff --git a/src/megaproxy_server/models.py b/src/megaproxy_server/models.py index 7d0d0b6..039d071 100644 --- a/src/megaproxy_server/models.py +++ b/src/megaproxy_server/models.py @@ -112,6 +112,7 @@ def any_enabled(self) -> Services: class Host(BaseModel): address: str + local: bool = False admin: AdminAccess = Field(default_factory=AdminAccess) services: Services diff --git a/src/megaproxy_server/wizard.py b/src/megaproxy_server/wizard.py index ba526eb..0f5e438 100644 --- a/src/megaproxy_server/wizard.py +++ b/src/megaproxy_server/wizard.py @@ -75,11 +75,11 @@ def ask_users(kind: str, host_name: str, forbidden_names: set[str] | None = None return users -def create_inventory(path: Path = DEFAULT_INVENTORY, existing: Inventory | None = None) -> Inventory: +def create_inventory(path: Path = DEFAULT_INVENTORY, existing: Inventory | None = None, *, local: bool = False) -> Inventory: hosts: dict[str, Host] = dict(existing.hosts) if existing else {} global_https_users = existing.users.https or None if existing else None global_ssh_users = existing.users.ssh or None if existing else None - chains_enabled = existing.settings.https_chains_enabled if existing else bool(questionary.confirm("Enable automatic HTTPS chains between hosts?", default=False).ask()) + chains_enabled = existing.settings.https_chains_enabled if existing else (not local and bool(questionary.confirm("Enable automatic HTTPS chains between hosts?", default=False).ask())) chain_domain = existing.settings.https_chain_domain if existing else (ask_required("Base DNS domain for chain hostnames") if chains_enabled else None) while True: name = ask_required("Inventory host name (for example proxy_eu)") @@ -87,17 +87,18 @@ def create_inventory(path: Path = DEFAULT_INVENTORY, existing: Inventory | None print("This host already exists. Choose a new inventory name.") continue address = ask_required("Public IP address or DNS name") - bootstrap_user = ask_required("Current SSH login used for the first setup", "root") - bootstrap_auth = questionary.select("Initial SSH authentication", choices=["password", "key"]).ask() + bootstrap_user = "root" if local else ask_required("Current SSH login used for the first setup", "root") + bootstrap_auth = "key" if local else questionary.select("Initial SSH authentication", choices=["password", "key"]).ask() if bootstrap_auth is None: raise KeyboardInterrupt - bootstrap_key = ask_required("Initial private key path") if bootstrap_auth == "key" else None - admin_user = ask_required("Permanent administrative user to create or update", getpass.getuser()) + bootstrap_key = ask_required("Initial private key path") if bootstrap_auth == "key" and not local else None + admin_default = "megaproxy-admin" if local else getpass.getuser() + admin_user = ask_required("Permanent administrative user to create or update", admin_default) existing_proxy_names = {user.name for user in (global_ssh_users or [])} while admin_user == "root" or admin_user in existing_proxy_names: reason = "cannot be root" if admin_user == "root" else "is already a global SSH proxy login" print(f"The permanent administrator {reason}.") - admin_user = ask_required("Permanent administrative user to create or update", getpass.getuser()) + admin_user = ask_required("Permanent administrative user to create or update", admin_default) admin_port = int(ask_required("Administrative SSH port", "22")) key_source = questionary.select( "Administrative SSH key", @@ -153,6 +154,7 @@ def create_inventory(path: Path = DEFAULT_INVENTORY, existing: Inventory | None ) hosts[name] = Host( address=address, + local=local, admin=AdminAccess( user=admin_user, bootstrap_user=bootstrap_user, @@ -164,7 +166,7 @@ def create_inventory(path: Path = DEFAULT_INVENTORY, existing: Inventory | None ), services=Services(https=https, ssh=ssh), ) - if not questionary.confirm("Add another host?", default=False).ask(): + if local or not questionary.confirm("Add another host?", default=False).ask(): break settings = existing.settings if existing else Settings(https_chains_enabled=chains_enabled, https_chain_domain=chain_domain) users = ProxyUsers( diff --git a/tests/integration/lxd.sh b/tests/integration/lxd.sh index 528dbe2..6f058e3 100755 --- a/tests/integration/lxd.sh +++ b/tests/integration/lxd.sh @@ -118,3 +118,44 @@ if [[ "$host_count" -ge 2 ]]; then echo | openssl s_client -connect 127.0.0.1:19443 -servername example.com -verify_return_error >/dev/null ssh -S "$jump_socket" -O exit "mp-ci@$ip_two" fi + +# Exercise on-server bootstrap with a new administrator, so the local account phase must run. +if [[ "$host_count" -eq 1 ]]; then + sudo lxc exec megaproxy-ci-one -- mkdir -p /opt/megaproxy-server + git archive HEAD | sudo lxc exec megaproxy-ci-one -- tar -xf - -C /opt/megaproxy-server + sudo lxc file push "$(command -v uv)" megaproxy-ci-one/usr/local/bin/uv + sudo lxc exec megaproxy-ci-one -- chmod +x /usr/local/bin/uv + sudo lxc file push "$inventory_file" megaproxy-ci-one/opt/megaproxy-server/local.yml + sudo lxc exec megaproxy-ci-one --cwd /opt/megaproxy-server -- uv run --frozen --python 3.12 python - <<'PY' +from pathlib import Path +from unittest.mock import patch + +from megaproxy_server.inventory import load, save +from megaproxy_server.local_setup import setup_local +from megaproxy_server.secrets import generate_key + +path = Path('local.yml').resolve() +inventory = load(path) +host = inventory.hosts['one'] +host.local = True +host.admin.user = 'ci-local-admin' +host.admin.bootstrap_user = 'root' +public, private = generate_key(Path('/root/local-admin'), 'local setup test') +host.admin.public_key = public +host.admin.private_key_file = str(private) +for user in inventory.users.ssh: + user.authentication.public_key = public + user.authentication.generated_private_key = str(private) +save(path, inventory) +with patch('questionary.confirm') as confirm: + confirm.return_value.ask.return_value = True + assert setup_local(path) == 0 +assert load(path).hosts['one'].admin.bootstrap_user is None +assert Path('.generated/configs/MegaProxy.json').is_file() +PY + sudo lxc file pull megaproxy-ci-one/root/local-admin "$work_dir/local-admin" + sudo chown "$(id -u):$(id -g)" "$work_dir/local-admin" + chmod 0600 "$work_dir/local-admin" + ssh -i "$work_dir/local-admin" -o BatchMode=yes -o IdentitiesOnly=yes \ + -o UserKnownHostsFile="$work_dir/known_hosts" "ci-local-admin@$ip_one" 'sudo -n true' +fi diff --git a/tests/test_local_setup.py b/tests/test_local_setup.py new file mode 100644 index 0000000..de2041c --- /dev/null +++ b/tests/test_local_setup.py @@ -0,0 +1,133 @@ +import subprocess as sp +from pathlib import Path +from types import SimpleNamespace + +import pytest +from ruamel.yaml import YAML + +from megaproxy_server import bootstrap, cli, inventory, local_setup, wizard +from megaproxy_server.models import Inventory + + +def local_inventory(): + return Inventory.model_validate({ + 'hosts': {'local_proxy': { + 'address': '192.0.2.1', 'local': True, + 'admin': {'user': 'deploy', 'bootstrap_user': 'root', + 'private_key_file': '/keys/admin', 'public_key': 'ssh-ed25519 AAAA'}, + 'services': {'https': {'endpoint': '192.0.2.1', 'certificate': 'self-signed', + 'users': [{'name': 'alice', 'password': 'long-test-password'}]}}, + }}, + }) + + +def test_local_transport_preserves_public_endpoint_and_checks_host_key(monkeypatch): + inv = local_inventory() + variables = inventory.ansible_inventory(inv)['all']['hosts']['local_proxy'] + assert variables['ansible_host'] == '127.0.0.1' + assert variables['megaproxy_services']['https']['endpoint'] == '192.0.2.1' + assert 'StrictHostKeyChecking=yes' in variables['ansible_ssh_common_args'] + calls = [] + monkeypatch.setattr(bootstrap.sp, 'run', lambda command, **kwargs: ( + calls.append(command) or SimpleNamespace(returncode=0) + )) + assert bootstrap.check_admin(inv.hosts['local_proxy']) + assert calls[0][-2] == '127.0.0.1' + assert 'StrictHostKeyChecking=yes' in calls[0] + + +def test_only_account_phase_runs_locally_as_root(monkeypatch): + inv = local_inventory() + monkeypatch.setattr(bootstrap.os, 'geteuid', lambda: 0) + phases = [] + + def run(command, **kwargs): + data = YAML(typ='safe').load(Path(command[command.index('-i') + 1])) + phases.append(data['all']['hosts']['local_proxy']) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(bootstrap.sp, 'run', run) + assert bootstrap.run_phase(inv, 'local_proxy', 'account') == 0 + assert bootstrap.run_phase(inv, 'local_proxy', 'policy') == 0 + assert phases[0]['ansible_connection'] == 'local' + assert phases[0]['ansible_python_interpreter'] == bootstrap.sys.executable + assert 'ansible_connection' not in phases[1] + assert phases[1]['ansible_user'] == 'deploy' + monkeypatch.setattr(bootstrap.os, 'geteuid', lambda: 1000) + with pytest.raises(ValueError, match='root'): + bootstrap.run_phase(inv, 'local_proxy', 'account') + + +@pytest.mark.parametrize('confirm,bootstrap_code,expected', [ + (False, 0, []), (True, 2, ['bootstrap']), + (True, 0, ['bootstrap', 'site.yml', 'verify.yml', 'configs']), +]) +def test_setup_resume_confirmation_and_failure(monkeypatch, tmp_path, confirm, bootstrap_code, expected): + inv = local_inventory() + path = tmp_path / 'inventory.yml' + path.touch() + key = tmp_path / 'host.pub' + key.write_text('ssh-ed25519 HOSTKEY\n') + monkeypatch.setattr(local_setup.os, 'geteuid', lambda: 0) + monkeypatch.setattr(local_setup.os, 'umask', lambda mask: None) + monkeypatch.setattr(local_setup, 'ROOT', tmp_path) + monkeypatch.setattr(Path, 'glob', lambda *args: [key]) + monkeypatch.setattr(local_setup, 'load', lambda path: inv) + monkeypatch.setattr(local_setup, 'create_inventory', lambda *args, **kwargs: pytest.fail('Overwrites inventory')) + monkeypatch.setattr(local_setup.questionary, 'confirm', lambda *args, **kwargs: SimpleNamespace(ask=lambda: confirm)) + calls = [] + monkeypatch.setattr(local_setup, 'bootstrap', lambda *args: calls.append('bootstrap') or bootstrap_code) + monkeypatch.setattr(cli, 'run_ansible', lambda path, playbook: calls.append(playbook) or 0) + monkeypatch.setattr(local_setup, 'generate', lambda *args: calls.append('configs')) + assert local_setup.setup_local(path) == (bootstrap_code if confirm else 0) + assert calls == expected + known = tmp_path / '.secrets/local-known-hosts' + assert known.read_text() == '127.0.0.1,[127.0.0.1]:22 ssh-ed25519 HOSTKEY\n' + assert known.stat().st_mode & 0o777 == 0o600 + + +def test_local_wizard_skips_remote_bootstrap_questions(monkeypatch, tmp_path): + answers = { + 'Inventory host name (for example proxy_eu)': 'local_proxy', + 'Public IP address or DNS name': '192.0.2.1', + 'Permanent administrative user to create or update': 'deploy', + 'Administrative SSH port': '22', + 'HTTPS endpoint': '192.0.2.1', + } + monkeypatch.setattr(wizard, 'ask_required', lambda message, default=None: answers[message]) + selections = {'Administrative SSH key': 'Generate a dedicated key', 'Certificate type': 'self-signed'} + monkeypatch.setattr(wizard.questionary, 'select', lambda message, **kwargs: SimpleNamespace(ask=lambda: selections[message])) + monkeypatch.setattr(wizard.questionary, 'checkbox', lambda *args, **kwargs: SimpleNamespace(ask=lambda: ['https'])) + monkeypatch.setattr(wizard.questionary, 'confirm', lambda message, **kwargs: SimpleNamespace(ask=lambda: False)) + monkeypatch.setattr(wizard, 'generate_key', lambda *args: ('ssh-ed25519 AAAA', tmp_path / 'key')) + monkeypatch.setattr(wizard, 'ask_users', lambda *args: local_inventory().users.https) + monkeypatch.setattr(wizard, 'save', lambda *args, **kwargs: None) + inv = wizard.create_inventory(tmp_path / 'inventory.yml', local=True) + assert len(inv.hosts) == 1 + assert inv.hosts['local_proxy'].local + assert inv.hosts['local_proxy'].admin.bootstrap_user == 'root' + assert inv.hosts['local_proxy'].admin.bootstrap_auth == 'key' + assert not inv.settings.https_chains_enabled + + +def test_setup_rejects_non_root_and_remote_inventory(monkeypatch, tmp_path): + path = tmp_path / 'inventory.yml' + path.touch() + inv = local_inventory() + monkeypatch.setattr(local_setup.os, 'geteuid', lambda: 1000) + with pytest.raises(ValueError, match='root'): + local_setup.setup_local(path) + monkeypatch.setattr(local_setup.os, 'geteuid', lambda: 0) + monkeypatch.setattr(local_setup.os, 'umask', lambda mask: None) + monkeypatch.setattr(local_setup, 'load', lambda path: inv) + inv.hosts['local_proxy'].local = False + with pytest.raises(ValueError, match='exactly one local host'): + local_setup.setup_local(path) + + +def test_installer_can_be_piped_and_is_valid_bash(): + script = Path('install.sh').read_text() + sp.run(['bash', '-n'], input=script, text=True, check=True) + result = sp.run(['bash', '-s', '--', '--help'], input=script, text=True, capture_output=True) + assert result.returncode == 0 + assert 'Interactive Debian/Ubuntu' in result.stdout