--- - name: Harden live Mihomo on ru-vps hosts: ru-vps gather_facts: false vars: ru_vps_mihomo_harden_confirm: false mihomo_root: /opt/services/ru-vps/mihomo mihomo_config_path: "{{ mihomo_root }}/config/config.yaml" mihomo_compose_path: "{{ mihomo_root }}/docker-compose.yml" mihomo_backup_dir: /var/backups/ru-vps-mihomo mihomo_state_dir: /var/lib/ru-vps-mihomo mihomo_state_path: "{{ mihomo_state_dir }}/auth.json" mihomo_harden_script_path: /usr/local/sbin/ru-vps-mihomo-harden mihomo_harden_script_src: "{{ playbook_dir }}/../files/ru-vps-mihomo-harden.py" mihomo_backup_path: "{{ mihomo_backup_dir }}/config.yaml.{{ lookup('pipe', 'date -u +%Y%m%d%H%M%S') }}" pre_tasks: - name: Require explicit Mihomo hardening confirmation ansible.builtin.assert: that: - ru_vps_mihomo_harden_confirm | bool fail_msg: Run with -e ru_vps_mihomo_harden_confirm=true. - name: Preflight Mihomo config file exists ansible.builtin.stat: path: "{{ mihomo_config_path }}" register: mihomo_config_stat - name: Preflight Mihomo compose file exists ansible.builtin.stat: path: "{{ mihomo_compose_path }}" register: mihomo_compose_stat - name: Preflight Mihomo state file exists ansible.builtin.stat: path: "{{ mihomo_state_path }}" register: mihomo_state_stat - name: Refuse to run without live Mihomo config and compose files ansible.builtin.assert: that: - mihomo_config_stat.stat.exists - mihomo_compose_stat.stat.exists fail_msg: Live Mihomo config or compose file is missing. - name: Preflight Mihomo container is running ansible.builtin.command: argv: - docker - inspect - -f - '{{ "{{" }}.State.Running{{ "}}" }}' - mihomo register: mihomo_container_state changed_when: false failed_when: mihomo_container_state.stdout.strip() != 'true' - name: Preflight PyYAML is available on the target ansible.builtin.command: argv: - python3 - -c - import yaml changed_when: false - name: Preflight current Mihomo config validates ansible.builtin.command: argv: - docker - compose - -f - "{{ mihomo_compose_path }}" - exec - -T - mihomo - /mihomo - -t - -d - /root/.config/mihomo changed_when: false - name: Ensure Mihomo backup directory exists ansible.builtin.file: path: "{{ mihomo_backup_dir }}" state: directory owner: root group: root mode: "0700" - name: Install root-only Mihomo hardening helper ansible.builtin.copy: src: "{{ mihomo_harden_script_src }}" dest: "{{ mihomo_harden_script_path }}" owner: root group: root mode: "0700" - name: Create fresh backup of the live Mihomo config after validation ansible.builtin.copy: src: "{{ mihomo_config_path }}" dest: "{{ mihomo_backup_path }}" remote_src: true owner: root group: root mode: "0600" tasks: - block: - name: Apply Mihomo hardening in place ansible.builtin.command: argv: - "{{ mihomo_harden_script_path }}" - apply - --config - "{{ mihomo_config_path }}" - --state - "{{ mihomo_state_path }}" register: mihomo_harden_apply changed_when: (mihomo_harden_apply.stdout | from_json).changed no_log: true - name: Validate hardened Mihomo config in the running container ansible.builtin.command: argv: - docker - compose - -f - "{{ mihomo_compose_path }}" - exec - -T - mihomo - /mihomo - -t - -d - /root/.config/mihomo changed_when: false - name: Recreate Mihomo container after config hardening ansible.builtin.command: argv: - docker - compose - -f - "{{ mihomo_compose_path }}" - up - -d - --force-recreate - mihomo changed_when: true - name: Wait for hardened Mihomo listeners on loopback ansible.builtin.wait_for: host: 127.0.0.1 port: "{{ item }}" state: started timeout: 30 loop: - 7890 - 7891 - name: Run authenticated Mihomo SOCKS probe to Telegram ansible.builtin.command: argv: - "{{ mihomo_harden_script_path }}" - probe - --config - "{{ mihomo_config_path }}" - --state - "{{ mihomo_state_path }}" register: mihomo_harden_probe changed_when: false no_log: true - name: Check anonymous SOCKS access fails ansible.builtin.command: argv: - curl - --silent - --show-error - --connect-timeout - "5" - --max-time - "15" - --proxy - socks5h://127.0.0.1:7891 - https://api.telegram.org - --output - /dev/null register: mihomo_anon_probe changed_when: false failed_when: false - name: Remove public Mihomo UFW rules after successful hardening community.general.ufw: rule: allow port: "{{ item.port }}" proto: "{{ item.proto }}" delete: true loop: - { port: 7890, proto: tcp } - { port: 7890, proto: udp } - { port: 7891, proto: tcp } - { port: 7891, proto: udp } - name: Verify Mihomo listeners are loopback only ansible.builtin.command: argv: - ss - -H - -ltnp - '( sport = :7890 or sport = :7891 )' register: mihomo_ss changed_when: false - name: Confirm hardened Mihomo is bound to loopback only and anonymous access fails ansible.builtin.assert: that: - mihomo_ss.stdout is search('127\\.0\\.0\\.1:7890') - mihomo_ss.stdout is search('127\\.0\\.0\\.1:7891') - mihomo_ss.stdout is not search('0\\.0\\.0\\.0:7890|:::7890|0\\.0\\.0\\.0:7891|:::7891') - mihomo_anon_probe.rc != 0 fail_msg: Hardened Mihomo must listen on loopback only and reject anonymous SOCKS access. rescue: - name: Restore the live Mihomo config from backup ansible.builtin.copy: src: "{{ mihomo_backup_path }}" dest: "{{ mihomo_config_path }}" remote_src: true owner: root group: root mode: "0640" - name: Recreate Mihomo container after rollback ansible.builtin.command: argv: - docker - compose - -f - "{{ mihomo_compose_path }}" - up - -d - --force-recreate - mihomo changed_when: true - name: Remove newly created Mihomo credentials state after rollback ansible.builtin.file: path: "{{ mihomo_state_path }}" state: absent when: not mihomo_state_stat.stat.exists - name: Wait for restored Mihomo listeners on loopback ansible.builtin.wait_for: host: 127.0.0.1 port: "{{ item }}" state: started timeout: 30 loop: - 7890 - 7891 - name: Validate restored Mihomo config in the running container ansible.builtin.command: argv: - docker - compose - -f - "{{ mihomo_compose_path }}" - exec - -T - mihomo - /mihomo - -t - -d - /root/.config/mihomo changed_when: false - name: Fail Mihomo hardening after restoring the backup ansible.builtin.fail: msg: Mihomo hardening failed and the live config was restored from backup.