Files
infra/ansible/playbooks/openvpn-phone.yml
T
DmitryandClaude Sonnet 5 c5c33986da
lint / yamllint + ansible-lint + syntax-check (push) Canceled after 0s
fix(openvpn-phone): add <ca> to the client profile for ics-openvpn
"OpenVPN for Android" fails at "Building configuration" ("Used 101 tries
to get current version of the profile") for a `client`-mode profile with
no <ca> block. The earlier tls-client p2p profile had no CA either but the
app treated it as a custom tunnel; `client` mode makes the CA mandatory in
the app's config builder.

Inline the server's self-signed certificate as <ca> (it is its own trust
anchor); peer-fingerprint still does the actual verification. Server config
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbuZrUoevfBgCpf5DCF4DG
2026-09-03 10:09:20 +03:00

378 lines
14 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
# ============================================================================
# Road-warrior OpenVPN профиль для телефона (Android).
#
# Отдельный OpenVPN server-инстанс на ru-vps (порт 9444/tcp, tun2, пул
# 10.80.0.0/29), параллельный тоннелю site-to-site (tun0, homelab-openvpn)
# и профилю ноутбука (tun1, openvpn-laptop.yml).
#
# КРИПТО: peer-fingerprint, БЕЗ CA/PKI.
# На ru-vps генерятся две самоподписанные EC-пары (server.* и client.*),
# стороны пинят друг друга по SHA256-отпечатку. Прежняя схема со static
# key (--secret) отброшена: OpenVPN 2.7 в Android-клиенте её не запускает.
#
# РЕЖИМ: полноценный `server` (не p2p), маршруты в LAN раздаются через
# `push "route ..."`. Через p2p static-key/tls «OpenVPN for Android»
# локальные `route` из конфига не подхватывал — тоннель вставал, но
# трафик в LAN не заворачивался. Через push маршруты ставит любой клиент.
#
# КЛИЕНТ
# 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/29 -> 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
# server-режим: нужен пул адресов, поэтому /29, а не /30 p2p.
phone_openvpn_network_cidr: 10.80.0.0/29
phone_openvpn_server_subnet: 10.80.0.0
phone_openvpn_server_netmask: 255.255.255.248
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 }}"
# Переходная уборка: p2p-версия использовала /30. Убрать после ~2026-11.
- name: Drop the legacy /30 ufw forward rule
community.general.ufw:
route: true
rule: allow
delete: true
interface_in: "{{ phone_openvpn_interface }}"
interface_out: "{{ phone_openvpn_site_interface }}"
src: 10.80.0.0/30
dest: "{{ homelab_lan_cidr }}"
- name: Drop the legacy /30 MASQUERADE rule
ansible.builtin.iptables:
table: nat
chain: POSTROUTING
source: 10.80.0.0/30
destination: "{{ homelab_lan_cidr }}"
out_interface: "{{ phone_openvpn_site_interface }}"
jump: MASQUERADE
state: absent
- 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
proto tcp-server
port {{ phone_openvpn_port }}
server {{ phone_openvpn_server_subnet }} {{ phone_openvpn_server_netmask }}
topology subnet
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
keepalive 10 60
{% for route in phone_openvpn_routes %}
push "route {{ route.network }} {{ route.netmask }}"
{% endfor %}
persist-key
persist-tun
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 server certificate for the profile CA block
# client-режим ics-openvpn отказывается собирать конфиг без <ca>.
# Серверный самоподписанный сертификат сам себе trust anchor; реальную
# проверку всё равно делает peer-fingerprint.
ansible.builtin.slurp:
src: "{{ phone_openvpn_server_cert }}"
register: phone_openvpn_server_cert_raw
no_log: 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: |
client
dev tun
dev-type tun
proto tcp-client
remote {{ phone_openvpn_remote }} {{ phone_openvpn_port }}
peer-fingerprint {{ phone_openvpn_server_fp }}
data-ciphers {{ phone_openvpn_data_ciphers }}
auth SHA256
persist-key
persist-tun
resolv-retry infinite
nobind
verb 3
<ca>
{{ phone_openvpn_server_cert_raw.content | b64decode | trim }}
</ca>
<cert>
{{ phone_openvpn_client_cert_raw.content | b64decode | trim }}
</cert>
<key>
{{ phone_openvpn_client_key_raw.content | b64decode | trim }}
</key>
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