Skip to content

Commit fbf4503

Browse files
committed
Fix proxy lifecycle, credential revocation and safe configuration writes
1 parent fc117bc commit fbf4503

28 files changed

Lines changed: 1011 additions & 393 deletions

‎.github/workflows/integration.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ jobs:
4747
- name: Verify authenticated SOCKS5 and MASQUE TCP and UDP
4848
run: uv run --frozen python tests/integration/transports.py --gost "$RUNNER_TEMP/megaproxy-gost/gost"
4949

50+
- name: Verify HTTPS config API through nginx
51+
run: |
52+
sudo apt-get update
53+
sudo apt-get install -y nginx
54+
uv run --frozen python tests/integration/config_api.py --nginx /usr/sbin/nginx
55+
5056
ubuntu:
5157
name: Ubuntu 24.04 host
5258
runs-on: ubuntu-latest

‎docs/en/README.md‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,31 @@ provide metadata for personalized delivery; the current `export` and `configs` c
147147
`services.ssh.generate_profile` defaults to false and selects hosts for direct SSH profiles in
148148
`export`, `configs` and the configuration API. Administrative SSH uses `admin` settings separately.
149149

150+
The wizard asks whether to publish direct SSH profiles. `enabled: false` also
151+
disables an existing proxy: HTTPS stops GOST, HAProxy and the renewal timer; SSH
152+
denies the `megaproxy` group and closes its sessions while preserving administrative
153+
SSH. Keep the disabled service in YAML: an absent service is unmanaged. Firewall
154+
records its own rules and revokes retired ports on the next full apply, preserving
155+
other rules. An initial upgrade records existing configured ports before later changes.
156+
157+
Only accounts whose primary group is `megaproxy` may be modified or removed;
158+
new proxy accounts are created in that group. Administrative/bootstrap accounts,
159+
reserved names and paths are rejected in `removed_ssh`. The administrative
160+
`authorized_keys` file is managed exclusively: replacing `admin.public_key`
161+
revokes the old key. Changing `admin.user` requires bootstrap through a working
162+
account: the new administrator is verified before retiring the previous managed
163+
account and sudo access, preserving its files.
164+
165+
Inventory and exports are replaced atomically with mode `0600`. Export validation
166+
runs before replacing an existing file; unknown country codes become empty strings.
167+
Port conflicts and HTTPS without routes are rejected before connecting to a host.
168+
Docker cleanup only removes old GOST/Certbot images, without global prune operations.
169+
150170
| enabled | generate_profile | Result |
151171
| --- | --- | --- |
152172
| true | true | Provision SSH proxy users and publish direct SSH profiles |
153173
| true | false | Provision SSH proxy users; use the host only in declared jump pairs |
154-
| false | any | Skip SSH proxy provisioning/verification and profile generation; jump pairs cannot reference this host |
174+
| false | any | Deny proxy access, close proxy sessions and omit profiles; jump pairs cannot reference this host |
155175

156176
Setting `enabled: false` skips the role; it does not remove previously provisioned accounts or
157177
stop the server's SSH daemon. To revoke an account, use `remove-users` and `apply`.

‎docs/ru/README.md‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,33 @@ HTTPS-пользователям; удаление SSH-аккаунта такж
154154
`services.ssh.generate_profile` по умолчанию false: выбирает хосты для прямых SSH-профилей
155155
в `export`, `configs` и API. Административный SSH отдельно задаётся через `admin`.
156156

157+
Мастер отдельно спрашивает, публиковать ли прямые SSH-профили. `enabled: false`
158+
отключает уже настроенный прокси: для HTTPS останавливает GOST, HAProxy и таймер
159+
renewal; для SSH запрещает вход группе `megaproxy` и закрывает её сессии, сохраняя
160+
административный SSH. Удалять службу из YAML вместо `enabled: false` не следует:
161+
отсутствующая служба не управляется. Firewall запоминает управляемые правила и
162+
удаляет устаревшие при следующем полном apply; остальные правила сохраняются.
163+
Первый apply после обновления запоминает существующие настроенные порты перед
164+
последующими изменениями.
165+
166+
SSH-аккаунты могут изменяться и удаляться только если их основная группа —
167+
`megaproxy`; новые аккаунты создаются в этой группе. `removed_ssh` не допускает
168+
администратора, bootstrap-логин, системные имена и пути. Административный
169+
`authorized_keys` целиком управляется инвентарём: смена `admin.public_key` отзывает
170+
старый ключ. При смене `admin.user` bootstrap сначала проверяет нового
171+
администратора, затем удаляет прежний управляемый аккаунт и sudo-доступ, сохраняя
172+
его файлы. Для миграции укажите `bootstrap_user` с действующим доступом.
173+
174+
Инвентарь и экспорт записываются атомарно с правами `0600`. Экспорт проверяется
175+
до замены прежнего файла; неизвестный код страны становится пустой строкой.
176+
Конфликты портов и HTTPS без маршрутов отклоняются до подключения к хосту.
177+
Docker cleanup удаляет только старые образы GOST/Certbot, без глобального prune.
178+
157179
| enabled | generate_profile | Результат |
158180
| --- | --- | --- |
159181
| true | true | Настройка SSH-прокси и генерация прямых SSH-профилей |
160182
| true | false | Настройка SSH-прокси; использование хоста только в явных jump-парах |
161-
| false | любое | Настройка и проверка SSH-прокси пропускаются, профили не генерируются; использовать хост в jump-парах нельзя |
183+
| false | любое | Вход прокси-аккаунтам запрещается, сессии закрываются, профили не генерируются; jump-пары не допускаются |
162184

