From 5b25ac2cdbfb551d5ef2a72eb050cfe5ae05477d Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Wed, 1 Jul 2026 13:06:14 +0300 Subject: [PATCH] feat: incremental user passwords and export on update Preserve passwords from server-info.yml and vault; generate only for new users; remove deleted users from output; re-export URL/QR only when password, domain, port, obfs or files changed. Co-authored-by: Cursor --- README.md | 10 ++- group_vars/hysteria2_servers/vars.yml.example | 2 + roles/hysteria2/defaults/main.yml | 3 + roles/hysteria2/tasks/export.yml | 24 ++++-- roles/hysteria2/tasks/export_prepare.yml | 84 +++++++++++++++++++ roles/hysteria2/tasks/reuse_export_user.yml | 4 + roles/hysteria2/tasks/users.yml | 39 ++++++++- 7 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 roles/hysteria2/tasks/export_prepare.yml create mode 100644 roles/hysteria2/tasks/reuse_export_user.yml diff --git a/README.md b/README.md index 8500704..172e2fa 100644 --- a/README.md +++ b/README.md @@ -196,9 +196,12 @@ hysteria2_user_passwords: friend: "custom-password" ``` -3. **Автогенерация** — Ansible `password` lookup (длина `hysteria2_password_length`), если пароль не задан. +3. **Автогенерация** — Ansible `password` lookup, если пароль не задан ни в inventory/vault, ни в `output//server-info.yml`. -При `make update` пароли подтягиваются из `output//server-info.yml`, если не указаны в vault/inventory. +При `make update` / `make export`: +- **существующие** пользователи сохраняют пароли из `server-info.yml` (и URL/QR, если домен/порт/obfs не менялись); +- **новые** получают автогенерацию и полный экспорт; +- **удалённые** из inventory убираются из конфига Hysteria2 и из `output//`. ### Salamander obfs (один на сервер) @@ -215,7 +218,7 @@ vault_hysteria2_obfs_passwords: ``` 2. **Авто:** Ansible `password` lookup (`hysteria2_obfs_password_length`) при первой установке -3. **При update:** загружается из `output//server-info.yml` +3. **При update/export:** загружается из `output//server-info.yml` (как VPN-пароли) > **Важно:** obfs-пароль на сервере и клиенте должен **совпадать**. При `make update` без vault пароль сохраняется из предыдущего экспорта. @@ -299,6 +302,7 @@ ASCII QR — `hysteria share --qr` → `user.qr.txt`. | `hysteria2_users` | host | VPN-пользователи | | `hysteria2_acme_email` | group | Email Let's Encrypt | | `hysteria2_user_passwords` | host/vault | Свои пароли VPN | +| `hysteria2_force_export` | group | Перегенерировать URL/QR для всех пользователей (`false` — только новые/изменённые) | | `hysteria2_obfs_password` | host/vault | Пароль Salamander (или авто) | | `hysteria2_obfs_password_length` | group | Длина автопароля obfs (32) | | `hysteria2_output_dir` | group | Папка экспорта (по умолчанию `./output`) | diff --git a/group_vars/hysteria2_servers/vars.yml.example b/group_vars/hysteria2_servers/vars.yml.example index 649fbd2..6396df4 100644 --- a/group_vars/hysteria2_servers/vars.yml.example +++ b/group_vars/hysteria2_servers/vars.yml.example @@ -3,5 +3,7 @@ # Файл group_vars/hysteria2_servers/vault.yml должен быть зашифрован (make vault-encrypt). # Проброс VPN-паролей из vault в переменные роли (опционально) +hysteria2_user_passwords: "{{ vault_hysteria2_user_passwords[inventory_hostname] | default({}) }}" # Опционально: фиксированный пароль Salamander obfs для сервера +# hysteria2_obfs_password: "{{ vault_hysteria2_obfs_passwords[inventory_hostname] | default('') }}" diff --git a/roles/hysteria2/defaults/main.yml b/roles/hysteria2/defaults/main.yml index 6784305..eccf54b 100644 --- a/roles/hysteria2/defaults/main.yml +++ b/roles/hysteria2/defaults/main.yml @@ -43,3 +43,6 @@ hysteria2_qr_png_error_correction: M hysteria2_wait_for_acme: true hysteria2_open_browser: true +# Перегенерировать URL/QR для всех пользователей (иначе — только новые/изменённые) +hysteria2_force_export: false + diff --git a/roles/hysteria2/tasks/export.yml b/roles/hysteria2/tasks/export.yml index 80ed715..06bea17 100644 --- a/roles/hysteria2/tasks/export.yml +++ b/roles/hysteria2/tasks/export.yml @@ -11,16 +11,28 @@ ansible.builtin.set_fact: hysteria2_export_users: [] +- name: Prepare incremental export (remove deleted users, split new vs existing) + ansible.builtin.import_tasks: export_prepare.yml + - name: Install qrencode on server for PNG QR export ansible.builtin.apt: name: qrencode state: present update_cache: false - when: hysteria2_generate_qr_png | bool + when: + - hysteria2_generate_qr_png | bool + - hysteria2_users_for_export | default([]) | length > 0 -- name: Build client share data for each user +- name: Reuse existing export data for unchanged users + ansible.builtin.include_tasks: reuse_export_user.yml + loop: "{{ hysteria2_users_for_reuse | default([]) }}" + loop_control: + loop_var: hysteria2_reuse_user + label: "{{ hysteria2_reuse_user.name }}" + +- name: Build client share data for new or changed users ansible.builtin.include_tasks: share_user.yml - loop: "{{ hysteria2_resolved_users }}" + loop: "{{ hysteria2_users_for_export | default([]) }}" loop_control: loop_var: hysteria2_current_user label: "{{ hysteria2_current_user.name }}" @@ -62,5 +74,7 @@ - name: Show export location ansible.builtin.debug: msg: >- - Клиентские URL, QR и index.html сохранены в - {{ hysteria2_output_dir }}/{{ hysteria2_output_name }}/ + Экспорт: {{ hysteria2_users_for_export | default([]) | length }} пользователь(ей) обновлено, + {{ hysteria2_users_for_reuse | default([]) | length }} без изменений, + {{ _hysteria2_removed_users | default([]) | length }} удалено. + Каталог: {{ hysteria2_output_dir }}/{{ hysteria2_output_name }}/ diff --git a/roles/hysteria2/tasks/export_prepare.yml b/roles/hysteria2/tasks/export_prepare.yml new file mode 100644 index 0000000..f85d7e5 --- /dev/null +++ b/roles/hysteria2/tasks/export_prepare.yml @@ -0,0 +1,84 @@ +--- +- name: Determine removed users from previous export + ansible.builtin.set_fact: + _hysteria2_removed_users: >- + {{ + (_hysteria2_saved_server_info.users | default([]) | map(attribute='name') | list) + | difference(hysteria2_resolved_users | map(attribute='name') | list) + }} + delegate_to: localhost + become: false + +- name: Build list of export files to remove for deleted users + ansible.builtin.set_fact: + _hysteria2_removed_user_files: >- + {{ + _hysteria2_removed_user_files | default([]) + [ + hysteria2_output_dir ~ '/' ~ hysteria2_output_name ~ '/' ~ item.0 ~ item.1 + ] + }} + loop: "{{ _hysteria2_removed_users | default([]) | product(_hysteria2_user_export_suffixes) | list }}" + vars: + _hysteria2_user_export_suffixes: + - ".url" + - ".txt" + - ".qr.txt" + - ".png" + when: _hysteria2_removed_users | default([]) | length > 0 + delegate_to: localhost + become: false + +- name: Remove export files for deleted users + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: "{{ _hysteria2_removed_user_files | default([]) }}" + delegate_to: localhost + become: false + when: _hysteria2_removed_user_files | default([]) | length > 0 + +- name: Check local URL files for resolved users + ansible.builtin.stat: + path: "{{ hysteria2_output_dir }}/{{ hysteria2_output_name }}/{{ item.name }}.url" + loop: "{{ hysteria2_resolved_users }}" + register: _hysteria2_url_file_stats + delegate_to: localhost + become: false + +- name: Split users into full export and reuse existing export data + ansible.builtin.set_fact: + hysteria2_users_for_export: "{{ hysteria2_users_for_export | default([]) + ([item] if _needs_export else []) }}" + hysteria2_users_for_reuse: "{{ hysteria2_users_for_reuse | default([]) + ([_reuse_entry] if not _needs_export else []) }}" + loop: "{{ hysteria2_resolved_users }}" + loop_control: + label: "{{ item.name }}" + vars: + _saved_user: >- + {{ + (_hysteria2_saved_server_info.users | default([]) + | selectattr('name', 'equalto', item.name) + | list | first) | default({}, true) + }} + _url_stat: >- + {{ + ((_hysteria2_url_file_stats.results + | selectattr('item.name', 'equalto', item.name) + | list | first).stat) | default({}, true) + }} + _needs_export: >- + {{ + hysteria2_force_export | bool + or (_saved_user | length == 0) + or (item.password != (_saved_user.password | default(''))) + or (hysteria2_domain != (_hysteria2_saved_server_info.domain | default(''))) + or ((hysteria2_listen_port | string) != (_hysteria2_saved_server_info.port | default('') | string)) + or ((hysteria2_obfs_password | default('')) != (_hysteria2_saved_server_info.obfs_password | default(''))) + or not (_url_stat.exists | default(false)) + }} + _reuse_entry: + name: "{{ item.name }}" + password: "{{ item.password }}" + url: "{{ _saved_user.url }}" + has_png: "{{ _saved_user.has_png | default(false) | bool }}" + delegate_to: localhost + become: false diff --git a/roles/hysteria2/tasks/reuse_export_user.yml b/roles/hysteria2/tasks/reuse_export_user.yml new file mode 100644 index 0000000..a2ce714 --- /dev/null +++ b/roles/hysteria2/tasks/reuse_export_user.yml @@ -0,0 +1,4 @@ +--- +- name: Register reused export data for HTML page + ansible.builtin.set_fact: + hysteria2_export_users: "{{ hysteria2_export_users | default([]) + [hysteria2_reuse_user] }}" diff --git a/roles/hysteria2/tasks/users.yml b/roles/hysteria2/tasks/users.yml index ab8e2ab..1d31968 100644 --- a/roles/hysteria2/tasks/users.yml +++ b/roles/hysteria2/tasks/users.yml @@ -10,6 +10,16 @@ - update - export +- name: Initialize empty saved export metadata + ansible.builtin.set_fact: + _hysteria2_saved_passwords: {} + _hysteria2_saved_server_info: {} + when: not _hysteria2_saved_info.stat.exists + tags: + - install + - update + - export + - name: Load saved user passwords from local export when: _hysteria2_saved_info.stat.exists block: @@ -20,8 +30,9 @@ delegate_to: localhost become: false - - name: Parse saved passwords into lookup dict + - name: Parse saved export metadata ansible.builtin.set_fact: + _hysteria2_saved_server_info: "{{ _hysteria2_saved_info_raw.content | b64decode | from_yaml }}" _hysteria2_saved_passwords: >- {{ dict( @@ -33,12 +44,14 @@ ) ) }} + delegate_to: localhost + become: false tags: - install - update - export -- name: Resolve user list with optional fixed passwords +- name: Resolve user list with passwords from inventory, vault and previous export ansible.builtin.set_fact: hysteria2_resolved_users: "{{ hysteria2_resolved_users | default([]) + [ _entry ] }}" vars: @@ -46,8 +59,26 @@ _password: >- {{ ( - item.password if item is mapping - ) | default('', true) + item.password + if item is mapping and (item.password | default('') | length > 0) + else none + ) + | default( + hysteria2_user_passwords[_username] + if ( + hysteria2_user_passwords is defined + and (hysteria2_user_passwords[_username] | default('') | length > 0) + ) + else none, + true + ) + | default( + _hysteria2_saved_passwords[_username] + if (_hysteria2_saved_passwords[_username] | default('') | length > 0) + else none, + true + ) + | default('', true) }} _entry: name: "{{ _username }}"