diff --git a/ansible/Makefile b/ansible/Makefile
index b5ab643..be337dd 100644
--- a/ansible/Makefile
+++ b/ansible/Makefile
@@ -221,6 +221,10 @@ openvpn: ## Поднять OpenVPN-транспорт ru-vps <-> ovpn-mini (ну
openvpn-laptop: ## Настроить OpenVPN-профиль ноутбука
$(ANSIBLE_PLAYBOOK) playbooks/openvpn-laptop.yml $(EXTRA)
+.PHONY: openvpn-phone
+openvpn-phone: ## Настроить OpenVPN-профиль телефона (Android), профиль -> ansible/generated/ada-phone.ovpn
+ $(ANSIBLE_PLAYBOOK) playbooks/openvpn-phone.yml $(EXTRA)
+
.PHONY: bash-config
bash-config: ## Раскатать единый bash-конфиг на shell_hosts
$(ANSIBLE_PLAYBOOK) playbooks/bash-config.yml $(EXTRA)
diff --git a/ansible/inventory/group_vars/all/main.yml b/ansible/inventory/group_vars/all/main.yml
index 01b79d3..95a98ff 100644
--- a/ansible/inventory/group_vars/all/main.yml
+++ b/ansible/inventory/group_vars/all/main.yml
@@ -23,6 +23,19 @@ homelab_service_range: 192.168.1.5-192.168.1.40
# Total votes меньше Expected votes и флаг NV у узлов.
homelab_pve_egress_ip: 85.143.112.108
+# Маршруты, которые road-warrior профили (openvpn-laptop.yml, openvpn-phone.yml)
+# добавляют клиенту. НЕ весь homelab_lan_cidr: домашняя сеть клиента почти
+# наверняка тоже 192.168.1.0/24, полный /24 перебил бы её. Диапазон покрывает
+# 192.168.1.5-.7, .8-.15, .16-.31, .32-.39, .40 — все managed-хосты и дашборд
+# (192.168.1.30), но не роутер (.1) и не игровой сервер (.100).
+homelab_vpn_client_routes:
+ - {network: 192.168.1.5, netmask: 255.255.255.255}
+ - {network: 192.168.1.6, netmask: 255.255.255.254}
+ - {network: 192.168.1.8, netmask: 255.255.255.248}
+ - {network: 192.168.1.16, netmask: 255.255.255.240}
+ - {network: 192.168.1.32, netmask: 255.255.255.248}
+ - {network: 192.168.1.40, netmask: 255.255.255.255}
+
openvpn_network_cidr: 10.78.0.0/30
openvpn_listen_port: 8443
openvpn_forwarded_services:
diff --git a/ansible/playbooks/openvpn-laptop.yml b/ansible/playbooks/openvpn-laptop.yml
index bddf80a..974eb7a 100644
--- a/ansible/playbooks/openvpn-laptop.yml
+++ b/ansible/playbooks/openvpn-laptop.yml
@@ -16,19 +16,8 @@
laptop_openvpn_client_ip: 10.79.0.2
laptop_openvpn_remote: 157.22.231.198
laptop_openvpn_client_config_local: "{{ playbook_dir }}/../generated/{{ laptop_profile_name }}.ovpn"
- laptop_openvpn_home_routes:
- - network: 192.168.1.5
- netmask: 255.255.255.255
- - network: 192.168.1.6
- netmask: 255.255.255.254
- - network: 192.168.1.8
- netmask: 255.255.255.248
- - network: 192.168.1.16
- netmask: 255.255.255.240
- - network: 192.168.1.32
- netmask: 255.255.255.248
- - network: 192.168.1.40
- netmask: 255.255.255.255
+ # Общий список с openvpn-phone.yml — см. group_vars/all/main.yml.
+ laptop_openvpn_home_routes: "{{ homelab_vpn_client_routes }}"
tasks:
- name: Install OpenVPN packages
@@ -156,8 +145,10 @@
path: "{{ playbook_dir }}/../generated"
state: directory
mode: "0700"
- become: false
delegate_to: localhost
+ vars:
+ ansible_connection: local
+ ansible_become: false
- name: Write local laptop OpenVPN profile
ansible.builtin.copy:
@@ -182,8 +173,10 @@
{{ laptop_openvpn_static_key_raw.content | b64decode }}
- become: false
delegate_to: localhost
+ vars:
+ ansible_connection: local
+ ansible_become: false
no_log: true
handlers:
diff --git a/ansible/playbooks/openvpn-phone.yml b/ansible/playbooks/openvpn-phone.yml
new file mode 100644
index 0000000..0f502d1
--- /dev/null
+++ b/ansible/playbooks/openvpn-phone.yml
@@ -0,0 +1,212 @@
+---
+# ============================================================================
+# Road-warrior OpenVPN профиль для телефона (Android).
+#
+# Отдельный point-to-point static-key инстанс на ru-vps (порт 9444/tcp, tun2,
+# сеть 10.80.0.0/30), параллельный тоннелю site-to-site (tun0, homelab-openvpn)
+# и профилю ноутбука (tun1, openvpn-laptop.yml). Клиент подключается один за
+# раз — static key не мультиплексируется.
+#
+# КЛИЕНТ
+# Приложение «OpenVPN for Android» (Arne Schwabe, F-Droid/Play). Официальный
+# OpenVPN Connect static-key конфиги НЕ поддерживает — на Android нужен
+# именно этот клиент.
+#
+# ЧТО ДЕЛАЕТ
+# Ставит openvpn на ru-vps, генерит static key (один раз), открывает
+# 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_key: /etc/openvpn/homelab-phone/static.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_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
+ 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: Generate phone static key if missing
+ ansible.builtin.command: "openvpn --genkey secret {{ phone_openvpn_key }}"
+ args:
+ creates: "{{ phone_openvpn_key }}"
+ no_log: true
+
+ - 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 }}
+ 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 }}
+ persist-key
+ persist-tun
+ verb 3
+ keepalive 10 60
+ 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 phone static key for client profile
+ ansible.builtin.slurp:
+ src: "{{ phone_openvpn_key }}"
+ register: phone_openvpn_static_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
+ proto tcp-client
+ remote {{ phone_openvpn_remote }} {{ phone_openvpn_port }}
+ ifconfig {{ phone_openvpn_client_ip }} {{ phone_openvpn_server_ip }}
+ cipher AES-256-CBC
+ 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_static_key_raw.content | b64decode }}
+
+ 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
diff --git a/docs/ai/plan.md b/docs/ai/plan.md
index 0fe5b5f..c3aeb43 100644
--- a/docs/ai/plan.md
+++ b/docs/ai/plan.md
@@ -2,6 +2,27 @@
## Активные задачи
+### 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 не умеет.
+
+Прогон идемпотентен (третий заход `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).
+
+Побочно: в `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 по-прежнему не применялся.
+
### Дашборд-обзор инфраструктуры (Homepage)
Решение от 2026-09-03: поднять стартовую страницу-обзор всей инфры (список