feat: adopt the ru-vps Caddy stack, fix cluster quorum, decommission ZeroTier
One ru-vps housekeeping sweep (2026-09-02/03): - playbooks/ru-vps-base.yml (new): adopt the Caddy compose stack into Ansible (pinned image by digest, homelab-caddy.service), and manage the corosync-qnetd UFW rule - allow 5403/tcp from homelab_pve_egress_ip, drop the stale rule for the retired ZeroTier 10.122.62.0/24. The qdevice had gone silent because its only allowed path was the decommissioned ZeroTier network. - group_vars/all/main.yml: homelab_pve_egress_ip (the NATed home egress the PVE nodes reach corosync-qnetd from - a direct path that does not depend on the OpenVPN tunnel). Marked dynamic: a change silently re-breaks the qdevice. - playbooks/status.yml: CLUSTER QUORUM section (pvecm status per PVE node) so a repeat failure is visible. Also drops the memoir-bot unit list and moves the gitea offsite-restic unit to the gitea host (see the OpenTofu-migration commit). - playbooks/ru-vps-zerotier-decommission.yml (new): stop the zerotier container, disable ssh-zt22.service, remove the interface/9993/9001/10.122.62.0/24 UFW rules. Node identity and data are kept; removal is a separate step. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e24b75534e
commit
79878e36f9
@@ -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
|
||||
@@ -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
|
||||
@@ -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: >-
|
||||
|
||||
Reference in New Issue
Block a user