lint / yamllint + ansible-lint + syntax-check (push) Canceled after 0s
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
329 lines
11 KiB
YAML
329 lines
11 KiB
YAML
---
|
||
# ============================================================================
|
||
# 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
|