163185
`enabled: false` пропускает роль, но не удаляет ранее созданные аккаунты и не останавливает
164186
SSH-демон сервера. Для отзыва аккаунта используйте `remove-users` и `apply`.

‎playbooks/site.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@
2121
when: megaproxy_settings.manage_firewall | bool
2222
tags: [firewall]
2323
- role: ssh_proxy
24-
when: megaproxy_services.ssh is defined and megaproxy_services.ssh.enabled | bool
24+
when: megaproxy_services.ssh is defined
2525
tags: [ssh]
2626
- role: https_proxy
27-
when: megaproxy_services.https is defined and megaproxy_services.https.enabled | bool
27+
when: megaproxy_services.https is defined
2828
tags: [https]
2929
- role: config_api
3030
when: megaproxy_services.config_api is defined

‎roles/admin/tasks/account.yml‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,9 @@
2525
mode: "0700"
2626

2727
- name: Install administrative public key
28-
ansible.builtin.lineinfile:
29-
path: "{{ megaproxy_admin_account.home }}/.ssh/authorized_keys"
30-
line: "{{ megaproxy_admin.public_key }}"
31-
create: true
28+
ansible.builtin.copy:
29+
dest: "{{ megaproxy_admin_account.home }}/.ssh/authorized_keys"
30+
content: "{{ megaproxy_admin.public_key }}\n"
3231
owner: "{{ megaproxy_admin.user }}"
3332
mode: "0600"
3433
no_log: true

‎roles/admin/tasks/policy.yml‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,56 @@
8282
- name: Report rejected administrative SSH policy
8383
ansible.builtin.fail:
8484
msg: "Administrative SSH policy validation failed; the previous policy was restored"
85+
86+
- name: Find previously managed administrative accounts
87+
ansible.builtin.find:
88+
paths: /etc/sudoers.d
89+
patterns: megaproxy-*
90+
file_type: file
91+
register: megaproxy_admin_sudoers
92+
93+
- name: Identify retired administrative logins
94+
ansible.builtin.set_fact:
95+
megaproxy_retired_admins: >-
96+
{{ megaproxy_admin_sudoers.files | map(attribute='path') | map('basename')
97+
| map('regex_replace', '^megaproxy-', '') | reject('equalto', megaproxy_admin.user) | list }}
98+
99+
- name: Require verified new administrator before retiring old access
100+
ansible.builtin.assert:
101+
that:
102+
- ansible_user == megaproxy_admin.user
103+
- item is match('^[a-z_][a-z0-9_-]*$')
104+
- item != 'root'
105+
fail_msg: "Administrative migration must run through the new verified administrative account"
106+
loop: "{{ megaproxy_retired_admins }}"
107+
108+
- name: Inspect retired administrative accounts
109+
ansible.builtin.command:
110+
argv: [getent, passwd, "{{ item }}"]
111+
loop: "{{ megaproxy_retired_admins }}"
112+
register: megaproxy_retired_admin_accounts
113+
changed_when: false
114+
failed_when: false
115+
check_mode: false
116+
117+
- name: Close sessions of retired administrators
118+
ansible.builtin.command:
119+
argv: [pkill, -KILL, -u, "{{ item.item }}"]
120+
loop: "{{ megaproxy_retired_admin_accounts.results }}"
121+
when: item.rc == 0
122+
register: megaproxy_retired_admin_sessions
123+
failed_when: megaproxy_retired_admin_sessions.rc not in [0, 1]
124+
changed_when: megaproxy_retired_admin_sessions.rc == 0
125+
126+
- name: Remove retired administrative accounts while preserving their files
127+
ansible.builtin.user:
128+
name: "{{ item }}"
129+
state: absent
130+
remove: false
131+
loop: "{{ megaproxy_retired_admins }}"
132+
133+
- name: Remove retired administrative sudo privileges
134+
ansible.builtin.file:
135+
path: "/etc/sudoers.d/megaproxy-{{ item }}"
136+
state: absent
137+
loop: "{{ megaproxy_retired_admins }}"

