Files
infra/ansible/playbooks/openvpn-phone.yml
T
DmitryandClaude Sonnet 5 1f27756afd
lint / yamllint + ansible-lint + syntax-check (push) Canceled after 0s
fix(openvpn-phone): switch from static key to TLS peer-fingerprint
The Android client ships OpenVPN 2.7, which refuses a --secret config
("Options error: No tls-client or tls-server option"; 2.8 drops it
entirely). Rework the phone instance to a CA-less TLS point-to-point:
two self-signed EC keypairs generated on ru-vps (server.* / client.*),
each side pins the other by SHA256 with peer-fingerprint, data-ciphers
AES-256-GCM. Everything else unchanged (tcp/9444, tun2, 10.80.0.0/30,
NAT via tun0, systemd unit, shared homelab_vpn_client_routes).

Verified on ru-vps 2026-09-03: openvpn 2.6.19 starts clean ("Using
certificate fingerprint to verify peer"), listens on 9444, tun2 up,
make openvpn-phone idempotent (changed=0 on rerun), make lint green.
The regenerated ansible/generated/ada-phone.ovpn (self-contained
cert+key, gitignored) was handed to the operator.

openvpn-laptop.yml is left on static key — not deployed; needs the same
TLS treatment when someone actually uses it.

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

329 lines
11 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).
#
# Отдельный 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
- 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 %}
<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