From fbf4503902f96f2290af5d3ca075080e50f2cfca Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Sun, 11 Oct 2026 16:55:20 +0300 Subject: [PATCH 1/2] Fix proxy lifecycle, credential revocation and safe configuration writes --- .github/workflows/integration.yml | 6 + docs/en/README.md | 22 +- docs/ru/README.md | 24 +- playbooks/site.yml | 4 +- roles/admin/tasks/account.yml | 7 +- roles/admin/tasks/policy.yml | 53 ++++ roles/firewall/tasks/main.yml | 51 +++- roles/https_proxy/tasks/disabled.yml | 27 ++ roles/https_proxy/tasks/enabled.yml | 229 +++++++++++++++++ roles/https_proxy/tasks/main.yml | 233 +----------------- .../templates/megaproxy-docker-clean.sh.j2 | 4 - roles/ssh_proxy/tasks/disabled.yml | 34 +++ roles/ssh_proxy/tasks/enabled.yml | 116 +++++++++ roles/ssh_proxy/tasks/main.yml | 120 +-------- roles/ssh_proxy/tasks/validate_accounts.yml | 36 +++ src/megaproxy_server/config_bundle.py | 20 +- src/megaproxy_server/config_format.py | 3 +- src/megaproxy_server/export.py | 8 +- src/megaproxy_server/generate.py | 5 +- src/megaproxy_server/inventory.py | 20 +- src/megaproxy_server/models.py | 49 +++- src/megaproxy_server/wizard.py | 1 + tests/integration/config_api.py | 21 +- tests/integration/lifecycle.py | 104 ++++++++ tests/integration/lxd.sh | 13 + tests/test_https_proxy_role.py | 9 +- tests/test_lifecycle.py | 181 ++++++++++++++ tests/test_ssh_proxy_role.py | 4 +- 28 files changed, 1011 insertions(+), 393 deletions(-) create mode 100644 roles/https_proxy/tasks/disabled.yml create mode 100644 roles/https_proxy/tasks/enabled.yml create mode 100644 roles/ssh_proxy/tasks/disabled.yml create mode 100644 roles/ssh_proxy/tasks/enabled.yml create mode 100644 roles/ssh_proxy/tasks/validate_accounts.yml create mode 100644 tests/integration/lifecycle.py create mode 100644 tests/test_lifecycle.py diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 69ef8b7..13c14a9 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -47,6 +47,12 @@ jobs: - name: Verify authenticated SOCKS5 and MASQUE TCP and UDP run: uv run --frozen python tests/integration/transports.py --gost "$RUNNER_TEMP/megaproxy-gost/gost" + - name: Verify HTTPS config API through nginx + run: | + sudo apt-get update + sudo apt-get install -y nginx + uv run --frozen python tests/integration/config_api.py --nginx /usr/sbin/nginx + ubuntu: name: Ubuntu 24.04 host runs-on: ubuntu-latest diff --git a/docs/en/README.md b/docs/en/README.md index ebd0e7c..fd234c4 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -147,11 +147,31 @@ provide metadata for personalized delivery; the current `export` and `configs` c `services.ssh.generate_profile` defaults to false and selects hosts for direct SSH profiles in `export`, `configs` and the configuration API. Administrative SSH uses `admin` settings separately. +The wizard asks whether to publish direct SSH profiles. `enabled: false` also +disables an existing proxy: HTTPS stops GOST, HAProxy and the renewal timer; SSH +denies the `megaproxy` group and closes its sessions while preserving administrative +SSH. Keep the disabled service in YAML: an absent service is unmanaged. Firewall +records its own rules and revokes retired ports on the next full apply, preserving +other rules. An initial upgrade records existing configured ports before later changes. + +Only accounts whose primary group is `megaproxy` may be modified or removed; +new proxy accounts are created in that group. Administrative/bootstrap accounts, +reserved names and paths are rejected in `removed_ssh`. The administrative +`authorized_keys` file is managed exclusively: replacing `admin.public_key` +revokes the old key. Changing `admin.user` requires bootstrap through a working +account: the new administrator is verified before retiring the previous managed +account and sudo access, preserving its files. + +Inventory and exports are replaced atomically with mode `0600`. Export validation +runs before replacing an existing file; unknown country codes become empty strings. +Port conflicts and HTTPS without routes are rejected before connecting to a host. +Docker cleanup only removes old GOST/Certbot images, without global prune operations. + | enabled | generate_profile | Result | | --- | --- | --- | | true | true | Provision SSH proxy users and publish direct SSH profiles | | true | false | Provision SSH proxy users; use the host only in declared jump pairs | -| false | any | Skip SSH proxy provisioning/verification and profile generation; jump pairs cannot reference this host | +| false | any | Deny proxy access, close proxy sessions and omit profiles; jump pairs cannot reference this host | Setting `enabled: false` skips the role; it does not remove previously provisioned accounts or stop the server's SSH daemon. To revoke an account, use `remove-users` and `apply`. diff --git a/docs/ru/README.md b/docs/ru/README.md index 9a1745a..19d7e3d 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -154,11 +154,33 @@ HTTPS-пользователям; удаление SSH-аккаунта такж `services.ssh.generate_profile` по умолчанию false: выбирает хосты для прямых SSH-профилей в `export`, `configs` и API. Административный SSH отдельно задаётся через `admin`. +Мастер отдельно спрашивает, публиковать ли прямые SSH-профили. `enabled: false` +отключает уже настроенный прокси: для HTTPS останавливает GOST, HAProxy и таймер +renewal; для SSH запрещает вход группе `megaproxy` и закрывает её сессии, сохраняя +административный SSH. Удалять службу из YAML вместо `enabled: false` не следует: +отсутствующая служба не управляется. Firewall запоминает управляемые правила и +удаляет устаревшие при следующем полном apply; остальные правила сохраняются. +Первый apply после обновления запоминает существующие настроенные порты перед +последующими изменениями. + +SSH-аккаунты могут изменяться и удаляться только если их основная группа — +`megaproxy`; новые аккаунты создаются в этой группе. `removed_ssh` не допускает +администратора, bootstrap-логин, системные имена и пути. Административный +`authorized_keys` целиком управляется инвентарём: смена `admin.public_key` отзывает +старый ключ. При смене `admin.user` bootstrap сначала проверяет нового +администратора, затем удаляет прежний управляемый аккаунт и sudo-доступ, сохраняя +его файлы. Для миграции укажите `bootstrap_user` с действующим доступом. + +Инвентарь и экспорт записываются атомарно с правами `0600`. Экспорт проверяется +до замены прежнего файла; неизвестный код страны становится пустой строкой. +Конфликты портов и HTTPS без маршрутов отклоняются до подключения к хосту. +Docker cleanup удаляет только старые образы GOST/Certbot, без глобального prune. + | enabled | generate_profile | Результат | | --- | --- | --- | | true | true | Настройка SSH-прокси и генерация прямых SSH-профилей | | true | false | Настройка SSH-прокси; использование хоста только в явных jump-парах | -| false | любое | Настройка и проверка SSH-прокси пропускаются, профили не генерируются; использовать хост в jump-парах нельзя | +| false | любое | Вход прокси-аккаунтам запрещается, сессии закрываются, профили не генерируются; jump-пары не допускаются | `enabled: false` пропускает роль, но не удаляет ранее созданные аккаунты и не останавливает SSH-демон сервера. Для отзыва аккаунта используйте `remove-users` и `apply`. diff --git a/playbooks/site.yml b/playbooks/site.yml index 9f3ec9d..1adefc6 100644 --- a/playbooks/site.yml +++ b/playbooks/site.yml @@ -21,10 +21,10 @@ when: megaproxy_settings.manage_firewall | bool tags: [firewall] - role: ssh_proxy - when: megaproxy_services.ssh is defined and megaproxy_services.ssh.enabled | bool + when: megaproxy_services.ssh is defined tags: [ssh] - role: https_proxy - when: megaproxy_services.https is defined and megaproxy_services.https.enabled | bool + when: megaproxy_services.https is defined tags: [https] - role: config_api when: megaproxy_services.config_api is defined diff --git a/roles/admin/tasks/account.yml b/roles/admin/tasks/account.yml index d0544ee..ee0b11c 100644 --- a/roles/admin/tasks/account.yml +++ b/roles/admin/tasks/account.yml @@ -25,10 +25,9 @@ mode: "0700" - name: Install administrative public key - ansible.builtin.lineinfile: - path: "{{ megaproxy_admin_account.home }}/.ssh/authorized_keys" - line: "{{ megaproxy_admin.public_key }}" - create: true + ansible.builtin.copy: + dest: "{{ megaproxy_admin_account.home }}/.ssh/authorized_keys" + content: "{{ megaproxy_admin.public_key }}\n" owner: "{{ megaproxy_admin.user }}" mode: "0600" no_log: true diff --git a/roles/admin/tasks/policy.yml b/roles/admin/tasks/policy.yml index 087360a..5b1d441 100644 --- a/roles/admin/tasks/policy.yml +++ b/roles/admin/tasks/policy.yml @@ -82,3 +82,56 @@ - name: Report rejected administrative SSH policy ansible.builtin.fail: msg: "Administrative SSH policy validation failed; the previous policy was restored" + +- name: Find previously managed administrative accounts + ansible.builtin.find: + paths: /etc/sudoers.d + patterns: megaproxy-* + file_type: file + register: megaproxy_admin_sudoers + +- name: Identify retired administrative logins + ansible.builtin.set_fact: + megaproxy_retired_admins: >- + {{ megaproxy_admin_sudoers.files | map(attribute='path') | map('basename') + | map('regex_replace', '^megaproxy-', '') | reject('equalto', megaproxy_admin.user) | list }} + +- name: Require verified new administrator before retiring old access + ansible.builtin.assert: + that: + - ansible_user == megaproxy_admin.user + - item is match('^[a-z_][a-z0-9_-]*$') + - item != 'root' + fail_msg: "Administrative migration must run through the new verified administrative account" + loop: "{{ megaproxy_retired_admins }}" + +- name: Inspect retired administrative accounts + ansible.builtin.command: + argv: [getent, passwd, "{{ item }}"] + loop: "{{ megaproxy_retired_admins }}" + register: megaproxy_retired_admin_accounts + changed_when: false + failed_when: false + check_mode: false + +- name: Close sessions of retired administrators + ansible.builtin.command: + argv: [pkill, -KILL, -u, "{{ item.item }}"] + loop: "{{ megaproxy_retired_admin_accounts.results }}" + when: item.rc == 0 + register: megaproxy_retired_admin_sessions + failed_when: megaproxy_retired_admin_sessions.rc not in [0, 1] + changed_when: megaproxy_retired_admin_sessions.rc == 0 + +- name: Remove retired administrative accounts while preserving their files + ansible.builtin.user: + name: "{{ item }}" + state: absent + remove: false + loop: "{{ megaproxy_retired_admins }}" + +- name: Remove retired administrative sudo privileges + ansible.builtin.file: + path: "/etc/sudoers.d/megaproxy-{{ item }}" + state: absent + loop: "{{ megaproxy_retired_admins }}" diff --git a/roles/firewall/tasks/main.yml b/roles/firewall/tasks/main.yml index f3eae3f..c0a4e21 100644 --- a/roles/firewall/tasks/main.yml +++ b/roles/firewall/tasks/main.yml @@ -11,10 +11,10 @@ | map('string') | unique | list }} - name: Allow required ports before enabling firewall - ansible.builtin.command: "ufw allow {{ item }}/tcp" + ansible.builtin.command: "ufw allow {{ item }}/tcp comment megaproxy" loop: "{{ megaproxy_allowed_ports }}" register: ufw_allow - changed_when: "'Rule added' in ufw_allow.stdout" + changed_when: "'Rule added' in ufw_allow.stdout or 'Rule updated' in ufw_allow.stdout" - name: Build optional UDP firewall port list ansible.builtin.set_fact: @@ -25,10 +25,53 @@ if megaproxy_services.https is defined and megaproxy_services.https.enabled else [] }} - name: Allow enabled HTTP3 and SOCKS5 UDP relay ports - ansible.builtin.command: "ufw allow {{ item }}/udp" + ansible.builtin.command: "ufw allow {{ item }}/udp comment megaproxy" loop: "{{ megaproxy_allowed_udp_ports }}" register: ufw_allow_udp - changed_when: "'Rule added' in ufw_allow_udp.stdout" + changed_when: "'Rule added' in ufw_allow_udp.stdout or 'Rule updated' in ufw_allow_udp.stdout" + +- name: Check previously managed firewall ports + ansible.builtin.stat: + path: /etc/megaproxy/firewall.json + register: megaproxy_firewall_state + +- name: Read previously managed firewall ports + ansible.builtin.slurp: + src: /etc/megaproxy/firewall.json + register: megaproxy_firewall_previous + when: megaproxy_firewall_state.stat.exists + +- name: Build current managed firewall rules + ansible.builtin.set_fact: + megaproxy_firewall_rules: >- + {{ (megaproxy_allowed_ports | map('regex_replace', '$', '/tcp') | list) + + (megaproxy_allowed_udp_ports | map('regex_replace', '$', '/udp') | list) }} + megaproxy_previous_firewall_rules: >- + {{ (megaproxy_firewall_previous.content | b64decode | from_json) + if megaproxy_firewall_state.stat.exists else [] }} + +- name: Revoke retired managed firewall rules + ansible.builtin.command: + argv: [ufw, --force, delete, allow, "{{ item }}"] + loop: "{{ megaproxy_previous_firewall_rules | difference(megaproxy_firewall_rules) }}" + register: megaproxy_ufw_delete + changed_when: "'Rule deleted' in megaproxy_ufw_delete.stdout" + +- name: Create private managed firewall state directory + ansible.builtin.file: + path: /etc/megaproxy + state: directory + owner: root + group: root + mode: "0755" + +- name: Remember managed firewall ports + ansible.builtin.copy: + dest: /etc/megaproxy/firewall.json + content: "{{ megaproxy_firewall_rules | to_json }}\n" + owner: root + group: root + mode: "0600" - name: Read firewall policy ansible.builtin.command: ufw status verbose diff --git a/roles/https_proxy/tasks/disabled.yml b/roles/https_proxy/tasks/disabled.yml new file mode 100644 index 0000000..bd21b5e --- /dev/null +++ b/roles/https_proxy/tasks/disabled.yml @@ -0,0 +1,27 @@ +--- +- name: Check managed HTTPS units + ansible.builtin.stat: + path: "/etc/systemd/system/{{ item }}" + loop: [megaproxy-gost.service, megaproxy-certbot-renew.timer] + register: megaproxy_https_units + +- name: Stop disabled HTTPS proxy and renewal timer + ansible.builtin.systemd_service: + name: "{{ item.item }}" + enabled: false + state: stopped + loop: "{{ megaproxy_https_units.results }}" + when: item.stat.exists and not ansible_check_mode + +- name: Stop the managed HTTPS SNI router + ansible.builtin.systemd_service: + name: haproxy + enabled: false + state: stopped + when: megaproxy_https_units.results[0].stat.exists and not ansible_check_mode + +- name: Remove disabled proxy credentials + ansible.builtin.file: + path: /opt/megaproxy/gost.yml + state: absent + no_log: true diff --git a/roles/https_proxy/tasks/enabled.yml b/roles/https_proxy/tasks/enabled.yml new file mode 100644 index 0000000..52fe9b7 --- /dev/null +++ b/roles/https_proxy/tasks/enabled.yml @@ -0,0 +1,229 @@ +--- +- name: Validate HTTPS service variables + ansible.builtin.assert: + that: + - megaproxy_services.https.users | length > 0 + +- name: Enable Docker + ansible.builtin.systemd_service: + name: docker + enabled: true + state: started + when: not ansible_check_mode + +- name: Create private configuration directory + ansible.builtin.file: + path: /opt/megaproxy + state: directory + owner: root + group: root + mode: "0700" + +- name: Select certificate directory + ansible.builtin.set_fact: + megaproxy_certificate_directory: >- + {{ '/opt/megaproxy/certificates' if megaproxy_services.https.certificate == 'self-signed' + else '/etc/letsencrypt/live/' ~ megaproxy_services.https.endpoint }} + megaproxy_container_certificate_directory: >- + {{ '/certificates' if megaproxy_services.https.certificate == 'self-signed' + else '/etc/letsencrypt/live/' ~ megaproxy_services.https.endpoint }} + megaproxy_certificate_volume: >- + {{ '/opt/megaproxy/certificates:/certificates:ro' if megaproxy_services.https.certificate == 'self-signed' + else '/etc/letsencrypt:/etc/letsencrypt:ro' }} + megaproxy_certificate_hostnames: "{{ megaproxy_services.https.routes | map(attribute='hostname') | list }}" + +- name: Check current certificate + ansible.builtin.stat: + path: "{{ megaproxy_certificate_directory }}/fullchain.pem" + register: megaproxy_certificate + +- name: Check certificate hostnames + ansible.builtin.command: + argv: [openssl, x509, -checkhost, "{{ item }}", -noout, -in, "{{ megaproxy_certificate_directory }}/fullchain.pem"] + loop: "{{ megaproxy_certificate_hostnames }}" + register: megaproxy_certificate_hostname_checks + changed_when: false + failed_when: false + when: + - megaproxy_certificate.stat.exists + - megaproxy_services.https.certificate == "domain" + +- name: Decide whether the domain certificate must be issued + ansible.builtin.set_fact: + megaproxy_certificate_needs_issue: >- + {{ + not megaproxy_certificate.stat.exists or + (megaproxy_certificate_hostname_checks.results | default([]) | selectattr('rc', 'defined') | selectattr('rc', 'ne', 0) | list | length > 0) or + (megaproxy_certificate_hostname_checks.results | default([]) | selectattr('stdout', 'defined') | selectattr('stdout', 'search', 'does NOT match certificate') | list | length > 0) + }} + +- name: Create self-signed certificate directory + ansible.builtin.file: + path: /opt/megaproxy/certificates + state: directory + owner: root + group: root + mode: "0700" + when: megaproxy_services.https.certificate == "self-signed" + +- name: Create self-signed certificate + ansible.builtin.command: + argv: [openssl, req, -x509, -newkey, rsa:3072, -nodes, -days, "365", -subj, "/CN={{ megaproxy_services.https.endpoint }}", -addext, "subjectAltName={{ megaproxy_https_san_type }}:{{ megaproxy_services.https.endpoint }}", -keyout, /opt/megaproxy/certificates/privkey.pem, -out, /opt/megaproxy/certificates/fullchain.pem] + when: + - megaproxy_services.https.certificate == "self-signed" + - not megaproxy_certificate.stat.exists + no_log: true + +- name: Obtain domain certificate + ansible.builtin.command: + argv: >- + {{ + ['docker', 'run', '--rm', '--network', 'host', '-v', '/etc/letsencrypt:/etc/letsencrypt', + '-v', '/var/lib/letsencrypt:/var/lib/letsencrypt', + 'certbot/certbot:' ~ megaproxy_services.https.certbot_version, + 'certonly', '--standalone', '--non-interactive', '--agree-tos', '--no-eff-email', '--expand', + '--cert-name', megaproxy_services.https.endpoint, '--email', megaproxy_services.https.acme_email] + + (megaproxy_certificate_hostnames | map('regex_replace', '^(.*)$', '--domain=\1') | list) + }} + when: + - megaproxy_services.https.certificate == "domain" + - megaproxy_certificate_needs_issue | bool + +- name: Obtain public IP certificate + ansible.builtin.command: + argv: [docker, run, --rm, --network, host, -v, /etc/letsencrypt:/etc/letsencrypt, -v, /var/lib/letsencrypt:/var/lib/letsencrypt, "certbot/certbot:{{ megaproxy_services.https.certbot_version }}", certonly, --standalone, --non-interactive, --agree-tos, --no-eff-email, --email, "{{ megaproxy_services.https.acme_email }}", --preferred-profile, shortlived, --ip-address, "{{ megaproxy_services.https.endpoint }}"] + when: + - megaproxy_services.https.certificate == "ip-acme" + - not megaproxy_certificate.stat.exists + +- name: Create decoy directory + ansible.builtin.file: + path: /opt/megaproxy/decoy + state: directory + owner: root + group: root + mode: "0755" + +- name: Generate a random static decoy site + ansible.builtin.script: + cmd: generate-decoy.py /opt/megaproxy/decoy + executable: /usr/bin/python3 + creates: /opt/megaproxy/decoy/index.html + when: megaproxy_services.https.routes | selectattr('probe_resistance.enabled') | list | length > 0 + +- name: Install secret GOST configuration + ansible.builtin.template: + src: gost.yml.j2 + dest: /opt/megaproxy/gost.yml + owner: root + group: root + mode: "0600" + no_log: true + notify: Restart MegaProxy GOST + +- name: Install GOST systemd service + ansible.builtin.template: + src: megaproxy-gost.service.j2 + dest: /etc/systemd/system/megaproxy-gost.service + owner: root + group: root + mode: "0644" + notify: + - Restart MegaProxy GOST + - Clean MegaProxy Docker resources + +- name: Install Docker cleanup helper + ansible.builtin.template: + src: megaproxy-docker-clean.sh.j2 + dest: /usr/local/sbin/megaproxy-docker-clean + owner: root + group: root + mode: "0755" + notify: Clean MegaProxy Docker resources + +- name: Install SNI routing configuration + ansible.builtin.template: + src: haproxy.cfg.j2 + dest: /etc/haproxy/haproxy.cfg + owner: root + group: root + mode: "0644" + validate: "{{ omit if ansible_check_mode else '/usr/sbin/haproxy -c -f %s' }}" + notify: Restart MegaProxy HAProxy + +- name: Activate GOST backends before the public SNI router + ansible.builtin.meta: flush_handlers + +- name: Check whether GOST predates the current certificate + ansible.builtin.shell: + cmd: >- + test "{{ megaproxy_certificate_directory }}/fullchain.pem" + -nt "/proc/$(systemctl show --property MainPID --value megaproxy-gost)" + register: megaproxy_gost_certificate_is_newer + changed_when: false + failed_when: false + when: not ansible_check_mode + +- name: Load a newer certificate into GOST + ansible.builtin.systemd_service: + name: megaproxy-gost + state: restarted + daemon_reload: true + when: + - not ansible_check_mode + - megaproxy_gost_certificate_is_newer.rc == 0 + +- name: Install containerized certificate renewal service + ansible.builtin.template: + src: megaproxy-certbot-renew.service.j2 + dest: /etc/systemd/system/megaproxy-certbot-renew.service + owner: root + group: root + mode: "0644" + notify: Clean MegaProxy Docker resources + when: megaproxy_services.https.certificate != "self-signed" + +- name: Install certificate renewal timer + ansible.builtin.copy: + dest: /etc/systemd/system/megaproxy-certbot-renew.timer + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Renew MegaProxy TLS certificate + + [Timer] + OnBootSec=10min + OnUnitActiveSec=12h + RandomizedDelaySec=1h + Persistent=true + + [Install] + WantedBy=timers.target + when: megaproxy_services.https.certificate != "self-signed" + +- name: Enable certificate renewal timer + ansible.builtin.systemd_service: + name: megaproxy-certbot-renew.timer + enabled: true + state: started + daemon_reload: true + when: + - megaproxy_services.https.certificate != "self-signed" + - not ansible_check_mode + +- name: Enable GOST service + ansible.builtin.systemd_service: + name: megaproxy-gost + enabled: true + state: started + daemon_reload: true + when: not ansible_check_mode + +- name: Enable HAProxy SNI router + ansible.builtin.systemd_service: + name: haproxy + enabled: true + state: started + when: not ansible_check_mode diff --git a/roles/https_proxy/tasks/main.yml b/roles/https_proxy/tasks/main.yml index 52fe9b7..b2c9316 100644 --- a/roles/https_proxy/tasks/main.yml +++ b/roles/https_proxy/tasks/main.yml @@ -1,229 +1,8 @@ --- -- name: Validate HTTPS service variables - ansible.builtin.assert: - that: - - megaproxy_services.https.users | length > 0 +- name: Configure enabled https_proxy service + ansible.builtin.include_tasks: enabled.yml + when: megaproxy_services.https.enabled | bool -- name: Enable Docker - ansible.builtin.systemd_service: - name: docker - enabled: true - state: started - when: not ansible_check_mode - -- name: Create private configuration directory - ansible.builtin.file: - path: /opt/megaproxy - state: directory - owner: root - group: root - mode: "0700" - -- name: Select certificate directory - ansible.builtin.set_fact: - megaproxy_certificate_directory: >- - {{ '/opt/megaproxy/certificates' if megaproxy_services.https.certificate == 'self-signed' - else '/etc/letsencrypt/live/' ~ megaproxy_services.https.endpoint }} - megaproxy_container_certificate_directory: >- - {{ '/certificates' if megaproxy_services.https.certificate == 'self-signed' - else '/etc/letsencrypt/live/' ~ megaproxy_services.https.endpoint }} - megaproxy_certificate_volume: >- - {{ '/opt/megaproxy/certificates:/certificates:ro' if megaproxy_services.https.certificate == 'self-signed' - else '/etc/letsencrypt:/etc/letsencrypt:ro' }} - megaproxy_certificate_hostnames: "{{ megaproxy_services.https.routes | map(attribute='hostname') | list }}" - -- name: Check current certificate - ansible.builtin.stat: - path: "{{ megaproxy_certificate_directory }}/fullchain.pem" - register: megaproxy_certificate - -- name: Check certificate hostnames - ansible.builtin.command: - argv: [openssl, x509, -checkhost, "{{ item }}", -noout, -in, "{{ megaproxy_certificate_directory }}/fullchain.pem"] - loop: "{{ megaproxy_certificate_hostnames }}" - register: megaproxy_certificate_hostname_checks - changed_when: false - failed_when: false - when: - - megaproxy_certificate.stat.exists - - megaproxy_services.https.certificate == "domain" - -- name: Decide whether the domain certificate must be issued - ansible.builtin.set_fact: - megaproxy_certificate_needs_issue: >- - {{ - not megaproxy_certificate.stat.exists or - (megaproxy_certificate_hostname_checks.results | default([]) | selectattr('rc', 'defined') | selectattr('rc', 'ne', 0) | list | length > 0) or - (megaproxy_certificate_hostname_checks.results | default([]) | selectattr('stdout', 'defined') | selectattr('stdout', 'search', 'does NOT match certificate') | list | length > 0) - }} - -- name: Create self-signed certificate directory - ansible.builtin.file: - path: /opt/megaproxy/certificates - state: directory - owner: root - group: root - mode: "0700" - when: megaproxy_services.https.certificate == "self-signed" - -- name: Create self-signed certificate - ansible.builtin.command: - argv: [openssl, req, -x509, -newkey, rsa:3072, -nodes, -days, "365", -subj, "/CN={{ megaproxy_services.https.endpoint }}", -addext, "subjectAltName={{ megaproxy_https_san_type }}:{{ megaproxy_services.https.endpoint }}", -keyout, /opt/megaproxy/certificates/privkey.pem, -out, /opt/megaproxy/certificates/fullchain.pem] - when: - - megaproxy_services.https.certificate == "self-signed" - - not megaproxy_certificate.stat.exists - no_log: true - -- name: Obtain domain certificate - ansible.builtin.command: - argv: >- - {{ - ['docker', 'run', '--rm', '--network', 'host', '-v', '/etc/letsencrypt:/etc/letsencrypt', - '-v', '/var/lib/letsencrypt:/var/lib/letsencrypt', - 'certbot/certbot:' ~ megaproxy_services.https.certbot_version, - 'certonly', '--standalone', '--non-interactive', '--agree-tos', '--no-eff-email', '--expand', - '--cert-name', megaproxy_services.https.endpoint, '--email', megaproxy_services.https.acme_email] - + (megaproxy_certificate_hostnames | map('regex_replace', '^(.*)$', '--domain=\1') | list) - }} - when: - - megaproxy_services.https.certificate == "domain" - - megaproxy_certificate_needs_issue | bool - -- name: Obtain public IP certificate - ansible.builtin.command: - argv: [docker, run, --rm, --network, host, -v, /etc/letsencrypt:/etc/letsencrypt, -v, /var/lib/letsencrypt:/var/lib/letsencrypt, "certbot/certbot:{{ megaproxy_services.https.certbot_version }}", certonly, --standalone, --non-interactive, --agree-tos, --no-eff-email, --email, "{{ megaproxy_services.https.acme_email }}", --preferred-profile, shortlived, --ip-address, "{{ megaproxy_services.https.endpoint }}"] - when: - - megaproxy_services.https.certificate == "ip-acme" - - not megaproxy_certificate.stat.exists - -- name: Create decoy directory - ansible.builtin.file: - path: /opt/megaproxy/decoy - state: directory - owner: root - group: root - mode: "0755" - -- name: Generate a random static decoy site - ansible.builtin.script: - cmd: generate-decoy.py /opt/megaproxy/decoy - executable: /usr/bin/python3 - creates: /opt/megaproxy/decoy/index.html - when: megaproxy_services.https.routes | selectattr('probe_resistance.enabled') | list | length > 0 - -- name: Install secret GOST configuration - ansible.builtin.template: - src: gost.yml.j2 - dest: /opt/megaproxy/gost.yml - owner: root - group: root - mode: "0600" - no_log: true - notify: Restart MegaProxy GOST - -- name: Install GOST systemd service - ansible.builtin.template: - src: megaproxy-gost.service.j2 - dest: /etc/systemd/system/megaproxy-gost.service - owner: root - group: root - mode: "0644" - notify: - - Restart MegaProxy GOST - - Clean MegaProxy Docker resources - -- name: Install Docker cleanup helper - ansible.builtin.template: - src: megaproxy-docker-clean.sh.j2 - dest: /usr/local/sbin/megaproxy-docker-clean - owner: root - group: root - mode: "0755" - notify: Clean MegaProxy Docker resources - -- name: Install SNI routing configuration - ansible.builtin.template: - src: haproxy.cfg.j2 - dest: /etc/haproxy/haproxy.cfg - owner: root - group: root - mode: "0644" - validate: "{{ omit if ansible_check_mode else '/usr/sbin/haproxy -c -f %s' }}" - notify: Restart MegaProxy HAProxy - -- name: Activate GOST backends before the public SNI router - ansible.builtin.meta: flush_handlers - -- name: Check whether GOST predates the current certificate - ansible.builtin.shell: - cmd: >- - test "{{ megaproxy_certificate_directory }}/fullchain.pem" - -nt "/proc/$(systemctl show --property MainPID --value megaproxy-gost)" - register: megaproxy_gost_certificate_is_newer - changed_when: false - failed_when: false - when: not ansible_check_mode - -- name: Load a newer certificate into GOST - ansible.builtin.systemd_service: - name: megaproxy-gost - state: restarted - daemon_reload: true - when: - - not ansible_check_mode - - megaproxy_gost_certificate_is_newer.rc == 0 - -- name: Install containerized certificate renewal service - ansible.builtin.template: - src: megaproxy-certbot-renew.service.j2 - dest: /etc/systemd/system/megaproxy-certbot-renew.service - owner: root - group: root - mode: "0644" - notify: Clean MegaProxy Docker resources - when: megaproxy_services.https.certificate != "self-signed" - -- name: Install certificate renewal timer - ansible.builtin.copy: - dest: /etc/systemd/system/megaproxy-certbot-renew.timer - owner: root - group: root - mode: "0644" - content: | - [Unit] - Description=Renew MegaProxy TLS certificate - - [Timer] - OnBootSec=10min - OnUnitActiveSec=12h - RandomizedDelaySec=1h - Persistent=true - - [Install] - WantedBy=timers.target - when: megaproxy_services.https.certificate != "self-signed" - -- name: Enable certificate renewal timer - ansible.builtin.systemd_service: - name: megaproxy-certbot-renew.timer - enabled: true - state: started - daemon_reload: true - when: - - megaproxy_services.https.certificate != "self-signed" - - not ansible_check_mode - -- name: Enable GOST service - ansible.builtin.systemd_service: - name: megaproxy-gost - enabled: true - state: started - daemon_reload: true - when: not ansible_check_mode - -- name: Enable HAProxy SNI router - ansible.builtin.systemd_service: - name: haproxy - enabled: true - state: started - when: not ansible_check_mode +- name: Disable https_proxy service + ansible.builtin.include_tasks: disabled.yml + when: not megaproxy_services.https.enabled | bool diff --git a/roles/https_proxy/templates/megaproxy-docker-clean.sh.j2 b/roles/https_proxy/templates/megaproxy-docker-clean.sh.j2 index 1cac6ce..71a32c2 100644 --- a/roles/https_proxy/templates/megaproxy-docker-clean.sh.j2 +++ b/roles/https_proxy/templates/megaproxy-docker-clean.sh.j2 @@ -17,7 +17,3 @@ certbot_image_id="$(docker image inspect --format '{{ '{{' }}.Id{{ '}}' }}' 'cer cleanup_old_images gogost/gost "$gost_image_id" cleanup_old_images certbot/certbot "$certbot_image_id" - -docker image prune --force -docker volume prune --force -docker network prune --force diff --git a/roles/ssh_proxy/tasks/disabled.yml b/roles/ssh_proxy/tasks/disabled.yml new file mode 100644 index 0000000..8a6d233 --- /dev/null +++ b/roles/ssh_proxy/tasks/disabled.yml @@ -0,0 +1,34 @@ +--- +- name: Deny all managed proxy accounts while keeping administrative SSH + ansible.builtin.copy: + dest: /etc/ssh/sshd_config.d/60-megaproxy.conf + content: "DenyGroups megaproxy\n" + owner: root + group: root + mode: "0644" + validate: /usr/sbin/sshd -t -f %s + notify: Validate and reload ssh + +- name: Keep only the administrative SSH port + ansible.builtin.copy: + dest: /etc/ssh/sshd_config.d/59-megaproxy-limits.conf + content: "Port {{ megaproxy_admin.port }}\n" + owner: root + group: root + mode: "0644" + notify: Validate and reload ssh + +- name: Check whether the managed proxy group exists + ansible.builtin.command: getent group megaproxy + register: megaproxy_disabled_group + changed_when: false + failed_when: false + check_mode: false + +- name: Close sessions of disabled proxy accounts + ansible.builtin.command: + argv: [pkill, -KILL, -G, megaproxy] + when: megaproxy_disabled_group.rc == 0 + register: megaproxy_disabled_sessions + failed_when: megaproxy_disabled_sessions.rc not in [0, 1] + changed_when: megaproxy_disabled_sessions.rc == 0 diff --git a/roles/ssh_proxy/tasks/enabled.yml b/roles/ssh_proxy/tasks/enabled.yml new file mode 100644 index 0000000..892789d --- /dev/null +++ b/roles/ssh_proxy/tasks/enabled.yml @@ -0,0 +1,116 @@ +--- +- name: Validate SSH service variables + ansible.builtin.assert: + that: + - megaproxy_services.ssh.users | length > 0 + +- name: Check whether restricted MegaProxy group exists + ansible.builtin.command: getent group megaproxy + register: megaproxy_group_current + changed_when: false + failed_when: false + check_mode: false + +- name: Create restricted MegaProxy group + ansible.builtin.group: + name: megaproxy + system: true + +- name: Create restricted SSH proxy users + ansible.builtin.user: + name: "{{ item.name }}" + group: megaproxy + groups: "" + append: false + create_home: false + shell: /usr/sbin/nologin + # A leading ! or * marks the whole account as locked, so OpenSSH rejects public keys too. + # "x" is not a usable crypt hash but leaves key-only accounts eligible for public-key auth. + password: "{{ item.authentication.password_hash if item.authentication.type == 'password' else 'x' }}" + password_lock: false + update_password: always + loop: "{{ megaproxy_services.ssh.users }}" + when: not ansible_check_mode or megaproxy_group_current.rc == 0 + no_log: true + +- name: Explain deferred SSH user planning + ansible.builtin.debug: + msg: >- + SSH proxy user creation is deferred in check mode because the megaproxy group does not exist + yet. A normal apply creates the group before these users. + when: + - ansible_check_mode + - megaproxy_group_current.rc != 0 + +- name: Create root-owned authorized keys directory + ansible.builtin.file: + path: /etc/ssh/megaproxy_authorized_keys + state: directory + owner: root + group: root + mode: "0755" + +- name: Install restricted authorized keys + ansible.builtin.copy: + dest: "/etc/ssh/megaproxy_authorized_keys/{{ item.name }}" + content: "{{ item.authentication.public_key }}\n" + owner: root + group: root + mode: "0644" + loop: "{{ megaproxy_services.ssh.users }}" + when: item.authentication.type == "key" + no_log: true + +- name: Remove key files for password-only users + ansible.builtin.file: + path: "/etc/ssh/megaproxy_authorized_keys/{{ item.name }}" + state: absent + loop: "{{ megaproxy_services.ssh.users }}" + when: item.authentication.type == "password" + no_log: true + +- name: Install hardened SSH proxy configuration + ansible.builtin.template: + src: 60-megaproxy.conf.j2 + dest: /etc/ssh/sshd_config.d/60-megaproxy.conf + owner: root + group: root + mode: "0644" + validate: /usr/sbin/sshd -t -f %s + notify: Validate and reload ssh + +- name: Configure unauthenticated SSH connection limits + ansible.builtin.copy: + dest: /etc/ssh/sshd_config.d/59-megaproxy-limits.conf + owner: root + group: root + mode: "0644" + content: | + Port {{ ansible_port | default(22) }} + {% if megaproxy_services.ssh.port | int != ansible_port | default(22) | int %} + Port {{ megaproxy_services.ssh.port }} + {% endif %} + MaxStartups {{ megaproxy_services.ssh.max_startups }} + PerSourceMaxStartups {{ megaproxy_services.ssh.per_source_max_startups }} + notify: Validate and reload ssh + +- name: Enable SSH fail2ban jail + ansible.builtin.copy: + dest: /etc/fail2ban/jail.d/megaproxy-sshd.local + mode: "0644" + content: | + [sshd] + enabled = true + backend = systemd + action = %(action_)s + bantime.increment = true + findtime = 10m + maxretry = 6 + when: megaproxy_services.ssh.fail2ban | bool + notify: Restart fail2ban + +- name: Ensure SSH is enabled + ansible.builtin.systemd_service: + name: ssh + enabled: true + state: started diff --git a/roles/ssh_proxy/tasks/main.yml b/roles/ssh_proxy/tasks/main.yml index ef143cd..065c998 100644 --- a/roles/ssh_proxy/tasks/main.yml +++ b/roles/ssh_proxy/tasks/main.yml @@ -1,20 +1,6 @@ --- -- name: Validate SSH service variables - ansible.builtin.assert: - that: - - megaproxy_services.ssh.users | length > 0 - -- name: Check whether restricted MegaProxy group exists - ansible.builtin.command: getent group megaproxy - register: megaproxy_group_current - changed_when: false - failed_when: false - check_mode: false - -- name: Create restricted MegaProxy group - ansible.builtin.group: - name: megaproxy - system: true +- name: Validate ownership of SSH proxy accounts + ansible.builtin.import_tasks: validate_accounts.yml - name: Remove retired SSH proxy users ansible.builtin.user: @@ -29,101 +15,17 @@ state: absent loop: "{{ megaproxy_services.ssh.removed_users | default([]) }}" -- name: Create restricted SSH proxy users - ansible.builtin.user: - name: "{{ item.name }}" - group: megaproxy - groups: "" - append: false - create_home: false - shell: /usr/sbin/nologin - # A leading ! or * marks the whole account as locked, so OpenSSH rejects public keys too. - # "x" is not a usable crypt hash but leaves key-only accounts eligible for public-key auth. - password: "{{ item.authentication.password_hash if item.authentication.type == 'password' else 'x' }}" - password_lock: false - update_password: always - loop: "{{ megaproxy_services.ssh.users }}" - when: not ansible_check_mode or megaproxy_group_current.rc == 0 - no_log: true - -- name: Explain deferred SSH user planning - ansible.builtin.debug: - msg: >- - SSH proxy user creation is deferred in check mode because the megaproxy group does not exist - yet. A normal apply creates the group before these users. - when: - - ansible_check_mode - - megaproxy_group_current.rc != 0 - -- name: Create root-owned authorized keys directory - ansible.builtin.file: - path: /etc/ssh/megaproxy_authorized_keys - state: directory - owner: root - group: root - mode: "0755" +- name: Configure enabled ssh_proxy service + ansible.builtin.include_tasks: enabled.yml + when: megaproxy_services.ssh.enabled | bool -- name: Install restricted authorized keys - ansible.builtin.copy: - dest: "/etc/ssh/megaproxy_authorized_keys/{{ item.name }}" - content: "{{ item.authentication.public_key }}\n" - owner: root - group: root - mode: "0644" - loop: "{{ megaproxy_services.ssh.users }}" - when: item.authentication.type == "key" - no_log: true +- name: Disable ssh_proxy service + ansible.builtin.include_tasks: disabled.yml + when: not megaproxy_services.ssh.enabled | bool -- name: Remove key files for password-only users +- name: Remove inactive SSH fail2ban jail ansible.builtin.file: - path: "/etc/ssh/megaproxy_authorized_keys/{{ item.name }}" + path: /etc/fail2ban/jail.d/megaproxy-sshd.local state: absent - loop: "{{ megaproxy_services.ssh.users }}" - when: item.authentication.type == "password" - no_log: true - -- name: Install hardened SSH proxy configuration - ansible.builtin.template: - src: 60-megaproxy.conf.j2 - dest: /etc/ssh/sshd_config.d/60-megaproxy.conf - owner: root - group: root - mode: "0644" - validate: /usr/sbin/sshd -t -f %s - notify: Validate and reload ssh - -- name: Configure unauthenticated SSH connection limits - ansible.builtin.copy: - dest: /etc/ssh/sshd_config.d/59-megaproxy-limits.conf - owner: root - group: root - mode: "0644" - content: | - Port {{ ansible_port | default(22) }} - {% if megaproxy_services.ssh.port | int != ansible_port | default(22) | int %} - Port {{ megaproxy_services.ssh.port }} - {% endif %} - MaxStartups {{ megaproxy_services.ssh.max_startups }} - PerSourceMaxStartups {{ megaproxy_services.ssh.per_source_max_startups }} - notify: Validate and reload ssh - -- name: Enable SSH fail2ban jail - ansible.builtin.copy: - dest: /etc/fail2ban/jail.d/megaproxy-sshd.local - mode: "0644" - content: | - [sshd] - enabled = true - backend = systemd - action = %(action_)s - bantime.increment = true - findtime = 10m - maxretry = 6 - when: megaproxy_services.ssh.fail2ban | bool + when: not megaproxy_services.ssh.enabled or not megaproxy_services.ssh.fail2ban notify: Restart fail2ban - -- name: Ensure SSH is enabled - ansible.builtin.systemd_service: - name: ssh - enabled: true - state: started diff --git a/roles/ssh_proxy/tasks/validate_accounts.yml b/roles/ssh_proxy/tasks/validate_accounts.yml new file mode 100644 index 0000000..db48487 --- /dev/null +++ b/roles/ssh_proxy/tasks/validate_accounts.yml @@ -0,0 +1,36 @@ +--- +- name: Require dedicated and safe SSH proxy logins + ansible.builtin.assert: + that: + - item is match('^[a-z_][a-z0-9_-]*$') + - item not in ['root', 'admin', 'ansible', 'ubuntu', 'debian', 'ec2-user', megaproxy_admin.user, megaproxy_admin.bootstrap_user | default('')] + fail_msg: "Refusing to modify an administrative or invalid proxy account" + loop: "{{ (megaproxy_services.ssh.users | map(attribute='name') | list) + (megaproxy_services.ssh.removed_users | default([])) }}" + +- name: Inspect existing proxy account ownership + ansible.builtin.command: + argv: [id, -gn, --, "{{ item }}"] + loop: "{{ (megaproxy_services.ssh.users | map(attribute='name') | list) + (megaproxy_services.ssh.removed_users | default([])) }}" + register: megaproxy_existing_accounts + changed_when: false + failed_when: false + check_mode: false + +- name: Refuse to modify accounts outside the managed proxy group + ansible.builtin.assert: + that: item.rc != 0 or item.stdout == 'megaproxy' + fail_msg: "Refusing to modify existing non-proxy account {{ item.item }}" + loop: "{{ megaproxy_existing_accounts.results }}" + loop_control: + label: "{{ item.item }}" + +- name: Close sessions of retired proxy accounts + ansible.builtin.command: + argv: [pkill, -KILL, -u, "{{ item.item }}"] + loop: "{{ megaproxy_existing_accounts.results }}" + when: + - item.item in megaproxy_services.ssh.removed_users | default([]) + - item.rc == 0 + register: megaproxy_retired_sessions + failed_when: megaproxy_retired_sessions.rc not in [0, 1] + changed_when: megaproxy_retired_sessions.rc == 0 diff --git a/src/megaproxy_server/config_bundle.py b/src/megaproxy_server/config_bundle.py index 621805f..fd54874 100644 --- a/src/megaproxy_server/config_bundle.py +++ b/src/megaproxy_server/config_bundle.py @@ -4,7 +4,6 @@ import json import os import re -import tempfile from copy import deepcopy from pathlib import Path from typing import Any @@ -14,7 +13,7 @@ from .config_api import AccessDenied, MAX_RESPONSE, credential_bytes, derive_key, render_config, unlock, user_id from .export import _secret -from .inventory import ROOT, https_jump_routes, https_profile_routes, https_routes, ssh_jump_routes +from .inventory import ROOT, atomic_write, https_jump_routes, https_profile_routes, https_routes, ssh_jump_routes from .models import ConfigApiService, Inventory @@ -24,8 +23,14 @@ def public_url(service: ConfigApiService) -> str: return f"https://{host}{port}{service.path}" -def validate_config(document: dict[str, Any]) -> None: +def validate_config(document: dict[str, Any], *, allow_legacy: bool = False) -> None: schema = json.loads((ROOT / "schemas/megaproxy-v8.schema.json").read_text()) + if allow_legacy and document.get("version") == 7: + # Generated v7 exports use the common fields, without the v8 transports. + schema["properties"]["version"]["const"] = 7 + if any(item.get("proxy", {}).get("type") not in {"HTTPS", "SSH", "SSH_JUMP"} + or "preferHttp3" in item.get("proxy", {}) for item in document.get("profiles", [])): + raise ValueError("Version 7 exports cannot contain version 8 transports") # Validation errors must never print a document containing credentials. errors = Draft202012Validator(schema).iter_errors(document) error = next(errors, None) @@ -159,11 +164,4 @@ def write_bundle(inventory: Inventory, path: Path) -> None: if path.is_file() and path.read_text() == content: return path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".config-bundle-") - try: - with os.fdopen(fd, "w") as stream: - stream.write(content) - os.replace(temporary, path) - finally: - if os.path.exists(temporary): - os.unlink(temporary) + atomic_write(path, content) diff --git a/src/megaproxy_server/config_format.py b/src/megaproxy_server/config_format.py index 19bdc45..cda1cf3 100644 --- a/src/megaproxy_server/config_format.py +++ b/src/megaproxy_server/config_format.py @@ -10,7 +10,8 @@ def profile(name: str, proxy: dict[str, Any], color: int, country_code: str = "", *, identity: str | None = None) -> dict[str, Any]: return { "id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"dev.megaproxy.server/{identity if identity is not None else name}")), - "name": name, "color": color, "countryCode": country_code, "proxy": proxy, + "name": name, "color": color, + "countryCode": country_code if re.fullmatch(r"[A-Z]{2}", country_code) else "", "proxy": proxy, "tls": {"fingerprint": "DEFAULT", "customJa3": ""}, "dns": {"provider": "CLOUDFLARE", "customDohUrl": ""}, "routing": {"routeAllApps": True, "selectedPackages": [], "allowIpv6": False, "bypassLocalNetworks": True}, diff --git a/src/megaproxy_server/export.py b/src/megaproxy_server/export.py index 9734c65..35d3095 100644 --- a/src/megaproxy_server/export.py +++ b/src/megaproxy_server/export.py @@ -6,7 +6,7 @@ from typing import Any from .config_format import configuration, https_jump_profile, profile as _profile -from .inventory import https_jump_routes, https_profile_routes, https_routes, ssh_jump_routes +from .inventory import atomic_write, https_jump_routes, https_profile_routes, https_routes, ssh_jump_routes from .models import Host, Inventory, SshUser @@ -111,6 +111,6 @@ def export_profiles(inventory: Inventory, output: Path, include_all_jumps: bool result = configuration(profiles) if inventory.settings.https_jump_pairs or any(host.services.https and host.services.https.enabled and (host.services.https.http3 or host.services.https.socks5.enabled) for host in inventory.hosts.values()): result["version"] = 8 - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") - output.chmod(0o600) + from .config_bundle import validate_config + validate_config(result, allow_legacy=True) + atomic_write(output, json.dumps(result, indent=2) + "\n") diff --git a/src/megaproxy_server/generate.py b/src/megaproxy_server/generate.py index 0740dd5..84e3e2b 100644 --- a/src/megaproxy_server/generate.py +++ b/src/megaproxy_server/generate.py @@ -6,7 +6,7 @@ from urllib.parse import quote, urlencode from .export import export_profiles -from .inventory import https_profile_routes, load +from .inventory import atomic_write, https_profile_routes, load from .models import Inventory COLORS = ["#b9c6f6", "#eef209", "#3100f1", "#8b0000", "#00ced1", "#0f98cf"] @@ -53,8 +53,7 @@ def render_super_proxy(entries: list[dict[str, object]]) -> str: def write_if_changed(path: Path, content: str) -> bool: if path.is_file() and path.read_text(encoding="utf-8") == content: return False - path.write_text(content, encoding="utf-8") - path.chmod(0o600) + atomic_write(path, content) return True diff --git a/src/megaproxy_server/inventory.py b/src/megaproxy_server/inventory.py index e63ba92..afa63e2 100644 --- a/src/megaproxy_server/inventory.py +++ b/src/megaproxy_server/inventory.py @@ -7,6 +7,7 @@ import re import shlex import subprocess +import tempfile from pathlib import Path from typing import Any @@ -96,12 +97,26 @@ def save( lambda representer, value: representer.represent_scalar("!vault", str(value), style="|"), ) yaml.dump(data, stream) - path.write_text(stream.getvalue(), encoding="utf-8") - os.chmod(path, 0o600) + atomic_write(path, stream.getvalue()) if remember_location: remember(path) +def atomic_write(path: Path, content: str) -> None: + """Replace a secret file only after a complete, private write succeeds.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}-") + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + def _vault_secret_value() -> VaultSecret: global _vault_secret if _vault_secret is not None: @@ -181,6 +196,7 @@ def https_routes(inventory: Inventory, name: str) -> list[dict[str, Any]]: "probe_resistance": probe_resistance.model_dump(mode="json"), }) for route in routes: + route["country_code"] = route["country_code"] if re.fullmatch(r"[A-Z]{2}", route["country_code"]) else "" route["http3_port"] = https.port if https.http3 and not route["chain"] else None return routes diff --git a/src/megaproxy_server/models.py b/src/megaproxy_server/models.py index 22a24be..08474a5 100644 --- a/src/megaproxy_server/models.py +++ b/src/megaproxy_server/models.py @@ -8,6 +8,8 @@ Port = Annotated[int, Field(ge=1, le=65535)] +SshLogin = Annotated[str, Field(min_length=1, pattern=r"^[a-z_][a-z0-9_-]*$")] +RESERVED_SSH_USERS = {"root", "admin", "ansible", "ubuntu", "debian", "ec2-user"} class AdminAccess(BaseModel): @@ -106,13 +108,12 @@ def validate_material(self) -> SshAuthentication: class SshUser(BaseModel): - name: str = Field(min_length=1, pattern=r"^[a-z_][a-z0-9_-]*$") + name: SshLogin authentication: SshAuthentication @model_validator(mode="after") def dedicated_account(self) -> SshUser: - reserved = {"root", "admin", "ansible", "ubuntu", "debian", "ec2-user"} - if self.name in reserved: + if self.name in RESERVED_SSH_USERS: raise ValueError("use a dedicated SSH proxy login, not a system account") return self @@ -123,7 +124,7 @@ class SshService(BaseModel): title: str = Field(min_length=1, pattern=r"\S") port: Port = 22 users: list[SshUser] = Field(min_length=1) - removed_users: list[str] = Field(default_factory=list) + removed_users: list[SshLogin] = Field(default_factory=list) max_startups: str = "10:30:60" per_source_max_startups: int = Field(default=5, ge=1, le=100) fail2ban: bool = True @@ -169,8 +170,8 @@ class Services(BaseModel): @model_validator(mode="after") def any_enabled(self) -> Services: - if not any(service and service.enabled for service in (self.https, self.ssh, self.config_api)) and not (self.config_api and not self.config_api.enabled): - raise ValueError("at least one service must be enabled") + if not any((self.https, self.ssh, self.config_api)): + raise ValueError("at least one service must be configured") if self.config_api and self.config_api.enabled and self.https and self.https.enabled: raise ValueError("deploy the config API on a separate host from the HTTPS proxy") return self @@ -209,6 +210,11 @@ def validate_users(self) -> Host: raise ValueError("an SSH proxy user must not be the administrative user") if set(names) & set(self.services.ssh.removed_users): raise ValueError("an SSH proxy user cannot be both present and removed") + forbidden = RESERVED_SSH_USERS | {self.admin.user, self.admin.bootstrap_user} + if forbidden & set(self.services.ssh.removed_users): + raise ValueError("cannot remove an administrative or reserved SSH account") + if self.admin.bootstrap_user in names: + raise ValueError("an SSH proxy user must not be the bootstrap user") return self @@ -256,7 +262,7 @@ def validate_https_chains(self) -> Settings: class ProxyUsers(BaseModel): https: list[HttpsUser] = Field(default_factory=list) ssh: list[SshUser] = Field(default_factory=list) - removed_ssh: list[str] = Field(default_factory=list) + removed_ssh: list[SshLogin] = Field(default_factory=list) class Inventory(BaseModel): @@ -319,13 +325,34 @@ def hosts_not_empty(self) -> Inventory: from .inventory import https_routes for name, host in self.hosts.items(): https = host.services.https - if not https or not https.enabled or not https.socks5.enabled: + if not https or not https.enabled: continue routes = https_routes(self, name) - if https.socks5.port in {route["port"] for route in routes}: - raise ValueError("SOCKS5 port conflicts with an HTTPS backend") - if any(https.socks5.udp_port_min <= route["http3_port"] <= https.socks5.udp_port_max for route in routes if route["http3_port"]): + if not routes: + raise ValueError(f"enabled HTTPS host {name!r} requires at least one route") + tcp_ports = {host.admin.port} + if host.services.ssh and host.services.ssh.enabled: + tcp_ports.add(host.services.ssh.port) # Both ports belong to the same sshd. + listeners = [https.port] + [route["port"] for route in routes] + if any(route["probe_resistance"]["enabled"] for route in routes): + listeners.append(18080) + if https.certificate != "self-signed": + listeners.append(80) + if https.socks5.enabled: + listeners.append(https.socks5.port) + for port in listeners: + if not 1 <= port <= 65535 or port in tcp_ports: + raise ValueError(f"HTTPS listener port {port} conflicts or is out of range on {name!r}") + tcp_ports.add(port) + if https.http3 and not https.direct: + raise ValueError("HTTP/3 requires a direct HTTPS route") + if https.socks5.enabled and any(https.socks5.udp_port_min <= route["http3_port"] <= https.socks5.udp_port_max for route in routes if route["http3_port"]): raise ValueError("SOCKS5 UDP range conflicts with HTTP/3") + forbidden_removals = RESERVED_SSH_USERS | { + value for host in self.hosts.values() for value in (host.admin.user, host.admin.bootstrap_user) + } + if forbidden_removals & set(self.users.removed_ssh): + raise ValueError("cannot remove an administrative or reserved SSH account") api_services = [host.services.config_api for host in self.hosts.values() if host.services.config_api and host.services.config_api.enabled] if len(api_services) > 8: raise ValueError("subscriptions support at most eight config API endpoints") diff --git a/src/megaproxy_server/wizard.py b/src/megaproxy_server/wizard.py index f94e8a3..7fd9090 100644 --- a/src/megaproxy_server/wizard.py +++ b/src/megaproxy_server/wizard.py @@ -179,6 +179,7 @@ def create_inventory(path: Path = DEFAULT_INVENTORY, existing: Inventory | None print("These SSH proxy users will be configured on every SSH-enabled host.") global_ssh_users = ask_users("SSH", "all-hosts", {admin_user} | {host.admin.user for host in hosts.values()}) ssh = SshService( + generate_profile=bool(questionary.confirm("Publish direct SSH profiles for this host?", default=True).ask()), title=ask_required("SSH profile title", https.title if https else name), port=int(ask_required("SSH proxy port", str(admin_port))), users=global_ssh_users, diff --git a/tests/integration/config_api.py b/tests/integration/config_api.py index b30b26b..ef52128 100644 --- a/tests/integration/config_api.py +++ b/tests/integration/config_api.py @@ -107,7 +107,26 @@ def request(path, headers=None, method="GET"): assert status == 403, (path, status) assert "noindex" in headers["X-Robots-Tag"] assert "WWW-Authenticate" not in headers - print("HTTPS/nginx integration passed: trusted TLS, v8, authenticated ETag, robots.txt, exact paths, 403 and noindex.") + context = ssl.create_default_context(cafile=str(certificates / "fullchain.pem")) + for raw in ( + f"GET {api.path} HTTP/1.1\r\nHost: localhost\r\nAuthorization: {authorization}\r\nAuthorization: Basic bad\r\nConnection: close\r\n\r\n", + "GET / HTTP/1.1\r\nBroken header\r\n\r\n", + ): + with socket.create_connection(("127.0.0.1", public_port), timeout=5) as sock: + with context.wrap_socket(sock, server_hostname="localhost") as secured: + secured.sendall(raw.encode()) + response = secured.recv(4096) + assert response.split(b"\r\n", 1)[0].split()[1] == b"403", response + assert b"www-authenticate" not in response.lower() + limited = nginx.replace("rate=60000r/m;", "rate=30r/m;").replace("burst=100 nodelay", "burst=20 nodelay") + configuration.write_text(f"pid {root}/nginx.pid;\nerror_log {root}/errors.log;\nevents {{ worker_connections 128; }}\nhttp {{\n{limited}\n}}\n") + subprocess.run(command + ["-s", "reload"], check=True, capture_output=True) + time.sleep(0.2) + limited_responses = [request(api.path) for _ in range(40)] + assert all(status == 403 for status, _, _ in limited_responses) + assert any(body != b"Forbidden\n" for _, _, body in limited_responses) + assert all("WWW-Authenticate" not in headers for _, headers, _ in limited_responses) + print("HTTPS/nginx integration passed: TLS, v8, ETag, exact paths, duplicate/malformed headers, rate limiting and fail-closed responses.") finally: process.terminate() process.wait(timeout=5) diff --git a/tests/integration/lifecycle.py b/tests/integration/lifecycle.py new file mode 100644 index 0000000..1b1a23f --- /dev/null +++ b/tests/integration/lifecycle.py @@ -0,0 +1,104 @@ +"""Exercise changes on an already provisioned LXD host, without creating more guests.""" +import argparse +import os +import subprocess +from pathlib import Path + +from megaproxy_server.inventory import load, save +from megaproxy_server.models import SshAuthentication, SshUser +from megaproxy_server.secrets import generate_key, password_hash +from megaproxy_server.users import UserRef, remove_users + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--inventory", type=Path, required=True) + parser.add_argument("--work-dir", type=Path, required=True) + args = parser.parse_args() + source = load(args.inventory) + host = source.hosts["one"] + original = source.model_copy(deep=True) + old_admin_key = host.admin.private_key_file + old_proxy_key = source.users.ssh[0].authentication.generated_private_key + public, admin_key = generate_key(args.work_dir / "rotated-admin", "CI admin rotation") + _, proxy_key = generate_key(args.work_dir / "rotated-proxy", "CI proxy rotation") + proxy_public = proxy_key.with_suffix(".pub").read_text().strip() + environment = os.environ.copy() + environment["SSH_ASKPASS_REQUIRE"] = "force" + environment["DISPLAY"] = ":0" + askpass = args.work_dir / "askpass" + askpass.write_text('#!/bin/sh\nprintf "%s\\n" "$MEGAPROXY_TEST_PASSWORD"\n') + askpass.chmod(0o700) + environment["SSH_ASKPASS"] = str(askpass) + + def ssh(user, key=None, *, password=None, forwarding=False): + command = ["ssh", "-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "ConnectTimeout=5", + "-o", "StrictHostKeyChecking=yes", "-o", f"UserKnownHostsFile={args.work_dir}/known_hosts"] + if password is None: + command += ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-i", str(key)] + else: + command += ["-o", "PubkeyAuthentication=no", "-o", "PreferredAuthentications=password", "-o", "NumberOfPasswordPrompts=1"] + command += (["-W", "127.0.0.1:18082"] if forwarding else []) + [f"{user}@{host.address}"] + if not forwarding: + command += ["sudo -n true"] + return subprocess.run(command, input=b"GET / HTTP/1.0\r\n\r\n" if forwarding else None, + capture_output=True, timeout=15, env=dict(environment, MEGAPROXY_TEST_PASSWORD=password or "")) + + def apply(tags): + save(args.inventory, source) + subprocess.run(["./mega-proxy", "--no-hooks", "-i", str(args.inventory), "apply", "--limit", "one", "--tags", tags], check=True) + + def tunnel(user, key=None, password=None, *, accepted=True): + result = ssh(user, key, password=password, forwarding=True) + if accepted: + assert result.returncode == 0 and b"CI local target" in result.stdout, result.stderr + else: + assert result.returncode != 0 and b"CI local target" not in result.stdout + + host.admin.public_key = public + apply("admin") # The old connection installs the new key before it is revoked. + host.admin.private_key_file = str(admin_key) + save(args.inventory, source) + assert ssh(host.admin.user, admin_key).returncode == 0 + assert ssh(host.admin.user, old_admin_key).returncode != 0 + source.users.ssh[0].authentication.public_key = proxy_public + source.users.ssh[0].authentication.generated_private_key = str(proxy_key) + password = "ci-password-authentication-123" + source.users.ssh.append(SshUser(name="mp-password", authentication=SshAuthentication( + type="password", password=password, password_hash=password_hash(password)))) + apply("ssh") + tunnel("mp-ci", proxy_key) + tunnel("mp-ci", old_proxy_key, accepted=False) + tunnel("mp-password", password=password) + tunnel("mp-password", password="incorrect-password", accepted=False) + rotated_password = "ci-rotated-password-456" + source.users.ssh[-1].authentication = SshAuthentication( + type="password", password=rotated_password, password_hash=password_hash(rotated_password)) + apply("ssh") + tunnel("mp-password", password=rotated_password) + tunnel("mp-password", password=password, accepted=False) + remove_users(source, [UserRef("SSH", "mp-ci")]) + apply("ssh") + tunnel("mp-ci", proxy_key, accepted=False) + host.services.ssh.enabled = False + host.services.https.enabled = False + apply("ssh,https,firewall") + tunnel("mp-password", password=rotated_password, accepted=False) + assert ssh(host.admin.user, admin_key).returncode == 0 + state = subprocess.run(["sudo", "lxc", "exec", "megaproxy-ci-one", "--", "systemctl", "is-active", "megaproxy-gost"], capture_output=True) + assert state.returncode != 0 + firewall = subprocess.check_output(["sudo", "lxc", "exec", "megaproxy-ci-one", "--", "ufw", "status"]) + assert b"443/tcp" not in firewall and b"22/tcp" in firewall + # Leave the guest ready for the existing on-server setup scenario. + original.hosts["one"].admin.public_key = public + original.hosts["one"].admin.private_key_file = str(admin_key) + original.users.removed_ssh.append("mp-password") + source = original + host = source.hosts["one"] + apply("ssh,https,firewall") + tunnel("mp-ci", old_proxy_key) + print("Lifecycle passed: admin/proxy key rotation, real SSH passwords, removal, disable/re-enable and administrative access.") + + +if __name__ == "__main__": + main() diff --git a/tests/integration/lxd.sh b/tests/integration/lxd.sh index dbc0d47..e548a63 100755 --- a/tests/integration/lxd.sh +++ b/tests/integration/lxd.sh @@ -93,6 +93,11 @@ chmod 0600 "$inventory_file" ./mega-proxy --inventory "$inventory_file" apply ./mega-proxy --inventory "$inventory_file" verify +sudo lxc exec megaproxy-ci-one -- mkdir -p /tmp/megaproxy-ci-target +printf '%s\n' 'CI local target' | sudo lxc exec megaproxy-ci-one -- tee /tmp/megaproxy-ci-target/index.html >/dev/null +sudo lxc exec megaproxy-ci-one -- systemd-run --unit=megaproxy-ci-target \ + python3 -m http.server 18082 --bind 127.0.0.1 --directory /tmp/megaproxy-ci-target + for instance in "${instances[@]}"; do sudo lxc exec "$instance" -- sshd -T -C user=root,host=localhost,addr=127.0.0.1 | grep -x 'permitrootlogin no' >/dev/null sudo lxc exec "$instance" -- sshd -T -C user=ci-admin,host=localhost,addr=127.0.0.1 | grep -x 'passwordauthentication no' >/dev/null @@ -131,6 +136,8 @@ if [[ "$host_count" -ge 2 ]]; then ssh -S "$jump_socket" -O exit "mp-ci@$ip_two" fi +uv run --frozen python tests/integration/lifecycle.py --inventory "$inventory_file" --work-dir "$work_dir" + # 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 @@ -170,4 +177,10 @@ PY 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' + if ssh -i "$work_dir/rotated-admin" -o BatchMode=yes -o IdentitiesOnly=yes \ + -o ControlMaster=no -o ControlPath=none -o UserKnownHostsFile="$work_dir/known_hosts" \ + "ci-admin@$ip_one" 'sudo -n true'; then + echo "Retired administrator still has access" >&2 + exit 1 + fi fi diff --git a/tests/test_https_proxy_role.py b/tests/test_https_proxy_role.py index 923f04f..a90e349 100644 --- a/tests/test_https_proxy_role.py +++ b/tests/test_https_proxy_role.py @@ -41,7 +41,7 @@ def render(): def test_https_role_loads_a_certificate_newer_than_the_service() -> None: - tasks = (ROOT / "roles" / "https_proxy" / "tasks" / "main.yml").read_text( + tasks = (ROOT / "roles" / "https_proxy" / "tasks" / "enabled.yml").read_text( encoding="utf-8" ) reload_task = tasks.split("- name: Load a newer certificate into GOST", 1)[1].split( @@ -53,7 +53,7 @@ def test_https_role_loads_a_certificate_newer_than_the_service() -> None: def test_domain_certificate_detects_openssl_textual_mismatch() -> None: - tasks = (ROOT / "roles" / "https_proxy" / "tasks" / "main.yml").read_text( + tasks = (ROOT / "roles" / "https_proxy" / "tasks" / "enabled.yml").read_text( encoding="utf-8" ) decision_task = tasks.split( @@ -98,7 +98,7 @@ def test_https_proxy_uses_http2() -> None: def test_docker_cleanup_runs_after_docker_service_changes() -> None: - tasks = (ROOT / "roles" / "https_proxy" / "tasks" / "main.yml").read_text( + tasks = (ROOT / "roles" / "https_proxy" / "tasks" / "enabled.yml").read_text( encoding="utf-8" ) handlers = (ROOT / "roles" / "https_proxy" / "handlers" / "main.yml").read_text( @@ -119,6 +119,3 @@ def test_docker_cleanup_runs_after_docker_service_changes() -> None: assert "cleanup_old_images gogost/gost" in cleanup assert "cleanup_old_images certbot/certbot" in cleanup assert "megaproxy_services.https.certbot_version" in cleanup - assert "docker image prune --force" in cleanup - assert "docker volume prune --force" in cleanup - assert "docker network prune --force" in cleanup diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py new file mode 100644 index 0000000..27ab132 --- /dev/null +++ b/tests/test_lifecycle.py @@ -0,0 +1,181 @@ +import json +import os +import subprocess +import sys +from copy import deepcopy +from pathlib import Path +from types import SimpleNamespace + +import pytest +from jinja2 import Environment +from ruamel.yaml import YAML + +from megaproxy_server import inventory as inventory_module, wizard +from megaproxy_server.config_bundle import validate_config +from megaproxy_server.export import export_profiles +from megaproxy_server.inventory import ROOT, load, save +from megaproxy_server.models import Inventory + + +@pytest.fixture +def raw_inventory(): + return {"users": { + "https": [{"name": "alice", "password": "synthetic-test-password"}], + "ssh": [{"name": "mp-alice", "authentication": { + "type": "password", "password": "synthetic-ssh-password", "password_hash": "$6$test"}}]}, + "hosts": {"one": {"address": "192.0.2.1", + "admin": {"user": "deploy", "private_key_file": "/unused", "public_key": "ssh-ed25519 TEST"}, + "services": {"https": {"title": "ONE", "endpoint": "one.example", "certificate": "self-signed"}, + "ssh": {"title": "ONE", "generate_profile": True}}}}} + + +@pytest.mark.parametrize("login", ["deploy", "root", "ubuntu", "first-admin", "../victim", "bad user"]) +def test_removal_rejects_administrators_and_invalid_logins(raw_inventory, login): + raw_inventory["hosts"]["one"]["admin"]["bootstrap_user"] = "first-admin" + raw_inventory["users"]["removed_ssh"] = [login] + with pytest.raises(ValueError): + Inventory.model_validate(raw_inventory) + + +@pytest.mark.parametrize("port", [443, 10443, 18080]) +def test_https_ports_cannot_conflict_with_administrative_ssh(raw_inventory, port): + raw_inventory["hosts"]["one"]["admin"]["port"] = port + raw_inventory["hosts"]["one"]["services"]["https"]["probe_resistance"] = {"enabled": True} + with pytest.raises(ValueError, match="port.*conflicts"): + Inventory.model_validate(raw_inventory) + + +def test_routes_must_exist_and_backend_ports_must_fit(raw_inventory): + source = deepcopy(raw_inventory) + raw_inventory["hosts"]["one"]["services"]["https"]["direct"] = False + with pytest.raises(ValueError, match="at least one route"): + Inventory.model_validate(raw_inventory) + source["hosts"]["one"]["services"]["https"].update( + certificate="domain", acme_email="test@example.com", chain_entry=True) + source["hosts"]["two"] = deepcopy(source["hosts"]["one"]) + source["hosts"]["two"]["services"]["https"].update( + endpoint="two.example", chain_entry=False, chain_exit=True, chain_password="chain-secret") + source["settings"] = {"https_chains_enabled": True, "https_chain_domain": "chain.example", + "https_chain_backend_port": 65535, + "https_chain_pairs": [{"entry": "one", "exit": "two", "title": "CHAIN", "country_code": "US"}]} + with pytest.raises(ValueError, match="65536"): + Inventory.model_validate(source) + + +@pytest.mark.parametrize("service", ["ssh", "https"]) +def test_disabled_only_host_can_be_kept_for_teardown(raw_inventory, service): + raw_inventory["hosts"]["one"]["services"] = {service: raw_inventory["hosts"]["one"]["services"][service]} + raw_inventory["hosts"]["one"]["services"][service]["enabled"] = False + Inventory.model_validate(raw_inventory) + + +def test_export_validates_before_replacing_file_and_normalizes_country(raw_inventory, tmp_path): + source = Inventory.model_validate(raw_inventory) + target = tmp_path / "export.json" + export_profiles(source, target) + document = json.loads(target.read_text()) + assert document["profiles"][0]["countryCode"] == "" + validate_config(document, allow_legacy=True) + before = target.read_bytes() + source.hosts["one"].services.https.title = "x" * 300 + with pytest.raises(ValueError, match="name"): + export_profiles(source, target) + assert target.read_bytes() == before + + +def test_inventory_write_failure_keeps_previous_file(raw_inventory, tmp_path, monkeypatch): + target = tmp_path / "inventory.yml" + source = Inventory.model_validate(raw_inventory) + save(target, source) + before = target.read_bytes() + def fail_replace(temporary, destination): + assert Path(temporary).stat().st_mode & 0o777 == 0o600 + assert destination == target + raise OSError("synthetic write failure") + monkeypatch.setattr(inventory_module.os, "replace", fail_replace) + with pytest.raises(OSError, match="synthetic"): + save(target, source) + assert target.read_bytes() == before + assert list(tmp_path.iterdir()) == [target] + assert load(target) == source + + +def test_ssh_only_wizard_publishes_selected_direct_profiles(raw_inventory, tmp_path, monkeypatch): + answers = {"Inventory host name (for example proxy_eu)": "one", + "Public IP address or DNS name": "192.0.2.1", + "Permanent administrative user to create or update": "deploy", + "Administrative SSH port": "22", "SSH proxy port": "22", "SSH profile title": "ONE"} + def question(value): + return SimpleNamespace(ask=lambda: value) + monkeypatch.setattr(wizard, "ask_required", lambda message, default=None: answers[message]) + monkeypatch.setattr(wizard.questionary, "select", lambda *a, **k: question("Generate a dedicated key")) + monkeypatch.setattr(wizard.questionary, "checkbox", lambda *a, **k: question(["ssh"])) + monkeypatch.setattr(wizard.questionary, "confirm", lambda message, **k: question(message.startswith("Publish direct SSH"))) + monkeypatch.setattr(wizard, "generate_key", lambda *a: ("ssh-ed25519 TEST", Path("/unused"))) + monkeypatch.setattr(wizard, "ask_users", lambda *a: Inventory.model_validate(raw_inventory).users.ssh) + monkeypatch.setattr(wizard, "save", lambda *a, **k: None) + source = wizard.create_inventory(tmp_path / "inventory.yml", local=True) + export_profiles(source, tmp_path / "export.json") + assert [p["proxy"]["type"] for p in json.loads((tmp_path / "export.json").read_text())["profiles"]] == ["SSH"] + + +def run_tasks(tasks, tmp_path): + playbook = tmp_path / "tasks.yml" + with playbook.open("w") as stream: + YAML().dump([{"hosts": "all", "gather_facts": False, "tasks": tasks}], stream) + environment = dict(os.environ, ANSIBLE_REMOTE_TEMP=str(tmp_path / "remote")) + environment["PATH"] = str(Path(sys.executable).parent) + os.pathsep + environment["PATH"] + return subprocess.run(["ansible-playbook", "-i", "localhost,", "-c", "local", str(playbook)], + cwd=ROOT, env=environment, capture_output=True, text=True) + + +def test_administrative_key_rotation_revokes_previous_key(tmp_path): + target = tmp_path / "authorized_keys" + target.write_text("ssh-ed25519 OLD\n") + task = next(t for t in YAML(typ="safe").load((ROOT / "roles/admin/tasks/account.yml").read_text()) + if t["name"] == "Install administrative public key") + task["ansible.builtin.copy"].update(dest=str(target), content="ssh-ed25519 NEW\n") + task["ansible.builtin.copy"].pop("owner") + result = run_tasks([task], tmp_path) + assert result.returncode == 0, result.stdout + result.stderr + assert target.read_text() == "ssh-ed25519 NEW\n" + + +def test_unmanaged_account_is_rejected_before_destructive_tasks(tmp_path): + tasks = YAML(typ="safe").load((ROOT / "roles/ssh_proxy/tasks/validate_accounts.yml").read_text()) + tasks[0]["vars"] = {"megaproxy_admin": {"user": "deploy"}, + "megaproxy_services": {"ssh": {"users": [], "removed_users": ["other-user"]}}} + # Replace only the OS lookup boundary; execute the actual guard before deletion. + tasks[1]["ansible.builtin.command"]["argv"] = ["echo", "other-group"] + tasks[1]["vars"] = tasks[0]["vars"] + marker = tmp_path / "deleted" + tasks.append({"ansible.builtin.copy": {"dest": str(marker), "content": "unsafe"}}) + result = run_tasks(tasks, tmp_path) + assert result.returncode != 0 + assert "Refusing to modify existing non-proxy account" in result.stdout + assert not marker.exists() + + +def test_docker_cleanup_keeps_unrelated_resources(tmp_path): + docker = tmp_path / "docker" + calls = tmp_path / "calls.jsonl" + docker.write_text(f'''#!{sys.executable} +import json, sys +with open({str(calls)!r}, "a") as stream: + stream.write(json.dumps(sys.argv[1:]) + "\\n") +if sys.argv[1:2] == ["inspect"]: + print("keep-gost") +elif sys.argv[1:3] == ["image", "inspect"]: + print("keep-certbot") +elif sys.argv[1:3] == ["image", "ls"]: + print("keep-gost gogost/gost\\nold-gost gogost/gost\\nkeep-certbot certbot/certbot\\nother other/project") +''') + docker.chmod(0o755) + content = Environment().from_string((ROOT / "roles/https_proxy/templates/megaproxy-docker-clean.sh.j2").read_text()).render( + megaproxy_services={"https": {"certbot_version": "test"}}) + result = subprocess.run(["sh"], input=content, text=True, capture_output=True, + env=dict(os.environ, PATH=str(tmp_path) + os.pathsep + os.environ["PATH"])) + assert result.returncode == 0, result.stderr + commands = [json.loads(line) for line in calls.read_text().splitlines()] + assert ["image", "rm", "old-gost"] in commands + assert not any("prune" in command or "other" in command for command in commands) diff --git a/tests/test_ssh_proxy_role.py b/tests/test_ssh_proxy_role.py index f2e586d..1db6459 100644 --- a/tests/test_ssh_proxy_role.py +++ b/tests/test_ssh_proxy_role.py @@ -5,7 +5,7 @@ def test_key_only_account_is_not_locked_before_public_key_authentication() -> None: - tasks = (ROOT / "roles" / "ssh_proxy" / "tasks" / "main.yml").read_text(encoding="utf-8") + tasks = (ROOT / "roles" / "ssh_proxy" / "tasks" / "enabled.yml").read_text(encoding="utf-8") user_task = tasks.split("- name: Create restricted SSH proxy users", 1)[1].split( "- name: Explain deferred SSH user planning", 1 )[0] @@ -15,7 +15,7 @@ def test_key_only_account_is_not_locked_before_public_key_authentication() -> No def test_authorized_key_relies_on_the_restricted_sshd_match_block() -> None: - tasks = (ROOT / "roles" / "ssh_proxy" / "tasks" / "main.yml").read_text(encoding="utf-8") + tasks = (ROOT / "roles" / "ssh_proxy" / "tasks" / "enabled.yml").read_text(encoding="utf-8") key_task = tasks.split("- name: Install restricted authorized keys", 1)[1].split( "- name: Remove key files for password-only users", 1 )[0] From d5f9ee853b249266dccc56c49cddd75d1269b578 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Sun, 11 Oct 2026 17:05:16 +0300 Subject: [PATCH 2/2] Verify HTTPS chain TLS and rotate admin keys through verified bootstrap --- .github/workflows/integration.yml | 3 + docs/en/README.md | 7 + docs/ru/README.md | 7 + roles/admin/tasks/account.yml | 11 ++ roles/admin/tasks/policy.yml | 4 + roles/https_proxy/templates/gost.yml.j2 | 3 + roles/ssh_proxy/tasks/validate_accounts.yml | 12 ++ src/megaproxy_server/bootstrap.py | 10 + src/megaproxy_server/cli.py | 13 +- tests/integration/https.py | 209 ++++++++++++++++++++ tests/integration/lifecycle.py | 6 +- tests/test_bootstrap.py | 1 + tests/test_lifecycle.py | 30 ++- 13 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 tests/integration/https.py diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 13c14a9..8390f7c 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -47,6 +47,9 @@ jobs: - name: Verify authenticated SOCKS5 and MASQUE TCP and UDP run: uv run --frozen python tests/integration/transports.py --gost "$RUNNER_TEMP/megaproxy-gost/gost" + - name: Verify HTTPS knock, server chains and Jump + run: uv run --frozen python tests/integration/https.py --gost "$RUNNER_TEMP/megaproxy-gost/gost" + - name: Verify HTTPS config API through nginx run: | sudo apt-get update diff --git a/docs/en/README.md b/docs/en/README.md index fd234c4..ad144a4 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -161,11 +161,18 @@ reserved names and paths are rejected in `removed_ssh`. The administrative revokes the old key. Changing `admin.user` requires bootstrap through a working account: the new administrator is verified before retiring the previous managed account and sudo access, preserving its files. +For key rotation, set the new `public_key`/`private_key_file`, the current admin as +`bootstrap_user`, and the old private key as `bootstrap_private_key_file`. +Bootstrap verifies the new login before revoking the old key; mismatched public +and private keys are rejected. Inventory and exports are replaced atomically with mode `0600`. Export validation runs before replacing an existing file; unknown country codes become empty strings. Port conflicts and HTTPS without routes are rejected before connecting to a host. Docker cleanup only removes old GOST/Certbot images, without global prune operations. +HTTPS chains verify the exit certificate and hostname. CI uses a local CA to test +knock/chain overrides, nested TLS Jump, and rejection of bad credentials, certificates +or unavailable exits, without external domains or ACME for these checks. | enabled | generate_profile | Result | | --- | --- | --- | diff --git a/docs/ru/README.md b/docs/ru/README.md index 19d7e3d..368ca36 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -170,11 +170,18 @@ SSH-аккаунты могут изменяться и удаляться то старый ключ. При смене `admin.user` bootstrap сначала проверяет нового администратора, затем удаляет прежний управляемый аккаунт и sudo-доступ, сохраняя его файлы. Для миграции укажите `bootstrap_user` с действующим доступом. +Для ротации ключа задайте новую пару `public_key`/`private_key_file`, текущего +администратора в `bootstrap_user`, а прежний приватный ключ — в +`bootstrap_private_key_file`. Bootstrap проверяет новый вход перед отзывом старого +ключа; несовпадающая пара публичного и приватного ключей отклоняется. Инвентарь и экспорт записываются атомарно с правами `0600`. Экспорт проверяется до замены прежнего файла; неизвестный код страны становится пустой строкой. Конфликты портов и HTTPS без маршрутов отклоняются до подключения к хосту. Docker cleanup удаляет только старые образы GOST/Certbot, без глобального prune. +HTTPS-цепочки проверяют сертификат и имя выхода. CI использует локальную CA и +проверяет knock, override цепочки, TLS Jump, отказ при неправильных credentials, +сертификате или недоступном выходе; внешние домены и ACME для этих тестов не нужны. | enabled | generate_profile | Результат | | --- | --- | --- | diff --git a/roles/admin/tasks/account.yml b/roles/admin/tasks/account.yml index ee0b11c..bd0a4f0 100644 --- a/roles/admin/tasks/account.yml +++ b/roles/admin/tasks/account.yml @@ -25,11 +25,22 @@ mode: "0700" - name: Install administrative public key + ansible.builtin.lineinfile: + path: "{{ megaproxy_admin_account.home }}/.ssh/authorized_keys" + line: "{{ megaproxy_admin.public_key }}" + create: true + owner: "{{ megaproxy_admin.user }}" + mode: "0600" + when: megaproxy_bootstrap_phase | default('') == 'account' + no_log: true + +- name: Replace verified administrative public key ansible.builtin.copy: dest: "{{ megaproxy_admin_account.home }}/.ssh/authorized_keys" content: "{{ megaproxy_admin.public_key }}\n" owner: "{{ megaproxy_admin.user }}" mode: "0600" + when: megaproxy_bootstrap_phase | default('') != 'account' no_log: true - name: Allow non-interactive administration through sudo diff --git a/roles/admin/tasks/policy.yml b/roles/admin/tasks/policy.yml index 5b1d441..a10609f 100644 --- a/roles/admin/tasks/policy.yml +++ b/roles/admin/tasks/policy.yml @@ -1,4 +1,8 @@ --- +- name: Finalize verified administrative key after bootstrap + ansible.builtin.include_tasks: account.yml + when: megaproxy_bootstrap_phase | default('') == 'policy' + - name: Read current SSH connection context ansible.builtin.command: printenv SSH_CONNECTION become: false diff --git a/roles/https_proxy/templates/gost.yml.j2 b/roles/https_proxy/templates/gost.yml.j2 index 6565852..6daf5e4 100644 --- a/roles/https_proxy/templates/gost.yml.j2 +++ b/roles/https_proxy/templates/gost.yml.j2 @@ -99,4 +99,7 @@ chains: password: {{ route.chain.password | to_json }} dialer: type: http2 + tls: + secure: true + serverName: {{ route.chain.host | to_json }} {% endfor %} diff --git a/roles/ssh_proxy/tasks/validate_accounts.yml b/roles/ssh_proxy/tasks/validate_accounts.yml index db48487..0704a3c 100644 --- a/roles/ssh_proxy/tasks/validate_accounts.yml +++ b/roles/ssh_proxy/tasks/validate_accounts.yml @@ -1,4 +1,16 @@ --- +- name: Inspect administrative group membership + ansible.builtin.command: + argv: [id, -Gn, --, "{{ megaproxy_admin.user }}"] + register: megaproxy_admin_groups + changed_when: false + check_mode: false + +- name: Keep administrative access outside the restricted proxy group + ansible.builtin.assert: + that: "'megaproxy' not in megaproxy_admin_groups.stdout.split()" + fail_msg: "The administrative account must not belong to the proxy group" + - name: Require dedicated and safe SSH proxy logins ansible.builtin.assert: that: diff --git a/src/megaproxy_server/bootstrap.py b/src/megaproxy_server/bootstrap.py index c2a70b3..0c424d9 100644 --- a/src/megaproxy_server/bootstrap.py +++ b/src/megaproxy_server/bootstrap.py @@ -12,6 +12,15 @@ from .inventory import LOCAL_SSH_ARGS, ROOT, ansible_inventory, save from .models import Host, Inventory +from .secrets import public_key + + +def validate_admin_key(host: Host) -> None: + private = Path(host.admin.private_key_file).expanduser() + if not private.is_file(): + raise ValueError("Administrative private_key_file is unavailable") + if host.admin.public_key and public_key(private).split()[:2] != host.admin.public_key.split()[:2]: + raise ValueError("Administrative public_key must match private_key_file; use bootstrap_private_key_file for the old key during rotation") def check_admin(host: Host, *, report_error: bool = False) -> bool: @@ -76,6 +85,7 @@ def bootstrap(path: Path, inventory: Inventory, limit: str | None = None) -> int host = inventory.hosts[name] if not host.admin.bootstrap_user: continue + validate_admin_key(host) print(f'Bootstrapping administrative access: {name}', flush=True) if not check_admin(host): password = None diff --git a/src/megaproxy_server/cli.py b/src/megaproxy_server/cli.py index 90f3f56..3f2c53a 100644 --- a/src/megaproxy_server/cli.py +++ b/src/megaproxy_server/cli.py @@ -9,7 +9,7 @@ import questionary from pydantic import ValidationError -from .bootstrap import bootstrap +from .bootstrap import bootstrap, validate_admin_key from .export import export_profiles, jump_candidates from .generate import generate from .hooks import run_hooks @@ -87,7 +87,16 @@ def choose_inventory(explicit: str | None, force_create: bool = False) -> Path: def run_ansible(path: Path, playbook: str, *, check: bool = False, limit: str | None = None, tags: str | None = None) -> int: - generated = write_ansible_inventory(path, load(path), prepare_config_api=playbook == "site.yml") + inventory = load(path) + generated = write_ansible_inventory(path, inventory, prepare_config_api=playbook == "site.yml") + if playbook == "site.yml" and not check: + from ansible.inventory.manager import InventoryManager + from ansible.parsing.dataloader import DataLoader + selected = InventoryManager(loader=DataLoader(), sources=[str(generated)]) + if limit: + selected.subset(limit) + for target in selected.get_hosts("all"): + validate_admin_key(inventory.hosts[target.name]) command = ["ansible-playbook", "-i", str(generated), str(ROOT / "playbooks" / playbook)] if check: command += ["--check", "--diff"] diff --git a/tests/integration/https.py b/tests/integration/https.py new file mode 100644 index 0000000..ba25d36 --- /dev/null +++ b/tests/integration/https.py @@ -0,0 +1,209 @@ +"""Check HTTPS masking, knock, server chains and nested TLS Jump on loopback.""" +import argparse +import base64 +import json +import socket +import ssl +import subprocess +import tempfile +import time +from copy import deepcopy +from pathlib import Path +from socketserver import ThreadingTCPServer +from threading import Thread + +from jinja2 import Environment, StrictUndefined +from ruamel.yaml import YAML + +from megaproxy_server.inventory import ROOT, ansible_inventory +from megaproxy_server.models import Inventory +from transports import PASSWORD, USERNAME, TcpEcho, port, receive + + +class NestedTLS: + """A TLS stream inside an existing TLS CONNECT tunnel, using stdlib MemoryBIO.""" + def __init__(self, outer, context): + self.outer = outer + self.incoming, self.outgoing = ssl.MemoryBIO(), ssl.MemoryBIO() + self.tls = context.wrap_bio(self.incoming, self.outgoing, server_hostname="localhost") + while True: + try: + self.tls.do_handshake() + self.flush() + break + except ssl.SSLWantReadError: + self.pump() + + def flush(self): + if self.outgoing.pending: + self.outer.sendall(self.outgoing.read()) + + def pump(self): + self.flush() + data = self.outer.recv(16384) + if not data: + raise EOFError("Nested TLS tunnel closed") + self.incoming.write(data) + + def sendall(self, data): + while data: + written = self.tls.write(data) + data = data[written:] + self.flush() + + def recv(self, size): + while True: + try: + return self.tls.read(size) + except ssl.SSLWantReadError: + self.pump() + + +def connect(sock, host, target_port, *, password=PASSWORD): + auth = base64.b64encode(f"{USERNAME}:{password}".encode()).decode() if password is not None else None + headers = f"CONNECT {host}:{target_port} HTTP/1.1\r\nHost: {host}:{target_port}\r\n" + if auth: + headers += f"Proxy-Authorization: Basic {auth}\r\n" + sock.sendall((headers + "\r\n").encode()) + response = b"" + while not response.endswith(b"\r\n\r\n"): + response += receive(sock, 1) + assert len(response) < 16384 + return int(response.split(b" ", 2)[1]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--gost", required=True) + args = parser.parse_args() + echo = ThreadingTCPServer(("127.0.0.1", 0), TcpEcho) + Thread(target=echo.serve_forever, daemon=True).start() + processes, logs = [], [] + yaml = YAML(typ="safe") + try: + with tempfile.TemporaryDirectory(prefix="megaproxy-https-") as directory: + root = Path(directory) + subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", + "-subj", "/CN=localhost", "-addext", "subjectAltName=DNS:localhost,DNS:chain.localhost", + "-keyout", str(root / "privkey.pem"), "-out", str(root / "fullchain.pem")], check=True, capture_output=True) + entry_port, exit_port, chain_port = port(), port(), port() + admin = {"user": "deploy", "private_key_file": "/unused", "public_key": "ssh-ed25519 TEST"} + inv = Inventory.model_validate({"users": {"https": [{"name": USERNAME, "password": PASSWORD}]}, + "settings": {"https_chains_enabled": True, "https_chain_domain": "localhost", + "https_chain_pairs": [{"entry": "entry", "exit": "exit", "title": "CHAIN", "country_code": "US", + "hostname": "chain.localhost", "probe_resistance": {"enabled": True, "knock": ["chain.localhost"]}}]}, + "hosts": { + "entry": {"address": "127.0.0.1", "admin": admin, "services": {"https": { + "title": "ENTRY", "endpoint": "localhost", "port": entry_port, "certificate": "domain", + "acme_email": "test@example.com", "chain_entry": True, + "probe_resistance": {"enabled": True, "knock": ["localhost"]}}}}, + "exit": {"address": "127.0.0.1", "admin": admin, "services": {"https": { + "title": "EXIT", "endpoint": "localhost", "port": exit_port, "certificate": "domain", + "acme_email": "test@example.com", "chain_exit": True, "chain_password": "machine-password-123"}}}}}) + env = Environment(undefined=StrictUndefined) + env.filters["to_json"] = json.dumps + template = env.from_string((ROOT / "roles/https_proxy/templates/gost.yml.j2").read_text()) + variables = ansible_inventory(inv)["all"]["hosts"] + configs = {} + for name, public in (("entry", entry_port), ("exit", exit_port)): + config = yaml.load(template.render(**dict(variables[name], megaproxy_container_certificate_directory=str(root)))) + # Distinct loopback ports replace separate host network namespaces and HAProxy. + for service in config["services"]: + if service["name"] == "direct": + service["addr"] = f"127.0.0.1:{public}" + elif service["name"] == "via-exit": + service["addr"] = f"127.0.0.1:{chain_port}" + elif service["name"] == "decoy": + decoy_port = port() + service["addr"] = f"127.0.0.1:{decoy_port}" + service["handler"]["metadata"]["dir"] = str(root) + for candidate in config["services"]: + metadata = candidate["handler"].get("metadata", {}) + if "probeResist" in metadata: + metadata["probeResist"] = f"host:127.0.0.1:{decoy_port}" + for chain in config.get("chains") or []: + chain["hops"][0]["nodes"][0]["dialer"]["tls"]["caFile"] = str(root / "fullchain.pem") + configs[name] = config + (root / "index.html").write_text("A local decoy page\n") + + def start(name, config, ready): + path = root / f"{name}.yml" + with path.open("w") as stream: + yaml.dump(config, stream) + log = (root / f"{name}.log").open("w+") + logs.append(log) + process = subprocess.Popen([args.gost, "-C", str(path)], stdout=log, stderr=log) + processes.append(process) + for _ in range(100): + assert process.poll() is None, f"{name} stopped" + try: + with socket.create_connection(("127.0.0.1", ready), timeout=.1): + return process + except OSError: + time.sleep(.05) + raise AssertionError(f"{name} did not start") + + exit_process = start("exit", configs["exit"], exit_port) + entry_process = start("entry", configs["entry"], chain_port) + context = ssl.create_default_context(cafile=str(root / "fullchain.pem")) + + def check(proxy_port, *, hostname="localhost", target="127.0.0.1", password=PASSWORD, accepted=True): + with context.wrap_socket(socket.create_connection(("127.0.0.1", proxy_port), timeout=5), server_hostname=hostname) as sock: + status = connect(sock, target, echo.server_address[1], password=password) + if accepted is True: + assert status == 200, status + sock.sendall(b"HTTPS transport check") + assert receive(sock, 21) == b"HTTPS transport check" + elif accepted is False: + assert status != 200 + return status + + assert check(entry_port, target="localhost", password=None, accepted=False) == 407 + assert check(chain_port, hostname="chain.localhost", target="chain.localhost", password=None, accepted=False) == 407 + assert check(entry_port, password=None, accepted=None) != 407 + check(entry_port) + check(chain_port, hostname="chain.localhost") + with context.wrap_socket(socket.create_connection(("127.0.0.1", entry_port), timeout=5), server_hostname="localhost") as outer: + assert connect(outer, "localhost", exit_port) == 200 + inner = NestedTLS(outer, context) + assert connect(inner, "127.0.0.1", echo.server_address[1]) == 200 + inner.sendall(b"HTTPS Jump check") + assert receive(inner, 16) == b"HTTPS Jump check" + for mutation in ("password", "certificate"): + entry_process.terminate() + entry_process.wait(timeout=5) + bad = deepcopy(configs["entry"]) + node = bad["chains"][0]["hops"][0]["nodes"][0] + if mutation == "password": + node["connector"]["auth"]["password"] = "incorrect-machine-password" + else: + node["dialer"]["tls"]["serverName"] = "wrong.example" + entry_process = start(f"entry-bad-{mutation}", bad, chain_port) + check(chain_port, hostname="chain.localhost", accepted=False) + check(entry_port) # Direct route still works, without bypassing a broken chain. + entry_process.terminate() + entry_process.wait(timeout=5) + start("entry-restored", configs["entry"], chain_port) + exit_process.terminate() + exit_process.wait(timeout=5) + check(chain_port, hostname="chain.localhost", accepted=False) + check(entry_port) + print("HTTPS passed: masking, knock and override, trusted server chain, nested TLS Jump, rejected machine auth/TLS and unavailable exit without bypass.") + except Exception: + for log in logs: + log.seek(0) + print(Path(log.name).name, log.read()[-4000:]) + raise + finally: + for process in processes: + if process.poll() is None: + process.terminate() + process.wait(timeout=5) + for log in logs: + log.close() + echo.shutdown() + echo.server_close() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/lifecycle.py b/tests/integration/lifecycle.py index 1b1a23f..8b2e6e7 100644 --- a/tests/integration/lifecycle.py +++ b/tests/integration/lifecycle.py @@ -56,8 +56,12 @@ def tunnel(user, key=None, password=None, *, accepted=True): assert result.returncode != 0 and b"CI local target" not in result.stdout host.admin.public_key = public - apply("admin") # The old connection installs the new key before it is revoked. host.admin.private_key_file = str(admin_key) + host.admin.bootstrap_user = host.admin.user + host.admin.bootstrap_private_key_file = old_admin_key + apply("admin") # Verify the new key before revoking the old one, without relying on multiplexing. + source = load(args.inventory) + host = source.hosts["one"] save(args.inventory, source) assert ssh(host.admin.user, admin_key).returncode == 0 assert ssh(host.admin.user, old_admin_key).returncode != 0 diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 71901f5..405cc41 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -29,6 +29,7 @@ def setup(monkeypatch, checks, phase_code=0): monkeypatch.setattr(module.getpass, 'getpass', lambda prompt: 'root-secret') monkeypatch.setattr(module, 'run_phase', lambda inv, name, phase, password=None: calls.append((phase, password)) or phase_code) monkeypatch.setattr(module, 'save', lambda path, inv: calls.append(('save', inv.hosts['new'].admin.bootstrap_user))) + monkeypatch.setattr(module, 'validate_admin_key', lambda host: None) return calls diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 27ab132..34adfef 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -11,10 +11,12 @@ from ruamel.yaml import YAML from megaproxy_server import inventory as inventory_module, wizard +from megaproxy_server.bootstrap import validate_admin_key from megaproxy_server.config_bundle import validate_config from megaproxy_server.export import export_profiles from megaproxy_server.inventory import ROOT, load, save from megaproxy_server.models import Inventory +from megaproxy_server.secrets import generate_key @pytest.fixture @@ -133,7 +135,7 @@ def test_administrative_key_rotation_revokes_previous_key(tmp_path): target = tmp_path / "authorized_keys" target.write_text("ssh-ed25519 OLD\n") task = next(t for t in YAML(typ="safe").load((ROOT / "roles/admin/tasks/account.yml").read_text()) - if t["name"] == "Install administrative public key") + if t["name"] == "Replace verified administrative public key") task["ansible.builtin.copy"].update(dest=str(target), content="ssh-ed25519 NEW\n") task["ansible.builtin.copy"].pop("owner") result = run_tasks([task], tmp_path) @@ -141,13 +143,33 @@ def test_administrative_key_rotation_revokes_previous_key(tmp_path): assert target.read_text() == "ssh-ed25519 NEW\n" +def test_controller_key_must_match_new_administrative_public_key(raw_inventory, tmp_path): + old_public, old_key = generate_key(tmp_path / "old-admin", "old") + new_public, new_key = generate_key(tmp_path / "new-admin", "new") + source = Inventory.model_validate(raw_inventory) + host = source.hosts["one"] + host.admin.private_key_file = str(old_key) + host.admin.public_key = new_public + with pytest.raises(ValueError, match="must match"): + validate_admin_key(host) + host.admin.public_key = old_public + validate_admin_key(host) + host.admin.public_key = new_public + host.admin.private_key_file = str(new_key) + host.admin.bootstrap_private_key_file = str(old_key) + validate_admin_key(host) + + def test_unmanaged_account_is_rejected_before_destructive_tasks(tmp_path): tasks = YAML(typ="safe").load((ROOT / "roles/ssh_proxy/tasks/validate_accounts.yml").read_text()) - tasks[0]["vars"] = {"megaproxy_admin": {"user": "deploy"}, + variables = {"megaproxy_admin": {"user": "deploy"}, "megaproxy_services": {"ssh": {"users": [], "removed_users": ["other-user"]}}} + for task in tasks: + task["vars"] = variables # Replace only the OS lookup boundary; execute the actual guard before deletion. - tasks[1]["ansible.builtin.command"]["argv"] = ["echo", "other-group"] - tasks[1]["vars"] = tasks[0]["vars"] + for task in tasks: + if "ansible.builtin.command" in task and task["ansible.builtin.command"]["argv"][0] == "id": + task["ansible.builtin.command"]["argv"] = ["echo", "other-group"] marker = tmp_path / "deleted" tasks.append({"ansible.builtin.copy": {"dest": str(marker), "content": "unsafe"}}) result = run_tasks(tasks, tmp_path)