‎roles/firewall/tasks/main.yml‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111
| map('string') | unique | list }}
1212
1313
- name: Allow required ports before enabling firewall
14-
ansible.builtin.command: "ufw allow {{ item }}/tcp"
14+
ansible.builtin.command: "ufw allow {{ item }}/tcp comment megaproxy"
1515
loop: "{{ megaproxy_allowed_ports }}"
1616
register: ufw_allow
17-
changed_when: "'Rule added' in ufw_allow.stdout"
17+
changed_when: "'Rule added' in ufw_allow.stdout or 'Rule updated' in ufw_allow.stdout"
1818

1919
- name: Build optional UDP firewall port list
2020
ansible.builtin.set_fact:
@@ -25,10 +25,53 @@
2525
if megaproxy_services.https is defined and megaproxy_services.https.enabled else [] }}
2626
2727
- name: Allow enabled HTTP3 and SOCKS5 UDP relay ports
28-
ansible.builtin.command: "ufw allow {{ item }}/udp"
28+
ansible.builtin.command: "ufw allow {{ item }}/udp comment megaproxy"
2929
loop: "{{ megaproxy_allowed_udp_ports }}"
3030
register: ufw_allow_udp
31-
changed_when: "'Rule added' in ufw_allow_udp.stdout"
31+
changed_when: "'Rule added' in ufw_allow_udp.stdout or 'Rule updated' in ufw_allow_udp.stdout"
32+
33+
- name: Check previously managed firewall ports
34+
ansible.builtin.stat:
35+
path: /etc/megaproxy/firewall.json
36+
register: megaproxy_firewall_state
37+
38+
- name: Read previously managed firewall ports
39+
ansible.builtin.slurp:
40+
src: /etc/megaproxy/firewall.json
41+
register: megaproxy_firewall_previous
42+
when: megaproxy_firewall_state.stat.exists
43+
44+
- name: Build current managed firewall rules
45+
ansible.builtin.set_fact:
46+
megaproxy_firewall_rules: >-
47+
{{ (megaproxy_allowed_ports | map('regex_replace', '$', '/tcp') | list)
48+
+ (megaproxy_allowed_udp_ports | map('regex_replace', '$', '/udp') | list) }}
49+
megaproxy_previous_firewall_rules: >-
50+
{{ (megaproxy_firewall_previous.content | b64decode | from_json)
51+
if megaproxy_firewall_state.stat.exists else [] }}
52+
53+
- name: Revoke retired managed firewall rules
54+
ansible.builtin.command:
55+
argv: [ufw, --force, delete, allow, "{{ item }}"]
56+
loop: "{{ megaproxy_previous_firewall_rules | difference(megaproxy_firewall_rules) }}"
57+
register: megaproxy_ufw_delete
58+
changed_when: "'Rule deleted' in megaproxy_ufw_delete.stdout"
59+
60+
- name: Create private managed firewall state directory
61+
ansible.builtin.file:
62+
path: /etc/megaproxy
63+
state: directory
64+
owner: root
65+
group: root
66+
mode: "0755"
67+
68+
- name: Remember managed firewall ports
69+
ansible.builtin.copy:
70+
dest: /etc/megaproxy/firewall.json
71+
content: "{{ megaproxy_firewall_rules | to_json }}\n"
72+
owner: root
73+
group: root
74+
mode: "0600"
3275

3376
- name: Read firewall policy
3477
ansible.builtin.command: ufw status verbose
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
- name: Check managed HTTPS units
3+
ansible.builtin.stat:
4+
path: "/etc/systemd/system/{{ item }}"
5+
loop: [megaproxy-gost.service, megaproxy-certbot-renew.timer]
6+
register: megaproxy_https_units
7+
8+
- name: Stop disabled HTTPS proxy and renewal timer
9+
ansible.builtin.systemd_service:
10+
name: "{{ item.item }}"
11+
enabled: false
12+
state: stopped
13+
loop: "{{ megaproxy_https_units.results }}"
14+
when: item.stat.exists and not ansible_check_mode
15+
16+
- name: Stop the managed HTTPS SNI router
17+
ansible.builtin.systemd_service:
18+
name: haproxy
19+
enabled: false
20+
state: stopped
21+
when: megaproxy_https_units.results[0].stat.exists and not ansible_check_mode
22+
23+
- name: Remove disabled proxy credentials
24+
ansible.builtin.file:
25+
path: /opt/megaproxy/gost.yml
26+
state: absent
27+
no_log: true

0 commit comments

Comments
 (0)