diff --git a/ansible/playbooks/openvpn-phone.yml b/ansible/playbooks/openvpn-phone.yml
index 0f502d1..c43e0a7 100644
--- a/ansible/playbooks/openvpn-phone.yml
+++ b/ansible/playbooks/openvpn-phone.yml
@@ -2,21 +2,27 @@
# ============================================================================
# Road-warrior OpenVPN профиль для телефона (Android).
#
-# Отдельный point-to-point static-key инстанс на ru-vps (порт 9444/tcp, tun2,
+# Отдельный TLS point-to-point инстанс на ru-vps (порт 9444/tcp, tun2,
# сеть 10.80.0.0/30), параллельный тоннелю site-to-site (tun0, homelab-openvpn)
# и профилю ноутбука (tun1, openvpn-laptop.yml). Клиент подключается один за
-# раз — static key не мультиплексируется.
+# раз.
+#
+# КРИПТО: 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 уберёт совсем.
#
# КЛИЕНТ
-# Приложение «OpenVPN for Android» (Arne Schwabe, F-Droid/Play). Официальный
-# OpenVPN Connect static-key конфиги НЕ поддерживает — на Android нужен
-# именно этот клиент.
+# Android — «OpenVPN for Android» (Arne Schwabe) или OpenVPN Connect:
+# TLS-конфиг понимают оба. Профиль самодостаточный (cert+key внутри).
#
# ЧТО ДЕЛАЕТ
-# Ставит openvpn на ru-vps, генерит static 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, содержит секрет).
+# ansible/generated/ada-phone.ovpn (mode 0600, содержит приватный ключ).
#
# ЗАПУСК
# make openvpn-phone
@@ -29,7 +35,10 @@
phone_profile_name: ada-phone
phone_openvpn_dir: /etc/openvpn/homelab-phone
phone_openvpn_config: /etc/openvpn/homelab-phone/server.conf
- phone_openvpn_key: /etc/openvpn/homelab-phone/static.key
+ 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
@@ -38,6 +47,7 @@
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 }}"
@@ -47,6 +57,7 @@
name:
- openvpn
- iptables
+ - openssl
state: present
update_cache: true
@@ -58,12 +69,101 @@
group: root
mode: "0700"
- - name: Generate phone static key if missing
- ansible.builtin.command: "openvpn --genkey secret {{ phone_openvpn_key }}"
+ - 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_key }}"
+ 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
@@ -85,16 +185,21 @@
mode: "0600"
content: |
dev {{ phone_openvpn_interface }}
+ dev-type tun
ifconfig {{ phone_openvpn_server_ip }} {{ phone_openvpn_client_ip }}
- secret {{ phone_openvpn_key }}
- cipher AES-256-CBC
- auth SHA256
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
- verb 3
keepalive 10 60
+ verb 3
notify: restart phone openvpn
- name: Render phone OpenVPN up script
@@ -156,10 +261,16 @@
state: started
enabled: true
- - name: Read phone static key for client profile
+ - name: Read the client certificate for the profile
ansible.builtin.slurp:
- src: "{{ phone_openvpn_key }}"
- register: phone_openvpn_static_key_raw
+ 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
@@ -177,12 +288,14 @@
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 }}
ifconfig {{ phone_openvpn_client_ip }} {{ phone_openvpn_server_ip }}
- cipher AES-256-CBC
+ tls-client
+ peer-fingerprint {{ phone_openvpn_server_fp }}
+ data-ciphers {{ phone_openvpn_data_ciphers }}
auth SHA256
persist-key
persist-tun
@@ -192,9 +305,12 @@
{% for route in phone_openvpn_routes %}
route {{ route.network }} {{ route.netmask }}
{% endfor %}
-
- {{ phone_openvpn_static_key_raw.content | b64decode }}
-
+
+ {{ phone_openvpn_client_cert_raw.content | b64decode | trim }}
+
+
+ {{ phone_openvpn_client_key_raw.content | b64decode | trim }}
+
delegate_to: localhost
vars:
ansible_connection: local
diff --git a/docs/ai/plan.md b/docs/ai/plan.md
index c3aeb43..2e3c5bf 100644
--- a/docs/ai/plan.md
+++ b/docs/ai/plan.md
@@ -5,23 +5,33 @@
### Road-warrior OpenVPN для телефона
Выполнено 2026-09-03. `playbooks/openvpn-phone.yml` (`make openvpn-phone`)
-поднимает на ru-vps отдельный point-to-point static-key инстанс:
-порт 9444/tcp, `tun2`, сеть 10.80.0.0/30, юнит `homelab-openvpn-phone`,
-NAT `10.80.0.0/30 -> LAN` через `tun0`. Профиль (с секретом) —
-`ansible/generated/ada-phone.ovpn` (gitignored). Клиент — «OpenVPN for
-Android» (Arne Schwabe); официальный OpenVPN Connect static key не умеет.
+поднимает на ru-vps отдельный TLS point-to-point инстанс: порт 9444/tcp,
+`tun2`, сеть 10.80.0.0/30, юнит `homelab-openvpn-phone`, NAT
+`10.80.0.0/30 -> LAN` через `tun0`. Профиль (с приватным ключом) —
+`ansible/generated/ada-phone.ovpn` (gitignored).
-Прогон идемпотентен (третий заход `changed=0`). Проверено: сервис active,
-`tun2` up, UFW 9444/tcp, NAT-правило на месте, с ru-vps `192.168.1.30:8082`
-достижим. Маршруты клиента вынесены в `homelab_vpn_client_routes`
-(`group_vars/all/main.yml`) — общий список с `openvpn-laptop.yml`, не весь
-`/24` (домашняя сеть телефона тоже 192.168.1.0/24).
+Крипто: **peer-fingerprint, без CA/PKI**. На ru-vps генерятся две
+самоподписанные EC-пары (`server.*`, `client.*`), стороны пинят друг друга
+по SHA256-отпечатку (`peer-fingerprint`), `data-ciphers AES-256-GCM`. Первая
+попытка была на static key (`--secret`) — Android-клиент несёт OpenVPN 2.7 и
+запускать её отказался («No tls-client or tls-server option»; 2.8 уберёт
+совсем). Клиент на Android — «OpenVPN for Android» (Arne Schwabe) или
+OpenVPN Connect: TLS-конфиг понимают оба.
+
+Прогон идемпотентен (`changed=0` на повторе). Проверено: сервис active,
+OpenVPN 2.6.19 слушает 9444, `tun2` up, `Using certificate fingerprint to
+verify peer`, UFW 9444/tcp, NAT-правило на месте, с ru-vps
+`192.168.1.30:8082` достижим. Маршруты клиента вынесены в
+`homelab_vpn_client_routes` (`group_vars/all/main.yml`) — общий список с
+`openvpn-laptop.yml`, не весь `/24` (домашняя сеть телефона тоже
+192.168.1.0/24).
Побочно: в `openvpn-laptop.yml` и `openvpn-phone.yml` локальная запись
профиля переведена с `become: false` на `vars: {ansible_connection: local,
ansible_become: false}` — ключ `become: false` на `delegate_to: localhost`
не подавлял унаследованный `ansible_become: true` (sudo password required).
-`openvpn-laptop.yml` на живом ru-vps по-прежнему не применялся.
+`openvpn-laptop.yml` по-прежнему на static key и на живом ru-vps не
+применялся — при реальном использовании ему нужен тот же перевод на TLS.
### Дашборд-обзор инфраструктуры (Homepage)