Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ 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
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
Expand Down
29 changes: 28 additions & 1 deletion docs/en/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,38 @@ 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.
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 |
| --- | --- | --- |
| 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`.
Expand Down
31 changes: 30 additions & 1 deletion docs/ru/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,40 @@ 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` с действующим доступом.
Для ротации ключа задайте новую пару `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 | Результат |
| --- | --- | --- |
| true | true | Настройка SSH-прокси и генерация прямых SSH-профилей |
| true | false | Настройка SSH-прокси; использование хоста только в явных jump-парах |
| false | любое | Настройка и проверка SSH-прокси пропускаются, профили не генерируются; использовать хост в jump-парах нельзя |
| false | любое | Вход прокси-аккаунтам запрещается, сессии закрываются, профили не генерируются; jump-пары не допускаются |

`enabled: false` пропускает роль, но не удаляет ранее созданные аккаунты и не останавливает
SSH-демон сервера. Для отзыва аккаунта используйте `remove-users` и `apply`.
Expand Down
4 changes: 2 additions & 2 deletions playbooks/site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions roles/admin/tasks/account.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@
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
Expand Down
57 changes: 57 additions & 0 deletions roles/admin/tasks/policy.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -82,3 +86,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 }}"
51 changes: 47 additions & 4 deletions roles/firewall/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions roles/https_proxy/tasks/disabled.yml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading