diff --git a/ansible/inventory/group_vars/all/main.yml b/ansible/inventory/group_vars/all/main.yml index 036f18c..01b79d3 100644 --- a/ansible/inventory/group_vars/all/main.yml +++ b/ansible/inventory/group_vars/all/main.yml @@ -13,6 +13,16 @@ ansible_become: true homelab_lan_cidr: 192.168.1.0/24 homelab_service_range: 192.168.1.5-192.168.1.40 +# Публичный адрес, с которого обе ноды PVE выходят в интернет (домашний NAT). +# Нужен для правила UFW на ru-vps, открывающего corosync-qnetd (5403/tcp): +# арбитр кластера обязан быть достижим по пути, не зависящему ни от одной ноды, +# поэтому ходим напрямую, а не через OpenVPN-туннель (он живёт в CT 160 на +# mini-pc — при падении mini-pc арбитр исчез бы вместе с ним). +# ВНИМАНИЕ: адрес динамический. Если он сменится, qdevice замолчит так же тихо, +# как это уже случилось после вывода ZeroTier. Признак — `pvecm status`: +# Total votes меньше Expected votes и флаг NV у узлов. +homelab_pve_egress_ip: 85.143.112.108 + openvpn_network_cidr: 10.78.0.0/30 openvpn_listen_port: 8443 openvpn_forwarded_services: diff --git a/ansible/playbooks/ru-vps-base.yml b/ansible/playbooks/ru-vps-base.yml new file mode 100644 index 0000000..ee3b8a8 --- /dev/null +++ b/ansible/playbooks/ru-vps-base.yml @@ -0,0 +1,137 @@ +--- +# Базовое состояние публичной VPS и стек Caddy. +# +# ОБЛАСТЬ ОТВЕТСТВЕННОСТИ +# Этот плейбук ПРИНИМАЕТ существующий ru-vps под управление Ansible, а не +# разворачивает его с нуля. Docker и compose-плагин уже установлены вручную +# (Docker CE 29.x из upstream-репозитория, не Ubuntu'шный docker.io), поэтому +# плейбук их проверяет, но не ставит: `apt install docker.io` поверх Docker CE +# на боевой VPS сломал бы все стеки сразу. +# +# Управляется здесь ТОЛЬКО стек Caddy. Остальные каталоги в +# /opt/services/ru-vps (gitea-runner, mihomo, resticprofile, squid, zerotier) +# развёрнуты вручную и осознанно оставлены вне управления — решение по каждому +# принимается отдельно, см. docs/ai/plan.md. +# +# ГРАНИЦА С reverse-proxy.yml +# Содержимое Caddyfile принадлежит playbooks/reverse-proxy.yml. Здесь файл +# только проверяется на наличие: стартовать Caddy без конфига бессмысленно, +# а перезаписать его отсюда — значит потерять все маршруты. +# +# ЧТО МЕНЯЕТСЯ НА ЖИВОМ ХОСТЕ ПРИ ПЕРВОМ ПРОГОНЕ +# 1. Образ caddy:2-alpine закрепляется по digest -> контейнер пересоздаётся. +# 2. Появляется systemd-юнит homelab-caddy.service: до сих пор жизненным +# циклом стека управляла только политика restart=unless-stopped, то есть +# после `docker compose down` его никто не поднимал. +# Это кратковременный перерыв в публичном HTTPS. Прогонять осознанно. +# +# ЗАМЕЧАНИЕ ПРО --check +# До первого реального прогона `--check` заканчивается ошибкой +# "Could not find the requested service homelab-caddy": в check-режиме юнит +# на диск не пишется, поэтому systemd его не видит. Это артефакт проверки, +# а не дефект плейбука. Diff'ы задач выше при этом достоверны. + +- name: Adopt the ru-vps Caddy stack into Ansible + hosts: ru-vps + gather_facts: true + + # roles: выполняются РАНЬШЕ tasks:, поэтому все проверки идут в pre_tasks — + # иначе стек разложился бы до проверки Caddyfile. + pre_tasks: + - name: Verify the container runtime is present + vars: + ru_vps_docker_binary: /usr/bin/docker + block: + - name: Check the Docker binary + ansible.builtin.command: "{{ ru_vps_docker_binary }} version --format '{{ '{{' }}.Server.Version{{ '}}' }}'" + register: ru_vps_docker_version + changed_when: false + # Read-only проверка, поэтому выполняется и в --check. + check_mode: false + + - name: Check the Compose plugin + ansible.builtin.command: "{{ ru_vps_docker_binary }} compose version --short" + register: ru_vps_compose_version + changed_when: false + # Read-only проверка, поэтому выполняется и в --check. + check_mode: false + + - name: Report the detected runtime + ansible.builtin.debug: + msg: >- + docker={{ ru_vps_docker_version.stdout | trim }} + compose={{ ru_vps_compose_version.stdout | trim }} + + # --- Арбитр кластера --------------------------------------------------- + # corosync-qnetd на ru-vps даёт двухнодовому кластеру третий голос. + # Ноды подключаются к нему по публичному адресу (см. corosync.conf, + # host: 157.22.231.198), а старое правило пускало 5403 только из + # 10.122.62.0/24 — сети ZeroTier, выведенной в июле. После этого qdevice + # молча перестал голосовать. + - name: Allow corosync-qnetd from the PVE nodes + community.general.ufw: + rule: allow + port: "5403" + proto: tcp + src: "{{ homelab_pve_egress_ip }}" + comment: corosync-qnetd for the HomeLab PVE cluster + + - name: Drop the qnetd rule for the retired ZeroTier network + community.general.ufw: + rule: allow + port: "5403" + proto: tcp + src: 10.122.62.0/24 + delete: true + + # Caddyfile принадлежит reverse-proxy.yml. Без него стек стартует пустым, + # поэтому сначала проверяем наличие и только потом раскладываем стек. + - name: Check the Caddyfile managed by reverse-proxy.yml + ansible.builtin.stat: + path: "{{ homelab_reverse_proxy_caddyfile }}" + register: ru_vps_caddyfile + + - name: Require the Caddyfile before touching the stack + ansible.builtin.assert: + that: + - ru_vps_caddyfile.stat.exists + - ru_vps_caddyfile.stat.size > 0 + fail_msg: >- + {{ homelab_reverse_proxy_caddyfile }} отсутствует или пуст. + Сначала прогони playbooks/reverse-proxy.yml, иначе Caddy стартует + без маршрутов и публичные сервисы лягут. + + roles: + - role: compose_service + compose_service_name: "{{ homelab_reverse_proxy_unit }}" + compose_service_description: Caddy reverse proxy for public HomeLab services + compose_service_root: "{{ homelab_reverse_proxy_dir }}" + # Каталог создан вручную с 0755 — сохраняем, чтобы прогон не давал + # косметического diff'а на боевом хосте. + compose_service_root_mode: "0755" + # Имя файла сохраняем: имя compose-проекта выводится из каталога, а сам + # файл должен остаться тем же, иначе стек станет orphan и поднимется + # второй контейнер на тех же 80/443. + compose_service_compose_file: docker-compose.yml + # /opt/data/caddy и /opt/configs/caddy принадлежат ada:ada и содержат + # выпущенные сертификаты. Роль их владельца не трогает намеренно. + compose_service_directories: [] + compose_service_compose_content: | + services: + caddy: + image: {{ homelab_reverse_proxy_image | trim }} + container_name: {{ homelab_reverse_proxy_container }} + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - /opt/data/caddy:/data + - /opt/configs/caddy:/config + networks: {} + # Caddy на голый IP отвечает 308 (редирект http -> https), а не 200. + compose_service_health_url: http://127.0.0.1:80/ + compose_service_health_status: [308] + compose_service_health_follow_redirects: none diff --git a/ansible/playbooks/ru-vps-zerotier-decommission.yml b/ansible/playbooks/ru-vps-zerotier-decommission.yml new file mode 100644 index 0000000..233f3a9 --- /dev/null +++ b/ansible/playbooks/ru-vps-zerotier-decommission.yml @@ -0,0 +1,183 @@ +--- +# Вывод ZeroTier с ru-vps и чистка оставшихся от него правил UFW. +# +# ПОЧЕМУ ЭТО БЕЗОПАСНО +# Проверено 2026-09-02: ZT-интерфейса на хосте нет, маршрутов через него нет, +# на ZT-адресах никто не слушает. Контейнер `zerotier` работает, но подключён +# в никуда (потому и unhealthy). `ssh-zt22.service` в состоянии failed: его +# sshd настроен на ListenAddress 10.122.62.65, которого больше не существует. +# +# Из Ansible ZeroTier выведен ещё в июле 2026 — на хосте остался хвост. +# +# ЧТО НЕ УДАЛЯЕТСЯ +# Каталог /opt/services/ru-vps/zerotier, данные /opt/data/zerotier и файл +# /etc/ssh/sshd_config_zt22 остаются на месте. `docker compose down` идёт БЕЗ +# -v, то есть identity узла ZeroTier сохраняется. Удаление этих файлов — +# отдельный осознанный шаг оператора после того, как всё устоялось. +# +# make zerotier-decommission CONFIRM=1 + +- name: Decommission ZeroTier on ru-vps + hosts: ru-vps + gather_facts: false + + vars: + zt_dir: /opt/services/ru-vps/zerotier + zt_interface: zt6q3dmi2d + zt_legacy_cidr: 10.122.62.0/24 + # Правила UFW для сервисов, которые на хосте ничего не слушают (проверено + # через ss): squid не запущен, danted слушает 1081 а не 1080, на 993 и 7892 + # слушателей нет. Это НЕ наследие ZeroTier, поэтому по умолчанию не трогаем. + zt_cleanup_unrelated_stale_rules: false + + tasks: + # --- Предполётные проверки --------------------------------------------- + - name: List the network interfaces + ansible.builtin.command: ip -br addr show + register: zt_ifaces + changed_when: false + check_mode: false + + - name: Read the current firewall rules + ansible.builtin.command: ufw status + register: zt_ufw + changed_when: false + check_mode: false + + - name: Refuse to run while a ZeroTier interface is up + ansible.builtin.assert: + that: + - zt_interface not in zt_ifaces.stdout + fail_msg: >- + Интерфейс {{ zt_interface }} присутствует на хосте — значит ZeroTier + снова используется. Плейбук рассчитан на вывод мёртвого стека и + отказывается работать: сначала подтверди, что сеть больше не нужна. + + # Управляющая сессия идёт по 3422/tcp. Если глобального разрешения на этот + # порт нет, чистка правил может оборвать доступ — лучше не начинать. + - name: Require the management SSH rule to survive the cleanup + ansible.builtin.assert: + that: + - "'3422/tcp' in zt_ufw.stdout" + fail_msg: >- + В UFW нет правила для 3422/tcp. Прерываюсь, чтобы не остаться без + управляющего доступа. + + # --- Контейнер ---------------------------------------------------------- + - name: Check whether the ZeroTier stack is present + ansible.builtin.stat: + path: "{{ zt_dir }}/docker-compose.yml" + register: zt_compose + + - name: Stop and remove the ZeroTier stack + ansible.builtin.command: + cmd: /usr/bin/docker compose -f {{ zt_dir }}/docker-compose.yml down + chdir: "{{ zt_dir }}" + register: zt_down + when: zt_compose.stat.exists + changed_when: "'Removing' in zt_down.stderr or 'Stopping' in zt_down.stderr" + + # --- sshd на ZeroTier --------------------------------------------------- + - name: Disable the ZeroTier-only sshd unit + ansible.builtin.systemd: + name: ssh-zt22.service + state: stopped + enabled: false + failed_when: false + + # `stop` не снимает состояние failed, и юнит навсегда остался бы в секции + # FAILED SYSTEMD UNITS отчёта `make status`. homelab-pve-routes.service — + # фантом той же эпохи: файла юнита уже нет, а запись о падении осталась. + - name: List the units currently in a failed state + ansible.builtin.command: systemctl list-units --state=failed --no-legend --plain --no-pager + register: zt_failed + changed_when: false + check_mode: false + + - name: Clear the leftover failed state of the ZeroTier-era units + ansible.builtin.command: "systemctl reset-failed {{ item }}" + loop: + - ssh-zt22.service + - homelab-pve-routes.service + # Без этого условия reset-failed рапортует changed на каждом прогоне: + # его код возврата нулевой и когда сбрасывать нечего. + when: item in zt_failed.stdout + register: zt_reset + changed_when: zt_reset.rc == 0 + failed_when: false + + # --- UFW ---------------------------------------------------------------- + - name: Remove the ZeroTier interface rules + community.general.ufw: + rule: allow + direction: "{{ item }}" + interface: "{{ zt_interface }}" + delete: true + loop: [in, out] + + - name: Remove the ZeroTier port rules + community.general.ufw: + rule: "{{ item.rule }}" + port: "{{ item.port }}" + proto: "{{ item.proto | default('any') }}" + interface: "{{ item.interface | default(omit) }}" + direction: "{{ 'in' if item.interface is defined else omit }}" + delete: true + loop: + - {rule: allow, port: "9993", proto: udp} # ZeroTier UDP + - {rule: deny, port: "9001"} # ZeroTier controller + - {rule: allow, port: "9001", interface: "zt+"} # он же, но с ZT-сетей + loop_control: + label: "{{ item.rule }} {{ item.port }}{{ '/' ~ item.proto if item.proto is defined else '' }}" + + - name: Remove rules scoped to the retired ZeroTier subnet + community.general.ufw: + rule: allow + port: "{{ item }}" + proto: tcp + src: "{{ zt_legacy_cidr }}" + delete: true + loop: ["22", "3422"] + + - name: Remove stale rules for services that no longer listen + community.general.ufw: + rule: allow + port: "{{ item.port }}" + proto: tcp + delete: true + loop: + - {port: "3128"} # squid: стек есть, контейнер не запущен + - {port: "1080"} # danted слушает 1081, не 1080 + - {port: "993"} # слушателей нет + - {port: "7892"} # слушателей нет + loop_control: + label: "{{ item.port }}/tcp" + when: zt_cleanup_unrelated_stale_rules | bool + + # --- Проверка ----------------------------------------------------------- + - name: List the running containers after the cleanup + ansible.builtin.command: docker ps --format {{ '{{' }}.Names{{ '}}' }} + register: zt_after_ps + changed_when: false + check_mode: false + + - name: Read the firewall after the cleanup + ansible.builtin.command: ufw status + register: zt_after_ufw + changed_when: false + check_mode: false + + - name: Confirm ZeroTier is gone and management access survived + ansible.builtin.assert: + that: + - "'zerotier' not in zt_after_ps.stdout_lines" + - zt_interface not in zt_after_ufw.stdout + - "'3422/tcp' in zt_after_ufw.stdout" + success_msg: ZeroTier выведен, правило для 3422/tcp на месте. + # В --check контейнер не останавливается и правила не удаляются, поэтому + # проверка результата заведомо не прошла бы. Diff'ы задач выше достоверны. + when: not ansible_check_mode + + - name: Show the resulting firewall + ansible.builtin.debug: + var: zt_after_ufw.stdout_lines diff --git a/ansible/playbooks/status.yml b/ansible/playbooks/status.yml index 34a60ae..c4af6c8 100644 --- a/ansible/playbooks/status.yml +++ b/ansible/playbooks/status.yml @@ -26,7 +26,6 @@ # grimmory{,-docker-firewall} playbooks/pve-grimmory.yml # gyro.timer roles/gyro (gyro.service is oneshot -> see jobs) # hermes-ai-tun-proxy.service playbooks/pve-hermes-ai.yml - # memoir-bot.service playbooks/pve-memoir-bot.yml # mihomo{,-ui}.service playbooks/pve-mihomo.yml # uptime-kuma.service roles/uptime_kuma # vaultwarden.service playbooks/pve-vaultwarden.yml @@ -39,7 +38,6 @@ grimmory: [docker.service, grimmory.service, grimmory-docker-firewall.service] gyro: [gyro.timer] hermes-ai: [docker.service, hermes-ai-tun-proxy.service] - memoir-bot: [docker.service, memoir-bot.service] mihomo: [docker.service, mihomo.service, mihomo-ui.service] monitoring: [docker.service, uptime-kuma.service] ru-vps: [docker.service] @@ -51,8 +49,9 @@ # Yandex Disk over the network, so we only read what systemd already knows. status_job_units: cloud-pc: - - homelab-restic-offsite-gitea.service - homelab-backup-audit-gitea.service + gitea: + - homelab-restic-offsite-gitea.service grimmory: - homelab-restic-offsite-grimmory.service - homelab-backup-audit-grimmory.service @@ -136,6 +135,18 @@ failed_when: false when: status_units | length > 0 + # Двухнодовый кластер держится на арбитре corosync-qnetd (ru-vps:5403). + # Когда арбитр молчит, Total votes < Expected votes и падение ЛЮБОЙ ноды + # оставляет выжившую без кворума. Один раз это уже сломалось молча — + # после вывода ZeroTier правило UFW для 5403 осталось на мёртвой сети. + - name: Read cluster quorum state + ansible.builtin.shell: + cmd: LC_ALL=C pvecm status 2>/dev/null | grep -E 'Quorate:|Expected votes|Total votes' | tr -s ' ' | tr '\n' ' ' + register: status_quorum + changed_when: false + failed_when: false + when: inventory_hostname in (groups['pve_nodes'] | default([])) + - name: List failed systemd units ansible.builtin.shell: cmd: >- @@ -232,6 +243,7 @@ if inventory_hostname in (groups['vpn_openvpn'] | default([])) else '-' }} vpn_peer_ip: "{{ openvpn_peer_ip | default('-') }}" vpn_probes: "{{ status_vpn_probe.stdout_lines | default([]) }}" + quorum: "{{ status_quorum.stdout | default('') | trim }}" - name: Print HomeLab status summary @@ -331,6 +343,19 @@ loop: "{{ status_hosts }}" when: hostvars[item].status_record.vpn_peer != '-' + - name: Add cluster quorum section header + ansible.builtin.set_fact: + status_report: >- + {{ status_report + ['', 'CLUSTER QUORUM (qdevice = третий голос)', '-' * 80] }} + + - name: Add quorum line per PVE node + ansible.builtin.set_fact: + status_report: >- + {{ status_report + [' ' ~ item ~ ': ' + ~ (hostvars[item].status_record.quorum | default('') | trim | default('нет данных', true))] }} + loop: "{{ status_hosts }}" + when: hostvars[item].status_record.quorum | default('') | length > 0 + - name: Add backup and job section header ansible.builtin.set_fact: status_report: >-