--- # ============================================================================ # Road-warrior OpenVPN профиль для телефона (Android). # # Отдельный TLS point-to-point инстанс на ru-vps (порт 9444/tcp, tun2, # сеть 10.80.0.0/30), параллельный тоннелю site-to-site (tun0, homelab-openvpn) # и профилю ноутбука (tun1, openvpn-laptop.yml). Клиент подключается один за # раз. # # КРИПТО: peer-fingerprint, БЕЗ CA/PKI. # На ru-vps генерятся две самоподписанные EC-пары (server.* и client.*), # каждая сторона пинит другую по SHA256-отпечатку. Это «quick setup» # из man-страницы OpenVPN 2.6+. Прежняя схема со static key (--secret) # отброшена: OpenVPN 2.7 в Android-клиенте запускать её отказывается # ("No tls-client or tls-server option"), а 2.8 уберёт совсем. # # КЛИЕНТ # Android — «OpenVPN for Android» (Arne Schwabe) или OpenVPN Connect: # TLS-конфиг понимают оба. Профиль самодостаточный (cert+key внутри). # # ЧТО ДЕЛАЕТ # Ставит openvpn на ru-vps, генерит EC-пары (один раз), открывает # 9444/tcp в UFW, поднимает systemd-юнит homelab-openvpn-phone, NAT'ит # 10.80.0.0/30 -> LAN через tun0, кладёт готовый профиль в # ansible/generated/ada-phone.ovpn (mode 0600, содержит приватный ключ). # # ЗАПУСК # make openvpn-phone # ============================================================================ - name: Configure OpenVPN phone access through ru-vps hosts: ru-vps gather_facts: true become: true vars: phone_profile_name: ada-phone phone_openvpn_dir: /etc/openvpn/homelab-phone phone_openvpn_config: /etc/openvpn/homelab-phone/server.conf phone_openvpn_server_cert: /etc/openvpn/homelab-phone/server.crt phone_openvpn_server_key: /etc/openvpn/homelab-phone/server.key phone_openvpn_client_cert: /etc/openvpn/homelab-phone/client.crt phone_openvpn_client_key: /etc/openvpn/homelab-phone/client.key phone_openvpn_service: homelab-openvpn-phone phone_openvpn_port: 9444 phone_openvpn_interface: tun2 phone_openvpn_site_interface: tun0 phone_openvpn_network_cidr: 10.80.0.0/30 phone_openvpn_server_ip: 10.80.0.1 phone_openvpn_client_ip: 10.80.0.2 phone_openvpn_remote: 157.22.231.198 phone_openvpn_data_ciphers: "AES-256-GCM:AES-128-GCM" phone_openvpn_client_config_local: "{{ playbook_dir }}/../generated/{{ phone_profile_name }}.ovpn" phone_openvpn_routes: "{{ homelab_vpn_client_routes }}" tasks: - name: Install OpenVPN packages ansible.builtin.apt: name: - openvpn - iptables - openssl state: present update_cache: true - name: Ensure phone OpenVPN config directory exists ansible.builtin.file: path: "{{ phone_openvpn_dir }}" state: directory owner: root group: root mode: "0700" - name: Remove the obsolete static key (crypto model changed to TLS) ansible.builtin.file: path: "{{ phone_openvpn_dir }}/static.key" state: absent - name: Generate the server EC keypair if missing ansible.builtin.command: argv: - openssl - req - -x509 - -newkey - ec - -pkeyopt - ec_paramgen_curve:prime256v1 - -keyout - "{{ phone_openvpn_server_key }}" - -out - "{{ phone_openvpn_server_cert }}" - -nodes - -sha256 - -days - "3650" - -subj - /CN=homelab-phone-server args: creates: "{{ phone_openvpn_server_cert }}" no_log: true - name: Generate the client EC keypair if missing ansible.builtin.command: argv: - openssl - req - -x509 - -newkey - ec - -pkeyopt - ec_paramgen_curve:prime256v1 - -keyout - "{{ phone_openvpn_client_key }}" - -out - "{{ phone_openvpn_client_cert }}" - -nodes - -sha256 - -days - "3650" - -subj - /CN=homelab-phone-client args: creates: "{{ phone_openvpn_client_cert }}" no_log: true - name: Enforce key permissions ansible.builtin.file: path: "{{ item }}" owner: root group: root mode: "0600" loop: - "{{ phone_openvpn_server_key }}" - "{{ phone_openvpn_client_key }}" - name: Read the server certificate fingerprint ansible.builtin.command: argv: - openssl - x509 - -in - "{{ phone_openvpn_server_cert }}" - -noout - -fingerprint - -sha256 register: phone_openvpn_server_fp_raw changed_when: false - name: Read the client certificate fingerprint ansible.builtin.command: argv: - openssl - x509 - -in - "{{ phone_openvpn_client_cert }}" - -noout - -fingerprint - -sha256 register: phone_openvpn_client_fp_raw changed_when: false - name: Extract the bare colon-separated fingerprints # openssl печатает "sha256 Fingerprint=AB:CD:...", отпечаток без '='. ansible.builtin.set_fact: phone_openvpn_server_fp: "{{ phone_openvpn_server_fp_raw.stdout | regex_replace('^.*=', '') | trim }}" phone_openvpn_client_fp: "{{ phone_openvpn_client_fp_raw.stdout | regex_replace('^.*=', '') | trim }}" - name: Enable IPv4 forwarding on ru-vps ansible.posix.sysctl: name: net.ipv4.ip_forward value: "1" state: present reload: true - name: Allow phone OpenVPN TCP in UFW ansible.builtin.command: "ufw allow {{ phone_openvpn_port }}/tcp" register: ufw_allow_phone_openvpn changed_when: "'Rule added' in ufw_allow_phone_openvpn.stdout or 'Rules updated' in ufw_allow_phone_openvpn.stdout" failed_when: false # Road-warrior трафик tun2 -> tun0 ФОРВАРДИТСЯ через ru-vps, а policy # FORWARD = DROP. Сайт-тоннель этого правила не требовал (он несёт # собственный трафик ru-vps, не forward). ufw route allow переживает # `ufw reload`, в отличие от сырого iptables -I FORWARD. Обратное # направление покрыто глобальным RELATED,ESTABLISHED в ufw-before-forward. - name: Allow forwarding phone VPN traffic into the LAN community.general.ufw: route: true rule: allow interface_in: "{{ phone_openvpn_interface }}" interface_out: "{{ phone_openvpn_site_interface }}" src: "{{ phone_openvpn_network_cidr }}" dest: "{{ homelab_lan_cidr }}" - name: Render phone OpenVPN server config ansible.builtin.copy: dest: "{{ phone_openvpn_config }}" owner: root group: root mode: "0600" content: | dev {{ phone_openvpn_interface }} dev-type tun ifconfig {{ phone_openvpn_server_ip }} {{ phone_openvpn_client_ip }} proto tcp-server port {{ phone_openvpn_port }} tls-server dh none cert {{ phone_openvpn_server_cert }} key {{ phone_openvpn_server_key }} peer-fingerprint {{ phone_openvpn_client_fp }} data-ciphers {{ phone_openvpn_data_ciphers }} auth SHA256 persist-key persist-tun keepalive 10 60 verb 3 notify: restart phone openvpn - name: Render phone OpenVPN up script ansible.builtin.copy: dest: "{{ phone_openvpn_dir }}/up.sh" owner: root group: root mode: "0700" content: | #!/bin/sh iptables -t nat -C POSTROUTING -s {{ phone_openvpn_network_cidr }} -d {{ homelab_lan_cidr }} -o {{ phone_openvpn_site_interface }} -j MASQUERADE 2>/dev/null || \ iptables -t nat -A POSTROUTING -s {{ phone_openvpn_network_cidr }} -d {{ homelab_lan_cidr }} -o {{ phone_openvpn_site_interface }} -j MASQUERADE notify: restart phone openvpn - name: Render phone OpenVPN down script ansible.builtin.copy: dest: "{{ phone_openvpn_dir }}/down.sh" owner: root group: root mode: "0700" content: | #!/bin/sh iptables -t nat -D POSTROUTING -s {{ phone_openvpn_network_cidr }} -d {{ homelab_lan_cidr }} -o {{ phone_openvpn_site_interface }} -j MASQUERADE 2>/dev/null || true notify: restart phone openvpn - name: Render phone OpenVPN systemd service ansible.builtin.copy: dest: "/etc/systemd/system/{{ phone_openvpn_service }}.service" owner: root group: root mode: "0644" content: | [Unit] Description=HomeLab OpenVPN phone access After=network-online.target homelab-openvpn.service Wants=network-online.target Requires=homelab-openvpn.service [Service] Type=simple ExecStartPre={{ phone_openvpn_dir }}/up.sh ExecStart=/usr/sbin/openvpn --config {{ phone_openvpn_config }} ExecStopPost={{ phone_openvpn_dir }}/down.sh Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target notify: - reload systemd - restart phone openvpn - name: Flush handlers before starting phone OpenVPN ansible.builtin.meta: flush_handlers - name: Enable and start phone OpenVPN ansible.builtin.systemd: name: "{{ phone_openvpn_service }}" state: started enabled: true - name: Read the client certificate for the profile ansible.builtin.slurp: src: "{{ phone_openvpn_client_cert }}" register: phone_openvpn_client_cert_raw no_log: true - name: Read the client key for the profile ansible.builtin.slurp: src: "{{ phone_openvpn_client_key }}" register: phone_openvpn_client_key_raw no_log: true - name: Ensure local generated directory exists ansible.builtin.file: path: "{{ playbook_dir }}/../generated" state: directory mode: "0700" delegate_to: localhost vars: ansible_connection: local ansible_become: false - name: Write local phone OpenVPN profile ansible.builtin.copy: dest: "{{ phone_openvpn_client_config_local }}" mode: "0600" content: | dev tun dev-type tun proto tcp-client remote {{ phone_openvpn_remote }} {{ phone_openvpn_port }} ifconfig {{ phone_openvpn_client_ip }} {{ phone_openvpn_server_ip }} tls-client peer-fingerprint {{ phone_openvpn_server_fp }} data-ciphers {{ phone_openvpn_data_ciphers }} auth SHA256 persist-key persist-tun resolv-retry infinite nobind verb 3 {% for route in phone_openvpn_routes %} route {{ route.network }} {{ route.netmask }} {% endfor %} {{ phone_openvpn_client_cert_raw.content | b64decode | trim }} {{ phone_openvpn_client_key_raw.content | b64decode | trim }} delegate_to: localhost vars: ansible_connection: local ansible_become: false no_log: true handlers: - name: reload systemd ansible.builtin.systemd: daemon_reload: true - name: restart phone openvpn ansible.builtin.systemd: name: "{{ phone_openvpn_service }}" state: restarted