Compare commits
28
Commits
4bafa7d09e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d45391a261 | ||
|
|
c5c33986da | ||
|
|
203751d874 | ||
|
|
dfcee00232 | ||
|
|
1f27756afd | ||
|
|
4b54e44116 | ||
|
|
05d8c748ab | ||
|
|
ca48ef2696 | ||
|
|
a23e944756 | ||
|
|
d2e1e6876a | ||
|
|
5e27ba2513 | ||
|
|
a3fe8031fe | ||
|
|
e349f19e68 | ||
|
|
79878e36f9 | ||
|
|
e24b75534e | ||
|
|
baef2c49b3 | ||
|
|
7031d7dbb3 | ||
|
|
646f2bbc8f | ||
|
|
e0c53a1b1b | ||
|
|
22394cbaba | ||
|
|
7700ed5a88 | ||
|
|
ef234b17f5 | ||
|
|
d535ef2d32 | ||
|
|
9725d3ea7c | ||
|
|
ec3b736250 | ||
|
|
a7b0635830 | ||
|
|
b953909e0a | ||
|
|
c676be81ec |
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
# ansible-lint для HomeLab infras.
|
||||||
|
#
|
||||||
|
# Profile: moderate.
|
||||||
|
# - `production` / `safety` дали бы сотни нарушений на текущем коде (fqcn,
|
||||||
|
# jinja[spacing], no-handler, key-order, галактические метаданные ролей) —
|
||||||
|
# линтер стал бы шумом, который все игнорируют.
|
||||||
|
# - Фактическая проверка: на этом репозитории `basic` и `moderate` дают
|
||||||
|
# ровно один и тот же набор нарушений (все сработавшие правила помечены
|
||||||
|
# profile:basic). То есть `moderate` сегодня ничего не стоит, но держит
|
||||||
|
# планку выше для нового кода. Отсюда выбор.
|
||||||
|
# - Правила корректности и безопасности (syntax-check, risky-file-permissions,
|
||||||
|
# risky-shell-pipe, risky-octal, no-changed-when, no-free-form, deprecated-*,
|
||||||
|
# jinja[invalid], sanity) НЕ отключены — они остаются fatal.
|
||||||
|
|
||||||
|
profile: moderate
|
||||||
|
|
||||||
|
exclude_paths:
|
||||||
|
- archive/ # исторические NixOS/docker-compose/ansible конфиги, read-only
|
||||||
|
- ansible/.venv/ # gitignored, локальное venv
|
||||||
|
- ansible/collections/ # gitignored, установленные galaxy-коллекции
|
||||||
|
- ansible/generated/ # gitignored артефакты
|
||||||
|
- .opencode/ # конфиги агентов + node_modules
|
||||||
|
- tools/ # tools/grimmory-mcp — JS, не ansible
|
||||||
|
- node_modules/
|
||||||
|
- .direnv/
|
||||||
|
- .git/
|
||||||
|
# Зашифрованный ansible-vault: линтер не может его расшифровать и шумит
|
||||||
|
# предупреждениями о Decryption failed.
|
||||||
|
- ansible/inventory/host_vars/gyro/vault.yml
|
||||||
|
|
||||||
|
# Явно указываем, что считать плейбуками/тасками — иначе ansible-lint
|
||||||
|
# принимает inventory/*.yml и roles/*/files/*.yml за плейбуки.
|
||||||
|
kinds:
|
||||||
|
- playbook: 'ansible/playbooks/*.yml'
|
||||||
|
- tasks: 'ansible/tasks/*.yml'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# warn_list — правила, которые СЕЙЧАС массово срабатывают на легаси-коде.
|
||||||
|
# Они видны в выводе как warning, но не роняют CI. Это осознанный
|
||||||
|
# «нулевой baseline»: CI зелёный, долг виден.
|
||||||
|
# По каждому пункту — почему и стоит ли возвращать в fatal.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
warn_list:
|
||||||
|
# 40 срабатываний. Роли используют осмысленные кросс-ролевые префиксы
|
||||||
|
# (openvpn_*, monitoring_*, emergency_*), а не имя роли. Переименование
|
||||||
|
# затронет inventory, host_vars и все плейбуки разом.
|
||||||
|
# ВЕРНУТЬ В FATAL: после разового переименования переменных ролей.
|
||||||
|
- var-naming[no-role-prefix]
|
||||||
|
|
||||||
|
# 19 срабатываний. Имена задач в нижнем регистре ("restart gitea lxc").
|
||||||
|
# Чисто косметика, на поведение не влияет.
|
||||||
|
# ВЕРНУТЬ В FATAL: после массового причёсывания имён (дешёвый разовый PR).
|
||||||
|
- name[casing]
|
||||||
|
|
||||||
|
# 18 срабатываний. Часть файлов без "---" в начале.
|
||||||
|
# ВЕРНУТЬ В FATAL: тривиально чинится, но затрагивает 18 файлов.
|
||||||
|
- yaml[document-start]
|
||||||
|
|
||||||
|
# 6 срабатываний. Все — верификационные команды с changed_when: false
|
||||||
|
# (curl для проверки HTTPS-эндпоинта, systemctl is-active, git config
|
||||||
|
# внутри чужого чекаута, docker exec caddy validate). Замена на
|
||||||
|
# uri/systemd/git-модули здесь не улучшает код, а иногда невозможна
|
||||||
|
# (команда исполняется внутри pct/docker exec).
|
||||||
|
# ВЕРНУТЬ В FATAL: вряд ли — правило по сути false-positive для этого стиля.
|
||||||
|
- command-instead-of-module
|
||||||
|
|
||||||
|
# 5 срабатываний: безымянные `- import_playbook:` записи в *-update.yml.
|
||||||
|
# 1 срабатывание: безымянный `- block:` в ru-vps-mihomo-harden.yml.
|
||||||
|
# Влияет только на читаемость вывода ansible-playbook.
|
||||||
|
# ВЕРНУТЬ В FATAL: да, после того как проставят name (мелкий PR).
|
||||||
|
- name[play]
|
||||||
|
- name[missing]
|
||||||
|
|
||||||
|
# 1 срабатывание: emergency_access/tasks/client.yml:46 — become_user без
|
||||||
|
# become. Это, вероятно, НАСТОЯЩИЙ баг (ключ создаётся не тем пользователем),
|
||||||
|
# но чинить его — задача не линтера. Держим в warn_list, чтобы CI не был
|
||||||
|
# красным с первого дня; ВЕРНУТЬ В FATAL сразу после фикса.
|
||||||
|
- partial-become
|
||||||
|
|
||||||
|
# skip_list пуст намеренно: ничего не отключаем полностью, всё либо fatal,
|
||||||
|
# либо видимый warning.
|
||||||
|
skip_list: []
|
||||||
|
|
||||||
|
use_default_rules: true
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export PROXMOX_HOST=192.168.1.10
|
||||||
|
export PROXMOX_USER='ansible@pve'
|
||||||
|
export PROXMOX_TOKEN_ID='homelab'
|
||||||
|
export PROXMOX_TOKEN_SECRET='replace-me'
|
||||||
|
export PROXMOX_VALIDATE_CERTS=false
|
||||||
|
# Optional local SSH-forward port when the control node reaches PVE through ru-vps.
|
||||||
|
# export PROXMOX_PORT=18006
|
||||||
|
|
||||||
|
# Override if the downloaded template name differs.
|
||||||
|
export PVE_LXC_OSTEMPLATE='local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst'
|
||||||
|
|
||||||
|
# Monitoring secrets. Keep actual values in ignored ansible/.env or Ansible Vault.
|
||||||
|
export MONITORING_TELEGRAM_BOT_TOKEN='replace-me'
|
||||||
|
export MONITORING_TELEGRAM_CHAT_ID='replace-me'
|
||||||
|
export MONITORING_GRAFANA_ADMIN_PASSWORD='replace-me'
|
||||||
|
export MONITORING_PVE_API_USER='monitoring@pve'
|
||||||
|
export MONITORING_PVE_API_TOKEN_ID='prometheus'
|
||||||
|
export MONITORING_PVE_API_TOKEN_SECRET='replace-me'
|
||||||
|
|
||||||
|
# Emergency reverse SSH Telegram bot. Keep actual values in ignored ansible/.env
|
||||||
|
# or Ansible Vault. Host keys are pinned public keys in the form "ssh-ed25519 AAAA...".
|
||||||
|
export EMERGENCY_BOT_TOKEN='replace-me'
|
||||||
|
export EMERGENCY_ALLOWED_USER_IDS='replace-me'
|
||||||
|
export EMERGENCY_VPS_HOST_KEY='ssh-ed25519 replace-me'
|
||||||
|
export EMERGENCY_MINI_PC_HOST_KEY='ssh-ed25519 replace-me'
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# direnv: load the Nix dev shell defined in flake.nix
|
||||||
|
# Enable once per checkout with: direnv allow
|
||||||
|
use flake
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
---
|
||||||
|
# Статические проверки Ansible-кода HomeLab infras.
|
||||||
|
#
|
||||||
|
# ГДЕ ЭТО ДОЛЖНО ВЫПОЛНЯТЬСЯ
|
||||||
|
# --------------------------
|
||||||
|
# Gitea живёт на LXC `gitea` (192.168.1.25). Gitea Actions по умолчанию
|
||||||
|
# ВЫКЛЮЧЕНЫ и не имеют ни одного раннера — этот workflow не запустится,
|
||||||
|
# пока раннер не зарегистрирован ОТДЕЛЬНО, вручную:
|
||||||
|
#
|
||||||
|
# 1. Включить Actions в Gitea:
|
||||||
|
# app.ini -> [actions] ENABLED = true
|
||||||
|
# и в настройках репозитория: Settings -> Actions -> Enable.
|
||||||
|
#
|
||||||
|
# 2. Поднять act_runner. Подходящий хост — LXC `docker-test`
|
||||||
|
# (192.168.1.29): там уже есть Docker, а сборка контейнеров раннера
|
||||||
|
# не мешает проду. Ставить раннер на сам LXC `gitea` не стоит —
|
||||||
|
# CI-нагрузка не должна валить git-сервис.
|
||||||
|
#
|
||||||
|
# 3. Зарегистрировать раннер (на docker-test):
|
||||||
|
# act_runner register --no-interactive \
|
||||||
|
# --instance http://192.168.1.25:3000 \
|
||||||
|
# --token <RUNNER_TOKEN из Gitea Settings -> Actions -> Runners> \
|
||||||
|
# --name docker-test-runner \
|
||||||
|
# --labels ubuntu-latest:docker://catthehacker/ubuntu:act-latest
|
||||||
|
#
|
||||||
|
# Метка `ubuntu-latest` обязательна — именно её просит `runs-on` ниже.
|
||||||
|
#
|
||||||
|
# 4. Раннеру нужен исходящий интернет (PyPI + Ansible Galaxy).
|
||||||
|
# На docker-test трафик может идти через mihomo/OpenVPN — проверить,
|
||||||
|
# что pip и galaxy резолвятся, иначе шаг установки упадёт.
|
||||||
|
#
|
||||||
|
# Регистрация раннера НЕ автоматизирована этим репозиторием: она требует
|
||||||
|
# одноразового токена из веб-интерфейса Gitea.
|
||||||
|
#
|
||||||
|
# Локально те же проверки воспроизводятся через nix:
|
||||||
|
# nix develop -c yamllint .
|
||||||
|
# nix develop -c ansible-lint
|
||||||
|
# nix develop -c sh -c 'cd ansible && for f in playbooks/*.yml; do ansible-playbook --syntax-check "$f"; done'
|
||||||
|
|
||||||
|
name: lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: yamllint + ansible-lint + syntax-check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
# ansible.cfg лежит в ansible/ и использует ОТНОСИТЕЛЬНЫЕ пути
|
||||||
|
# (roles_path = roles). Из корня репозитория он не работает, поэтому
|
||||||
|
# пути задаются абсолютно через окружение. Без этого ansible-lint
|
||||||
|
# выдаёт 12 ложных syntax-check[specific] «role not found».
|
||||||
|
ANSIBLE_ROLES_PATH: ${{ github.workspace }}/ansible/roles
|
||||||
|
ANSIBLE_COLLECTIONS_PATH: ${{ github.workspace }}/ansible/collections
|
||||||
|
ANSIBLE_INVENTORY: ${{ github.workspace }}/ansible/inventory/hosts.yml
|
||||||
|
# Ansible шумит депрекейшенами ядра — в CI они не наши.
|
||||||
|
ANSIBLE_DEPRECATION_WARNINGS: "false"
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Образ catthehacker/ubuntu:act-latest уже несёт python3, но не всегда
|
||||||
|
# python3-venv. Ставим явно, чтобы шаг не был хрупким.
|
||||||
|
- name: Ensure python3 + venv
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
if ! command -v python3 >/dev/null 2>&1 || ! python3 -m venv --help >/dev/null 2>&1; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends python3 python3-venv python3-pip
|
||||||
|
fi
|
||||||
|
python3 --version
|
||||||
|
|
||||||
|
- name: Install ansible-core, ansible-lint, yamllint
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
python3 -m venv /tmp/lintenv
|
||||||
|
. /tmp/lintenv/bin/activate
|
||||||
|
python3 -m pip install --upgrade pip
|
||||||
|
# ansible-core/proxmoxer/requests берём из репозитория, чтобы CI и
|
||||||
|
# локальное окружение не разъезжались.
|
||||||
|
python3 -m pip install -r ansible/requirements.txt
|
||||||
|
# Линтеры пинуем: обновление ansible-lint регулярно добавляет новые
|
||||||
|
# правила и красит CI без единого коммита в инфраструктуру.
|
||||||
|
python3 -m pip install 'ansible-lint==25.8.2' 'yamllint==1.37.1'
|
||||||
|
echo "/tmp/lintenv/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Install Galaxy collections
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
ansible-galaxy collection install \
|
||||||
|
-r ansible/requirements.yml \
|
||||||
|
-p ansible/collections
|
||||||
|
|
||||||
|
- name: Versions
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
ansible --version | head -n1
|
||||||
|
ansible-lint --version
|
||||||
|
yamllint --version
|
||||||
|
|
||||||
|
# Конфиг в /.yamllint. Падает только на ошибках (табы, дубли ключей,
|
||||||
|
# битый YAML); стилевые замечания идут как warning и CI не роняют.
|
||||||
|
- name: yamllint
|
||||||
|
run: yamllint -f standard .
|
||||||
|
|
||||||
|
# Конфиг в /.ansible-lint, profile: moderate.
|
||||||
|
- name: ansible-lint
|
||||||
|
run: ansible-lint
|
||||||
|
|
||||||
|
# syntax-check запускается ИЗ ansible/, иначе ansible.cfg с
|
||||||
|
# относительными roles_path не подхватывается и 13 плейбуков падают
|
||||||
|
# с «role not found». Переменные Proxmox (PROXMOX_*) для syntax-check
|
||||||
|
# НЕ нужны: `lookup('env', ...)` на этапе парсинга не вычисляется,
|
||||||
|
# проверено — все 37 плейбуков проходят с пустым окружением.
|
||||||
|
- name: ansible-playbook --syntax-check (все плейбуки)
|
||||||
|
working-directory: ansible
|
||||||
|
run: |
|
||||||
|
set -u
|
||||||
|
rc=0
|
||||||
|
for f in playbooks/*.yml; do
|
||||||
|
if ansible-playbook --syntax-check "$f" >/tmp/sc.log 2>&1; then
|
||||||
|
echo "ok $f"
|
||||||
|
else
|
||||||
|
rc=1
|
||||||
|
echo "FAIL $f"
|
||||||
|
sed 's/^/ /' /tmp/sc.log
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit "$rc"
|
||||||
+29
@@ -6,3 +6,32 @@ passwd
|
|||||||
ansible/.venv/
|
ansible/.venv/
|
||||||
ansible/collections/
|
ansible/collections/
|
||||||
ansible/generated/
|
ansible/generated/
|
||||||
|
ansible/inventory/host_vars/gyro/vault.yml
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Python bytecode
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# Local build artifacts
|
||||||
|
*.tar.xz
|
||||||
|
|
||||||
|
# Nix / direnv
|
||||||
|
.direnv/
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
|
||||||
|
# direnv shell hook lives in the repo (global gitignore hides it)
|
||||||
|
!.envrc
|
||||||
|
|
||||||
|
# Ansible local runtime dir (facts cache, locks)
|
||||||
|
.ansible/
|
||||||
|
|
||||||
|
# OpenTofu: state содержит фактическую конфигурацию гостей и потому не
|
||||||
|
# коммитится. Лок-файл провайдера (.terraform.lock.hcl) наоборот нужен в Git —
|
||||||
|
# он закрепляет версию, поэтому под исключение не попадает.
|
||||||
|
tofu/.terraform/
|
||||||
|
tofu/*.tfstate
|
||||||
|
tofu/*.tfstate.*
|
||||||
|
tofu/*.tfplan
|
||||||
|
tofu/.env
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
description: Reviews Ansible playbooks and roles for unsafe, non-idempotent, or secret-exposing changes before infrastructure execution.
|
||||||
|
mode: all
|
||||||
|
model: openai/gpt-5.5
|
||||||
|
permission:
|
||||||
|
read: allow
|
||||||
|
glob: allow
|
||||||
|
grep: allow
|
||||||
|
edit: deny
|
||||||
|
bash: deny
|
||||||
|
task: deny
|
||||||
|
todowrite: deny
|
||||||
|
skill: deny
|
||||||
|
---
|
||||||
|
You are a read-only Ansible safety reviewer for HomeLab infrastructure.
|
||||||
|
|
||||||
|
Review the requested files or git diff for destructive operations, unsafe shell commands, missing idempotency, incorrect `changed_when` or `failed_when`, missing `no_log` around secrets, excessive privilege, broad host targeting, risky handlers, firewall/network exposure, and absent validation. Respect repository instructions and distinguish definite defects from risks.
|
||||||
|
|
||||||
|
Return findings first, ordered by severity, with `path:line`, impact, and a minimal remediation. State explicitly when no findings are identified. Do not edit files or run commands.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
description: Validates Ansible inventory, YAML, and playbook syntax without changing infrastructure. Use after Ansible configuration changes.
|
||||||
|
mode: all
|
||||||
|
model: openai/gpt-5.5
|
||||||
|
permission:
|
||||||
|
read: allow
|
||||||
|
glob: allow
|
||||||
|
grep: allow
|
||||||
|
edit: deny
|
||||||
|
bash:
|
||||||
|
"*": deny
|
||||||
|
"ansible-inventory *": allow
|
||||||
|
"ansible-playbook * --syntax-check": allow
|
||||||
|
"ansible-lint *": allow
|
||||||
|
"yamllint *": allow
|
||||||
|
task: deny
|
||||||
|
todowrite: deny
|
||||||
|
skill: deny
|
||||||
|
---
|
||||||
|
You validate Ansible changes using the smallest safe local checks.
|
||||||
|
|
||||||
|
Never run a playbook against remote hosts, use `-K`, load `.env`, or run commands that can change infrastructure. Inspect the changed files and project guidance first, then select only relevant validation commands.
|
||||||
|
|
||||||
|
Report commands run, pass/fail status, exact failures with paths, checks intentionally not run, and the smallest next action. Do not edit files.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
description: Analyzes PBS, restic, and systemd backup-audit output to assess backup freshness and identify failures without changing backup configuration.
|
||||||
|
mode: all
|
||||||
|
model: openai/gpt-5.5
|
||||||
|
permission:
|
||||||
|
read: allow
|
||||||
|
glob: allow
|
||||||
|
grep: allow
|
||||||
|
edit: deny
|
||||||
|
bash:
|
||||||
|
"*": deny
|
||||||
|
"systemctl status *": ask
|
||||||
|
"journalctl *": ask
|
||||||
|
"proxmox-backup-client *": ask
|
||||||
|
"restic *": ask
|
||||||
|
task: deny
|
||||||
|
todowrite: deny
|
||||||
|
skill: deny
|
||||||
|
---
|
||||||
|
You diagnose backup freshness and failures from provided output or explicitly approved read-only backup commands.
|
||||||
|
|
||||||
|
Understand the active `backup_audit` role before interpreting results. Do not change schedules, repositories, retention, credentials, timers, services, or backup state. Never reveal secrets.
|
||||||
|
|
||||||
|
Return backup status by target, the newest verified backup timestamp when available, root cause evidence, uncertainty, and one safe next check. Keep raw-log quotations minimal.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
description: Compresses large Ansible, systemd, Docker, and command outputs into structured failures, changed hosts, and safe next steps.
|
||||||
|
mode: all
|
||||||
|
model: openai/gpt-5.5
|
||||||
|
permission:
|
||||||
|
read: allow
|
||||||
|
glob: allow
|
||||||
|
grep: allow
|
||||||
|
edit: deny
|
||||||
|
bash: deny
|
||||||
|
task: deny
|
||||||
|
todowrite: deny
|
||||||
|
skill: deny
|
||||||
|
---
|
||||||
|
You summarize supplied command output without executing commands or editing files.
|
||||||
|
|
||||||
|
Extract successful and failed hosts, changed tasks, warnings, error signatures, probable root cause, relevant output fragments, and the smallest safe next check. Do not repeat routine output. Redact or omit tokens, keys, passwords, URLs with credentials, and `.env` values.
|
||||||
|
|
||||||
|
Use this format: Summary, Failures, Changed State, Evidence, Safe Next Step, Unknowns.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
description: Reviews the current Git diff for HomeLab infrastructure regressions, missing documentation, and overly broad changes before execution or commit.
|
||||||
|
mode: all
|
||||||
|
model: openai/gpt-5.5
|
||||||
|
permission:
|
||||||
|
read: allow
|
||||||
|
glob: allow
|
||||||
|
grep: allow
|
||||||
|
edit: deny
|
||||||
|
bash:
|
||||||
|
"*": deny
|
||||||
|
"git status *": allow
|
||||||
|
"git diff *": allow
|
||||||
|
"git log *": allow
|
||||||
|
task: deny
|
||||||
|
todowrite: deny
|
||||||
|
skill: deny
|
||||||
|
---
|
||||||
|
You perform a read-only review of the working-tree or staged Git diff in this HomeLab repository.
|
||||||
|
|
||||||
|
Follow AGENTS.md and compare changes to relevant inventory, roles, playbooks, and Obsidian documentation when needed. Identify behavioral regressions, overly broad targeting, secret exposure, missing documentation, and missing safe checks. Do not modify files, stage changes, or commit.
|
||||||
|
|
||||||
|
Return findings first by severity with `path:line`, then residual risks and a short change summary. Say explicitly if no findings are found.
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
---
|
||||||
|
description: >-
|
||||||
|
Use this agent when you need read-only reconnaissance of a HomeLab repository
|
||||||
|
before making infrastructure changes. Use it to navigate the codebase, locate
|
||||||
|
relevant Ansible inventory variables, host_vars/group_vars, playbooks, roles,
|
||||||
|
tasks, templates, handlers, dependencies, and Obsidian documentation; identify
|
||||||
|
existing patterns and constraints; determine the minimal set of files that
|
||||||
|
should be changed by a later implementation agent; or verify configuration
|
||||||
|
consistency without modifying files or infrastructure.
|
||||||
|
|
||||||
|
|
||||||
|
<example>
|
||||||
|
|
||||||
|
Context: The user wants to add a new backup setting for a HomeLab service, but
|
||||||
|
the relevant Ansible role and variables are unknown.
|
||||||
|
|
||||||
|
user: "Нужно добавить настройку retention для backups в HomeLab. Сначала
|
||||||
|
разберись, где это правильно делать."
|
||||||
|
|
||||||
|
assistant: "Сначала запущу homelab-recon agent для read-only анализа
|
||||||
|
репозитория и поиска существующих паттернов конфигурации."
|
||||||
|
|
||||||
|
<commentary>
|
||||||
|
|
||||||
|
Since the user needs to find the correct place for a new infrastructure
|
||||||
|
setting before changes, use the Task/Agent tool to launch the homelab-recon
|
||||||
|
agent. The agent should inspect inventory, vars, roles, templates, handlers,
|
||||||
|
dependencies, and docs without editing anything.
|
||||||
|
|
||||||
|
</commentary>
|
||||||
|
|
||||||
|
</example>
|
||||||
|
|
||||||
|
|
||||||
|
<example>
|
||||||
|
|
||||||
|
Context: The assistant has just completed a logical chunk of planning for an
|
||||||
|
Ansible change and needs to validate that it matches the current repository
|
||||||
|
structure.
|
||||||
|
|
||||||
|
user: "Проверь, не пропустили ли мы какие-то group_vars или шаблоны для
|
||||||
|
nginx."
|
||||||
|
|
||||||
|
assistant: "Запущу homelab-recon agent, чтобы read-only проверить связанные
|
||||||
|
vars, templates, роли, handlers и документацию по nginx."
|
||||||
|
|
||||||
|
<commentary>
|
||||||
|
|
||||||
|
Since the user asks for consistency/navigation across existing HomeLab
|
||||||
|
configuration, use the Task/Agent tool to launch the homelab-recon agent
|
||||||
|
rather than making assumptions from memory.
|
||||||
|
|
||||||
|
</commentary>
|
||||||
|
|
||||||
|
</example>
|
||||||
|
|
||||||
|
|
||||||
|
<example>
|
||||||
|
|
||||||
|
Context: The user asks where to implement a new monitoring exporter in the
|
||||||
|
HomeLab repo.
|
||||||
|
|
||||||
|
user: "Куда лучше добавить node exporter для нового хоста?"
|
||||||
|
|
||||||
|
assistant: "Использую homelab-recon agent для исследования inventory,
|
||||||
|
host_vars/group_vars, ролей мониторинга и Obsidian-документации перед
|
||||||
|
предложением места изменения."
|
||||||
|
|
||||||
|
<commentary>
|
||||||
|
|
||||||
|
Since the task is to locate the appropriate implementation point and
|
||||||
|
understand existing patterns, use the Task/Agent tool to launch the
|
||||||
|
homelab-recon agent in read-only mode.
|
||||||
|
|
||||||
|
</commentary>
|
||||||
|
|
||||||
|
</example>
|
||||||
|
mode: all
|
||||||
|
model: openai/gpt-5.5
|
||||||
|
permission:
|
||||||
|
read: allow
|
||||||
|
glob: allow
|
||||||
|
grep: allow
|
||||||
|
bash: deny
|
||||||
|
edit: deny
|
||||||
|
webfetch: deny
|
||||||
|
task: deny
|
||||||
|
todowrite: deny
|
||||||
|
websearch: deny
|
||||||
|
lsp: deny
|
||||||
|
skill: deny
|
||||||
|
external_directory:
|
||||||
|
"*": deny
|
||||||
|
"/home/ada/Documents/Vaults/SecondBrain/02 Projects/HomeLab/**": allow
|
||||||
|
---
|
||||||
|
You are a senior HomeLab infrastructure reconnaissance specialist focused on read-only analysis of Ansible-based repositories and adjacent Obsidian documentation. Your mission is to investigate the current implementation before any change is made, identify the existing patterns and constraints, and provide a precise map of the minimal files that a later implementation step would need to modify.
|
||||||
|
|
||||||
|
You operate strictly in read-only mode.
|
||||||
|
|
||||||
|
Core responsibilities:
|
||||||
|
1. Locate relevant Ansible configuration:
|
||||||
|
- inventories and inventory variables
|
||||||
|
- host_vars and group_vars
|
||||||
|
- playbooks and included playbooks
|
||||||
|
- roles, defaults, vars, tasks, templates, files, handlers, meta dependencies
|
||||||
|
- collections, requirements files, plugins, filters, and lookup usage when relevant
|
||||||
|
- service-specific configuration files and generated artifacts referenced by Ansible
|
||||||
|
2. Locate and use supporting documentation:
|
||||||
|
- Obsidian notes, markdown documentation, runbooks, architecture notes, decision records, operational checklists, and service documentation
|
||||||
|
- README files and inline comments that explain conventions or constraints
|
||||||
|
3. Reconstruct the current behavior:
|
||||||
|
- determine how the relevant service, host, group, or infrastructure component is configured today
|
||||||
|
- trace variable precedence where possible
|
||||||
|
- identify conditionals, tags, includes, dependencies, handlers, templates, and restart/reload behavior
|
||||||
|
- identify deployment ordering and cross-role relationships
|
||||||
|
4. Identify repository patterns:
|
||||||
|
- naming conventions for hosts, groups, variables, roles, templates, tasks, and tags
|
||||||
|
- common ways new services/settings are added
|
||||||
|
- secrets handling conventions, vault usage, and boundaries around sensitive values
|
||||||
|
- idempotency and handler patterns
|
||||||
|
- documentation conventions
|
||||||
|
5. Define the minimal change surface:
|
||||||
|
- list the smallest set of files that likely need modification for the requested future change
|
||||||
|
- list files that are relevant for context but should probably not be modified
|
||||||
|
- call out unknowns or decisions requiring user confirmation
|
||||||
|
6. Verify consistency:
|
||||||
|
- check whether related vars/templates/tasks/docs agree with each other
|
||||||
|
- identify duplicate, stale, conflicting, or shadowed variables
|
||||||
|
- identify missing documentation or mismatches between docs and implementation
|
||||||
|
|
||||||
|
Strict read-only constraints:
|
||||||
|
- You must not edit, create, delete, rename, format, or write any file.
|
||||||
|
- You must not run commands that change repository state, infrastructure state, secrets, generated files, caches, lockfiles, or external systems.
|
||||||
|
- You must not run Ansible playbooks against infrastructure, apply Terraform/OpenTofu, restart services, install dependencies, or execute scripts that may mutate state.
|
||||||
|
- You may inspect files, search text, list directories, and run clearly read-only commands such as grep/rg/find/ls/cat/sed for viewing, git status/log/diff/show, ansible-inventory --list when safe and local, and syntax-like inspection only if it is clearly non-mutating.
|
||||||
|
- If a command may be mutating or ambiguous, do not run it. Explain the risk and suggest a safe alternative.
|
||||||
|
- If repository instructions from CLAUDE.md or similar files define stricter rules, follow those rules.
|
||||||
|
|
||||||
|
Investigation workflow:
|
||||||
|
1. Clarify scope if needed:
|
||||||
|
- If the target service, host, group, environment, or desired setting is ambiguous, ask a concise clarification question.
|
||||||
|
- If enough context exists to begin, proceed and state your assumptions.
|
||||||
|
2. Read project guidance first:
|
||||||
|
- Look for CLAUDE.md, README files, docs indexes, inventory layout notes, or repository conventions.
|
||||||
|
- Incorporate those rules into your analysis.
|
||||||
|
3. Map the repository structure:
|
||||||
|
- Identify the inventory root, playbook entry points, role directories, documentation directories, and Obsidian vault locations.
|
||||||
|
- Note nonstandard layout choices.
|
||||||
|
4. Search broadly, then narrow:
|
||||||
|
- Search for the service/component name, hostnames, group names, variable prefixes, role names, template names, ports, domains, package names, container names, systemd units, and documentation aliases.
|
||||||
|
- Follow references from playbooks to roles, from roles to tasks/templates/handlers, and from variables to template usage.
|
||||||
|
5. Trace configuration flow:
|
||||||
|
- Determine where defaults are defined, where they are overridden, and where they are consumed.
|
||||||
|
- Pay special attention to group_vars/host_vars precedence, role defaults versus role vars, include_vars, set_fact, vars_files, extra vars references, and inventory group hierarchy.
|
||||||
|
6. Analyze dependencies and side effects:
|
||||||
|
- Identify role dependencies, handlers triggered by template/task changes, service reload/restart behavior, firewall/DNS/reverse-proxy/monitoring/backup interactions, and documentation requirements.
|
||||||
|
7. Produce a concise but actionable report.
|
||||||
|
|
||||||
|
Output format:
|
||||||
|
Provide your findings in a structured report with these sections:
|
||||||
|
|
||||||
|
1. Scope and assumptions
|
||||||
|
- State what you investigated and any assumptions made.
|
||||||
|
|
||||||
|
2. Relevant files and why they matter
|
||||||
|
- List paths grouped by category: inventory, host/group vars, playbooks, roles/tasks, templates/files, handlers, dependencies, docs.
|
||||||
|
- For each path, include a short reason it is relevant.
|
||||||
|
|
||||||
|
3. Current implementation summary
|
||||||
|
- Explain how the current configuration works, including variable flow and execution flow.
|
||||||
|
|
||||||
|
4. Existing patterns and constraints
|
||||||
|
- Summarize naming, structure, variable, secrets, handler, template, documentation, and deployment conventions.
|
||||||
|
|
||||||
|
5. Consistency findings
|
||||||
|
- Note conflicts, stale docs, duplicate variables, unclear precedence, missing references, or mismatches.
|
||||||
|
- If no issues were found, say so explicitly while noting the limits of the inspection.
|
||||||
|
|
||||||
|
6. Minimal files for a future change
|
||||||
|
- Provide a prioritized list of files likely requiring modification.
|
||||||
|
- Separate "must change", "may need change", and "context only / probably do not change".
|
||||||
|
|
||||||
|
7. Open questions and risks
|
||||||
|
- List decisions that require user confirmation, unresolved ambiguity, and operational risks.
|
||||||
|
|
||||||
|
8. Suggested next step
|
||||||
|
- Recommend what the implementation agent or user should do next, without making changes yourself.
|
||||||
|
|
||||||
|
Quality standards:
|
||||||
|
- Be evidence-driven. Reference concrete file paths and, when useful, specific variable names, role names, task names, or documentation headings.
|
||||||
|
- Do not overstate certainty. Distinguish confirmed facts from inferred patterns.
|
||||||
|
- Prefer the minimal viable change surface over broad rewrites.
|
||||||
|
- Preserve HomeLab safety: avoid recommendations that could accidentally affect unrelated hosts or services.
|
||||||
|
- Treat secrets carefully: identify where secret values are referenced, but do not print secret contents. If secrets appear in plain text, mention the exposure without repeating the value.
|
||||||
|
- If documentation conflicts with code, clearly identify both sources and which appears authoritative.
|
||||||
|
- If you cannot find relevant files, report the searches performed and propose likely next search terms or clarification questions.
|
||||||
|
|
||||||
|
Behavioral boundaries:
|
||||||
|
- You are not an implementation agent. Do not patch files.
|
||||||
|
- You are not an operations executor. Do not deploy or validate against live infrastructure.
|
||||||
|
- You are a repository reconnaissance and consistency-analysis agent. Your deliverable is a map, diagnosis, and minimal-change recommendation for subsequent work.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
description: Audits HomeLab inventory against Ansible documentation and Obsidian notes to find stale hosts, groups, services, and topology drift.
|
||||||
|
mode: all
|
||||||
|
model: openai/gpt-5.5
|
||||||
|
permission:
|
||||||
|
read: allow
|
||||||
|
glob: allow
|
||||||
|
grep: allow
|
||||||
|
edit: deny
|
||||||
|
bash: deny
|
||||||
|
task: deny
|
||||||
|
todowrite: deny
|
||||||
|
skill: deny
|
||||||
|
external_directory:
|
||||||
|
"*": deny
|
||||||
|
"/home/ada/Documents/Vaults/SecondBrain/02 Projects/HomeLab/**": allow
|
||||||
|
---
|
||||||
|
You are a read-only consistency auditor for HomeLab.
|
||||||
|
|
||||||
|
Compare the canonical Ansible inventory with active playbooks, repository README files, and the HomeLab Obsidian vault. Check host names, groups, IPs, service assignments, topology, and operational instructions. Treat active Ansible configuration as authoritative unless the repository says otherwise; do not use the archive as an active source of truth.
|
||||||
|
|
||||||
|
Return confirmed mismatches with both sources and paths, suspected drift separately, the minimal files to update, and any ambiguity. Do not edit files or infrastructure.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
description: Diagnoses bounded Ansible, systemd, Docker, and service logs to identify root causes and safe next checks without changing systems.
|
||||||
|
mode: all
|
||||||
|
model: openai/gpt-5.5
|
||||||
|
permission:
|
||||||
|
read: allow
|
||||||
|
glob: allow
|
||||||
|
grep: allow
|
||||||
|
edit: deny
|
||||||
|
bash:
|
||||||
|
"*": deny
|
||||||
|
"journalctl *": ask
|
||||||
|
"systemctl status *": ask
|
||||||
|
"docker logs *": ask
|
||||||
|
task: deny
|
||||||
|
todowrite: deny
|
||||||
|
skill: deny
|
||||||
|
---
|
||||||
|
You are a read-only operations diagnostician. Analyze provided logs or run only explicitly approved, bounded diagnostic commands.
|
||||||
|
|
||||||
|
For logs, use time and line limits such as `--since`, `-n`, and `--tail`. Do not restart, install, reconfigure, or contact services in a way that changes state. Never expose secrets.
|
||||||
|
|
||||||
|
Return Summary, most likely root cause, evidence with timestamps, alternative hypotheses, and one smallest safe next check. Avoid listing every log line.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
description: Diagnoses HomeLab OpenVPN, ProxyJump, DNS, routing, and service reachability from configuration and approved read-only diagnostics.
|
||||||
|
mode: all
|
||||||
|
model: openai/gpt-5.5
|
||||||
|
permission:
|
||||||
|
read: allow
|
||||||
|
glob: allow
|
||||||
|
grep: allow
|
||||||
|
edit: deny
|
||||||
|
bash:
|
||||||
|
"*": deny
|
||||||
|
"ip route *": ask
|
||||||
|
"ip addr *": ask
|
||||||
|
"ss *": ask
|
||||||
|
"ping *": ask
|
||||||
|
"nc *": ask
|
||||||
|
"getent hosts *": ask
|
||||||
|
"journalctl *": ask
|
||||||
|
task: deny
|
||||||
|
todowrite: deny
|
||||||
|
skill: deny
|
||||||
|
---
|
||||||
|
You diagnose HomeLab network issues using active Ansible configuration, documentation, and explicitly approved read-only diagnostics.
|
||||||
|
|
||||||
|
Trace the expected path through OpenVPN, the JumpHost, routes, DNS, firewall ports, and target services. Do not modify interfaces, firewall rules, VPN configuration, routes, DNS, or remote hosts. Do not expose credentials.
|
||||||
|
|
||||||
|
Return expected path, observed break point, evidence, likely root cause, and the smallest safe next check.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
description: Reads relevant HomeLab Obsidian notes before infrastructure work and extracts decisions, constraints, topology, and recent operational context.
|
||||||
|
mode: all
|
||||||
|
model: openai/gpt-5.5
|
||||||
|
permission:
|
||||||
|
read: allow
|
||||||
|
glob: allow
|
||||||
|
grep: allow
|
||||||
|
edit: deny
|
||||||
|
bash: deny
|
||||||
|
task: deny
|
||||||
|
todowrite: deny
|
||||||
|
skill: deny
|
||||||
|
external_directory:
|
||||||
|
"*": deny
|
||||||
|
"/home/ada/Documents/Vaults/SecondBrain/02 Projects/HomeLab/**": allow
|
||||||
|
---
|
||||||
|
You are a read-only documentation researcher for HomeLab infrastructure.
|
||||||
|
|
||||||
|
Find only the notes relevant to the requested service or change. Extract confirmed decisions, current topology, operational constraints, recent changes, and documentation that should be updated afterward. Cross-check any important claim with active Ansible configuration when it is available. Do not edit notes, repository files, or infrastructure.
|
||||||
|
|
||||||
|
Return a concise report with sources, confirmed constraints, potential documentation drift, and open questions.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
# yamllint для HomeLab infras.
|
||||||
|
# Цель: ловить реальные поломки YAML (табы, дубли ключей, битые отступы,
|
||||||
|
# незакрытые кавычки), а не навязывать стиль. Всё, что даёт шум на живом
|
||||||
|
# Ansible-коде, ослаблено осознанно — см. комментарии.
|
||||||
|
|
||||||
|
extends: default
|
||||||
|
|
||||||
|
# gitignore-style пути, которые линтить не нужно.
|
||||||
|
ignore: |
|
||||||
|
/ansible/.venv/
|
||||||
|
/ansible/collections/
|
||||||
|
/ansible/generated/
|
||||||
|
/archive/
|
||||||
|
/.opencode/
|
||||||
|
/tools/
|
||||||
|
node_modules/
|
||||||
|
/ansible/inventory/host_vars/*/vault.yml
|
||||||
|
/.direnv/
|
||||||
|
/result
|
||||||
|
/result-*
|
||||||
|
|
||||||
|
rules:
|
||||||
|
# Длинные строки — норма: pct/docker/curl-команды в one-line shell,
|
||||||
|
# длинные URL, ZeroTier/OpenVPN-конфиги. Перенос сделал бы их менее читаемыми.
|
||||||
|
line-length: disable
|
||||||
|
|
||||||
|
# Ansible исторически допускает yes/no наравне с true/false, и часть репо
|
||||||
|
# написана так. Не ошибка — не мешаем.
|
||||||
|
truthy:
|
||||||
|
allowed-values: ['true', 'false', 'yes', 'no']
|
||||||
|
check-keys: false
|
||||||
|
|
||||||
|
# Комментарии вида "#comment" и inline-комментарии на одном пробеле
|
||||||
|
# встречаются часто и ни на что не влияют.
|
||||||
|
comments:
|
||||||
|
require-starting-space: true
|
||||||
|
min-spaces-from-content: 1
|
||||||
|
comments-indentation: disable
|
||||||
|
|
||||||
|
# Ansible-стиль: списки под ключом с отступом — вопрос вкуса, обе формы валидны.
|
||||||
|
indentation:
|
||||||
|
spaces: 2
|
||||||
|
indent-sequences: consistent
|
||||||
|
check-multi-line-strings: false
|
||||||
|
|
||||||
|
# Пустая строка в конце файла обязательна (реальные diff-артефакты),
|
||||||
|
# но лишние пробелы в конце строк — только предупреждение.
|
||||||
|
trailing-spaces: enable
|
||||||
|
new-line-at-end-of-file: enable
|
||||||
|
|
||||||
|
# Ошибки, которые ломают парсинг или молча меняют смысл — строго.
|
||||||
|
key-duplicates: enable
|
||||||
|
octal-values:
|
||||||
|
forbid-implicit-octal: true
|
||||||
|
forbid-explicit-octal: true
|
||||||
|
|
||||||
|
# "---" в начале файла — полезная конвенция Ansible, но не критично.
|
||||||
|
document-start:
|
||||||
|
level: warning
|
||||||
|
|
||||||
|
# braces/brackets: Jinja-выражения в inline-словарях часто дают ложные
|
||||||
|
# срабатывания на пробелах внутри {{ }}.
|
||||||
|
braces:
|
||||||
|
min-spaces-inside: 0
|
||||||
|
max-spaces-inside: 1
|
||||||
|
brackets:
|
||||||
|
min-spaces-inside: 0
|
||||||
|
max-spaces-inside: 1
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
HomeLab infras - Ansible-first control plane для домашней инфраструктуры на
|
||||||
|
Proxmox VE. Желаемое состояние хранится в inventory, vars, roles и playbooks;
|
||||||
|
прямые изменения на серверах допустимы только для read-only диагностики или
|
||||||
|
break-glass восстановления и затем должны быть отражены в Ansible.
|
||||||
|
|
||||||
|
Стабильный контекст проекта находится в [`docs/ai/`](docs/ai/README.md). Не
|
||||||
|
загружай весь набор по умолчанию: читай только документы, относящиеся к задаче.
|
||||||
|
|
||||||
|
## Что читать
|
||||||
|
|
||||||
|
| Задача | Контекст |
|
||||||
|
|---|---|
|
||||||
|
| Любое изменение | `README.md`, [`docs/ai/README.md`](docs/ai/README.md), релевантная секция [`architecture.md`](docs/ai/architecture.md) |
|
||||||
|
| Inventory или новый service | `ansible/inventory/hosts.yml`, `ansible/inventory/group_vars/all/services.yml`, [`architecture.md`](docs/ai/architecture.md), [`edge-cases.md`](docs/ai/edge-cases.md) |
|
||||||
|
| Provisioning или update | `ansible/README.md`, relevant playbook/role, [`edge-cases.md`](docs/ai/edge-cases.md), [`legacy-warning.md`](docs/ai/legacy-warning.md) |
|
||||||
|
| Network, SSH, OpenVPN, Caddy | `ansible/ssh_config`, `ansible/inventory/group_vars/all/main.yml`, [`architecture.md`](docs/ai/architecture.md), [`links.md`](docs/ai/links.md) |
|
||||||
|
| Backups и recovery | `ansible/playbooks/pve-backup-jobs.yml`, `ansible/roles/backup_audit/`, [`architecture.md`](docs/ai/architecture.md), [`edge-cases.md`](docs/ai/edge-cases.md) |
|
||||||
|
| Grimmory MCP | `tools/grimmory-mcp/README.md`, [`tech-stack.md`](docs/ai/tech-stack.md), [`edge-cases.md`](docs/ai/edge-cases.md) |
|
||||||
|
| Legacy/frozen code | [`legacy-warning.md`](docs/ai/legacy-warning.md) |
|
||||||
|
| Переезд на OpenTofu | [`migration-tofu.md`](docs/ai/migration-tofu.md), `tofu/README.md`, `tofu/*.tf` |
|
||||||
|
| Текущая работа | [`plan.md`](docs/ai/plan.md); `current-task.md` является историческим планом |
|
||||||
|
|
||||||
|
Перед инфраструктурными изменениями прочитай релевантные заметки в Obsidian:
|
||||||
|
|
||||||
|
`/home/ada/Documents/Vaults/SecondBrain/02 Projects/HomeLab/`
|
||||||
|
|
||||||
|
Основные заметки: `HomeLab.md`,
|
||||||
|
`Notes/Текущее состояние HomeLab после миграции на Proxmox.md`, `Log.md`.
|
||||||
|
После изменения обнови соответствующую заметку, если изменились topology,
|
||||||
|
операционная процедура, решение или неочевидное ограничение.
|
||||||
|
|
||||||
|
## Источники правды
|
||||||
|
|
||||||
|
- Hosts и groups: `ansible/inventory/hosts.yml`.
|
||||||
|
- Shared network/access vars: `ansible/inventory/group_vars/all/main.yml`.
|
||||||
|
- Service facts: `ansible/inventory/group_vars/all/services.yml`.
|
||||||
|
- SSH users, keys, ports и ProxyJump: `ansible/ssh_config`.
|
||||||
|
- Manual operations и safety gates: `ansible/Makefile`.
|
||||||
|
- Secrets: ignored `.env` в КОРНЕ репозитория; его читают и Make/Ansible, и OpenTofu.
|
||||||
|
- Human decisions и operations log: HomeLab Obsidian vault.
|
||||||
|
|
||||||
|
Программные потребители реестра:
|
||||||
|
|
||||||
|
- `playbooks/reverse-proxy.yml` — сборка Caddyfile;
|
||||||
|
- `playbooks/ru-vps-base.yml` — стек Caddy и его закреплённый образ;
|
||||||
|
- `playbooks/pve-backup-jobs.yml` — списки VMID заданий PBS (поле `backup.job`);
|
||||||
|
- `roles/backup_audit` — VMID для аудита (флаг `monitoring.backup_audit_vmid`);
|
||||||
|
- `playbooks/validate.yml` — сверка реестра с фактическим состоянием Proxmox;
|
||||||
|
- `playbooks/dashboard.yml` — `services.yaml` для Homepage-обзора инфраструктуры.
|
||||||
|
|
||||||
|
Остальное (`pve-*.yml`, `status.yml`, monitoring, SSH config) по-прежнему
|
||||||
|
дублирует значения и должно меняться согласованно.
|
||||||
|
|
||||||
|
`make validate` — read-only gate, проверяющий это расхождение.
|
||||||
|
|
||||||
|
## Границы
|
||||||
|
|
||||||
|
- Active control plane находится в `ansible/`.
|
||||||
|
- `archive/2026-07-proxmox-migration/` - только историческая справка; не редактировать
|
||||||
|
и не возвращать из него конфигурацию как active implementation.
|
||||||
|
- Не редактировать generated artifacts, `ansible/collections/`, `.venv/`,
|
||||||
|
`node_modules/`, `.direnv/` и secret-bearing ignored files.
|
||||||
|
- `roles/compose_service` подключён в `playbooks/ru-vps-base.yml` (стек Caddy).
|
||||||
|
`roles/lxc_docker_host` не подключён нигде.
|
||||||
|
Не считать active service playbooks устаревшими до отдельной миграции.
|
||||||
|
- Не исправлять найденный technical debt в несвязанной задаче без отдельного решения.
|
||||||
|
- Для новой VM/LXC/managed host по умолчанию provision public SSH key пользователя,
|
||||||
|
если пользователь явно не указал иное.
|
||||||
|
|
||||||
|
## Setup и команды
|
||||||
|
|
||||||
|
Предпочтительное окружение - Nix:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop # или один раз: direnv allow
|
||||||
|
ansible-galaxy collection install \
|
||||||
|
-r ansible/requirements.yml \
|
||||||
|
-p ansible/collections # один раз на clone
|
||||||
|
```
|
||||||
|
|
||||||
|
Работай через Make из `ansible/` или с `make -C ansible` из root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -C ansible help
|
||||||
|
make -C ansible check
|
||||||
|
make -C ansible status EXTRA="--limit '!gyro'"
|
||||||
|
make -C ansible inventory
|
||||||
|
make -C ansible docs
|
||||||
|
make -C ansible lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Локальный syntax-check всех playbooks, соответствующий CI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop -c sh -c \
|
||||||
|
'cd ansible && for f in playbooks/*.yml; do ansible-playbook --syntax-check "$f"; done'
|
||||||
|
```
|
||||||
|
|
||||||
|
Grimmory MCP требует отдельный Node.js `>=22` runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --prefix tools/grimmory-mcp
|
||||||
|
npm test --prefix tools/grimmory-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safety Contract
|
||||||
|
|
||||||
|
- Сначала выполняй smallest safe local validation, затем bounded live check только
|
||||||
|
когда он нужен задаче.
|
||||||
|
- `make dry-<service>` использует `--check --diff`, но не является полной симуляцией
|
||||||
|
Proxmox API или command-heavy `pct` playbooks.
|
||||||
|
- `make status` read-only, но его exit code не является health gate.
|
||||||
|
- `update-all`, `mihomo-harden` и frozen `monitoring` требуют `CONFIRM=1`.
|
||||||
|
- `gyro` требует Vault password; не обходи `make gyro` без явной причины.
|
||||||
|
- Service update order: fresh backup/audit -> update -> health check. Backup не
|
||||||
|
означает automatic rollback.
|
||||||
|
- Uptime Kuma активен. Prometheus/Alertmanager/Grafana stack заморожен; не запускать
|
||||||
|
его параллельно без отдельного architecture decision.
|
||||||
|
- Active remote images обычно pin по `tag@sha256:digest`; frozen Prometheus images
|
||||||
|
tag-only. Floating auto-updaters не используются.
|
||||||
|
- Не включать cluster-wide PVE firewall без аудита всех guests с `firewall=1`.
|
||||||
|
- Не останавливать production services для fault injection без согласованного
|
||||||
|
maintenance window.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
- Никогда не коммить и не цитировать реальные passwords, tokens, private keys,
|
||||||
|
`.env`, Vault plaintext, PBS/restic credentials или TLS keys.
|
||||||
|
- Используй ignored `.env` в корне репозитория, Ansible Vault, runtime prompt или
|
||||||
|
external local secret file.
|
||||||
|
- `PROXMOX_ROOT_PASSWORD` (root@pam) нужен ТОЛЬКО целям `tofu-*` и только для
|
||||||
|
привилегированных полей LXC. Ansible им не пользуется. Не логировать и не
|
||||||
|
передавать в playbook vars.
|
||||||
|
- В Git допустимы только sanitized `.env.example` и encrypted Vault content.
|
||||||
|
- Secret-bearing tasks должны использовать `no_log: true`, а files - минимальные
|
||||||
|
permissions.
|
||||||
|
|
||||||
|
## Рабочий процесс
|
||||||
|
|
||||||
|
1. Прочитай минимальный релевантный context и Obsidian notes.
|
||||||
|
2. Проверь active inventory, vars, playbook/role и связанные consumers.
|
||||||
|
3. Внеси smallest correct declarative change; не используй ad-hoc server edits.
|
||||||
|
4. Запусти минимальные local checks, затем только необходимые bounded live checks.
|
||||||
|
5. Проверь diff на broad targeting, secrets, destructive behavior и docs drift.
|
||||||
|
6. Обнови repository/Obsidian documentation для изменившихся решений и процедур.
|
||||||
|
7. В отчете перечисли changed files, проверки, неисполненные live checks и unknowns.
|
||||||
|
|
||||||
|
Для read-only reconnaissance, Ansible safety review, syntax validation, network/log
|
||||||
|
diagnostics, backup audit и Obsidian context используй специализированные агенты из
|
||||||
|
`.opencode/agents/`. Большие logs и command outputs передавай соответствующему
|
||||||
|
read-only summarizer и всегда ограничивай `--since`, `-n` или `--tail`.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Этот файл содержит адаптер инструкций для Claude Code (claude.ai/code) при работе с этим репозиторием.
|
||||||
|
|
||||||
|
Канонические проектные инструкции находятся в [AGENTS.md](./AGENTS.md). Не дублируй их здесь, чтобы `CLAUDE.md` и `AGENTS.md` не расходились.
|
||||||
|
|
||||||
|
@AGENTS.md
|
||||||
@@ -1,26 +1,70 @@
|
|||||||
# HomeLab Infrastructure
|
# HomeLab Infrastructure
|
||||||
|
|
||||||
Active HomeLab infrastructure is managed through Ansible.
|
Активная инфраструктура домашней лаборатории управляется через Ansible.
|
||||||
|
Каноничные инструкции для людей и агентов — в [AGENTS.md](./AGENTS.md).
|
||||||
|
Стабильный архитектурный контекст и риски — в [docs/ai/](./docs/ai/README.md).
|
||||||
|
|
||||||
## Active Files
|
## Быстрый старт
|
||||||
|
|
||||||
- `ansible/` — current control plane.
|
Окружение собрано в Nix, venv не нужен:
|
||||||
- `ansible/inventory/hosts.yml` — inventory and host facts.
|
|
||||||
- `ansible/playbooks/check.yml` — safe connectivity/facts check.
|
|
||||||
|
|
||||||
## Archive
|
```bash
|
||||||
|
nix develop # или один раз: direnv allow
|
||||||
|
|
||||||
Historical pre-Proxmox material is kept under:
|
# Один раз на клон
|
||||||
|
ansible-galaxy collection install -r ansible/requirements.yml -p ansible/collections
|
||||||
```text
|
|
||||||
archive/2026-07-proxmox-migration/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
It contains old NixOS configs, Docker Compose service definitions, Gitea workflows, deploy scripts and old Ansible bootstrap playbooks.
|
Всё управление — через `make` из `ansible/`:
|
||||||
|
|
||||||
## Basic Check
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ansible
|
cd ansible
|
||||||
ansible-playbook playbooks/check.yml
|
make help # список целей, начинать отсюда
|
||||||
|
make check # связность и ожидаемые IP
|
||||||
|
make status # read-only сводка по всей инфраструктуре
|
||||||
|
make lint # ansible-lint + yamllint
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Деплой и обновления:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dry-gitea # предпросмотр (--check --diff)
|
||||||
|
make deploy-gitea # применить
|
||||||
|
make update-gitea # бэкап -> обновление -> health-check
|
||||||
|
```
|
||||||
|
|
||||||
|
Секреты лежат в `.env` в **корне репозитория** (в `.gitignore`); его подхватывают
|
||||||
|
и Ansible, и OpenTofu. Опасные цели требуют `CONFIRM=1`.
|
||||||
|
|
||||||
|
## SSH руками
|
||||||
|
|
||||||
|
`ansible/ssh_config` — единый источник правды по SSH и для Ansible, и для терминала.
|
||||||
|
Добавь в `~/.ssh/config`, чтобы заработал `ssh gitea`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Include /home/ada/Documents/Projects/HomeLab/infras/ansible/ssh_config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
- `ansible/` — control plane: `Makefile`, `inventory/`, `playbooks/`, `roles/`, `ssh_config`
|
||||||
|
- `ansible/inventory/group_vars/all/services.yml` — реестр сервисов (VMID, IP, порты, домены, образы)
|
||||||
|
- `flake.nix` — dev-окружение
|
||||||
|
- `.gitea/workflows/lint.yml` — CI: yamllint, ansible-lint, syntax-check
|
||||||
|
- `docs/ai/` — архитектура, stack, edge cases и legacy boundaries для агентов
|
||||||
|
- `archive/2026-07-proxmox-migration/` — исторические NixOS/Docker конфиги, только как справка
|
||||||
|
|
||||||
|
## Grimmory MCP
|
||||||
|
|
||||||
|
`tools/grimmory-mcp/` содержит read-only интеграцию с Grimmory API для OpenCode.
|
||||||
|
Явно вызываемые sync tools записывают сгенерированные заметки и обложки в Obsidian.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --prefix tools/grimmory-mcp
|
||||||
|
npm run configure --prefix tools/grimmory-mcp
|
||||||
|
npm test --prefix tools/grimmory-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Глобальная регистрация MCP в OpenCode выполняется вне этого репозитория. После
|
||||||
|
настройки используй `/grimmory-sync` для обновления заметок книг в
|
||||||
|
`90 Library/Books` и обложек в `99 System/Export/Grimmory/Covers`.
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
export PROXMOX_HOST=192.168.1.10
|
|
||||||
export PROXMOX_USER='ansible@pve'
|
|
||||||
export PROXMOX_TOKEN_ID='homelab'
|
|
||||||
export PROXMOX_TOKEN_SECRET='replace-me'
|
|
||||||
export PROXMOX_VALIDATE_CERTS=false
|
|
||||||
|
|
||||||
# Override if the downloaded template name differs.
|
|
||||||
export PVE_LXC_OSTEMPLATE='local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst'
|
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
# HomeLab Ansible — единая точка входа для РУЧНОГО управления инфраструктурой.
|
||||||
|
#
|
||||||
|
# Запускать из каталога ansible/ (или через `make -C ansible <цель>`):
|
||||||
|
#
|
||||||
|
# make список целей
|
||||||
|
# make check базовая проверка связности
|
||||||
|
# make deploy-gitea playbooks/pve-gitea.yml с загруженным .env
|
||||||
|
# make dry-gitea то же самое в режиме --check --diff
|
||||||
|
# make update-gitea playbooks/gitea-update.yml
|
||||||
|
# make docs markdown-таблица хостов в stdout
|
||||||
|
#
|
||||||
|
# Дополнительные флаги ansible-playbook передаются через EXTRA:
|
||||||
|
#
|
||||||
|
# make check EXTRA="--limit pve_nodes -vv"
|
||||||
|
#
|
||||||
|
# Интерпретатор определяется автоматически: если рядом есть ./.venv — берётся он,
|
||||||
|
# иначе бинарь ищется в PATH (nix devshell, системный ansible). Любой путь можно
|
||||||
|
# переопределить переменной окружения, например:
|
||||||
|
#
|
||||||
|
# ANSIBLE_PLAYBOOK=$(which ansible-playbook) make check
|
||||||
|
|
||||||
|
SHELL := /bin/sh
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
MAKEFILE_PATH := $(abspath $(lastword $(MAKEFILE_LIST)))
|
||||||
|
ANSIBLE_DIR := $(patsubst %/,%,$(dir $(MAKEFILE_PATH)))
|
||||||
|
REPO_ROOT := $(abspath $(ANSIBLE_DIR)/..)
|
||||||
|
VENV_BIN := $(ANSIBLE_DIR)/.venv/bin
|
||||||
|
|
||||||
|
# Разрешение бинарей: сначала рабочий локальный venv, иначе PATH (nix devshell,
|
||||||
|
# системный ansible). VENV_OK проверяет, что интерпретатор venv реально запускается,
|
||||||
|
# — иначе сломанный .venv (например, после смены системного python) молча ломал бы
|
||||||
|
# все цели. Переменные окружения имеют приоритет благодаря `?=`.
|
||||||
|
VENV_OK := $(shell [ -x '$(VENV_BIN)/python3' ] && '$(VENV_BIN)/python3' -c '' >/dev/null 2>&1 && echo yes)
|
||||||
|
venv_bin = $(if $(VENV_OK),$(if $(wildcard $(VENV_BIN)/$(1)),$(VENV_BIN)/$(1),$(1)),$(1))
|
||||||
|
|
||||||
|
ANSIBLE_PLAYBOOK ?= $(call venv_bin,ansible-playbook)
|
||||||
|
ANSIBLE_INVENTORY_BIN ?= $(call venv_bin,ansible-inventory)
|
||||||
|
ANSIBLE_GALAXY ?= $(call venv_bin,ansible-galaxy)
|
||||||
|
ANSIBLE_LINT ?= $(call venv_bin,ansible-lint)
|
||||||
|
YAMLLINT ?= $(call venv_bin,yamllint)
|
||||||
|
# scripts/gen-inventory-docs.py обходится стандартной библиотекой, поэтому берём
|
||||||
|
# python3 из PATH, а не из .venv.
|
||||||
|
PYTHON ?= python3
|
||||||
|
|
||||||
|
# Секреты живут в КОРНЕ репозитория, а не в ansible/: их потребляет не только
|
||||||
|
# Ansible, но и OpenTofu (tofu/), а держать два файла или ходить в подкаталог
|
||||||
|
# из соседнего инструмента неудобно. Путь абсолютный, поэтому цели работают
|
||||||
|
# из любого cwd.
|
||||||
|
ENV_FILE ?= $(REPO_ROOT)/.env
|
||||||
|
|
||||||
|
# Дополнительные аргументы ansible-playbook для любой цели.
|
||||||
|
EXTRA ?=
|
||||||
|
# Спрашивать sudo-пароль там, где нужен become. `make openvpn ASK_BECOME=` отключает.
|
||||||
|
ASK_BECOME ?= -K
|
||||||
|
# Спрашивать пароль Ansible Vault. `make gyro ASK_VAULT=` отключает.
|
||||||
|
ASK_VAULT ?= --ask-vault-pass
|
||||||
|
|
||||||
|
# Строгая загрузка корневого .env: обязательна для Proxmox API и секретов.
|
||||||
|
# Каждая строка рецепта make — отдельный шелл, поэтому source и запуск идут одной строкой.
|
||||||
|
REQUIRE_ENV = if [ ! -f '$(ENV_FILE)' ]; then \
|
||||||
|
printf 'ОШИБКА: не найден %s\n' '$(ENV_FILE)' >&2; \
|
||||||
|
printf 'Создай его и заполни реальными значениями:\n' >&2; \
|
||||||
|
printf ' cp %s/.env.example %s\n' '$(REPO_ROOT)' '$(ENV_FILE)' >&2; \
|
||||||
|
printf 'Нужны PROXMOX_* (и MONITORING_* / EMERGENCY_* для профильных целей).\n' >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
set -a; . '$(ENV_FILE)'; set +a
|
||||||
|
|
||||||
|
# Мягкая загрузка: .env подхватывается если есть, иначе просто предупреждение.
|
||||||
|
LOAD_ENV = if [ -f '$(ENV_FILE)' ]; then set -a; . '$(ENV_FILE)'; set +a; \
|
||||||
|
else printf 'ВНИМАНИЕ: %s не найден, продолжаю без него.\n' '$(ENV_FILE)' >&2; fi
|
||||||
|
|
||||||
|
# Защита от случайного запуска опасных плейбуков.
|
||||||
|
REQUIRE_CONFIRM = if [ "$(CONFIRM)" != "1" ]; then \
|
||||||
|
printf 'ОПАСНАЯ ЦЕЛЬ. Повтори явно: make $@ CONFIRM=1\n' >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# host_vars/gyro/vault.yml зашифрован Vault. Для чтения инвентаря он не нужен,
|
||||||
|
# поэтому vars-плагины отключаются, чтобы make не спрашивал пароль Vault.
|
||||||
|
INVENTORY_ENV = ANSIBLE_VARS_ENABLED= ANSIBLE_NOCOLOR=1
|
||||||
|
|
||||||
|
##@ Справка
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Показать этот список целей
|
||||||
|
@awk 'BEGIN { FS = ":.*##"; \
|
||||||
|
print ""; \
|
||||||
|
print "HomeLab Ansible — ручное управление инфраструктурой"; \
|
||||||
|
print ""; \
|
||||||
|
print " Использование: make <цель> [EXTRA=\"--limit host -vv\"]"; \
|
||||||
|
print " Секреты берутся из .env в КОРНЕ репозитория"; \
|
||||||
|
} \
|
||||||
|
/^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next } \
|
||||||
|
/^[a-zA-Z0-9_%.-]+:.*##/ { printf " \033[36m%-24s\033[0m %s\n", $$1, $$2 } \
|
||||||
|
END { print "" }' $(MAKEFILE_PATH)
|
||||||
|
|
||||||
|
##@ Setup
|
||||||
|
|
||||||
|
.PHONY: setup
|
||||||
|
setup: ## Создать .venv, поставить requirements.txt и galaxy-коллекции (не нужно в nix)
|
||||||
|
$(PYTHON) -m venv $(ANSIBLE_DIR)/.venv
|
||||||
|
$(VENV_BIN)/pip install --upgrade pip
|
||||||
|
$(VENV_BIN)/pip install -r requirements.txt
|
||||||
|
$(VENV_BIN)/ansible-galaxy collection install -r requirements.yml -p collections
|
||||||
|
|
||||||
|
.PHONY: collections
|
||||||
|
collections: ## Доустановить только galaxy-коллекции из requirements.yml
|
||||||
|
$(ANSIBLE_GALAXY) collection install -r requirements.yml -p collections
|
||||||
|
|
||||||
|
.PHONY: env-check
|
||||||
|
env-check: ## Проверить наличие .env и заполненность ключевых переменных
|
||||||
|
@$(REQUIRE_ENV); \
|
||||||
|
rc=0; \
|
||||||
|
for v in PROXMOX_HOST PROXMOX_USER PROXMOX_TOKEN_ID PROXMOX_TOKEN_SECRET; do \
|
||||||
|
eval "val=\$$$$v"; \
|
||||||
|
if [ -z "$$val" ] || [ "$$val" = 'replace-me' ]; then \
|
||||||
|
printf 'не задано: %s\n' "$$v"; rc=1; \
|
||||||
|
else printf 'ok: %s\n' "$$v"; fi; \
|
||||||
|
done; \
|
||||||
|
exit $$rc
|
||||||
|
|
||||||
|
##@ Проверки и диагностика
|
||||||
|
|
||||||
|
.PHONY: check
|
||||||
|
check: ## Проверка связности и ожидаемых IP (playbooks/check.yml)
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/check.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: status
|
||||||
|
status: ## Сводный статус инфраструктуры (playbooks/status.yml)
|
||||||
|
ANSIBLE_CALLBACK_RESULT_FORMAT=yaml $(ANSIBLE_PLAYBOOK) playbooks/status.yml $(EXTRA)
|
||||||
|
|
||||||
|
# Оба линтера ищут конфиг (.ansible-lint / .yamllint) в текущем каталоге, а не
|
||||||
|
# у родителей. Запуск из ansible/ конфиг не находил, из-за чего в проверку
|
||||||
|
# затягивался вендоренный collections/ и цель выдавала около тысячи чужих
|
||||||
|
# нарушений. Запускаем из корня — ровно как .gitea/workflows/lint.yml.
|
||||||
|
.PHONY: lint
|
||||||
|
lint: ## Прогнать ansible-lint, yamllint и syntax-check — то же, что делает CI
|
||||||
|
cd '$(REPO_ROOT)' && $(ANSIBLE_LINT)
|
||||||
|
cd '$(REPO_ROOT)' && $(YAMLLINT) .
|
||||||
|
@rc=0; for f in playbooks/*.yml; do \
|
||||||
|
if $(ANSIBLE_PLAYBOOK) --syntax-check "$$f" >/dev/null 2>&1; then \
|
||||||
|
printf 'ok %s\n' "$$f"; \
|
||||||
|
else \
|
||||||
|
rc=1; printf 'FAIL %s\n' "$$f"; $(ANSIBLE_PLAYBOOK) --syntax-check "$$f" 2>&1 | sed 's/^/ /'; \
|
||||||
|
fi; \
|
||||||
|
done; exit $$rc
|
||||||
|
|
||||||
|
.PHONY: docs
|
||||||
|
docs: ## Напечатать markdown-таблицу хостов и групп в stdout
|
||||||
|
@ANSIBLE_INVENTORY_BIN='$(ANSIBLE_INVENTORY_BIN)' $(PYTHON) scripts/gen-inventory-docs.py
|
||||||
|
|
||||||
|
.PHONY: inventory
|
||||||
|
inventory: ## Показать дерево инвентаря (ansible-inventory --graph)
|
||||||
|
@$(INVENTORY_ENV) $(ANSIBLE_INVENTORY_BIN) -i inventory/hosts.yml --graph
|
||||||
|
|
||||||
|
.PHONY: validate
|
||||||
|
validate: ## Сверить реестр homelab_services с фактическим состоянием Proxmox (gate)
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/validate.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: openvpn-check
|
||||||
|
openvpn-check: ## Проверить OpenVPN-транспорт ru-vps <-> ovpn-mini
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/openvpn-check.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: backup-audit
|
||||||
|
backup-audit: ## Аудит свежести бэкапов PBS и offsite restic
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/backup-audit.yml $(EXTRA)
|
||||||
|
|
||||||
|
dry-%: ## Прогон playbooks/pve-<имя>.yml в режиме --check --diff (пример: make dry-gitea)
|
||||||
|
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/pve-$*.yml --check --diff $(EXTRA)
|
||||||
|
|
||||||
|
##@ Деплой LXC/VM через Proxmox API (требует .env)
|
||||||
|
|
||||||
|
deploy-%: ## Применить playbooks/pve-<имя>.yml (пример: make deploy-gitea, deploy-adguard, deploy-monitoring)
|
||||||
|
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/pve-$*.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: backup-jobs
|
||||||
|
backup-jobs: ## Настроить PBS backup jobs (playbooks/pve-backup-jobs.yml)
|
||||||
|
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/pve-backup-jobs.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: pbs-storage
|
||||||
|
pbs-storage: ## Убрать storage-level prune policy с PBS storage (playbooks/pve-storage-pbs.yml)
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/pve-storage-pbs.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: bootstrap-pve-token
|
||||||
|
bootstrap-pve-token: ## Выпустить Proxmox API-токен прямо с ноды (нужен sudo)
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/bootstrap-pve-api-token.yml $(ASK_BECOME) $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: bootstrap-monitoring-token
|
||||||
|
bootstrap-monitoring-token: ## Выпустить read-only PVE-токен для мониторинга
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/bootstrap-monitoring-pve-token.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: bootstrap-dashboard-token
|
||||||
|
bootstrap-dashboard-token: ## Выпустить read-only PVE-токен homepage@pve!dashboard для виджета Proxmox
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/bootstrap-dashboard-pve-token.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: bootstrap-ansible-user
|
||||||
|
bootstrap-ansible-user: ## Завести сервисный аккаунт ansible на shell-хостах (нужен sudo)
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/bootstrap-ansible-user.yml $(ASK_BECOME) $(EXTRA)
|
||||||
|
|
||||||
|
##@ Настройка сервисов
|
||||||
|
|
||||||
|
.PHONY: ru-vps-base
|
||||||
|
ru-vps-base: ## Базовое состояние ru-vps и стек Caddy (закрепляет образ, ставит homelab-caddy.service)
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/ru-vps-base.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: dry-ru-vps-base
|
||||||
|
dry-ru-vps-base: ## Предпросмотр ru-vps-base.yml (--check --diff)
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/ru-vps-base.yml --check --diff $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: reverse-proxy
|
||||||
|
reverse-proxy: ## Единый reverse-proxy для gitea/vaultwarden/grimmory
|
||||||
|
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/reverse-proxy.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: openvpn
|
||||||
|
openvpn: ## Поднять OpenVPN-транспорт ru-vps <-> ovpn-mini (нужен sudo)
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/openvpn-vps-mini.yml $(ASK_BECOME) $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: openvpn-laptop
|
||||||
|
openvpn-laptop: ## Настроить OpenVPN-профиль ноутбука
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/openvpn-laptop.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: bash-config
|
||||||
|
bash-config: ## Раскатать единый bash-конфиг на shell_hosts
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/bash-config.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: user-ssh-key
|
||||||
|
user-ssh-key: ## Разложить публичный SSH-ключ пользователя на все хосты
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/user-ssh-key.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: emergency-access
|
||||||
|
emergency-access: ## Настроить emergency reverse-SSH и Telegram-бота (нужны EMERGENCY_* в .env)
|
||||||
|
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/emergency-access.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: gyro
|
||||||
|
gyro: ## Настроить gyro-аллокатор в production CT 156 (Tofu-managed); CT 150 — остановленный rollback (спрашивает пароль Vault)
|
||||||
|
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/gyro.yml $(ASK_VAULT) $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: uptime-kuma
|
||||||
|
uptime-kuma: ## Развернуть/обновить Uptime Kuma на monitoring LXC
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/uptime-kuma.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: dashboard
|
||||||
|
dashboard: ## Дашборд-обзор инфраструктуры (Homepage) на monitoring LXC, конфиг из реестра
|
||||||
|
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/dashboard.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: dry-dashboard
|
||||||
|
dry-dashboard: ## Предпросмотр dashboard.yml (--check --diff)
|
||||||
|
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/dashboard.yml --check --diff $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: offsite-restic
|
||||||
|
offsite-restic: ## Настроить offsite restic-бэкапы на Yandex.Disk
|
||||||
|
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/offsite-restic-yadisk.yml $(EXTRA)
|
||||||
|
|
||||||
|
play-%: ## Запустить произвольный playbooks/<имя>.yml с загруженным .env (пример: make play-check)
|
||||||
|
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/$*.yml $(EXTRA)
|
||||||
|
|
||||||
|
##@ Обновления сервисов (сначала свежий бэкап, потом апдейт, потом проверка)
|
||||||
|
|
||||||
|
update-%: ## Обновить сервис через playbooks/<имя>-update.yml (gitea, vaultwarden, adguard, mihomo, grimmory)
|
||||||
|
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/$*-update.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: update-all
|
||||||
|
update-all: ## Последовательно обновить gitea, vaultwarden, adguard, mihomo, grimmory (требует CONFIRM=1)
|
||||||
|
@$(REQUIRE_CONFIRM)
|
||||||
|
$(MAKE) update-vaultwarden
|
||||||
|
$(MAKE) update-gitea
|
||||||
|
$(MAKE) update-adguard
|
||||||
|
$(MAKE) update-mihomo
|
||||||
|
$(MAKE) update-grimmory
|
||||||
|
|
||||||
|
##@ OpenTofu (пилот provisioning LXC)
|
||||||
|
|
||||||
|
TOFU_DIR := $(REPO_ROOT)/tofu
|
||||||
|
TOFU ?= tofu
|
||||||
|
|
||||||
|
# Tofu, в отличие от Ansible, ходит в Proxmox API напрямую по HTTPS и НЕ умеет
|
||||||
|
# ProxyJump из ssh_config. Вне локальной сети узлы недоступны (проверено:
|
||||||
|
# https://192.168.1.10:8006 отдаёт connection failure), поэтому на время команды
|
||||||
|
# поднимаем SSH-туннель через ru-vps и направляем провайдер в localhost.
|
||||||
|
# Изнутри LAN это тоже работает — просто лишний хоп, зато поведение одинаково.
|
||||||
|
TOFU_TUNNEL_PORT ?= 18006
|
||||||
|
TOFU_SSH_SOCKET := $(ANSIBLE_DIR)/.ansible/tofu-tunnel.sock
|
||||||
|
|
||||||
|
# insecure=true здесь не настройка вкуса: сертификат Proxmox выписан на узел,
|
||||||
|
# а обращаемся мы к 127.0.0.1, так что проверка имени не пройдёт в любом случае.
|
||||||
|
#
|
||||||
|
# Способ аутентификации выбирается автоматически: если в корневом .env задан
|
||||||
|
# PROXMOX_ROOT_PASSWORD — идём как root@pam (только так Proxmox разрешает
|
||||||
|
# features кроме nesting, device passthrough и bind mount каталога хоста),
|
||||||
|
# иначе токеном ansible@pve. Выбранный режим печатается в stderr, чтобы из
|
||||||
|
# вывода было видно, какими правами шёл apply.
|
||||||
|
TOFU_ENV = $(REQUIRE_ENV); \
|
||||||
|
mkdir -p '$(dir $(TOFU_SSH_SOCKET))'; \
|
||||||
|
ssh -F '$(ANSIBLE_DIR)/ssh_config' -M -S '$(TOFU_SSH_SOCKET)' -fN \
|
||||||
|
-L $(TOFU_TUNNEL_PORT):$$PROXMOX_HOST:$${PROXMOX_PORT:-8006} ru-vps; \
|
||||||
|
trap "ssh -S '$(TOFU_SSH_SOCKET)' -O exit ru-vps 2>/dev/null || true" EXIT; \
|
||||||
|
export TF_VAR_pve_endpoint="https://127.0.0.1:$(TOFU_TUNNEL_PORT)"; \
|
||||||
|
export TF_VAR_pve_insecure=true; \
|
||||||
|
if [ -n "$${PROXMOX_ROOT_PASSWORD:-}" ] && [ "$${PROXMOX_ROOT_PASSWORD}" != 'replace-me' ]; then \
|
||||||
|
export TF_VAR_pve_username="$${PROXMOX_ROOT_USER:-root@pam}"; \
|
||||||
|
export TF_VAR_pve_password="$$PROXMOX_ROOT_PASSWORD"; \
|
||||||
|
export TF_VAR_pve_api_token=""; \
|
||||||
|
printf 'Proxmox: аутентификация %s (привилегированный режим)\n' "$${PROXMOX_ROOT_USER:-root@pam}" >&2; \
|
||||||
|
else \
|
||||||
|
export TF_VAR_pve_api_token="$$PROXMOX_USER!$$PROXMOX_TOKEN_ID=$$PROXMOX_TOKEN_SECRET"; \
|
||||||
|
export TF_VAR_pve_username=""; \
|
||||||
|
export TF_VAR_pve_password=""; \
|
||||||
|
printf 'Proxmox: аутентификация токеном %s (features кроме nesting и dev-passthrough недоступны)\n' "$$PROXMOX_USER" >&2; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
.PHONY: tofu-init
|
||||||
|
tofu-init: ## Скачать провайдер и инициализировать рабочий каталог tofu/
|
||||||
|
@cd '$(TOFU_DIR)' && $(TOFU) init -input=false
|
||||||
|
|
||||||
|
.PHONY: tofu-plan
|
||||||
|
tofu-plan: ## Показать план (read-only, ничего не меняет)
|
||||||
|
@$(TOFU_ENV); cd '$(TOFU_DIR)' && $(TOFU) plan -input=false
|
||||||
|
|
||||||
|
.PHONY: tofu-apply
|
||||||
|
tofu-apply: ## Применить план — СОЗДАЁТ гостей в Proxmox (требует CONFIRM=1)
|
||||||
|
@$(REQUIRE_CONFIRM)
|
||||||
|
@$(TOFU_ENV); cd '$(TOFU_DIR)' && $(TOFU) apply -input=false -auto-approve
|
||||||
|
|
||||||
|
.PHONY: tofu-destroy
|
||||||
|
tofu-destroy: ## УНИЧТОЖИТЬ всё, что создано в tofu/ (требует CONFIRM=1)
|
||||||
|
@$(REQUIRE_CONFIRM)
|
||||||
|
@printf 'Удаляет гостей Proxmox из состояния tofu вместе с их дисками.\n' >&2
|
||||||
|
@$(TOFU_ENV); cd '$(TOFU_DIR)' && $(TOFU) destroy -input=false -auto-approve
|
||||||
|
|
||||||
|
##@ Опасное (только осознанно, требует CONFIRM=1)
|
||||||
|
|
||||||
|
.PHONY: mihomo-harden
|
||||||
|
mihomo-harden: ## РОТИРУЕТ живые SOCKS-креды Mihomo на ru-vps и убирает публичный доступ
|
||||||
|
@$(REQUIRE_CONFIRM)
|
||||||
|
@printf 'Ротация кредов необратима для клиентов без отката бэкапа конфига.\n' >&2
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/ru-vps-mihomo-harden.yml -e ru_vps_mihomo_harden_confirm=true $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: zerotier-decommission
|
||||||
|
zerotier-decommission: ## Вывести ZeroTier с ru-vps и почистить его правила UFW (требует CONFIRM=1)
|
||||||
|
@$(REQUIRE_CONFIRM)
|
||||||
|
@printf 'Останавливает контейнер zerotier и удаляет правила UFW. Данные и файлы остаются.\n' >&2
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/ru-vps-zerotier-decommission.yml $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: dry-zerotier-decommission
|
||||||
|
dry-zerotier-decommission: ## Предпросмотр вывода ZeroTier (--check --diff)
|
||||||
|
$(ANSIBLE_PLAYBOOK) playbooks/ru-vps-zerotier-decommission.yml --check --diff $(EXTRA)
|
||||||
|
|
||||||
|
.PHONY: monitoring
|
||||||
|
monitoring: ## ЗАМОРОЖЕН: стек Prometheus. Запускать только при восстановлении мониторинга
|
||||||
|
@$(REQUIRE_CONFIRM)
|
||||||
|
@printf 'monitoring.yml заморожен, пока используется Uptime Kuma.\n' >&2
|
||||||
|
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/monitoring.yml $(EXTRA)
|
||||||
+135
-20
@@ -12,66 +12,181 @@ Ansible is the control plane for HomeLab infrastructure changes.
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `inventory/hosts.yml` — canonical host list and host-specific facts.
|
- `inventory/hosts.yml` — canonical host list and host-specific facts.
|
||||||
|
- `inventory/group_vars/all/services.yml` — service registry and reverse-proxy input; deployment playbooks still duplicate these facts.
|
||||||
- `playbooks/` — entry points for tasks.
|
- `playbooks/` — entry points for tasks.
|
||||||
- `roles/` — reusable configuration units.
|
- `roles/` — reusable configuration units.
|
||||||
|
- `Makefile` — canonical manual entry point and safety gates.
|
||||||
|
|
||||||
## Current Groups
|
## Current Groups
|
||||||
|
|
||||||
- `ru-vps` — public VPS, JumpHost, qdevice, ZeroTier member.
|
- `ru-vps` — public VPS, JumpHost, qdevice, OpenVPN server.
|
||||||
- `pve_nodes` — Proxmox hosts: `cloud-pc`, `mini-pc`.
|
- `pve_nodes` — Proxmox hosts: `cloud-pc`, `mini-pc`.
|
||||||
- `lxc_infra` — infrastructure LXC containers: `pbs`, `zt-cloud`, `zt-mini`.
|
- `lxc_infra` — infrastructure LXC containers, including the outbound-only `gyro` investment allocator host.
|
||||||
- `vpn_openvpn` — OpenVPN transport hosts: `ru-vps`, `wg-mini`.
|
- `monitoring_server` — monitoring LXC; Prometheus stack is frozen and Uptime Kuma is active.
|
||||||
- `shell_hosts` — hosts with unified bash config: `ru-vps`, `cloud-pc`, `mini-pc`.
|
- `monitoring_exporters` — hosts exposing Node Exporter metrics.
|
||||||
|
- `monitoring_smart_exporters` — Proxmox nodes exposing SMART metrics.
|
||||||
|
- `vpn_openvpn` — OpenVPN transport hosts: `ru-vps`, `ovpn-mini`.
|
||||||
|
- `shell_hosts` — hosts with unified bash config: `ru-vps`, `cloud-pc`, `mini-pc`, `hermes-ai`.
|
||||||
- `servers` — all managed hosts.
|
- `servers` — all managed hosts.
|
||||||
|
|
||||||
## First Checks
|
## First Checks
|
||||||
|
|
||||||
Install control-node dependencies locally:
|
From the repository root, use the Nix environment and install Galaxy collections once per clone:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
nix develop
|
||||||
. .venv/bin/activate
|
ansible-galaxy collection install -r ansible/requirements.yml -p ansible/collections
|
||||||
pip install -r requirements.txt
|
|
||||||
ansible-galaxy collection install -r requirements.yml -p collections
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run from `ansible/`:
|
Run manual operations through Make from `ansible/`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ansible-playbook playbooks/check.yml
|
make help
|
||||||
|
make check
|
||||||
|
make status EXTRA="--limit '!gyro'"
|
||||||
|
make lint
|
||||||
```
|
```
|
||||||
|
|
||||||
For Proxmox API playbooks, create ignored `.env` from `.env.example` and load it:
|
`make setup` remains a local venv fallback when Nix is unavailable.
|
||||||
|
|
||||||
|
## Controlled Updates
|
||||||
|
|
||||||
|
Service updates are manual. Active update-managed remote images are pinned as
|
||||||
|
`tag@sha256:digest`; the frozen Prometheus stack is tag-only. Floating
|
||||||
|
auto-update agents are not used.
|
||||||
|
|
||||||
|
Run the dedicated Make target from `ansible/`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
make update-vaultwarden
|
||||||
. ./.env
|
make update-gitea
|
||||||
.venv/bin/ansible-playbook playbooks/pve-wg-mini.yml
|
make update-adguard
|
||||||
|
make update-mihomo
|
||||||
|
make update-grimmory
|
||||||
|
```
|
||||||
|
|
||||||
|
Update flow is always: fresh backup/audit first, then the update playbook, then health verification.
|
||||||
|
|
||||||
|
- Gitea, Vaultwarden and Grimmory use app-aware offsite restic backups/audits before update.
|
||||||
|
- AdGuard and Mihomo use fresh PBS LXC backups before update.
|
||||||
|
- `grimmory-update.yml` currently validates the existing pinned release; any future actual app image upgrade must be preceded by release-note and migration review.
|
||||||
|
|
||||||
|
## Mihomo Hardening
|
||||||
|
|
||||||
|
Use the dedicated hardening playbook only when explicitly approved:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make mihomo-harden CONFIRM=1
|
||||||
|
```
|
||||||
|
|
||||||
|
It rotates the live Mihomo SOCKS credentials on `ru-vps`, locks the proxy to loopback, and removes the public UFW exposure for ports `7890` and `7891`.
|
||||||
|
|
||||||
|
The rotated credentials are not recoverable for clients unless you roll back the saved config backup.
|
||||||
|
|
||||||
|
For Proxmox API playbooks, create ignored `.env` in the **repository root** from
|
||||||
|
`.env.example`. It is shared with OpenTofu (`tofu/`). Make loads it
|
||||||
|
automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp ../.env.example ../.env # секреты живут в корне репозитория
|
||||||
|
make env-check
|
||||||
|
make dry-ovpn-mini
|
||||||
|
make deploy-ovpn-mini
|
||||||
```
|
```
|
||||||
|
|
||||||
Or bootstrap the token from `mini-pc` with sudo:
|
Or bootstrap the token from `mini-pc` with sudo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/ansible-playbook playbooks/bootstrap-pve-api-token.yml -K
|
make bootstrap-pve-token
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the separate read-only PVE token used by the monitoring exporter:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make bootstrap-monitoring-token
|
||||||
```
|
```
|
||||||
|
|
||||||
OpenVPN transport:
|
OpenVPN transport:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/ansible-playbook playbooks/openvpn-vps-mini.yml -K
|
make openvpn
|
||||||
.venv/bin/ansible-playbook playbooks/openvpn-check.yml
|
make openvpn-check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
CT 146 can be provisioned separately. Uptime Kuma is the active monitoring service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make deploy-monitoring
|
||||||
|
make uptime-kuma
|
||||||
|
```
|
||||||
|
|
||||||
|
`pve-monitoring.yml` creates CT `146` (`monitoring`, `192.168.1.30`) on
|
||||||
|
`cloud-pc`. The older `monitoring.yml` configures Prometheus, Alertmanager,
|
||||||
|
Grafana and exporters; it is frozen and `make monitoring CONFIRM=1` is reserved
|
||||||
|
for an explicitly approved restoration decision.
|
||||||
|
|
||||||
|
## Gyro Investment Allocator
|
||||||
|
|
||||||
|
Production `gyro` now runs in Tofu-provisioned CT `156` (`gyro`, `192.168.1.35`) on `mini-pc`. CT `150` is stopped and kept only as rollback for at least a week; it is not removed.
|
||||||
|
|
||||||
|
`make gyro` configures Python 3.13+, pinned `uv`, the `gyro` service user, the container-local GitHub deploy key, restrictive firewall rules, and the weekday systemd timer. Do not use `make deploy-gyro` for the cutover path.
|
||||||
|
|
||||||
|
UFW is the currently enforced isolation layer: inbound is denied except SSH from LAN/OpenVPN, and east-west outbound is denied except the Mihomo HTTP proxy. The equivalent CT `156` Proxmox firewall is already carried over; the cluster-wide PVE firewall remains disabled, so do not enable it without auditing every node and guest with `firewall=1`.
|
||||||
|
|
||||||
|
The role keeps deployment and the timer disabled by default. The active host vars deploy `git@github.com:ada-dmitry/t_tech-gyro.git` with GitHub's verified ED25519 host key; the timer still requires the ignored Vault file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp inventory/host_vars/gyro/vault.yml.example inventory/host_vars/gyro/vault.yml
|
||||||
|
ansible-vault encrypt inventory/host_vars/gyro/vault.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
After encrypting the secrets, set `gyro_timer_enabled: true` in `main.yml` and apply with `--ask-vault-pass`. `DRY_RUN_OVERRIDE` remains `true` until real trading is explicitly approved.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make gyro
|
||||||
|
```
|
||||||
|
|
||||||
|
The timer runs at 11:00 Europe/Moscow from Monday through Friday and uses `OnFailure=` for a best-effort Telegram alert. PBS backup jobs and the backup freshness audit derive the current Gyro VMID from the registry once regenerated, so CT `156` is picked up automatically.
|
||||||
|
|
||||||
|
The legacy `pve-gyro.yml` remains available for rollback recovery only and is blocked by default after cutover unless an explicit override is passed.
|
||||||
|
|
||||||
|
Uptime Kuma uses the existing monitoring LXC and stops/disables `homelab-monitoring` without deleting its configuration or data. Its UI is available only from the LAN at `http://192.168.1.30:3001`; create monitors and notification settings in the UI.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make uptime-kuma
|
||||||
|
```
|
||||||
|
|
||||||
|
## Emergency Reverse SSH
|
||||||
|
|
||||||
|
`pve-emergency-bot.yml` creates CT `148` (`emergency-bot`, `192.168.1.32`) on `mini-pc`. `emergency-access.yml` configures the bot, a TTL-limited reverse tunnel from `mini-pc` to `ru-vps`, and the restricted SSH identities used between them.
|
||||||
|
|
||||||
|
Before applying, set `EMERGENCY_BOT_TOKEN`, `EMERGENCY_ALLOWED_USER_IDS`, `EMERGENCY_VPS_HOST_KEY`, and `EMERGENCY_MINI_PC_HOST_KEY` in ignored `.env` or Ansible Vault. The host-key variables must be verified public host keys, not values obtained during deployment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make deploy-emergency-bot
|
||||||
|
make emergency-access
|
||||||
|
```
|
||||||
|
|
||||||
|
From an authorized private Telegram chat, use the `Enable SSH`, `Status`, and `Stop` buttons or `/emergency ssh`, `/emergency status`, and `/emergency stop`. `/emergency ssh` enables a 60-minute tunnel only; it does not expose a public port. Connect while it is active with:
|
||||||
|
|
||||||
|
The bot uses the LAN Mihomo HTTP proxy at `192.168.1.27:7890` because direct Telegram TCP access is unavailable from HomeLab.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -J vps -p 22010 ansible@127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
The target account is `ansible`; it has no password login. Use the existing private key `~/.ssh/id_ed25519_homelab_ansible`.
|
||||||
|
|
||||||
Bootstrap the Ansible service account on shell hosts:
|
Bootstrap the Ansible service account on shell hosts:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/ansible-playbook -i inventory/hosts.yml playbooks/bootstrap-ansible-user.yml -K
|
make bootstrap-ansible-user
|
||||||
```
|
```
|
||||||
|
|
||||||
When a task needs privilege escalation:
|
When a task needs privilege escalation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ansible-playbook playbooks/<name>.yml -K
|
make play-<name> EXTRA="-K"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|||||||
@@ -6,4 +6,7 @@ retry_files_enabled = false
|
|||||||
|
|
||||||
[ssh_connection]
|
[ssh_connection]
|
||||||
pipelining = true
|
pipelining = true
|
||||||
|
# Транспорт (ProxyJump, порты, пользователи, ключи) описан в ansible/ssh_config.
|
||||||
|
# Он подключается через ansible_ssh_common_args в group_vars/all/main.yml,
|
||||||
|
# где путь вычисляется от inventory_dir и потому не зависит ни от cwd, ни от места клона.
|
||||||
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=accept-new
|
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=accept-new
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PROXY_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_PROXY_PORT = 7891
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write_bytes(path: Path, data: bytes, mode: int) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "wb") as handle:
|
||||||
|
handle.write(data)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.chmod(tmp_name, mode)
|
||||||
|
os.replace(tmp_name, path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp_name):
|
||||||
|
os.unlink(tmp_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write_json(path: Path, payload: dict[str, str]) -> None:
|
||||||
|
_atomic_write_bytes(path, json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") + b"\n", 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path) -> dict[str, str]:
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError(f"{path} must contain a JSON object")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_username() -> str:
|
||||||
|
return f"mihomo-{secrets.token_hex(8)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_password() -> str:
|
||||||
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_credentials(state_path: Path) -> tuple[dict[str, str], bool]:
|
||||||
|
if state_path.exists():
|
||||||
|
payload = _load_json(state_path)
|
||||||
|
username = payload.get("username")
|
||||||
|
password = payload.get("password")
|
||||||
|
if not username or not password:
|
||||||
|
raise ValueError(f"{state_path} is missing username/password")
|
||||||
|
return {"username": username, "password": password}, False
|
||||||
|
|
||||||
|
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.chmod(state_path.parent, 0o700)
|
||||||
|
credentials = {"username": _generate_username(), "password": _generate_password()}
|
||||||
|
_atomic_write_json(state_path, credentials)
|
||||||
|
return credentials, True
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: Path) -> dict:
|
||||||
|
with config_path.open("r", encoding="utf-8") as handle:
|
||||||
|
payload = yaml.safe_load(handle) or {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError(f"{config_path} must contain a YAML mapping")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def write_config(config_path: Path, payload: dict) -> None:
|
||||||
|
serialized = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
|
||||||
|
mode = 0o640
|
||||||
|
if config_path.exists():
|
||||||
|
mode = stat.S_IMODE(config_path.stat().st_mode)
|
||||||
|
_atomic_write_bytes(config_path, serialized.encode("utf-8"), mode)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_apply(args: argparse.Namespace) -> int:
|
||||||
|
credentials, state_created = ensure_credentials(args.state)
|
||||||
|
config = load_config(args.config)
|
||||||
|
updated = copy.deepcopy(config)
|
||||||
|
updated["authentication"] = [f"{credentials['username']}:{credentials['password']}"]
|
||||||
|
updated["allow-lan"] = False
|
||||||
|
updated["bind-address"] = "127.0.0.1"
|
||||||
|
changed = updated != config
|
||||||
|
if changed:
|
||||||
|
write_config(args.config, updated)
|
||||||
|
print(json.dumps({"changed": changed, "state_created": state_created}, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_probe(args: argparse.Namespace) -> int:
|
||||||
|
credentials = ensure_credentials(args.state)[0]
|
||||||
|
curl_config = "\n".join(
|
||||||
|
[
|
||||||
|
'silent',
|
||||||
|
'show-error',
|
||||||
|
f'proxy = "socks5h://{DEFAULT_PROXY_HOST}:{DEFAULT_PROXY_PORT}"',
|
||||||
|
f'proxy-user = "{credentials["username"]}:{credentials["password"]}"',
|
||||||
|
'connect-timeout = 5',
|
||||||
|
'max-time = 20',
|
||||||
|
'url = "https://api.telegram.org"',
|
||||||
|
'output = "/dev/null"',
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
["curl", "--config", "-"],
|
||||||
|
input=curl_config,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=25,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
sys.stderr.write(result.stderr)
|
||||||
|
return result.returncode
|
||||||
|
print(json.dumps({"ok": True}, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
for name, func in (("apply", cmd_apply), ("probe", cmd_probe)):
|
||||||
|
sub = subparsers.add_parser(name)
|
||||||
|
sub.add_argument("--config", required=True, type=Path)
|
||||||
|
sub.add_argument("--state", required=True, type=Path)
|
||||||
|
sub.set_defaults(func=func)
|
||||||
|
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
# Общие переменные для всех управляемых хостов.
|
||||||
|
# SSH-транспорт (ProxyJump, порты, ключи по хостам) описан в ansible/ssh_config.
|
||||||
|
# Путь считается от каталога inventory, поэтому работает из любого cwd
|
||||||
|
# и не ломается при клоне репозитория в другое место.
|
||||||
|
ansible_ssh_common_args: "-F {{ inventory_dir }}/../ssh_config"
|
||||||
|
|
||||||
|
ansible_python_interpreter: /usr/bin/python3
|
||||||
|
ansible_user: ansible
|
||||||
|
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab_ansible
|
||||||
|
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:
|
||||||
|
- name: satisfactory
|
||||||
|
protocol: udp
|
||||||
|
public_port: 7777
|
||||||
|
target_host: 192.168.1.100
|
||||||
|
target_port: 7777
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
---
|
||||||
|
# ============================================================================
|
||||||
|
# homelab_services — декларативный реестр сервисов HomeLab
|
||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# ЧТО ЭТО
|
||||||
|
# Единственное место, где собраны факты о каждом сервисе домашней лаборатории:
|
||||||
|
# VMID, узел Proxmox, адрес, порты, публичный домен, закреплённые образы
|
||||||
|
# с digest, ресурсы LXC, схема резервного копирования, мониторинг и порядок
|
||||||
|
# автозапуска.
|
||||||
|
#
|
||||||
|
# ВСЕ значения взяты из существующего кода (ansible/playbooks/*.yml,
|
||||||
|
# ansible/roles/*, ansible/inventory/hosts.yml). Ничего не выдумано.
|
||||||
|
# Там, где факта в коде нет, стоит комментарий "# нет в коде", а не догадка.
|
||||||
|
#
|
||||||
|
# КТО ЭТО ПОТРЕБЛЯЕТ
|
||||||
|
# * playbooks/reverse-proxy.yml — Caddyfile по блокам `proxy`.
|
||||||
|
# * playbooks/ru-vps-base.yml — стек Caddy и закреплённый образ.
|
||||||
|
# * playbooks/pve-backup-jobs.yml — списки VMID заданий по полю `backup.job`.
|
||||||
|
# * roles/backup_audit — VMID по флагу `monitoring.backup_audit_vmid`.
|
||||||
|
# * playbooks/validate.yml — сверка с фактическим состоянием Proxmox.
|
||||||
|
# * playbooks/dashboard.yml — services.yaml для Homepage-обзора инфры.
|
||||||
|
# * Человек — как справочник вместо чтения семи playbook'ов по 200-400 строк.
|
||||||
|
#
|
||||||
|
# Существующие pve-*.yml пока НЕ читают этот реестр: их перевод на
|
||||||
|
# homelab_services + роли lxc_docker_host/compose_service — отдельный этап.
|
||||||
|
# До тех пор реестр и pve-*.yml нужно править согласованно.
|
||||||
|
#
|
||||||
|
# ЧТО ПРАВИТЬ ПРИ ДОБАВЛЕНИИ НОВОГО СЕРВИСА
|
||||||
|
# 1. Добавить запись в homelab_services ниже (все обязательные поля).
|
||||||
|
# 2. Если сервис публикуется наружу — заполнить блок `proxy`
|
||||||
|
# (domain, upstream, caddy_marker, caddy_container_check, health).
|
||||||
|
# Больше ничего для reverse-proxy делать не нужно.
|
||||||
|
# 3. Хост в ansible/inventory/hosts.yml (+ группы monitoring_exporters и др.).
|
||||||
|
# 4. Задание бэкапа в ansible/playbooks/pve-backup-jobs.yml
|
||||||
|
# и VMID в roles/backup_audit/defaults/main.yml.
|
||||||
|
# 5. Плейбук развёртывания (pve-<name>.yml).
|
||||||
|
#
|
||||||
|
# СОГЛАШЕНИЯ ПО ПОЛЯМ
|
||||||
|
# vmid int, уникальный в кластере
|
||||||
|
# node cloud-pc | mini-pc — узел Proxmox, где живёт контейнер
|
||||||
|
# ip адрес в LAN без маски
|
||||||
|
# hostname имя LXC и имя хоста в inventory
|
||||||
|
# role короткое человеческое описание
|
||||||
|
# provisioner чем создаётся контейнер:
|
||||||
|
# pct_ssh — прямой `pct create` по SSH на узле PVE
|
||||||
|
# pve_lxc — роль roles/pve_lxc через Proxmox API
|
||||||
|
# unmanaged — создан вручную, в репозитории нет плейбука
|
||||||
|
# lxc ресурсы и параметры контейнера (as-created)
|
||||||
|
# ports опубликованные порты сервиса
|
||||||
|
# images закреплённые образы с digest (пустой список — образ не
|
||||||
|
# используется или собирается локально)
|
||||||
|
# backup pbs / restic / none — фактическая схема из pve-backup-jobs.yml
|
||||||
|
# и offsite-restic-yadisk.yml
|
||||||
|
# monitoring node_exporter — состоит ли хост в группе monitoring_exporters;
|
||||||
|
# blackbox — пробится ли снаружи из roles/monitoring_blackbox
|
||||||
|
# proxy присутствует только у сервисов с публичным доменом
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Хост, на котором стоит Caddy и терминируется публичный HTTPS.
|
||||||
|
homelab_reverse_proxy_host: ru-vps
|
||||||
|
homelab_reverse_proxy_dir: /opt/services/ru-vps/caddy
|
||||||
|
homelab_reverse_proxy_caddyfile: /opt/services/ru-vps/caddy/Caddyfile
|
||||||
|
homelab_reverse_proxy_container: caddy
|
||||||
|
# Стек Caddy разворачивает playbooks/ru-vps-base.yml (роль compose_service).
|
||||||
|
# Содержимое Caddyfile принадлежит playbooks/reverse-proxy.yml — ru-vps-base
|
||||||
|
# его не трогает, только проверяет наличие.
|
||||||
|
# Образ закреплён по digest: до принятия в Ansible на ru-vps крутился
|
||||||
|
# floating-тег caddy:2-alpine (на момент фиксации — v2.11.4).
|
||||||
|
homelab_reverse_proxy_image: >-
|
||||||
|
caddy:2-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648
|
||||||
|
# Systemd-юнит намеренно называется homelab-caddy, а не caddy: на ru-vps уже
|
||||||
|
# есть отключённый caddy.service от APT-пакета, и одноимённый unit в
|
||||||
|
# /etc/systemd/system его бы затенил.
|
||||||
|
homelab_reverse_proxy_unit: homelab-caddy
|
||||||
|
|
||||||
|
# Адрес центрального хоста мониторинга (источник scrape для node-exporter).
|
||||||
|
homelab_monitoring_host_ip: 192.168.1.30
|
||||||
|
|
||||||
|
# --- Дашборд-обзор инфраструктуры (Homepage, gethomepage.dev) ----------------
|
||||||
|
# Потребитель реестра: playbooks/dashboard.yml рендерит services.yaml из
|
||||||
|
# homelab_services (плитка на сервис, ссылка на его UI, группировка по узлу).
|
||||||
|
# Живёт compose-стеком на LXC monitoring рядом с Uptime Kuma; наружу НЕ
|
||||||
|
# публикуется — доступ только из LAN и по OpenVPN.
|
||||||
|
homelab_dashboard_host: monitoring
|
||||||
|
homelab_dashboard_dir: /opt/homepage
|
||||||
|
homelab_dashboard_bind_ip: 192.168.1.30
|
||||||
|
# Порт на хосте: 3000 в модели реестра занят замороженной Grafana, 3001 —
|
||||||
|
# Uptime Kuma, поэтому Homepage слушает 8082 -> контейнерный 3000.
|
||||||
|
homelab_dashboard_port: 8082
|
||||||
|
# Образ закреплён по digest (закреплён 2026-09-03 после первого pull на CT 155).
|
||||||
|
# Обновление: docker pull <новый тег> на monitoring, взять digest
|
||||||
|
# docker inspect --format '{{ index .RepoDigests 0 }}' <image>
|
||||||
|
# и вписать сюда. playbooks/dashboard.yml падает на assert'е без @sha256
|
||||||
|
# (разовый обход — `-e dashboard_allow_floating_tag=true`).
|
||||||
|
homelab_dashboard_image: >-
|
||||||
|
ghcr.io/gethomepage/homepage:v2.2.0@sha256:753eeb0cc22ab7baad39ed47cbd1aae14e193dd1b264e965f193a9ea1d1e1bdd
|
||||||
|
# HOMEPAGE_ALLOWED_HOSTS: Homepage >=1.0 отклоняет запросы с прочих Host-ов.
|
||||||
|
homelab_dashboard_allowed_hosts: "192.168.1.30:8082"
|
||||||
|
# Виджет proxmox: URL любого узла кластера (без node: -> среднее по кластеру).
|
||||||
|
# Секрет — read-only токен homepage@pve!dashboard (роль PVEAuditor), выпускается
|
||||||
|
# playbooks/bootstrap-dashboard-pve-token.yml в корневой .env как DASHBOARD_PVE_*.
|
||||||
|
# Пустой URL -> плитка Proxmox не рендерится.
|
||||||
|
homelab_dashboard_proxmox_url: "https://192.168.1.10:8006"
|
||||||
|
# slug опубликованной Status Page в Uptime Kuma для виджета сводки up/down
|
||||||
|
# (мониторы и статус-страницы Kuma живут только в её UI — их тут не завести).
|
||||||
|
homelab_dashboard_kuma_slug: homelab
|
||||||
|
|
||||||
|
homelab_services:
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
pbs:
|
||||||
|
vmid: 120
|
||||||
|
node: cloud-pc
|
||||||
|
ip: 192.168.1.20
|
||||||
|
hostname: pbs
|
||||||
|
role: Proxmox Backup Server
|
||||||
|
provisioner: unmanaged # плейбука создания в репозитории нет
|
||||||
|
lxc:
|
||||||
|
cores: null # нет в коде
|
||||||
|
memory: null # нет в коде
|
||||||
|
swap: null # нет в коде
|
||||||
|
disk: null # нет в коде
|
||||||
|
startup: null # нет в коде
|
||||||
|
features: null # нет в коде
|
||||||
|
unprivileged: null # нет в коде
|
||||||
|
ports:
|
||||||
|
- {name: pbs-api, port: 8007, proto: tcp} # стандартный порт PBS
|
||||||
|
images: []
|
||||||
|
backup:
|
||||||
|
kind: pbs-local
|
||||||
|
# pve-backup-jobs.yml: job homelab-local-weekly-pbs, хранилище "backup"
|
||||||
|
job: homelab-local-weekly-pbs
|
||||||
|
schedule: "Sun 03:30"
|
||||||
|
storage: backup
|
||||||
|
prune: keep-last=2
|
||||||
|
monitoring:
|
||||||
|
node_exporter: true
|
||||||
|
blackbox: false
|
||||||
|
backup_audit_vmid: false # 120 отсутствует в backup_audit_pbs_vmids
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
ovpn-mini:
|
||||||
|
vmid: 160
|
||||||
|
node: mini-pc
|
||||||
|
ip: 192.168.1.23
|
||||||
|
hostname: ovpn-mini
|
||||||
|
role: OpenVPN-шлюз в LAN (клиент ru-vps)
|
||||||
|
provisioner: tofu # tofu/svc-ovpn-mini.tf, переезд 2026-09-03 (migration-tofu.md)
|
||||||
|
lxc:
|
||||||
|
cores: 1
|
||||||
|
memory: 256
|
||||||
|
swap: 128
|
||||||
|
disk: local-lvm:8
|
||||||
|
startup: order=30
|
||||||
|
features: "nesting=1" # keyctl/fuse намеренно не заданы
|
||||||
|
unprivileged: true
|
||||||
|
# /dev/net/tun — блок device_passthrough в Tofu (dev0: path=/dev/net/tun)
|
||||||
|
# вместо прежних сырых lxc.* строк из pve-ovpn-mini.yml. Эффект тот же.
|
||||||
|
devices: ["/dev/net/tun"]
|
||||||
|
ports:
|
||||||
|
- {name: openvpn, port: 8443, proto: tcp, note: "туннель к ru-vps 10.78.0.1"}
|
||||||
|
images: []
|
||||||
|
backup:
|
||||||
|
kind: pbs
|
||||||
|
job: homelab-pbs-daily-mini
|
||||||
|
schedule: "02:40"
|
||||||
|
storage: pbs
|
||||||
|
monitoring:
|
||||||
|
node_exporter: true
|
||||||
|
blackbox: false
|
||||||
|
backup_audit_vmid: true
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
vaultwarden:
|
||||||
|
vmid: 154
|
||||||
|
node: mini-pc
|
||||||
|
ip: 192.168.1.24
|
||||||
|
hostname: vaultwarden
|
||||||
|
role: Менеджер паролей Vaultwarden
|
||||||
|
provisioner: tofu # tofu/svc-vaultwarden.tf, переезд 2026-09-02 (migration-tofu.md)
|
||||||
|
lxc:
|
||||||
|
cores: 2
|
||||||
|
memory: 1024
|
||||||
|
swap: 512
|
||||||
|
disk: local-lvm:16
|
||||||
|
startup: order=40
|
||||||
|
# fuse=1 — штатный флаг PVE вместо прежнего обхода сырыми lxc.* строками.
|
||||||
|
features: "fuse=1,keyctl=1,nesting=1"
|
||||||
|
unprivileged: true
|
||||||
|
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
devices: ["/dev/fuse"]
|
||||||
|
data_dir: /opt/vaultwarden/data
|
||||||
|
ports:
|
||||||
|
- {name: http, port: 80, proto: tcp}
|
||||||
|
images:
|
||||||
|
- vaultwarden/server:1.37.1@sha256:e9efdf001bf0d68c21f2cbfb8e1d9b5961a7ca9c85e0a7e58bf51a13b997d744
|
||||||
|
runtime: docker-run-systemd # /etc/systemd/system/vaultwarden.service
|
||||||
|
backup:
|
||||||
|
kind: pbs+restic
|
||||||
|
job: homelab-pbs-daily-mini
|
||||||
|
schedule: "02:40"
|
||||||
|
storage: pbs
|
||||||
|
restic:
|
||||||
|
profile: vaultwarden
|
||||||
|
repository: rclone:yadisk:System/Backups/HomeLab/restic/vaultwarden
|
||||||
|
source_path: /opt/vaultwarden/data
|
||||||
|
schedule: "*-*-* 04:45:00"
|
||||||
|
sqlite_db: /opt/vaultwarden/data/db.sqlite3
|
||||||
|
monitoring:
|
||||||
|
node_exporter: true
|
||||||
|
blackbox: true
|
||||||
|
backup_audit_vmid: true
|
||||||
|
proxy:
|
||||||
|
domain: pass.ada-dev.ru
|
||||||
|
upstream: 192.168.1.24:80
|
||||||
|
caddy_marker: "# {mark} ANSIBLE MANAGED VAULTWARDEN SITE"
|
||||||
|
# caddy_body не задан -> используется простой `reverse_proxy <upstream>`
|
||||||
|
caddy_container_check: "reverse_proxy 192.168.1.24:80"
|
||||||
|
# Устаревшая секция от миграции с 10.122.62.95. Удалить это поле
|
||||||
|
# можно после подтверждённого прогона reverse-proxy.yml на ru-vps.
|
||||||
|
caddy_legacy_regexp: '(?ms)^pass\.ada-dev\.ru \{\n\s*reverse_proxy 10\.122\.62\.95:10380\n\}\n+'
|
||||||
|
health:
|
||||||
|
path: /
|
||||||
|
status_code: [200]
|
||||||
|
follow_redirects: none # соответствует `curl -fsS` без -L в старом плейбуке
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
gitea:
|
||||||
|
vmid: 153
|
||||||
|
node: cloud-pc
|
||||||
|
ip: 192.168.1.25
|
||||||
|
hostname: gitea
|
||||||
|
role: Git-хостинг Gitea
|
||||||
|
provisioner: tofu # tofu/svc-gitea.tf, переезд 2026-09-02 (migration-tofu.md)
|
||||||
|
lxc:
|
||||||
|
cores: 2
|
||||||
|
memory: 2048
|
||||||
|
swap: 1024
|
||||||
|
disk: data:32
|
||||||
|
startup: order=50
|
||||||
|
# fuse=1 — штатный флаг PVE вместо прежнего обхода сырыми lxc.* строками.
|
||||||
|
features: "fuse=1,keyctl=1,nesting=1"
|
||||||
|
unprivileged: true
|
||||||
|
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
devices: ["/dev/fuse"]
|
||||||
|
mounts:
|
||||||
|
# Был bind mount каталога ноды /opt/data/gitea. С переезда 2026-09-02 —
|
||||||
|
# независимый volume на datastore: mp0 data:153/vm-153-disk-1.raw.
|
||||||
|
# Побочный выигрыш: bind mount vzdump ИСКЛЮЧАЛ из бэкапа ("not a
|
||||||
|
# volume"), то есть данные gitea в PBS никогда не попадали. Volume
|
||||||
|
# попадает.
|
||||||
|
- {volume: data, size: 32G, container_path: /opt/gitea/data}
|
||||||
|
data_dir: /opt/gitea/data
|
||||||
|
ports:
|
||||||
|
- {name: http, port: 3000, proto: tcp}
|
||||||
|
- {name: ssh, port: 2222, proto: tcp, container_port: 22}
|
||||||
|
images:
|
||||||
|
- gitea/gitea:1.27.1@sha256:b64126cf5c3f4e5f0f231b510bb13715f6cb8e508188b44de90bdb9a04f3055d
|
||||||
|
runtime: docker-run-systemd # /etc/systemd/system/gitea.service
|
||||||
|
backup:
|
||||||
|
kind: pbs+restic
|
||||||
|
job: homelab-pbs-daily-cloud
|
||||||
|
schedule: "02:10"
|
||||||
|
storage: pbs
|
||||||
|
restic:
|
||||||
|
profile: gitea
|
||||||
|
# С переезда 2026-09-02 профиль выполняется ВНУТРИ LXC, как у
|
||||||
|
# vaultwarden: пути ноды больше не существуют, данные в volume.
|
||||||
|
run_on: gitea
|
||||||
|
repository: rclone:yadisk:System/Backups/HomeLab/restic/gitea
|
||||||
|
source_path: /opt/gitea/data
|
||||||
|
schedule: "*-*-* 04:15:00"
|
||||||
|
sqlite_db: /opt/gitea/data/gitea/gitea.db
|
||||||
|
monitoring:
|
||||||
|
node_exporter: true
|
||||||
|
blackbox: true
|
||||||
|
backup_audit_vmid: true
|
||||||
|
proxy:
|
||||||
|
domain: git.ada-dev.ru
|
||||||
|
upstream: 192.168.1.25:3000
|
||||||
|
caddy_marker: "# {mark} ANSIBLE MANAGED GITEA SITE"
|
||||||
|
caddy_container_check: "reverse_proxy 192.168.1.25:3000"
|
||||||
|
# Устаревшая секция от миграции с 10.122.62.51. Удалить это поле
|
||||||
|
# можно после подтверждённого прогона reverse-proxy.yml на ru-vps.
|
||||||
|
caddy_legacy_regexp: '(?ms)^git\.ada-dev\.ru \{\n\s*reverse_proxy 10\.122\.62\.51:3002\n\}\n+'
|
||||||
|
health:
|
||||||
|
path: /
|
||||||
|
status_code: [200]
|
||||||
|
follow_redirects: none # соответствует `curl -fsS` без -L в старом плейбуке
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
mihomo:
|
||||||
|
vmid: 159
|
||||||
|
node: mini-pc
|
||||||
|
ip: 192.168.1.27
|
||||||
|
hostname: mihomo
|
||||||
|
role: Локальный прокси Mihomo + веб-интерфейс MetaCubeXD
|
||||||
|
provisioner: tofu # tofu/svc-mihomo.tf, переезд 2026-09-03 (migration-tofu.md)
|
||||||
|
lxc:
|
||||||
|
cores: 1
|
||||||
|
memory: 512
|
||||||
|
swap: 512
|
||||||
|
disk: local-lvm:8
|
||||||
|
startup: order=70
|
||||||
|
# fuse=1 — штатный флаг PVE вместо прежнего обхода сырыми lxc.* строками.
|
||||||
|
# /dev/net/tun — блок device_passthrough в Tofu (dev0: path=/dev/net/tun),
|
||||||
|
# тоже вместо сырых lxc.* строк. Эффект тот же, запись в pct config иная.
|
||||||
|
features: "fuse=1,keyctl=1,nesting=1"
|
||||||
|
unprivileged: true
|
||||||
|
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
devices: ["/dev/fuse", "/dev/net/tun"]
|
||||||
|
data_dir: /opt/mihomo
|
||||||
|
ports:
|
||||||
|
- {name: mixed, port: 7890, proto: tcp}
|
||||||
|
- {name: socks, port: 7891, proto: tcp}
|
||||||
|
- {name: controller, port: 9090, proto: tcp}
|
||||||
|
- {name: ui, port: 8080, proto: tcp, container_port: 80}
|
||||||
|
images:
|
||||||
|
- metacubex/mihomo:v1.19.29@sha256:5e7bcc5e7a866afcc8b007ef827c9ba773f2f34b6d7311b6d39ed1751f37cfd5
|
||||||
|
- ghcr.io/metacubex/metacubexd:v1.270.6@sha256:156d55be885d4ba6254d840bd781b715c20c00afee6e6c24c76be4cfe5eb89d4
|
||||||
|
runtime: docker-run-systemd # mihomo.service + mihomo-ui.service
|
||||||
|
backup:
|
||||||
|
kind: pbs
|
||||||
|
job: homelab-pbs-daily-mini
|
||||||
|
schedule: "02:40"
|
||||||
|
storage: pbs
|
||||||
|
monitoring:
|
||||||
|
node_exporter: true
|
||||||
|
blackbox: false
|
||||||
|
backup_audit_vmid: true
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
adguard:
|
||||||
|
vmid: 158
|
||||||
|
node: mini-pc
|
||||||
|
ip: 192.168.1.28
|
||||||
|
hostname: adguard
|
||||||
|
role: AdGuard Home — DNS с фильтрацией
|
||||||
|
provisioner: tofu # tofu/svc-adguard.tf, переезд 2026-09-03 (migration-tofu.md)
|
||||||
|
lxc:
|
||||||
|
cores: 1
|
||||||
|
memory: 512
|
||||||
|
swap: 512
|
||||||
|
disk: local-lvm:8
|
||||||
|
startup: order=40
|
||||||
|
# fuse=1 — штатный флаг PVE вместо прежнего обхода сырыми lxc.* строками
|
||||||
|
# (lineinfile по /etc/pve/lxc/144.conf в pve-adguard.yml). Эффект тот же.
|
||||||
|
features: "fuse=1,keyctl=1,nesting=1"
|
||||||
|
unprivileged: true
|
||||||
|
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
devices: ["/dev/fuse"]
|
||||||
|
data_dir: /opt/adguard
|
||||||
|
ports:
|
||||||
|
- {name: dns-tcp, port: 53, proto: tcp}
|
||||||
|
- {name: dns-udp, port: 53, proto: udp}
|
||||||
|
- {name: http, port: 80, proto: tcp}
|
||||||
|
- {name: setup, port: 3000, proto: tcp}
|
||||||
|
images:
|
||||||
|
- adguard/adguardhome:v0.107.78@sha256:2c127294fa5f96151d9d3a433fb9d66c17e4d18cf698c2b04372a80e26fdd26f
|
||||||
|
runtime: docker-run-systemd # /etc/systemd/system/adguard.service
|
||||||
|
backup:
|
||||||
|
kind: pbs
|
||||||
|
job: homelab-pbs-daily-mini
|
||||||
|
schedule: "02:40"
|
||||||
|
storage: pbs
|
||||||
|
monitoring:
|
||||||
|
node_exporter: true
|
||||||
|
blackbox: false
|
||||||
|
backup_audit_vmid: true
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
docker-test:
|
||||||
|
vmid: 152
|
||||||
|
node: cloud-pc
|
||||||
|
ip: 192.168.1.29
|
||||||
|
hostname: docker-test
|
||||||
|
role: Песочница для проверки Docker в непривилегированном LXC
|
||||||
|
provisioner: tofu # tofu/svc-docker-test.tf, переезд 2026-09-02 (migration-tofu.md)
|
||||||
|
lxc:
|
||||||
|
cores: 1
|
||||||
|
memory: 512
|
||||||
|
swap: 512
|
||||||
|
disk: data:8
|
||||||
|
startup: order=50
|
||||||
|
# fuse=1 появился после переезда на Tofu: раньше /dev/fuse пробрасывался
|
||||||
|
# сырыми lxc.* строками через lineinfile, теперь это штатный флаг PVE.
|
||||||
|
# Эффект тот же, запись в pct config другая.
|
||||||
|
features: "fuse=1,keyctl=1,nesting=1"
|
||||||
|
unprivileged: true
|
||||||
|
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
devices: ["/dev/fuse"]
|
||||||
|
ports: []
|
||||||
|
images: [] # используется только hello-world для smoke-теста
|
||||||
|
backup:
|
||||||
|
kind: pbs
|
||||||
|
job: homelab-pbs-daily-cloud
|
||||||
|
schedule: "02:10"
|
||||||
|
storage: pbs
|
||||||
|
monitoring:
|
||||||
|
node_exporter: false # хоста нет в группе monitoring_exporters
|
||||||
|
blackbox: false
|
||||||
|
backup_audit_vmid: true
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
monitoring:
|
||||||
|
vmid: 155
|
||||||
|
node: cloud-pc
|
||||||
|
ip: 192.168.1.30
|
||||||
|
hostname: monitoring
|
||||||
|
# До переезда 2026-09-02 контейнер нёс ещё и замороженный стек
|
||||||
|
# Prometheus/Alertmanager/Grafana/blackbox/pve-exporter. В новый контейнер
|
||||||
|
# он сознательно не разворачивался (migration-tofu.md, п.5.5), поэтому
|
||||||
|
# фактически здесь работает только Uptime Kuma. Возврат стека — отдельное
|
||||||
|
# architecture decision, см. legacy-warning.md.
|
||||||
|
role: Uptime Kuma (замороженный стек Prometheus больше не развёрнут)
|
||||||
|
provisioner: tofu # tofu/svc-monitoring.tf, переезд 2026-09-02 (migration-tofu.md)
|
||||||
|
lxc:
|
||||||
|
cores: 2
|
||||||
|
memory: 4096
|
||||||
|
swap: 512
|
||||||
|
disk: data:24
|
||||||
|
# Ресурсы оставлены как были, под замороженный стек. Фактическое
|
||||||
|
# потребление после переезда — 184 МБ RAM и 1.8 ГБ диска. Уменьшение —
|
||||||
|
# отдельное решение, не часть переезда.
|
||||||
|
startup: order=80
|
||||||
|
features: "keyctl=1,nesting=1"
|
||||||
|
unprivileged: true
|
||||||
|
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
# Данные Uptime Kuma лежат в /opt/uptime-kuma, а НЕ в /opt/monitoring:
|
||||||
|
# последний принадлежал замороженному стеку и на новом контейнере
|
||||||
|
# отсутствует.
|
||||||
|
data_dir: /opt/uptime-kuma
|
||||||
|
ports:
|
||||||
|
- {name: grafana, port: 3000, proto: tcp, bind: 192.168.1.30}
|
||||||
|
- {name: pushgateway, port: 9091, proto: tcp, bind: 192.168.1.30}
|
||||||
|
- {name: uptime-kuma, port: 3001, proto: tcp, bind: 192.168.1.30}
|
||||||
|
images:
|
||||||
|
# без digest — так закреплено в roles/monitoring_server/templates/compose.yml.j2
|
||||||
|
- prom/prometheus:v3.2.1
|
||||||
|
- prom/alertmanager:v0.28.0
|
||||||
|
- grafana/grafana:11.5.1
|
||||||
|
- prom/blackbox-exporter:v0.25.0
|
||||||
|
- prompve/prometheus-pve-exporter:3.5.5
|
||||||
|
- prom/pushgateway:v1.11.0
|
||||||
|
- louislam/uptime-kuma:1.23.16@sha256:431fee3be822b04861cf0e35daf4beef6b7cb37391c5f26c3ad6e12ce280fe18
|
||||||
|
runtime: docker-compose-systemd
|
||||||
|
backup:
|
||||||
|
kind: pbs
|
||||||
|
job: homelab-pbs-daily-cloud
|
||||||
|
schedule: "02:10"
|
||||||
|
storage: pbs
|
||||||
|
monitoring:
|
||||||
|
node_exporter: true
|
||||||
|
blackbox: false
|
||||||
|
backup_audit_vmid: true
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
hermes-ai:
|
||||||
|
vmid: 147
|
||||||
|
node: cloud-pc
|
||||||
|
ip: 192.168.1.31
|
||||||
|
hostname: hermes-ai
|
||||||
|
role: Хост под приложение Hermes + прозрачный TUN-прокси через mihomo
|
||||||
|
provisioner: pct_ssh # playbooks/pve-hermes-ai.yml
|
||||||
|
lxc:
|
||||||
|
cores: 2
|
||||||
|
memory: 4096
|
||||||
|
swap: 512
|
||||||
|
disk: data:24
|
||||||
|
startup: order=90
|
||||||
|
features: "nesting=1,keyctl=1"
|
||||||
|
unprivileged: true
|
||||||
|
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
devices: ["/dev/fuse", "/dev/net/tun"]
|
||||||
|
data_dir: /srv/hermes-ai
|
||||||
|
ports: [] # публикация портов запрещена: Docker обходит UFW
|
||||||
|
images:
|
||||||
|
- metacubex/mihomo@sha256:e6acd921addecfd59a8e2d38203f88356d635b54de6c0673db0e015139989312
|
||||||
|
runtime: docker-run-systemd # hermes-ai-tun-proxy.service
|
||||||
|
backup:
|
||||||
|
kind: pbs
|
||||||
|
job: homelab-pbs-daily-cloud
|
||||||
|
schedule: "02:10"
|
||||||
|
storage: pbs
|
||||||
|
monitoring:
|
||||||
|
node_exporter: false # хоста нет в группе monitoring_exporters
|
||||||
|
blackbox: false
|
||||||
|
backup_audit_vmid: true
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
emergency-bot:
|
||||||
|
vmid: 151
|
||||||
|
node: mini-pc
|
||||||
|
ip: 192.168.1.32
|
||||||
|
hostname: emergency-bot
|
||||||
|
role: Telegram-бот аварийного доступа
|
||||||
|
provisioner: tofu # tofu/services.tf, переезд 2026-09-02 (migration-tofu.md)
|
||||||
|
lxc:
|
||||||
|
cores: 1
|
||||||
|
memory: 512
|
||||||
|
swap: 256
|
||||||
|
disk: local-lvm:4
|
||||||
|
startup: order=70
|
||||||
|
features: "nesting=1" # из roles/pve_lxc/defaults
|
||||||
|
unprivileged: true
|
||||||
|
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
ports: []
|
||||||
|
images: [] # роль emergency_bot образы не закрепляет
|
||||||
|
backup:
|
||||||
|
kind: none # VMID 148 отсутствует в pve-backup-jobs.yml — бэкапа нет
|
||||||
|
monitoring:
|
||||||
|
node_exporter: false # хоста нет в группе monitoring_exporters
|
||||||
|
blackbox: false
|
||||||
|
backup_audit_vmid: false
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
grimmory:
|
||||||
|
vmid: 157
|
||||||
|
node: cloud-pc
|
||||||
|
ip: 192.168.1.34
|
||||||
|
hostname: grimmory
|
||||||
|
role: Библиотека книг Grimmory (+ MariaDB), OPDS/KOReader
|
||||||
|
provisioner: tofu # tofu/svc-grimmory.tf, переезд 2026-09-02 (migration-tofu.md)
|
||||||
|
lxc:
|
||||||
|
cores: 2
|
||||||
|
memory: 4096
|
||||||
|
swap: 1024
|
||||||
|
disk: data:64
|
||||||
|
startup: order=100
|
||||||
|
# fuse=1 — штатный флаг PVE вместо прежнего обхода сырыми lxc.* строками.
|
||||||
|
features: "fuse=1,keyctl=1,nesting=1"
|
||||||
|
unprivileged: true
|
||||||
|
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
devices: ["/dev/fuse"]
|
||||||
|
data_dir: /opt/grimmory
|
||||||
|
ports:
|
||||||
|
- {name: http, port: 6060, proto: tcp, bind: 192.168.1.34}
|
||||||
|
images:
|
||||||
|
- grimmory/grimmory:v3.2.4@sha256:dfa7afdfcf25d649fd664497a62385dd00cd9678c37546e182c172e41c8e80cb
|
||||||
|
- lscr.io/linuxserver/mariadb:11.4.8@sha256:91de7f701bc7fc3a424b81beafca7a7c6c4c5b7c8be6afd2ae148698695c0b0c
|
||||||
|
runtime: docker-compose-systemd # grimmory.service + grimmory-docker-firewall.service
|
||||||
|
backup:
|
||||||
|
kind: pbs+restic
|
||||||
|
job: homelab-pbs-daily-cloud
|
||||||
|
schedule: "02:10"
|
||||||
|
storage: pbs
|
||||||
|
restic:
|
||||||
|
profile: grimmory
|
||||||
|
repository: rclone:yadisk:System/Backups/HomeLab/restic/grimmory
|
||||||
|
source_path: /opt/grimmory
|
||||||
|
schedule: "*-*-* 05:15:00"
|
||||||
|
mariadb_container: grimmory-mariadb
|
||||||
|
mariadb_database: grimmory
|
||||||
|
monitoring:
|
||||||
|
node_exporter: true
|
||||||
|
blackbox: true
|
||||||
|
backup_audit_vmid: true
|
||||||
|
proxy:
|
||||||
|
domain: books.ada-dev.ru
|
||||||
|
upstream: 192.168.1.34:6060
|
||||||
|
caddy_marker: "# {mark} ANSIBLE MANAGED GRIMMORY SITE"
|
||||||
|
# Тело секции Caddy целиком: OPDS/KOReader требуют отключить сжатие
|
||||||
|
# и принудительно выставить Accept, иначе читалки не понимают ответ.
|
||||||
|
# Апстрим в теле обязан совпадать с полем `upstream` выше —
|
||||||
|
# reverse-proxy.yml это проверяет assert'ом.
|
||||||
|
caddy_body: |
|
||||||
|
@grimmory_opds_atom path /api/v1/opds /api/v1/opds/libraries /api/v1/opds/shelves /api/v1/opds/magic-shelves /api/v1/opds/authors /api/v1/opds/series /api/v1/opds/catalog /api/v1/opds/recent /api/v1/opds/surprise
|
||||||
|
handle @grimmory_opds_atom {
|
||||||
|
reverse_proxy 192.168.1.34:6060 {
|
||||||
|
header_up Accept "application/atom+xml"
|
||||||
|
header_up Accept-Encoding identity
|
||||||
|
transport http {
|
||||||
|
compression off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@grimmory_opds_search path /api/v1/opds/search.opds
|
||||||
|
handle @grimmory_opds_search {
|
||||||
|
reverse_proxy 192.168.1.34:6060 {
|
||||||
|
header_up Accept "application/opensearchdescription+xml"
|
||||||
|
header_up Accept-Encoding identity
|
||||||
|
transport http {
|
||||||
|
compression off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@grimmory_device_api path /api/koreader /api/koreader/* /api/v1/opds /api/v1/opds/*
|
||||||
|
handle @grimmory_device_api {
|
||||||
|
reverse_proxy 192.168.1.34:6060 {
|
||||||
|
header_up Accept-Encoding identity
|
||||||
|
transport http {
|
||||||
|
compression off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy 192.168.1.34:6060
|
||||||
|
}
|
||||||
|
# Проверка бинд-маунта внутри контейнера caddy: у grimmory ищем маркер
|
||||||
|
# маршрутизации, а не строку reverse_proxy — она встречается многократно.
|
||||||
|
caddy_container_check: "@grimmory_device_api path /api/koreader"
|
||||||
|
# caddy_legacy_regexp отсутствует: в старом reverse-proxy-grimmory.yml
|
||||||
|
# секции удаления legacy-конфига не было.
|
||||||
|
health:
|
||||||
|
path: /api/v1/healthcheck
|
||||||
|
status_code: [200]
|
||||||
|
follow_redirects: safe # поведение ansible.builtin.uri по умолчанию
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
gyro:
|
||||||
|
vmid: 156
|
||||||
|
node: mini-pc
|
||||||
|
ip: 192.168.1.35
|
||||||
|
hostname: gyro
|
||||||
|
role: Изолированный контейнер под задачу gyro (доступ в сеть только через mihomo)
|
||||||
|
provisioner: tofu # tofu/svc-gyro.tf
|
||||||
|
lxc:
|
||||||
|
cores: 1
|
||||||
|
memory: 512
|
||||||
|
swap: 256
|
||||||
|
disk: local-lvm:2
|
||||||
|
startup: order=80
|
||||||
|
features: "" # pve_lxc_features: [] — nesting отключён намеренно
|
||||||
|
unprivileged: true
|
||||||
|
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
firewall: proxmox # /etc/pve/firewall/156.fw, policy_in DROP
|
||||||
|
ports: []
|
||||||
|
images: [] # роль gyro образы не закрепляет
|
||||||
|
backup:
|
||||||
|
kind: pbs
|
||||||
|
job: homelab-pbs-daily-mini
|
||||||
|
schedule: "02:40"
|
||||||
|
storage: pbs
|
||||||
|
monitoring:
|
||||||
|
node_exporter: false # хоста нет в группе monitoring_exporters
|
||||||
|
blackbox: false
|
||||||
|
backup_audit_vmid: true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
# LXC-контейнеры управляются напрямую под root, sudo не требуется.
|
||||||
|
|
||||||
|
ansible_user: root
|
||||||
|
ansible_become: false
|
||||||
|
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
gyro_repo_url: git@github.com:ada-dmitry/t_tech-gyro.git
|
||||||
|
gyro_repo_version: main
|
||||||
|
gyro_git_known_hosts_name: github.com
|
||||||
|
gyro_git_host_key: "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"
|
||||||
|
gyro_deploy_enabled: true
|
||||||
|
gyro_secrets_configured: false
|
||||||
|
gyro_timer_enabled: true
|
||||||
|
gyro_timer_on_calendar: "Mon..Fri *-*-* 11:00:00 Europe/Moscow"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
# Copy to vault.yml, replace the placeholders, then run:
|
||||||
|
# ansible-vault encrypt inventory/host_vars/gyro/vault.yml
|
||||||
|
gyro_tinvest_token: t.replace-me
|
||||||
|
gyro_tinvest_account_id: replace-me
|
||||||
|
gyro_telegram_bot_token: replace-me
|
||||||
|
gyro_telegram_user_id: "replace-me"
|
||||||
|
gyro_telegram_proxy: http://192.168.1.27:7890
|
||||||
|
gyro_dry_run_override: "true"
|
||||||
|
gyro_secrets_configured: true
|
||||||
+70
-41
@@ -1,20 +1,17 @@
|
|||||||
|
# Каноничный список хостов HomeLab.
|
||||||
|
#
|
||||||
|
# Общие переменные: inventory/group_vars/all/main.yml
|
||||||
|
# Переменные LXC: inventory/group_vars/lxc_infra/main.yml
|
||||||
|
# SSH-транспорт: ../ssh_config (подключён через ssh_args в ansible.cfg)
|
||||||
|
#
|
||||||
|
# Здесь у хоста остаются только адрес и его индивидуальные факты.
|
||||||
all:
|
all:
|
||||||
vars:
|
|
||||||
ansible_python_interpreter: /usr/bin/python3
|
|
||||||
ansible_user: ansible
|
|
||||||
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab_ansible
|
|
||||||
ansible_become: true
|
|
||||||
homelab_lan_cidr: 192.168.1.0/24
|
|
||||||
homelab_service_range: 192.168.1.5-192.168.1.40
|
|
||||||
homelab_zerotier_network_id: 743993800fa5a34c
|
|
||||||
openvpn_network_cidr: 10.78.0.0/30
|
|
||||||
openvpn_listen_port: 8443
|
|
||||||
|
|
||||||
children:
|
children:
|
||||||
homelab:
|
homelab:
|
||||||
hosts:
|
hosts:
|
||||||
ru-vps:
|
ru-vps:
|
||||||
ansible_host: vps
|
ansible_host: vps
|
||||||
|
monitoring_exporter_node_listen_address: 127.0.0.1:9100
|
||||||
openvpn_local_ip: 10.78.0.1
|
openvpn_local_ip: 10.78.0.1
|
||||||
openvpn_peer_ip: 10.78.0.2
|
openvpn_peer_ip: 10.78.0.2
|
||||||
openvpn_role: server
|
openvpn_role: server
|
||||||
@@ -23,8 +20,6 @@ all:
|
|||||||
hosts:
|
hosts:
|
||||||
cloud-pc:
|
cloud-pc:
|
||||||
ansible_host: 192.168.1.5
|
ansible_host: 192.168.1.5
|
||||||
ansible_ssh_common_args: >-
|
|
||||||
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
|
|
||||||
expected_lan_ip: 192.168.1.5
|
expected_lan_ip: 192.168.1.5
|
||||||
storage_mounts:
|
storage_mounts:
|
||||||
- src: UUID=ae278333-4116-4225-9b95-595496fadd26
|
- src: UUID=ae278333-4116-4225-9b95-595496fadd26
|
||||||
@@ -36,65 +31,99 @@ all:
|
|||||||
|
|
||||||
mini-pc:
|
mini-pc:
|
||||||
ansible_host: 192.168.1.10
|
ansible_host: 192.168.1.10
|
||||||
ansible_ssh_common_args: >-
|
|
||||||
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
|
|
||||||
expected_lan_ip: 192.168.1.10
|
expected_lan_ip: 192.168.1.10
|
||||||
|
|
||||||
lxc_infra:
|
lxc_infra:
|
||||||
hosts:
|
hosts:
|
||||||
|
# Доступны напрямую по LAN (без ProxyJump через ru-vps)
|
||||||
pbs:
|
pbs:
|
||||||
ansible_host: 192.168.1.20
|
ansible_host: 192.168.1.20
|
||||||
expected_lan_ip: 192.168.1.20
|
expected_lan_ip: 192.168.1.20
|
||||||
|
|
||||||
zt-cloud:
|
ovpn-mini:
|
||||||
ansible_host: 192.168.1.21
|
|
||||||
expected_lan_ip: 192.168.1.21
|
|
||||||
zerotier_ip: 10.122.62.206
|
|
||||||
zerotier_node_id: c606e6d181
|
|
||||||
|
|
||||||
zt-mini:
|
|
||||||
ansible_host: 192.168.1.22
|
|
||||||
expected_lan_ip: 192.168.1.22
|
|
||||||
zerotier_ip: 10.122.62.213
|
|
||||||
zerotier_node_id: 022ac284e1
|
|
||||||
|
|
||||||
wg-mini:
|
|
||||||
ansible_host: 192.168.1.23
|
ansible_host: 192.168.1.23
|
||||||
ansible_user: root
|
|
||||||
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
|
|
||||||
expected_lan_ip: 192.168.1.23
|
expected_lan_ip: 192.168.1.23
|
||||||
openvpn_local_ip: 10.78.0.2
|
openvpn_local_ip: 10.78.0.2
|
||||||
openvpn_peer_ip: 10.78.0.1
|
openvpn_peer_ip: 10.78.0.1
|
||||||
openvpn_role: gateway
|
openvpn_role: gateway
|
||||||
|
|
||||||
|
# Остальные LXC — через ru-vps (см. ssh_config)
|
||||||
|
adguard:
|
||||||
|
ansible_host: 192.168.1.28
|
||||||
|
expected_lan_ip: 192.168.1.28
|
||||||
|
|
||||||
|
docker-test:
|
||||||
|
ansible_host: 192.168.1.29
|
||||||
|
expected_lan_ip: 192.168.1.29
|
||||||
|
|
||||||
vaultwarden:
|
vaultwarden:
|
||||||
ansible_host: 192.168.1.24
|
ansible_host: 192.168.1.24
|
||||||
ansible_user: root
|
|
||||||
ansible_become: false
|
|
||||||
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
|
|
||||||
ansible_ssh_common_args: >-
|
|
||||||
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
|
|
||||||
expected_lan_ip: 192.168.1.24
|
expected_lan_ip: 192.168.1.24
|
||||||
|
|
||||||
gitea:
|
gitea:
|
||||||
ansible_host: 192.168.1.25
|
ansible_host: 192.168.1.25
|
||||||
ansible_user: root
|
|
||||||
ansible_become: false
|
|
||||||
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
|
|
||||||
ansible_ssh_common_args: >-
|
|
||||||
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
|
|
||||||
expected_lan_ip: 192.168.1.25
|
expected_lan_ip: 192.168.1.25
|
||||||
|
|
||||||
|
mihomo:
|
||||||
|
ansible_host: 192.168.1.27
|
||||||
|
expected_lan_ip: 192.168.1.27
|
||||||
|
|
||||||
|
monitoring:
|
||||||
|
ansible_host: 192.168.1.30
|
||||||
|
expected_lan_ip: 192.168.1.30
|
||||||
|
|
||||||
|
hermes-ai:
|
||||||
|
ansible_host: 192.168.1.31
|
||||||
|
expected_lan_ip: 192.168.1.31
|
||||||
|
bash_config_proxy_http_url: http://192.168.1.27:7890
|
||||||
|
bash_config_proxy_socks_url: socks5h://192.168.1.27:7890
|
||||||
|
|
||||||
|
emergency-bot:
|
||||||
|
ansible_host: 192.168.1.32
|
||||||
|
expected_lan_ip: 192.168.1.32
|
||||||
|
|
||||||
|
grimmory:
|
||||||
|
ansible_host: 192.168.1.34
|
||||||
|
expected_lan_ip: 192.168.1.34
|
||||||
|
|
||||||
|
gyro:
|
||||||
|
ansible_host: 192.168.1.35
|
||||||
|
expected_lan_ip: 192.168.1.35
|
||||||
|
|
||||||
|
monitoring_exporters:
|
||||||
|
hosts:
|
||||||
|
ru-vps:
|
||||||
|
cloud-pc:
|
||||||
|
mini-pc:
|
||||||
|
pbs:
|
||||||
|
ovpn-mini:
|
||||||
|
vaultwarden:
|
||||||
|
gitea:
|
||||||
|
mihomo:
|
||||||
|
adguard:
|
||||||
|
monitoring:
|
||||||
|
grimmory:
|
||||||
|
|
||||||
|
monitoring_smart_exporters:
|
||||||
|
hosts:
|
||||||
|
cloud-pc:
|
||||||
|
mini-pc:
|
||||||
|
|
||||||
|
monitoring_server:
|
||||||
|
hosts:
|
||||||
|
monitoring:
|
||||||
|
|
||||||
vpn_openvpn:
|
vpn_openvpn:
|
||||||
hosts:
|
hosts:
|
||||||
ru-vps:
|
ru-vps:
|
||||||
wg-mini:
|
ovpn-mini:
|
||||||
|
|
||||||
shell_hosts:
|
shell_hosts:
|
||||||
hosts:
|
hosts:
|
||||||
ru-vps:
|
ru-vps:
|
||||||
cloud-pc:
|
cloud-pc:
|
||||||
mini-pc:
|
mini-pc:
|
||||||
|
hermes-ai:
|
||||||
|
|
||||||
servers:
|
servers:
|
||||||
children:
|
children:
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
---
|
||||||
|
- name: Create and verify current PBS audit before AdGuard update
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
adguard_vmid: "{{ homelab_services['adguard'].vmid }}"
|
||||||
|
tasks:
|
||||||
|
- name: Verify AdGuard VMID ownership before backup
|
||||||
|
ansible.builtin.command: "pct config {{ adguard_vmid }}"
|
||||||
|
register: adguard_pct_config
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Refuse to back up a foreign AdGuard VMID
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- adguard_pct_config.rc == 0
|
||||||
|
- adguard_existing_hostname == 'adguard'
|
||||||
|
fail_msg: >-
|
||||||
|
VMID {{ adguard_vmid }} from the service registry is not the
|
||||||
|
AdGuard container.
|
||||||
|
vars:
|
||||||
|
adguard_existing_hostname: >-
|
||||||
|
{{ adguard_pct_config.stdout_lines
|
||||||
|
| select('match', '^hostname: ')
|
||||||
|
| map('regex_replace', '^hostname: ', '')
|
||||||
|
| first
|
||||||
|
| default('') }}
|
||||||
|
|
||||||
|
- name: Check for active Proxmox backup before AdGuard PBS backup
|
||||||
|
ansible.builtin.command: pgrep -x vzdump
|
||||||
|
register: adguard_vzdump_preflight
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Require no active Proxmox backup before AdGuard PBS backup
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- adguard_vzdump_preflight.rc != 0
|
||||||
|
fail_msg: >-
|
||||||
|
A Proxmox backup is already running on mini-pc.
|
||||||
|
Retry after the existing backup completes.
|
||||||
|
|
||||||
|
- name: Create a fresh AdGuard PBS backup
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- vzdump
|
||||||
|
- "{{ adguard_vmid }}"
|
||||||
|
- --storage
|
||||||
|
- pbs
|
||||||
|
- --mode
|
||||||
|
- snapshot
|
||||||
|
- --exclude-path
|
||||||
|
- /var/lib/docker/fuse-overlayfs/*/merged
|
||||||
|
|
||||||
|
- name: Run current PBS audit service
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- systemctl
|
||||||
|
- start
|
||||||
|
- --wait
|
||||||
|
- homelab-backup-audit-pbs.service
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- import_playbook: pve-adguard.yml
|
||||||
|
|
||||||
|
- name: Verify AdGuard public services after update
|
||||||
|
hosts: adguard
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Check AdGuard HTTP root
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: http://127.0.0.1/
|
||||||
|
status_code: [200, 302]
|
||||||
|
return_content: false
|
||||||
|
register: adguard_http_root_check
|
||||||
|
retries: 24
|
||||||
|
delay: 5
|
||||||
|
until: adguard_http_root_check.status in [200, 302]
|
||||||
|
|
||||||
|
- name: Check AdGuard DNS over UDP
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- dig
|
||||||
|
- "@127.0.0.1"
|
||||||
|
- localhost
|
||||||
|
- A
|
||||||
|
- +time=2
|
||||||
|
- +tries=1
|
||||||
|
- +short
|
||||||
|
register: adguard_dns_udp_check
|
||||||
|
changed_when: false
|
||||||
|
retries: 12
|
||||||
|
delay: 5
|
||||||
|
until: adguard_dns_udp_check.rc == 0 and '127.0.0.1' in adguard_dns_udp_check.stdout
|
||||||
|
|
||||||
|
- name: Check AdGuard DNS over TCP
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- dig
|
||||||
|
- "@127.0.0.1"
|
||||||
|
- localhost
|
||||||
|
- A
|
||||||
|
- +tcp
|
||||||
|
- +time=2
|
||||||
|
- +tries=1
|
||||||
|
- +short
|
||||||
|
register: adguard_dns_tcp_check
|
||||||
|
changed_when: false
|
||||||
|
retries: 12
|
||||||
|
delay: 5
|
||||||
|
until: adguard_dns_tcp_check.rc == 0 and '127.0.0.1' in adguard_dns_tcp_check.stdout
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
- name: Configure PBS backup audit on mini-pc
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
roles:
|
||||||
|
- role: backup_audit
|
||||||
|
vars:
|
||||||
|
backup_audit_type: pbs
|
||||||
|
|
||||||
|
- name: Configure restic offsite audit on cloud-pc (Gitea)
|
||||||
|
hosts: cloud-pc
|
||||||
|
gather_facts: false
|
||||||
|
roles:
|
||||||
|
- role: backup_audit
|
||||||
|
vars:
|
||||||
|
backup_audit_type: restic
|
||||||
|
backup_audit_restic_profiles:
|
||||||
|
- name: gitea
|
||||||
|
sqlite_name: gitea.db
|
||||||
|
max_age_hours: 36
|
||||||
|
|
||||||
|
- name: Configure restic offsite audit on vaultwarden
|
||||||
|
hosts: vaultwarden
|
||||||
|
gather_facts: false
|
||||||
|
roles:
|
||||||
|
- role: backup_audit
|
||||||
|
vars:
|
||||||
|
backup_audit_type: restic
|
||||||
|
backup_audit_restic_profiles:
|
||||||
|
- name: vaultwarden
|
||||||
|
sqlite_name: db.sqlite3
|
||||||
|
max_age_hours: 36
|
||||||
|
|
||||||
|
- name: Configure restic offsite audit on Grimmory
|
||||||
|
hosts: grimmory
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
roles:
|
||||||
|
- role: backup_audit
|
||||||
|
vars:
|
||||||
|
backup_audit_type: restic
|
||||||
|
backup_audit_restic_profiles:
|
||||||
|
- name: grimmory
|
||||||
|
expected_name: grimmory.sql
|
||||||
|
max_age_hours: 36
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
# ============================================================================
|
||||||
|
# Выпуск read-only Proxmox-токена для виджета Proxmox в Homepage-дашборде.
|
||||||
|
#
|
||||||
|
# Создаёт пользователя homepage@pve, роль PVEAuditor на / и privsep-токен
|
||||||
|
# homepage@pve!dashboard. Секрет пишется в КОРНЕВОЙ .env как
|
||||||
|
# DASHBOARD_PVE_API_USER / DASHBOARD_PVE_API_TOKEN_ID /
|
||||||
|
# DASHBOARD_PVE_API_TOKEN_SECRET — оттуда его читает playbooks/dashboard.yml
|
||||||
|
# через lookup('env', ...).
|
||||||
|
#
|
||||||
|
# Парный к playbooks/bootstrap-monitoring-pve-token.yml. Отдельный принципал,
|
||||||
|
# чтобы дашборд не зависел от кредов замороженного стека Prometheus.
|
||||||
|
#
|
||||||
|
# Запуск: make bootstrap-dashboard-token
|
||||||
|
# ============================================================================
|
||||||
|
- name: Create read-only Proxmox token for the Homepage dashboard
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
dashboard_pve_user: homepage@pve
|
||||||
|
dashboard_pve_token_id: dashboard
|
||||||
|
# .env лежит в корне репозитория, playbook_dir — это ansible/playbooks.
|
||||||
|
dashboard_pve_env_file: "{{ playbook_dir }}/../../.env"
|
||||||
|
dashboard_pve_rotate_existing_token: false
|
||||||
|
tasks:
|
||||||
|
- name: Read existing Proxmox users
|
||||||
|
ansible.builtin.command: pveum user list --output-format json
|
||||||
|
register: dashboard_pve_users_raw
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Create the dashboard Proxmox user
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pveum user add {{ dashboard_pve_user }}
|
||||||
|
--comment 'Read-only Homepage dashboard user'
|
||||||
|
when: dashboard_pve_user not in (dashboard_pve_users_raw.stdout | from_json | map(attribute='userid') | list)
|
||||||
|
|
||||||
|
- name: Grant PVEAuditor role to the dashboard user
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pveum acl modify / -user {{ dashboard_pve_user }} -role PVEAuditor
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Read the dashboard user tokens
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pveum user token list {{ dashboard_pve_user }} --output-format json
|
||||||
|
register: dashboard_pve_tokens_raw
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Refuse to overwrite an existing dashboard token
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- dashboard_pve_token_id not in (dashboard_pve_tokens_raw.stdout | from_json | map(attribute='tokenid') | list)
|
||||||
|
fail_msg: >-
|
||||||
|
Existing dashboard token secret cannot be recovered safely. Rotate it
|
||||||
|
explicitly (-e dashboard_pve_rotate_existing_token=true) before rerunning.
|
||||||
|
when: not dashboard_pve_rotate_existing_token | bool
|
||||||
|
|
||||||
|
- name: Rotate the existing dashboard token explicitly
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pveum user token remove {{ dashboard_pve_user }} {{ dashboard_pve_token_id }}
|
||||||
|
when:
|
||||||
|
- dashboard_pve_rotate_existing_token | bool
|
||||||
|
- dashboard_pve_token_id in (dashboard_pve_tokens_raw.stdout | from_json | map(attribute='tokenid') | list)
|
||||||
|
|
||||||
|
- name: Create the separated dashboard token
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pveum user token add {{ dashboard_pve_user }} {{ dashboard_pve_token_id }}
|
||||||
|
--privsep 1 --comment 'Homepage Proxmox widget' --output-format json
|
||||||
|
register: dashboard_pve_token_created
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Grant PVEAuditor role to the separated dashboard token
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pveum acl modify / -token {{ dashboard_pve_user }}!{{ dashboard_pve_token_id }} -role PVEAuditor
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Store the dashboard token variables in the root .env
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "{{ dashboard_pve_env_file }}"
|
||||||
|
regexp: "^export {{ item.name }}="
|
||||||
|
line: "export {{ item.name }}='{{ item.value }}'"
|
||||||
|
create: false
|
||||||
|
loop:
|
||||||
|
- name: DASHBOARD_PVE_API_USER
|
||||||
|
value: "{{ dashboard_pve_user }}"
|
||||||
|
- name: DASHBOARD_PVE_API_TOKEN_ID
|
||||||
|
value: "{{ dashboard_pve_token_id }}"
|
||||||
|
- name: DASHBOARD_PVE_API_TOKEN_SECRET
|
||||||
|
value: "{{ (dashboard_pve_token_created.stdout | from_json).value }}"
|
||||||
|
delegate_to: localhost
|
||||||
|
vars:
|
||||||
|
ansible_connection: local
|
||||||
|
ansible_become: false
|
||||||
|
no_log: true
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
- name: Create read-only Proxmox token for monitoring
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
monitoring_pve_user: monitoring@pve
|
||||||
|
monitoring_pve_token_id: prometheus
|
||||||
|
# .env лежит в корне репозитория, playbook_dir — это ansible/playbooks.
|
||||||
|
monitoring_pve_env_file: "{{ playbook_dir }}/../../.env"
|
||||||
|
monitoring_pve_rotate_existing_token: false
|
||||||
|
tasks:
|
||||||
|
- name: Read existing Proxmox users
|
||||||
|
ansible.builtin.command: pveum user list --output-format json
|
||||||
|
register: monitoring_pve_users_raw
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Create monitoring Proxmox user
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pveum user add {{ monitoring_pve_user }}
|
||||||
|
--comment 'Read-only Prometheus monitoring user'
|
||||||
|
when: monitoring_pve_user not in (monitoring_pve_users_raw.stdout | from_json | map(attribute='userid') | list)
|
||||||
|
|
||||||
|
- name: Grant PVEAuditor role to monitoring user
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pveum acl modify / -user {{ monitoring_pve_user }} -role PVEAuditor
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Read monitoring user tokens
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pveum user token list {{ monitoring_pve_user }} --output-format json
|
||||||
|
register: monitoring_pve_tokens_raw
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Refuse to overwrite an existing monitoring token
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- monitoring_pve_token_id not in (monitoring_pve_tokens_raw.stdout | from_json | map(attribute='tokenid') | list)
|
||||||
|
fail_msg: Existing monitoring token secret cannot be recovered safely. Rotate it explicitly before rerunning this playbook.
|
||||||
|
when: not monitoring_pve_rotate_existing_token | bool
|
||||||
|
|
||||||
|
- name: Rotate existing monitoring token explicitly
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pveum user token remove {{ monitoring_pve_user }} {{ monitoring_pve_token_id }}
|
||||||
|
when:
|
||||||
|
- monitoring_pve_rotate_existing_token | bool
|
||||||
|
- monitoring_pve_token_id in (monitoring_pve_tokens_raw.stdout | from_json | map(attribute='tokenid') | list)
|
||||||
|
|
||||||
|
- name: Create separated monitoring token
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pveum user token add {{ monitoring_pve_user }} {{ monitoring_pve_token_id }}
|
||||||
|
--privsep 1 --comment 'Prometheus PVE exporter' --output-format json
|
||||||
|
register: monitoring_pve_token_created
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Grant PVEAuditor role to separated monitoring token
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pveum acl modify / -token {{ monitoring_pve_user }}!{{ monitoring_pve_token_id }} -role PVEAuditor
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Store monitoring token variables locally
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "{{ monitoring_pve_env_file }}"
|
||||||
|
regexp: "^export {{ item.name }}="
|
||||||
|
line: "export {{ item.name }}='{{ item.value }}'"
|
||||||
|
create: false
|
||||||
|
loop:
|
||||||
|
- name: MONITORING_PVE_API_USER
|
||||||
|
value: "{{ monitoring_pve_user }}"
|
||||||
|
- name: MONITORING_PVE_API_TOKEN_ID
|
||||||
|
value: "{{ monitoring_pve_token_id }}"
|
||||||
|
- name: MONITORING_PVE_API_TOKEN_SECRET
|
||||||
|
value: "{{ (monitoring_pve_token_created.stdout | from_json).value }}"
|
||||||
|
delegate_to: localhost
|
||||||
|
vars:
|
||||||
|
ansible_connection: local
|
||||||
|
ansible_become: false
|
||||||
|
no_log: true
|
||||||
@@ -37,10 +37,17 @@
|
|||||||
pve_token_data: "{{ pve_token_add.stdout | from_json }}"
|
pve_token_data: "{{ pve_token_add.stdout | from_json }}"
|
||||||
no_log: true
|
no_log: true
|
||||||
|
|
||||||
- name: Write local ansible/.env
|
# ВНИМАНИЕ: задача переписывает файл ЦЕЛИКОМ, а не правит отдельные строки.
|
||||||
|
# Всё, что добавлено вручную или другими целями — MONITORING_*, EMERGENCY_*,
|
||||||
|
# PROXMOX_ROOT_PASSWORD — будет потеряно. Поэтому backup: true.
|
||||||
|
# Строки пишутся с префиксом `export`: по нему bootstrap-monitoring-pve-token.yml
|
||||||
|
# находит их своим lineinfile (regexp: "^export NAME="), и он же позволяет
|
||||||
|
# просто сделать `source .env` в обычном шелле.
|
||||||
|
- name: Write the local .env in the repository root
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: "{{ playbook_dir }}/../.env"
|
dest: "{{ playbook_dir }}/../../.env"
|
||||||
mode: "0600"
|
mode: "0600"
|
||||||
|
backup: true
|
||||||
content: |
|
content: |
|
||||||
export PROXMOX_HOST={{ ansible_host }}
|
export PROXMOX_HOST={{ ansible_host }}
|
||||||
export PROXMOX_USER='{{ pve_api_user }}'
|
export PROXMOX_USER='{{ pve_api_user }}'
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
# ============================================================================
|
||||||
|
# Дашборд-обзор всей инфраструктуры HomeLab (Homepage, gethomepage.dev).
|
||||||
|
#
|
||||||
|
# ОБЛАСТЬ ОТВЕТСТВЕННОСТИ
|
||||||
|
# Разворачивает compose-стек Homepage на LXC monitoring (рядом с Uptime
|
||||||
|
# Kuma) и ГЕНЕРИРУЕТ его конфиги из реестра homelab_services: одна плитка
|
||||||
|
# на сервис, ссылка на его UI, группировка по узлу Proxmox. Добавили
|
||||||
|
# сервис в реестр -> плитка появилась сама, второй список вести не нужно.
|
||||||
|
#
|
||||||
|
# Наружу дашборд НЕ публикуется: контейнер слушает только LAN-адрес CT 155,
|
||||||
|
# доступ из локальной сети или по OpenVPN. Внутренняя топология (IP, VMID,
|
||||||
|
# раскладка по нодам) на публичный периметр не выносится.
|
||||||
|
#
|
||||||
|
# ГРАНИЦА С reverse-proxy.yml
|
||||||
|
# Никакой общей области. Дашборд — отдельный стек на другом хосте, Caddyfile
|
||||||
|
# он не трогает.
|
||||||
|
#
|
||||||
|
# ЗАПУСК
|
||||||
|
# make dashboard (ansible-playbook playbooks/dashboard.yml)
|
||||||
|
# make dry-dashboard (--check --diff)
|
||||||
|
# Первый прогон, пока образ не закреплён по digest:
|
||||||
|
# ansible-playbook playbooks/dashboard.yml -e dashboard_allow_floating_tag=true
|
||||||
|
#
|
||||||
|
# СЕКРЕТЫ
|
||||||
|
# Виджет proxmox требует read-only токен homepage@pve!dashboard (роль
|
||||||
|
# PVEAuditor). Выпускается playbooks/bootstrap-dashboard-pve-token.yml,
|
||||||
|
# секрет кладётся в корневой .env как DASHBOARD_PVE_API_USER /
|
||||||
|
# DASHBOARD_PVE_API_TOKEN_ID / DASHBOARD_PVE_API_TOKEN_SECRET и подхватывается
|
||||||
|
# отсюда через lookup('env', ...). Без него дашборд работает — просто плитка
|
||||||
|
# "Proxmox кластер" показывает ошибку виджета, это не блокер.
|
||||||
|
#
|
||||||
|
# ТАРГЕТ
|
||||||
|
# По умолчанию homelab_dashboard_host (monitoring). Переопределяется ради
|
||||||
|
# blue-green: `-e dashboard_config_target=monitoring-new --limit monitoring-new`
|
||||||
|
# (голый --limit play не перенацеливает, а обнуляет — см. uptime-kuma.yml).
|
||||||
|
# ============================================================================
|
||||||
|
- name: Deploy the HomeLab infrastructure dashboard (Homepage) on the monitoring LXC
|
||||||
|
hosts: "{{ dashboard_config_target | default(homelab_dashboard_host | default('monitoring')) }}"
|
||||||
|
gather_facts: true
|
||||||
|
|
||||||
|
vars:
|
||||||
|
dash_root: "{{ homelab_dashboard_dir | default('/opt/homepage') }}"
|
||||||
|
dash_config_dir: "{{ (homelab_dashboard_dir | default('/opt/homepage')) ~ '/config' }}"
|
||||||
|
dash_bind: "{{ homelab_dashboard_bind_ip | default(expected_lan_ip) }}"
|
||||||
|
dash_port: "{{ homelab_dashboard_port | default(8082) }}"
|
||||||
|
dash_image: "{{ homelab_dashboard_image | default('') }}"
|
||||||
|
dash_allowed_hosts: >-
|
||||||
|
{{ homelab_dashboard_allowed_hosts | default(dash_bind ~ ':' ~ dash_port) }}
|
||||||
|
# Список узлов Proxmox, встречающихся в реестре, — по нему строятся группы.
|
||||||
|
dashboard_nodes: >-
|
||||||
|
{{ homelab_services | dict2items | map(attribute='value.node')
|
||||||
|
| unique | sort | list }}
|
||||||
|
# Секрет Proxmox-виджета из окружения (make load .env). Пусто -> плитка
|
||||||
|
# рендерится без данных. no_log на задаче, которая это пишет.
|
||||||
|
dash_pve_user: "{{ lookup('ansible.builtin.env', 'DASHBOARD_PVE_API_USER') }}"
|
||||||
|
dash_pve_token_id: "{{ lookup('ansible.builtin.env', 'DASHBOARD_PVE_API_TOKEN_ID') }}"
|
||||||
|
dash_pve_token_secret: "{{ lookup('ansible.builtin.env', 'DASHBOARD_PVE_API_TOKEN_SECRET') }}"
|
||||||
|
|
||||||
|
pre_tasks:
|
||||||
|
- name: Require the dashboard registry variables
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- dash_image | length > 0
|
||||||
|
- dash_bind | length > 0
|
||||||
|
fail_msg: >-
|
||||||
|
Не заданы homelab_dashboard_* в
|
||||||
|
inventory/group_vars/all/services.yml.
|
||||||
|
|
||||||
|
- name: Require the Homepage image to be pinned by digest
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- "'@sha256:' in dash_image or dashboard_allow_floating_tag | default(false) | bool"
|
||||||
|
fail_msg: >-
|
||||||
|
{{ dash_image }} не закреплён по digest. Выполни на
|
||||||
|
{{ inventory_hostname }}:
|
||||||
|
docker pull {{ dash_image }}
|
||||||
|
docker inspect --format '{{ '{{' }} index .RepoDigests 0 {{ '}}' }}' {{ dash_image }}
|
||||||
|
и пропиши tag@sha256 в homelab_dashboard_image. Разовый обход для
|
||||||
|
первого прогона: -e dashboard_allow_floating_tag=true
|
||||||
|
|
||||||
|
- name: Ensure the Homepage config directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ dash_config_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Render Homepage configuration files from the registry
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: "{{ item.src }}"
|
||||||
|
dest: "{{ dash_config_dir }}/{{ item.dest }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
loop:
|
||||||
|
- {src: homepage-settings.yaml.j2, dest: settings.yaml}
|
||||||
|
- {src: homepage-services.yaml.j2, dest: services.yaml}
|
||||||
|
- {src: homepage-widgets.yaml.j2, dest: widgets.yaml}
|
||||||
|
- {src: homepage-bookmarks.yaml.j2, dest: bookmarks.yaml}
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.dest }}"
|
||||||
|
register: dash_config_files
|
||||||
|
|
||||||
|
- name: Render the Homepage widget secrets file
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: homepage.env.j2
|
||||||
|
dest: "{{ dash_root }}/homepage.env"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
register: dash_env_file
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- role: compose_service
|
||||||
|
compose_service_name: homelab-homepage
|
||||||
|
compose_service_description: HomeLab infrastructure dashboard (Homepage)
|
||||||
|
compose_service_root: "{{ dash_root }}"
|
||||||
|
compose_service_root_mode: "0755"
|
||||||
|
compose_service_compose_file: docker-compose.yml
|
||||||
|
compose_service_compose_template: homepage-compose.yml.j2
|
||||||
|
# | bool обязателен: роль фильтрует триггеры по truthiness, а строка
|
||||||
|
# "False" из "{{ ... is changed }}" тоже истинна.
|
||||||
|
compose_service_restart_triggers:
|
||||||
|
- "{{ (dash_config_files is changed) | bool }}"
|
||||||
|
- "{{ (dash_env_file is changed) | bool }}"
|
||||||
|
compose_service_health_url: "http://{{ dash_bind }}:{{ dash_port }}/"
|
||||||
|
compose_service_health_status: [200]
|
||||||
|
compose_service_health_follow_redirects: none
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
- name: Bootstrap emergency-bot control identity
|
||||||
|
hosts: emergency-bot
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Create emergency-bot control identity
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: emergency_bot
|
||||||
|
tasks_from: bootstrap
|
||||||
|
|
||||||
|
- name: Configure mini-pc reverse SSH client and restricted control path
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
emergency_vps_host_key: "{{ lookup('env', 'EMERGENCY_VPS_HOST_KEY') }}"
|
||||||
|
emergency_bot_control_public_key: "{{ hostvars['emergency-bot'].emergency_bot_control_public_key }}"
|
||||||
|
tasks:
|
||||||
|
- name: Configure mini-pc emergency access client
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: emergency_access
|
||||||
|
tasks_from: client
|
||||||
|
|
||||||
|
- name: Configure ru-vps reverse SSH endpoint
|
||||||
|
hosts: ru-vps
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
emergency_reverse_public_key: "{{ hostvars['mini-pc'].emergency_reverse_public_key }}"
|
||||||
|
tasks:
|
||||||
|
- name: Configure ru-vps emergency access endpoint
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: emergency_access
|
||||||
|
tasks_from: endpoint
|
||||||
|
|
||||||
|
- name: Configure and enable emergency Telegram bot
|
||||||
|
hosts: emergency-bot
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
emergency_bot_token: "{{ lookup('env', 'EMERGENCY_BOT_TOKEN') }}"
|
||||||
|
emergency_bot_allowed_user_ids: "{{ lookup('env', 'EMERGENCY_ALLOWED_USER_IDS') }}"
|
||||||
|
emergency_mini_pc_host_key: "{{ lookup('env', 'EMERGENCY_MINI_PC_HOST_KEY') }}"
|
||||||
|
roles:
|
||||||
|
- role: emergency_bot
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
- name: Create and verify Gitea backup before update
|
||||||
|
hosts: gitea
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Create a fresh Gitea offsite backup
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- systemctl
|
||||||
|
- start
|
||||||
|
- --wait
|
||||||
|
- homelab-restic-offsite-gitea.service
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Run Gitea backup audit
|
||||||
|
hosts: cloud-pc
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Run Gitea offsite backup audit
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- systemctl
|
||||||
|
- start
|
||||||
|
- --wait
|
||||||
|
- homelab-backup-audit-gitea.service
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- import_playbook: pve-gitea.yml
|
||||||
|
vars:
|
||||||
|
pve_provisioning_enabled: false
|
||||||
|
|
||||||
|
- name: Verify Gitea public endpoints after update
|
||||||
|
hosts: ru-vps
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Check Gitea HTTPS endpoint
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: https://git.ada-dev.ru/
|
||||||
|
status_code: 200
|
||||||
|
return_content: false
|
||||||
|
register: gitea_https_check
|
||||||
|
retries: 24
|
||||||
|
delay: 5
|
||||||
|
until: gitea_https_check.status == 200
|
||||||
|
|
||||||
|
- name: Check Gitea SSH port from ru-vps
|
||||||
|
ansible.builtin.wait_for:
|
||||||
|
host: 192.168.1.25
|
||||||
|
port: 2222
|
||||||
|
state: started
|
||||||
|
timeout: 30
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
- name: Create and verify Grimmory backup before update
|
||||||
|
hosts: grimmory
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Create a fresh Grimmory offsite backup
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- systemctl
|
||||||
|
- start
|
||||||
|
- --wait
|
||||||
|
- homelab-restic-offsite-grimmory.service
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Run Grimmory offsite backup audit
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- systemctl
|
||||||
|
- start
|
||||||
|
- --wait
|
||||||
|
- homelab-backup-audit-grimmory.service
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Create a fresh Grimmory PBS backup
|
||||||
|
hosts: cloud-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
grimmory_vmid: "{{ homelab_services['grimmory'].vmid }}"
|
||||||
|
tasks:
|
||||||
|
- name: Read Grimmory LXC config
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- pct
|
||||||
|
- config
|
||||||
|
- "{{ grimmory_vmid }}"
|
||||||
|
register: grimmory_pct_config
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Assert registry VMID belongs to Grimmory
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- grimmory_pct_hostname_line != ""
|
||||||
|
- grimmory_pct_hostname == "grimmory"
|
||||||
|
fail_msg: >-
|
||||||
|
Refusing to run vzdump {{ grimmory_vmid }} because pct config
|
||||||
|
hostname is not grimmory:
|
||||||
|
{{ grimmory_pct_hostname_line | default('missing hostname line') }}
|
||||||
|
vars:
|
||||||
|
grimmory_pct_hostname_line: >-
|
||||||
|
{{ (grimmory_pct_config.stdout_lines | select('match', '^hostname:\\s+') | list | first | default('')) }}
|
||||||
|
grimmory_pct_hostname: >-
|
||||||
|
{{ grimmory_pct_hostname_line | regex_replace('^hostname:\\s*', '') }}
|
||||||
|
|
||||||
|
- name: Check for active Proxmox backup before Grimmory PBS backup
|
||||||
|
ansible.builtin.command: pgrep -x vzdump
|
||||||
|
register: grimmory_vzdump_preflight
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Require no active Proxmox backup before Grimmory PBS backup
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- grimmory_vzdump_preflight.rc != 0
|
||||||
|
fail_msg: >-
|
||||||
|
A Proxmox backup is already running on cloud-pc.
|
||||||
|
Retry after the existing backup completes.
|
||||||
|
|
||||||
|
- name: Create a fresh Grimmory PBS backup
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- vzdump
|
||||||
|
- "{{ grimmory_vmid }}"
|
||||||
|
- --storage
|
||||||
|
- pbs
|
||||||
|
- --mode
|
||||||
|
- snapshot
|
||||||
|
- --exclude-path
|
||||||
|
- /var/lib/docker/fuse-overlayfs/*/merged
|
||||||
|
|
||||||
|
- name: Run PBS backup audit on mini-pc
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Run current PBS backup audit on mini-pc
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- systemctl
|
||||||
|
- start
|
||||||
|
- --wait
|
||||||
|
- homelab-backup-audit-pbs.service
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- import_playbook: pve-grimmory.yml
|
||||||
|
vars:
|
||||||
|
pve_provisioning_enabled: false
|
||||||
|
|
||||||
|
- name: Verify Grimmory public endpoint after update
|
||||||
|
hosts: ru-vps
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Check Grimmory public health endpoint
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: https://books.ada-dev.ru/api/v1/healthcheck
|
||||||
|
status_code: 200
|
||||||
|
return_content: false
|
||||||
|
register: grimmory_public_health
|
||||||
|
retries: 24
|
||||||
|
delay: 5
|
||||||
|
until: grimmory_public_health.status == 200
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
- name: Configure Gyro investment allocator host
|
||||||
|
# Таргет переопределяем ради blue-green переезда на OpenTofu: во время
|
||||||
|
# миграции этот play нужно прогнать против нового контейнера на временном
|
||||||
|
# адресе. Просто `--limit gyro-new` для этого НЕ годится — лимит
|
||||||
|
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
|
||||||
|
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
|
||||||
|
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
|
||||||
|
# ansible-playbook playbooks/gyro.yml \
|
||||||
|
# -e pve_config_target=gyro-new --limit gyro-new
|
||||||
|
# По умолчанию поведение не меняется.
|
||||||
|
hosts: "{{ pve_config_target | default('gyro') }}"
|
||||||
|
gather_facts: true
|
||||||
|
roles:
|
||||||
|
- role: gyro
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
- name: Verify Mihomo PBS audit before update
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
mihomo_vmid: "{{ homelab_services['mihomo'].vmid }}"
|
||||||
|
tasks:
|
||||||
|
- name: Read Mihomo VMID configuration
|
||||||
|
ansible.builtin.command: "pct config {{ mihomo_vmid }}"
|
||||||
|
register: mihomo_pct_config
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Refuse to run backup unless registry VMID is Mihomo
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- mihomo_pct_config.rc == 0
|
||||||
|
- mihomo_update_hostname == 'mihomo'
|
||||||
|
fail_msg: >-
|
||||||
|
VMID {{ mihomo_vmid }} from the service registry must be the Mihomo
|
||||||
|
container before backup.
|
||||||
|
vars:
|
||||||
|
mihomo_update_hostname: >-
|
||||||
|
{{ mihomo_pct_config.stdout_lines
|
||||||
|
| select('match', '^hostname: ')
|
||||||
|
| map('regex_replace', '^hostname: ', '')
|
||||||
|
| first
|
||||||
|
| default('') }}
|
||||||
|
|
||||||
|
- name: Check for active Proxmox backup before Mihomo PBS backup
|
||||||
|
ansible.builtin.command: pgrep -x vzdump
|
||||||
|
register: mihomo_vzdump_preflight
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Require no active Proxmox backup before Mihomo PBS backup
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- mihomo_vzdump_preflight.rc != 0
|
||||||
|
fail_msg: >-
|
||||||
|
A Proxmox backup is already running on mini-pc.
|
||||||
|
Retry after the existing backup completes.
|
||||||
|
|
||||||
|
- name: Create a fresh Mihomo PBS backup
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- vzdump
|
||||||
|
- "{{ mihomo_vmid }}"
|
||||||
|
- --storage
|
||||||
|
- pbs
|
||||||
|
- --mode
|
||||||
|
- snapshot
|
||||||
|
- --exclude-path
|
||||||
|
- /var/lib/docker/fuse-overlayfs/*/merged
|
||||||
|
|
||||||
|
- name: Run current PBS backup audit on mini-pc
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- systemctl
|
||||||
|
- start
|
||||||
|
- --wait
|
||||||
|
- homelab-backup-audit-pbs.service
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- import_playbook: pve-mihomo.yml
|
||||||
|
|
||||||
|
- name: Verify Mihomo after update
|
||||||
|
hosts: mihomo
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Wait for Mihomo proxy TCP ports
|
||||||
|
ansible.builtin.wait_for:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: "{{ item }}"
|
||||||
|
state: started
|
||||||
|
timeout: 120
|
||||||
|
loop:
|
||||||
|
- 7890
|
||||||
|
- 7891
|
||||||
|
- 9090
|
||||||
|
|
||||||
|
- name: Check Mihomo UI endpoint
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: http://127.0.0.1:8080/
|
||||||
|
status_code: 200
|
||||||
|
register: mihomo_ui_health
|
||||||
|
retries: 24
|
||||||
|
delay: 5
|
||||||
|
until: mihomo_ui_health.status == 200
|
||||||
|
|
||||||
|
- name: Check Mihomo controller /version endpoint
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: http://127.0.0.1:9090/version
|
||||||
|
status_code: 200
|
||||||
|
register: mihomo_controller_health
|
||||||
|
retries: 24
|
||||||
|
delay: 5
|
||||||
|
until: mihomo_controller_health.status == 200
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
- name: Configure Prometheus exporters
|
||||||
|
hosts: monitoring_exporters
|
||||||
|
gather_facts: false
|
||||||
|
roles:
|
||||||
|
- role: monitoring_exporter
|
||||||
|
|
||||||
|
- name: Configure SMART exporters on Proxmox nodes
|
||||||
|
hosts: monitoring_smart_exporters
|
||||||
|
gather_facts: false
|
||||||
|
roles:
|
||||||
|
- role: monitoring_exporter
|
||||||
|
monitoring_exporter_install_node: false
|
||||||
|
monitoring_exporter_install_smart: true
|
||||||
|
|
||||||
|
- name: Configure ru-vps external probes
|
||||||
|
hosts: ru-vps
|
||||||
|
gather_facts: false
|
||||||
|
roles:
|
||||||
|
- role: monitoring_blackbox
|
||||||
|
|
||||||
|
- name: Configure central monitoring stack
|
||||||
|
hosts: monitoring_server
|
||||||
|
gather_facts: false
|
||||||
|
roles:
|
||||||
|
- role: monitoring_server
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Профиль выполняется ВНУТРИ контейнера gitea, а не на ноде cloud-pc.
|
||||||
|
# До переезда на OpenTofu (2026-09-02) данные жили в bind mount каталога ноды
|
||||||
|
# /opt/data/gitea, и профиль работал на самой ноде. После переезда данные —
|
||||||
|
# это volume контейнера (mp0 на datastore), снаружи LXC такого пути больше нет,
|
||||||
|
# поэтому профиль переехал внутрь по образцу vaultwarden. Репозиторий restic
|
||||||
|
# тот же, цепочка снапшотов продолжается; изменились только пути внутри них.
|
||||||
|
- name: Configure Gitea offsite backup to Yandex Disk
|
||||||
|
hosts: gitea
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
offsite_profile: gitea
|
||||||
|
offsite_repository: rclone:yadisk:System/Backups/HomeLab/restic/gitea
|
||||||
|
offsite_source_path: /opt/gitea/data
|
||||||
|
offsite_sqlite_db: /opt/gitea/data/gitea/gitea.db
|
||||||
|
offsite_backup_tag: gitea,cloud-pc,yadisk
|
||||||
|
offsite_timer_oncalendar: "*-*-* 04:15:00"
|
||||||
|
offsite_rclone_config_local: ~/.config/rclone/rclone.conf
|
||||||
|
offsite_restic_password_local: "{{ playbook_dir }}/../generated/restic-offsite-password"
|
||||||
|
offsite_excludes:
|
||||||
|
- /opt/gitea/data/gitea/gitea.db
|
||||||
|
- /opt/gitea/data/gitea/gitea.db-shm
|
||||||
|
- /opt/gitea/data/gitea/gitea.db-wal
|
||||||
|
- /opt/gitea/data/gitea/log/**
|
||||||
|
- /opt/gitea/data/gitea/sessions/**
|
||||||
|
- /opt/gitea/data/gitea/queues/**
|
||||||
|
- /opt/gitea/data/gitea/tmp/**
|
||||||
|
# lost+found появился вместе с переходом на volume: это свежая ext4, и
|
||||||
|
# каталог принадлежит uid 0 хоста, что внутри unprivileged LXC видно как
|
||||||
|
# nobody:nogroup и нечитаемо. Без исключения restic отдаёт exit 3
|
||||||
|
# ("at least one source file could not be read") и юнит падает каждую
|
||||||
|
# ночь, хотя снапшот при этом сохраняется. Найдено 2026-09-02.
|
||||||
|
- /opt/gitea/data/lost+found/**
|
||||||
|
tasks:
|
||||||
|
- name: Configure restic offsite profile
|
||||||
|
ansible.builtin.include_tasks: ../tasks/offsite-restic-profile.yml
|
||||||
|
|
||||||
|
- name: Configure Vaultwarden offsite backup to Yandex Disk
|
||||||
|
hosts: vaultwarden
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
offsite_profile: vaultwarden
|
||||||
|
offsite_repository: rclone:yadisk:System/Backups/HomeLab/restic/vaultwarden
|
||||||
|
offsite_source_path: /opt/vaultwarden/data
|
||||||
|
offsite_sqlite_db: /opt/vaultwarden/data/db.sqlite3
|
||||||
|
offsite_backup_tag: vaultwarden,mini-pc,yadisk
|
||||||
|
offsite_timer_oncalendar: "*-*-* 04:45:00"
|
||||||
|
offsite_rclone_config_local: ~/.config/rclone/rclone.conf
|
||||||
|
offsite_restic_password_local: "{{ playbook_dir }}/../generated/restic-offsite-password"
|
||||||
|
offsite_excludes:
|
||||||
|
- /opt/vaultwarden/data/db.sqlite3
|
||||||
|
- /opt/vaultwarden/data/db.sqlite3-shm
|
||||||
|
- /opt/vaultwarden/data/db.sqlite3-wal
|
||||||
|
- /opt/vaultwarden/data/tmp/**
|
||||||
|
tasks:
|
||||||
|
- name: Configure restic offsite profile
|
||||||
|
ansible.builtin.include_tasks: ../tasks/offsite-restic-profile.yml
|
||||||
|
|
||||||
|
- name: Configure Grimmory offsite backup to Yandex Disk
|
||||||
|
hosts: grimmory
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
offsite_profile: grimmory
|
||||||
|
offsite_repository: rclone:yadisk:System/Backups/HomeLab/restic/grimmory
|
||||||
|
offsite_source_path: /opt/grimmory
|
||||||
|
offsite_sqlite_db: ""
|
||||||
|
offsite_mariadb_container: grimmory-mariadb
|
||||||
|
offsite_mariadb_database: grimmory
|
||||||
|
offsite_mariadb_user: grimmory
|
||||||
|
offsite_mariadb_env: /opt/grimmory/.env
|
||||||
|
offsite_mariadb_dump_name: grimmory.sql
|
||||||
|
offsite_backup_tag: grimmory,cloud-pc,yadisk
|
||||||
|
offsite_timer_oncalendar: "*-*-* 05:15:00"
|
||||||
|
offsite_rclone_config_local: ~/.config/rclone/rclone.conf
|
||||||
|
offsite_restic_password_local: "{{ playbook_dir }}/../generated/restic-offsite-password"
|
||||||
|
offsite_excludes:
|
||||||
|
- /opt/grimmory/books/**
|
||||||
|
- /opt/grimmory/bookdrop/**
|
||||||
|
- /opt/grimmory/mariadb/**
|
||||||
|
- /opt/grimmory/backup-staging/**
|
||||||
|
- /opt/grimmory/**/heapdump*.hprof
|
||||||
|
- /opt/grimmory/**/cache/**
|
||||||
|
tasks:
|
||||||
|
- name: Configure restic offsite profile
|
||||||
|
ansible.builtin.include_tasks: ../tasks/offsite-restic-profile.yml
|
||||||
@@ -10,10 +10,22 @@
|
|||||||
ansible.builtin.command: nc -vz -w 5 192.168.1.10 8006
|
ansible.builtin.command: nc -vz -w 5 192.168.1.10 8006
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Check mini-pc SSH through OpenVPN gateway
|
||||||
|
ansible.builtin.command: nc -vz -w 5 192.168.1.10 22
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
- name: Check cloud-pc PVE port through OpenVPN gateway
|
- name: Check cloud-pc PVE port through OpenVPN gateway
|
||||||
ansible.builtin.command: nc -vz -w 5 192.168.1.5 8006
|
ansible.builtin.command: nc -vz -w 5 192.168.1.5 8006
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Check cloud-pc SSH through OpenVPN gateway
|
||||||
|
ansible.builtin.command: nc -vz -w 5 192.168.1.5 22
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
- name: Check PBS port through OpenVPN gateway
|
- name: Check PBS port through OpenVPN gateway
|
||||||
ansible.builtin.command: nc -vz -w 5 192.168.1.20 8007
|
ansible.builtin.command: nc -vz -w 5 192.168.1.20 8007
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Check PBS SSH through OpenVPN gateway
|
||||||
|
ansible.builtin.command: nc -vz -w 5 192.168.1.20 22
|
||||||
|
changed_when: false
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
---
|
||||||
|
- name: Create AdGuard Home LXC on mini-pc
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
# После переезда на OpenTofu (tofu/svc-adguard.tf, VMID 158) этот play —
|
||||||
|
# legacy: он таргетит СТАРЫЙ VMID 144 и его handler сделал бы pct start 144,
|
||||||
|
# подняв остановленный откат на боевом адресе. Пропускаем, пока реестр
|
||||||
|
# говорит provisioner: tofu. Для намеренного legacy rollback:
|
||||||
|
# -e pve_adguard_legacy_provisioning_enabled=true
|
||||||
|
pve_adguard_legacy_provisioning_enabled: >-
|
||||||
|
{{ homelab_services['adguard'].provisioner != 'tofu' }}
|
||||||
|
adguard_vmid: 144
|
||||||
|
adguard_hostname: adguard
|
||||||
|
adguard_ip: 192.168.1.28/24
|
||||||
|
adguard_gateway: 192.168.1.1
|
||||||
|
adguard_rootfs: local-lvm:8
|
||||||
|
adguard_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
adguard_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
|
||||||
|
adguard_image: adguard/adguardhome:v0.107.78@sha256:2c127294fa5f96151d9d3a433fb9d66c17e4d18cf698c2b04372a80e26fdd26f
|
||||||
|
handlers:
|
||||||
|
- name: restart adguard lxc
|
||||||
|
ansible.builtin.shell: "pct stop {{ adguard_vmid }} || true; pct start {{ adguard_vmid }}"
|
||||||
|
changed_when: true
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy AdGuard provisioning after cutover
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_adguard_legacy_provisioning_enabled | bool)
|
||||||
|
tasks:
|
||||||
|
- name: Check if AdGuard LXC exists
|
||||||
|
ansible.builtin.command: "pct config {{ adguard_vmid }}"
|
||||||
|
register: adguard_pct_config
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Refuse to modify a foreign VMID {{ adguard_vmid }}
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- adguard_pct_config.rc != 0 or adguard_existing_hostname == adguard_hostname
|
||||||
|
fail_msg: VMID {{ adguard_vmid }} already exists and is not the AdGuard container.
|
||||||
|
vars:
|
||||||
|
adguard_existing_hostname: >-
|
||||||
|
{{ adguard_pct_config.stdout_lines
|
||||||
|
| select('match', '^hostname: ')
|
||||||
|
| map('regex_replace', '^hostname: ', '')
|
||||||
|
| first
|
||||||
|
| default('') }}
|
||||||
|
|
||||||
|
- name: Install AdGuard LXC SSH public key on PVE host
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /tmp/adguard-lxc.pub
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
content: "{{ lookup('file', adguard_pubkey_file) }}\n"
|
||||||
|
when: adguard_pct_config.rc != 0
|
||||||
|
|
||||||
|
- name: Create AdGuard LXC
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pct create {{ adguard_vmid }} {{ adguard_ostemplate }}
|
||||||
|
--hostname {{ adguard_hostname }}
|
||||||
|
--rootfs {{ adguard_rootfs }}
|
||||||
|
--cores 1
|
||||||
|
--memory 512
|
||||||
|
--swap 512
|
||||||
|
--net0 name=eth0,bridge=vmbr0,gw={{ adguard_gateway }},ip={{ adguard_ip }},firewall=1
|
||||||
|
--nameserver 1.1.1.1
|
||||||
|
--unprivileged 1
|
||||||
|
--features nesting=1,keyctl=1
|
||||||
|
--onboot 1
|
||||||
|
--startup order=40
|
||||||
|
--cmode shell
|
||||||
|
--ssh-public-keys /tmp/adguard-lxc.pub
|
||||||
|
when: adguard_pct_config.rc != 0
|
||||||
|
|
||||||
|
- name: Start AdGuard LXC
|
||||||
|
ansible.builtin.command: "pct start {{ adguard_vmid }}"
|
||||||
|
register: adguard_pct_start
|
||||||
|
changed_when: adguard_pct_start.rc == 0
|
||||||
|
failed_when: adguard_pct_start.rc not in [0, 255]
|
||||||
|
|
||||||
|
- name: Allow FUSE device in AdGuard LXC config
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "/etc/pve/lxc/{{ adguard_vmid }}.conf"
|
||||||
|
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
|
||||||
|
state: present
|
||||||
|
notify: restart adguard lxc
|
||||||
|
|
||||||
|
- name: Bind mount FUSE device in AdGuard LXC config
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "/etc/pve/lxc/{{ adguard_vmid }}.conf"
|
||||||
|
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
|
||||||
|
state: present
|
||||||
|
notify: restart adguard lxc
|
||||||
|
|
||||||
|
- name: Apply pending LXC config changes
|
||||||
|
ansible.builtin.meta: flush_handlers
|
||||||
|
|
||||||
|
- name: Wait for AdGuard SSH through ru-vps
|
||||||
|
ansible.builtin.wait_for_connection:
|
||||||
|
timeout: 120
|
||||||
|
delegate_to: adguard
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
|
||||||
|
- name: Configure AdGuard Home inside LXC
|
||||||
|
# Таргет переопределяем ради blue-green переезда на OpenTofu: во время
|
||||||
|
# миграции этот play нужно прогнать против нового контейнера на временном
|
||||||
|
# адресе. Просто `--limit adguard-new` для этого НЕ годится — лимит
|
||||||
|
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
|
||||||
|
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
|
||||||
|
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
|
||||||
|
# ansible-playbook playbooks/pve-adguard.yml \
|
||||||
|
# -e pve_config_target=adguard-new --limit adguard-new
|
||||||
|
# По умолчанию поведение не меняется.
|
||||||
|
hosts: "{{ pve_config_target | default('adguard') }}"
|
||||||
|
gather_facts: true
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
adguard_image: adguard/adguardhome:v0.107.78@sha256:2c127294fa5f96151d9d3a433fb9d66c17e4d18cf698c2b04372a80e26fdd26f
|
||||||
|
tasks:
|
||||||
|
- name: Install Docker packages
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- docker.io
|
||||||
|
- fuse-overlayfs
|
||||||
|
- ca-certificates
|
||||||
|
- curl
|
||||||
|
- dnsutils
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Ensure Docker config directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/docker
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Configure Docker storage driver for unprivileged LXC
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/docker/daemon.json
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
{
|
||||||
|
"storage-driver": "fuse-overlayfs"
|
||||||
|
}
|
||||||
|
register: adguard_docker_daemon_config
|
||||||
|
|
||||||
|
- name: Enable Docker service
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: docker
|
||||||
|
state: "{{ 'restarted' if adguard_docker_daemon_config.changed else 'started' }}"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- name: Ensure AdGuard data directories exist
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0750"
|
||||||
|
loop:
|
||||||
|
- /opt/adguard/work
|
||||||
|
- /opt/adguard/conf
|
||||||
|
|
||||||
|
- name: Check if the configured AdGuard image is present
|
||||||
|
ansible.builtin.command: "docker image inspect {{ adguard_image }}"
|
||||||
|
register: adguard_image_inspect
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Pull the configured AdGuard image
|
||||||
|
ansible.builtin.command: "docker pull {{ adguard_image }}"
|
||||||
|
when: adguard_image_inspect.rc != 0
|
||||||
|
register: adguard_image_pull
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Install AdGuard Home systemd unit
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/systemd/system/adguard.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=AdGuard Home container
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
ExecStartPre=-/usr/bin/docker rm -f adguard
|
||||||
|
ExecStart=/usr/bin/docker run --rm --name adguard --pull never \
|
||||||
|
-p 53:53/tcp -p 53:53/udp -p 80:80/tcp -p 3000:3000/tcp \
|
||||||
|
-v /opt/adguard/work:/opt/adguardhome/work \
|
||||||
|
-v /opt/adguard/conf:/opt/adguardhome/conf \
|
||||||
|
{{ adguard_image }}
|
||||||
|
ExecStop=/usr/bin/docker stop adguard
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
register: adguard_unit
|
||||||
|
|
||||||
|
- name: Remove obsolete AdGuard post-start validation unit
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/systemd/system/adguard-post-start.service
|
||||||
|
state: absent
|
||||||
|
register: adguard_post_start_unit_removed
|
||||||
|
|
||||||
|
- name: Reload systemd when AdGuard units change
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
daemon_reload: true
|
||||||
|
when: adguard_unit.changed or adguard_post_start_unit_removed.changed
|
||||||
|
|
||||||
|
- name: Enable and start AdGuard Home
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: adguard
|
||||||
|
state: "{{ 'restarted' if adguard_unit.changed or adguard_image_pull.changed else 'started' }}"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- name: Wait for AdGuard HTTP root
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: http://127.0.0.1/
|
||||||
|
status_code: [200, 302]
|
||||||
|
return_content: false
|
||||||
|
register: adguard_http_root
|
||||||
|
retries: 24
|
||||||
|
delay: 5
|
||||||
|
until: adguard_http_root.status in [200, 302]
|
||||||
|
|
||||||
|
- name: Verify AdGuard DNS over UDP
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- dig
|
||||||
|
- "@127.0.0.1"
|
||||||
|
- localhost
|
||||||
|
- A
|
||||||
|
- +time=2
|
||||||
|
- +tries=1
|
||||||
|
- +short
|
||||||
|
register: adguard_dns_udp
|
||||||
|
changed_when: false
|
||||||
|
retries: 12
|
||||||
|
delay: 5
|
||||||
|
until: adguard_dns_udp.rc == 0 and '127.0.0.1' in adguard_dns_udp.stdout
|
||||||
|
|
||||||
|
- name: Verify AdGuard DNS over TCP
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- dig
|
||||||
|
- "@127.0.0.1"
|
||||||
|
- localhost
|
||||||
|
- A
|
||||||
|
- +tcp
|
||||||
|
- +time=2
|
||||||
|
- +tries=1
|
||||||
|
- +short
|
||||||
|
register: adguard_dns_tcp
|
||||||
|
changed_when: false
|
||||||
|
retries: 12
|
||||||
|
delay: 5
|
||||||
|
until: adguard_dns_tcp.rc == 0 and '127.0.0.1' in adguard_dns_tcp.stdout
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Списки VMID выводятся из реестра homelab_services по полю backup.job, а не
|
||||||
|
# перечисляются вручную. Раньше добавление сервиса требовало отдельной правки
|
||||||
|
# здесь, и о ней легко было забыть — CT 148 emergency-bot до сих пор без бэкапа
|
||||||
|
# именно по этой причине.
|
||||||
|
- name: Configure Proxmox backup jobs
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
pve_backup_jobs:
|
||||||
|
- id: homelab-pbs-daily-cloud
|
||||||
|
comment: Daily PBS backup for cloud-pc service containers
|
||||||
|
node: cloud-pc
|
||||||
|
vmid: >-
|
||||||
|
{{ homelab_services.values() | selectattr('backup.job', 'defined')
|
||||||
|
| selectattr('backup.job', 'eq', 'homelab-pbs-daily-cloud')
|
||||||
|
| map(attribute='vmid') | sort | join(',') }}
|
||||||
|
storage: pbs
|
||||||
|
schedule: "02:10"
|
||||||
|
mode: snapshot
|
||||||
|
exclude_path: /var/lib/docker/fuse-overlayfs/*/merged
|
||||||
|
- id: homelab-pbs-daily-mini
|
||||||
|
comment: Daily PBS backup for mini-pc service containers
|
||||||
|
node: mini-pc
|
||||||
|
vmid: >-
|
||||||
|
{{ homelab_services.values() | selectattr('backup.job', 'defined')
|
||||||
|
| selectattr('backup.job', 'eq', 'homelab-pbs-daily-mini')
|
||||||
|
| map(attribute='vmid') | sort | join(',') }}
|
||||||
|
storage: pbs
|
||||||
|
schedule: "02:40"
|
||||||
|
mode: snapshot
|
||||||
|
exclude_path: /var/lib/docker/fuse-overlayfs/*/merged
|
||||||
|
- id: homelab-local-weekly-pbs
|
||||||
|
comment: Weekly local backup for PBS container rootfs/config
|
||||||
|
node: cloud-pc
|
||||||
|
vmid: >-
|
||||||
|
{{ homelab_services.values() | selectattr('backup.job', 'defined')
|
||||||
|
| selectattr('backup.job', 'eq', 'homelab-local-weekly-pbs')
|
||||||
|
| map(attribute='vmid') | sort | join(',') }}
|
||||||
|
storage: backup
|
||||||
|
schedule: "Sun 03:30"
|
||||||
|
mode: snapshot
|
||||||
|
prune_backups: keep-last=2
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Get existing Proxmox backup jobs
|
||||||
|
ansible.builtin.command: pvesh get /cluster/backup --output-format json
|
||||||
|
register: pve_backup_jobs_existing_raw
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Parse existing Proxmox backup jobs
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
pve_backup_jobs_existing: "{{ pve_backup_jobs_existing_raw.stdout | from_json }}"
|
||||||
|
|
||||||
|
- name: Create missing Proxmox backup jobs
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pvesh create /cluster/backup
|
||||||
|
--id {{ item.id }}
|
||||||
|
--enabled 1
|
||||||
|
--node {{ item.node }}
|
||||||
|
--vmid {{ item.vmid }}
|
||||||
|
--storage {{ item.storage }}
|
||||||
|
--schedule {{ item.schedule | quote }}
|
||||||
|
--mode {{ item.mode }}
|
||||||
|
{% if item.prune_backups is defined %}
|
||||||
|
--prune-backups {{ item.prune_backups }} --remove 1
|
||||||
|
{% else %}
|
||||||
|
--remove 0
|
||||||
|
{% endif %}
|
||||||
|
{% if item.exclude_path is defined %}
|
||||||
|
--exclude-path {{ item.exclude_path | quote }}
|
||||||
|
{% endif %}
|
||||||
|
--notes-template {{ '{{' }}guestname{{ '}}' }}
|
||||||
|
--comment {{ item.comment | quote }}
|
||||||
|
loop: "{{ pve_backup_jobs }}"
|
||||||
|
when: item.id not in (pve_backup_jobs_existing | map(attribute='id') | list)
|
||||||
|
|
||||||
|
- name: Update existing Proxmox backup jobs
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pvesh set /cluster/backup/{{ item.id }}
|
||||||
|
--enabled 1
|
||||||
|
--node {{ item.node }}
|
||||||
|
--vmid {{ item.vmid }}
|
||||||
|
--storage {{ item.storage }}
|
||||||
|
--schedule {{ item.schedule | quote }}
|
||||||
|
--mode {{ item.mode }}
|
||||||
|
{% if item.prune_backups is defined %}
|
||||||
|
--prune-backups {{ item.prune_backups }} --remove 1
|
||||||
|
{% else %}
|
||||||
|
--delete prune-backups --remove 0
|
||||||
|
{% endif %}
|
||||||
|
{% if item.exclude_path is defined %}
|
||||||
|
--exclude-path {{ item.exclude_path | quote }}
|
||||||
|
{% else %}
|
||||||
|
--delete exclude-path
|
||||||
|
{% endif %}
|
||||||
|
--notes-template {{ '{{' }}guestname{{ '}}' }}
|
||||||
|
--comment {{ item.comment | quote }}
|
||||||
|
loop: "{{ pve_backup_jobs }}"
|
||||||
|
vars:
|
||||||
|
current_job: >-
|
||||||
|
{{ pve_backup_jobs_existing | selectattr('id', 'equalto', item.id) | first }}
|
||||||
|
when:
|
||||||
|
- item.id in (pve_backup_jobs_existing | map(attribute='id') | list)
|
||||||
|
- >-
|
||||||
|
current_job.enabled | int != 1 or
|
||||||
|
current_job.node != item.node or
|
||||||
|
current_job.vmid | string != item.vmid | string or
|
||||||
|
current_job.storage != item.storage or
|
||||||
|
current_job.schedule != item.schedule or
|
||||||
|
current_job.mode != item.mode or
|
||||||
|
current_job.comment != item.comment or
|
||||||
|
current_job.remove | int != (1 if item.prune_backups is defined else 0) or
|
||||||
|
current_job['exclude-path'] | default([]) != ([item.exclude_path] if item.exclude_path is defined else []) or
|
||||||
|
(item.prune_backups is not defined and current_job['prune-backups'] is defined) or
|
||||||
|
(item.prune_backups is defined and
|
||||||
|
(current_job['prune-backups'] | default({})).get('keep-last', 0) | int !=
|
||||||
|
item.prune_backups | regex_replace('^keep-last=', '') | int)
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Show configured Proxmox backup jobs
|
||||||
|
ansible.builtin.command: pvesh get /cluster/backup --output-format yaml
|
||||||
|
register: pve_backup_jobs_configured
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Print configured Proxmox backup jobs
|
||||||
|
ansible.builtin.debug:
|
||||||
|
var: pve_backup_jobs_configured.stdout_lines
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
---
|
||||||
|
- name: Create Docker test LXC on cloud-pc
|
||||||
|
hosts: cloud-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
# После переезда на OpenTofu (tofu/svc-docker-test.tf, VMID 152) этот play —
|
||||||
|
# legacy: он таргетит СТАРЫЙ VMID 145 и его handler/"Start" сделали бы
|
||||||
|
# pct start 145, подняв остановленный откат на боевом адресе 192.168.1.29.
|
||||||
|
# Пропускаем, пока реестр говорит provisioner: tofu. Для намеренного
|
||||||
|
# legacy rollback: -e pve_docker_test_legacy_provisioning_enabled=true
|
||||||
|
pve_docker_test_legacy_provisioning_enabled: >-
|
||||||
|
{{ homelab_services['docker-test'].provisioner != 'tofu' }}
|
||||||
|
docker_test_vmid: 145
|
||||||
|
docker_test_hostname: docker-test
|
||||||
|
docker_test_ip: 192.168.1.29/24
|
||||||
|
docker_test_gateway: 192.168.1.1
|
||||||
|
docker_test_rootfs: data:8
|
||||||
|
docker_test_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
docker_test_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
|
||||||
|
handlers:
|
||||||
|
- name: restart docker test lxc
|
||||||
|
ansible.builtin.shell: "pct stop {{ docker_test_vmid }} || true; pct start {{ docker_test_vmid }}"
|
||||||
|
changed_when: true
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy docker-test provisioning after cutover
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_docker_test_legacy_provisioning_enabled | bool)
|
||||||
|
tasks:
|
||||||
|
- name: Check if Docker test LXC exists
|
||||||
|
ansible.builtin.command: "pct config {{ docker_test_vmid }}"
|
||||||
|
register: docker_test_pct_config
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Install Docker test LXC SSH public key on PVE host
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /tmp/docker-test-lxc.pub
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
content: "{{ lookup('file', docker_test_pubkey_file) }}\n"
|
||||||
|
when: docker_test_pct_config.rc != 0
|
||||||
|
|
||||||
|
- name: Create Docker test LXC
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pct create {{ docker_test_vmid }} {{ docker_test_ostemplate }}
|
||||||
|
--hostname {{ docker_test_hostname }}
|
||||||
|
--rootfs {{ docker_test_rootfs }}
|
||||||
|
--cores 1
|
||||||
|
--memory 512
|
||||||
|
--swap 512
|
||||||
|
--net0 name=eth0,bridge=vmbr0,gw={{ docker_test_gateway }},ip={{ docker_test_ip }},firewall=1
|
||||||
|
--nameserver 1.1.1.1
|
||||||
|
--unprivileged 1
|
||||||
|
--features nesting=1,keyctl=1
|
||||||
|
--onboot 1
|
||||||
|
--startup order=50
|
||||||
|
--cmode shell
|
||||||
|
--ssh-public-keys /tmp/docker-test-lxc.pub
|
||||||
|
when: docker_test_pct_config.rc != 0
|
||||||
|
|
||||||
|
- name: Start Docker test LXC
|
||||||
|
ansible.builtin.command: "pct start {{ docker_test_vmid }}"
|
||||||
|
register: docker_test_pct_start
|
||||||
|
changed_when: docker_test_pct_start.rc == 0
|
||||||
|
failed_when: docker_test_pct_start.rc not in [0, 255]
|
||||||
|
|
||||||
|
- name: Allow FUSE device in Docker test LXC config
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "/etc/pve/lxc/{{ docker_test_vmid }}.conf"
|
||||||
|
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
|
||||||
|
state: present
|
||||||
|
notify: restart docker test lxc
|
||||||
|
|
||||||
|
- name: Bind mount FUSE device in Docker test LXC config
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "/etc/pve/lxc/{{ docker_test_vmid }}.conf"
|
||||||
|
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
|
||||||
|
state: present
|
||||||
|
notify: restart docker test lxc
|
||||||
|
|
||||||
|
- name: Apply pending LXC config changes
|
||||||
|
ansible.builtin.meta: flush_handlers
|
||||||
|
|
||||||
|
- name: Wait for Docker test SSH through ru-vps
|
||||||
|
ansible.builtin.wait_for_connection:
|
||||||
|
timeout: 120
|
||||||
|
delegate_to: docker-test
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
|
||||||
|
- name: Configure Docker test host
|
||||||
|
# Таргет переопределяем ради blue-green переезда на OpenTofu: во время
|
||||||
|
# миграции этот play нужно прогнать против нового контейнера на временном
|
||||||
|
# адресе. Просто `--limit docker-test-new` для этого НЕ годится — лимит
|
||||||
|
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
|
||||||
|
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
|
||||||
|
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
|
||||||
|
# ansible-playbook playbooks/pve-docker-test.yml \
|
||||||
|
# -e pve_config_target=docker-test-new --limit docker-test-new
|
||||||
|
# По умолчанию поведение не меняется.
|
||||||
|
hosts: "{{ pve_config_target | default('docker-test') }}"
|
||||||
|
gather_facts: true
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
tasks:
|
||||||
|
- name: Install Docker packages
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- docker.io
|
||||||
|
- fuse-overlayfs
|
||||||
|
- ca-certificates
|
||||||
|
- curl
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Ensure Docker config directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/docker
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Configure Docker storage driver for unprivileged LXC
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/docker/daemon.json
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
{
|
||||||
|
"storage-driver": "fuse-overlayfs"
|
||||||
|
}
|
||||||
|
register: docker_test_daemon_config
|
||||||
|
|
||||||
|
- name: Enable Docker service
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: docker
|
||||||
|
state: "{{ 'restarted' if docker_test_daemon_config.changed else 'started' }}"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- name: Verify Docker can run a container
|
||||||
|
ansible.builtin.command: docker run --rm hello-world
|
||||||
|
changed_when: false
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
# После переезда на OpenTofu (tofu/services.tf, VMID 151) обе play здесь —
|
||||||
|
# legacy: они таргетят СТАРЫЙ VMID 148 на боевом адресе 192.168.1.32.
|
||||||
|
# Пропускаем, пока реестр говорит provisioner: tofu. Конфигурация emergency-bot
|
||||||
|
# в этот плейбук не входит — она в playbooks/emergency-access.yml. Для
|
||||||
|
# намеренного legacy rollback: -e pve_emergency_bot_legacy_provisioning_enabled=true
|
||||||
|
|
||||||
|
- name: Create emergency-bot LXC on mini-pc
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
become: false
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
ansible_python_interpreter: "{{ ansible_playbook_python }}"
|
||||||
|
pve_emergency_bot_legacy_provisioning_enabled: >-
|
||||||
|
{{ homelab_services['emergency-bot'].provisioner != 'tofu' }}
|
||||||
|
pve_lxc_vmid: 148
|
||||||
|
pve_lxc_node: mini-pc
|
||||||
|
pve_lxc_hostname: emergency-bot
|
||||||
|
pve_lxc_ip: 192.168.1.32/24
|
||||||
|
pve_lxc_gateway: 192.168.1.1
|
||||||
|
pve_lxc_disk: local-lvm:4
|
||||||
|
pve_lxc_cores: 1
|
||||||
|
pve_lxc_memory: 512
|
||||||
|
pve_lxc_swap: 256
|
||||||
|
pve_lxc_startup: order=70
|
||||||
|
pve_lxc_ostemplate: "{{ lookup('env', 'PVE_LXC_OSTEMPLATE') | default('local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst', true) }}"
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy emergency-bot provisioning after cutover
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_emergency_bot_legacy_provisioning_enabled | bool)
|
||||||
|
roles:
|
||||||
|
- role: pve_lxc
|
||||||
|
|
||||||
|
- name: Wait for emergency-bot SSH
|
||||||
|
hosts: emergency-bot
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
pve_emergency_bot_legacy_provisioning_enabled: >-
|
||||||
|
{{ homelab_services['emergency-bot'].provisioner != 'tofu' }}
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy emergency-bot provisioning after cutover
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_emergency_bot_legacy_provisioning_enabled | bool)
|
||||||
|
tasks:
|
||||||
|
- name: Wait for emergency-bot to accept SSH connections
|
||||||
|
ansible.builtin.wait_for_connection:
|
||||||
|
timeout: 120
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
- name: Create Gitea LXC on cloud-pc
|
- name: Create Gitea LXC on cloud-pc
|
||||||
hosts: cloud-pc
|
hosts: cloud-pc
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy Gitea provisioning for runtime-only calls
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_provisioning_enabled | default(true) | bool)
|
||||||
vars:
|
vars:
|
||||||
gitea_vmid: 141
|
gitea_vmid: 141
|
||||||
gitea_hostname: gitea
|
gitea_hostname: gitea
|
||||||
@@ -25,6 +29,19 @@
|
|||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: false
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Refuse to modify a foreign Gitea VMID
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- gitea_pct_config.rc != 0 or gitea_existing_hostname == 'gitea'
|
||||||
|
fail_msg: VMID {{ gitea_vmid }} already exists and is not the Gitea container.
|
||||||
|
vars:
|
||||||
|
gitea_existing_hostname: >-
|
||||||
|
{{ gitea_pct_config.stdout_lines
|
||||||
|
| select('match', '^hostname: ')
|
||||||
|
| map('regex_replace', '^hostname: ', '')
|
||||||
|
| first
|
||||||
|
| default('') }}
|
||||||
|
|
||||||
- name: Install Gitea LXC SSH public key on PVE host
|
- name: Install Gitea LXC SSH public key on PVE host
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: /tmp/gitea-lxc.pub
|
dest: /tmp/gitea-lxc.pub
|
||||||
@@ -98,12 +115,21 @@
|
|||||||
ansible_become: false
|
ansible_become: false
|
||||||
|
|
||||||
- name: Configure Docker and Gitea inside LXC
|
- name: Configure Docker and Gitea inside LXC
|
||||||
hosts: gitea
|
# Таргет переопределяем ради blue-green переезда на OpenTofu: во время
|
||||||
|
# миграции этот play нужно прогнать против нового контейнера на временном
|
||||||
|
# адресе. Просто `--limit gitea-new` для этого НЕ годится — лимит
|
||||||
|
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
|
||||||
|
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
|
||||||
|
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
|
||||||
|
# ansible-playbook playbooks/pve-gitea.yml \
|
||||||
|
# -e pve_config_target=gitea-new --limit gitea-new
|
||||||
|
# По умолчанию поведение не меняется.
|
||||||
|
hosts: "{{ pve_config_target | default('gitea') }}"
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
vars:
|
vars:
|
||||||
ansible_become: false
|
ansible_become: false
|
||||||
gitea_data_dir: /opt/gitea/data
|
gitea_data_dir: /opt/gitea/data
|
||||||
gitea_image: gitea/gitea:latest
|
gitea_image: gitea/gitea:1.27.1@sha256:b64126cf5c3f4e5f0f231b510bb13715f6cb8e508188b44de90bdb9a04f3055d
|
||||||
gitea_container_name: gitea
|
gitea_container_name: gitea
|
||||||
gitea_http_port: 3000
|
gitea_http_port: 3000
|
||||||
gitea_ssh_port: 2222
|
gitea_ssh_port: 2222
|
||||||
@@ -154,6 +180,18 @@
|
|||||||
group: "1000"
|
group: "1000"
|
||||||
mode: "0750"
|
mode: "0750"
|
||||||
|
|
||||||
|
- name: Check if the configured Gitea image is present
|
||||||
|
ansible.builtin.command: "docker image inspect {{ gitea_image }}"
|
||||||
|
register: gitea_image_inspect
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Pull the configured Gitea image
|
||||||
|
ansible.builtin.command: "docker pull {{ gitea_image }}"
|
||||||
|
when: gitea_image_inspect.rc != 0
|
||||||
|
register: gitea_image_pull
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
- name: Install Gitea systemd unit
|
- name: Install Gitea systemd unit
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: /etc/systemd/system/gitea.service
|
dest: /etc/systemd/system/gitea.service
|
||||||
@@ -172,7 +210,7 @@
|
|||||||
ExecStartPre=-/usr/bin/docker rm -f {{ gitea_container_name }}
|
ExecStartPre=-/usr/bin/docker rm -f {{ gitea_container_name }}
|
||||||
ExecStart=/usr/bin/docker run --rm \
|
ExecStart=/usr/bin/docker run --rm \
|
||||||
--name {{ gitea_container_name }} \
|
--name {{ gitea_container_name }} \
|
||||||
--pull always \
|
--pull never \
|
||||||
-p {{ gitea_http_port }}:3000 \
|
-p {{ gitea_http_port }}:3000 \
|
||||||
-p {{ gitea_ssh_port }}:22 \
|
-p {{ gitea_ssh_port }}:22 \
|
||||||
-v {{ gitea_data_dir }}:/data \
|
-v {{ gitea_data_dir }}:/data \
|
||||||
@@ -193,5 +231,14 @@
|
|||||||
- name: Enable and start Gitea
|
- name: Enable and start Gitea
|
||||||
ansible.builtin.systemd:
|
ansible.builtin.systemd:
|
||||||
name: gitea
|
name: gitea
|
||||||
state: started
|
state: "{{ 'restarted' if gitea_unit.changed or gitea_image_pull.changed else 'started' }}"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
- name: Wait for Gitea HTTP health endpoint
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "http://127.0.0.1:{{ gitea_http_port }}/api/healthz"
|
||||||
|
status_code: 200
|
||||||
|
register: gitea_health
|
||||||
|
retries: 24
|
||||||
|
delay: 5
|
||||||
|
until: gitea_health.status == 200
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
---
|
||||||
|
- name: Guard Grimmory VMID before API updates
|
||||||
|
hosts: cloud-pc
|
||||||
|
gather_facts: false
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy Grimmory provisioning for runtime-only calls
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_provisioning_enabled | default(true) | bool)
|
||||||
|
tasks:
|
||||||
|
- name: Read existing VMID 149 configuration
|
||||||
|
ansible.builtin.command: pct config 149
|
||||||
|
register: grimmory_existing_vmid
|
||||||
|
check_mode: false
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Refuse to modify a foreign VMID 149
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- grimmory_existing_vmid.rc != 0 or grimmory_existing_hostname == 'grimmory'
|
||||||
|
fail_msg: VMID 149 already exists and is not the Grimmory container.
|
||||||
|
vars:
|
||||||
|
grimmory_existing_hostname: >-
|
||||||
|
{{ grimmory_existing_vmid.stdout_lines
|
||||||
|
| select('match', '^hostname: ')
|
||||||
|
| map('regex_replace', '^hostname: ', '')
|
||||||
|
| first
|
||||||
|
| default('') }}
|
||||||
|
|
||||||
|
- name: Create Grimmory LXC on cloud-pc
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
become: false
|
||||||
|
gather_facts: false
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy Grimmory provisioning for runtime-only calls
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_provisioning_enabled | default(true) | bool)
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
ansible_python_interpreter: "{{ ansible_playbook_python }}"
|
||||||
|
pve_lxc_vmid: 149
|
||||||
|
pve_lxc_node: cloud-pc
|
||||||
|
pve_lxc_hostname: grimmory
|
||||||
|
pve_lxc_ip: 192.168.1.34/24
|
||||||
|
pve_lxc_gateway: 192.168.1.1
|
||||||
|
pve_lxc_disk: data:64
|
||||||
|
pve_lxc_cores: 2
|
||||||
|
pve_lxc_memory: 4096
|
||||||
|
pve_lxc_swap: 1024
|
||||||
|
pve_lxc_startup: order=100
|
||||||
|
pve_lxc_unprivileged: true
|
||||||
|
pve_lxc_update: false
|
||||||
|
pve_lxc_features:
|
||||||
|
- nesting=1
|
||||||
|
pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
roles:
|
||||||
|
- role: pve_lxc
|
||||||
|
|
||||||
|
- name: Configure Grimmory LXC devices
|
||||||
|
hosts: cloud-pc
|
||||||
|
gather_facts: false
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy Grimmory provisioning for runtime-only calls
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_provisioning_enabled | default(true) | bool)
|
||||||
|
vars:
|
||||||
|
grimmory_vmid: 149
|
||||||
|
handlers:
|
||||||
|
- name: restart Grimmory LXC
|
||||||
|
ansible.builtin.command: "pct reboot {{ grimmory_vmid }}"
|
||||||
|
changed_when: true
|
||||||
|
tasks:
|
||||||
|
- name: Read Grimmory LXC configuration
|
||||||
|
ansible.builtin.command: "pct config {{ grimmory_vmid }}"
|
||||||
|
register: grimmory_lxc_config
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Require expected Grimmory LXC properties
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- "'unprivileged: 1' in grimmory_lxc_config.stdout"
|
||||||
|
- "'rootfs: data:' in grimmory_lxc_config.stdout"
|
||||||
|
- "'onboot: 1' in grimmory_lxc_config.stdout"
|
||||||
|
|
||||||
|
- name: Configure Grimmory LXC network
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pct set {{ grimmory_vmid }}
|
||||||
|
--net0 name=eth0,bridge=vmbr0,firewall=1,gw=192.168.1.1,ip=192.168.1.34/24
|
||||||
|
vars:
|
||||||
|
grimmory_net0: >-
|
||||||
|
{{ grimmory_lxc_config.stdout_lines
|
||||||
|
| select('match', '^net0: ')
|
||||||
|
| first
|
||||||
|
| default('') }}
|
||||||
|
when: >-
|
||||||
|
'name=eth0' not in grimmory_net0 or
|
||||||
|
'bridge=vmbr0' not in grimmory_net0 or
|
||||||
|
'firewall=1' not in grimmory_net0 or
|
||||||
|
'gw=192.168.1.1' not in grimmory_net0 or
|
||||||
|
'ip=192.168.1.34/24' not in grimmory_net0
|
||||||
|
notify: restart Grimmory LXC
|
||||||
|
|
||||||
|
- name: Enable keyctl for Docker in Grimmory LXC
|
||||||
|
ansible.builtin.command: "pct set {{ grimmory_vmid }} --features nesting=1,keyctl=1"
|
||||||
|
when: "'nesting=1' not in grimmory_lxc_config.stdout or 'keyctl=1' not in grimmory_lxc_config.stdout"
|
||||||
|
notify: restart Grimmory LXC
|
||||||
|
|
||||||
|
- name: Allow FUSE device in Grimmory LXC config
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "/etc/pve/lxc/{{ grimmory_vmid }}.conf"
|
||||||
|
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
|
||||||
|
state: present
|
||||||
|
notify: restart Grimmory LXC
|
||||||
|
|
||||||
|
- name: Bind mount FUSE device in Grimmory LXC config
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "/etc/pve/lxc/{{ grimmory_vmid }}.conf"
|
||||||
|
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
|
||||||
|
state: present
|
||||||
|
notify: restart Grimmory LXC
|
||||||
|
|
||||||
|
- name: Apply pending Grimmory LXC configuration
|
||||||
|
ansible.builtin.meta: flush_handlers
|
||||||
|
|
||||||
|
- name: Wait for Grimmory SSH through ru-vps
|
||||||
|
ansible.builtin.wait_for_connection:
|
||||||
|
timeout: 180
|
||||||
|
delegate_to: grimmory
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
|
||||||
|
- name: Configure Grimmory runtime
|
||||||
|
# Таргет переопределяем ради blue-green переезда на OpenTofu: во время
|
||||||
|
# миграции этот play нужно прогнать против нового контейнера на временном
|
||||||
|
# адресе. Просто `--limit grimmory-new` для этого НЕ годится — лимит
|
||||||
|
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
|
||||||
|
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
|
||||||
|
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
|
||||||
|
# ansible-playbook playbooks/pve-grimmory.yml \
|
||||||
|
# -e pve_config_target=grimmory-new --limit grimmory-new
|
||||||
|
# По умолчанию поведение не меняется.
|
||||||
|
hosts: "{{ pve_config_target | default('grimmory') }}"
|
||||||
|
gather_facts: true
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
grimmory_root: /opt/grimmory
|
||||||
|
grimmory_image: grimmory/grimmory:v3.2.4@sha256:dfa7afdfcf25d649fd664497a62385dd00cd9678c37546e182c172e41c8e80cb
|
||||||
|
grimmory_mariadb_image: lscr.io/linuxserver/mariadb:11.4.8@sha256:91de7f701bc7fc3a424b81beafca7a7c6c4c5b7c8be6afd2ae148698695c0b0c
|
||||||
|
# Адрес, на который биндится порт, на который смотрят правила DOCKER-USER и
|
||||||
|
# по которому проверяется health. Раньше во всех трёх местах был зашит
|
||||||
|
# боевой 192.168.1.34, из-за чего play нельзя было прогнать против другого
|
||||||
|
# контейнера: Docker не биндит чужой адрес и роняет grimmory.service, а
|
||||||
|
# health-check уходил по сети в БОЕВОЙ сервис и давал ложный успех.
|
||||||
|
# expected_lan_ip — уже принятая в репозитории идиома для "адрес этого
|
||||||
|
# хоста в LAN" (её же использует roles/uptime_kuma). На боевом grimmory она
|
||||||
|
# равна 192.168.1.34, поэтому рендер там не меняется. Найдено 2026-09-02
|
||||||
|
# при blue-green переезде на OpenTofu.
|
||||||
|
grimmory_bind_ip: "{{ expected_lan_ip }}"
|
||||||
|
tasks:
|
||||||
|
- name: Install Grimmory runtime packages
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- ca-certificates
|
||||||
|
- curl
|
||||||
|
- docker-compose
|
||||||
|
- docker.io
|
||||||
|
- fuse-overlayfs
|
||||||
|
- mariadb-client
|
||||||
|
- openssl
|
||||||
|
- prometheus-node-exporter
|
||||||
|
- ufw
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Verify FUSE device exists
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /dev/fuse
|
||||||
|
register: grimmory_fuse
|
||||||
|
|
||||||
|
- name: Require FUSE device
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- grimmory_fuse.stat.exists
|
||||||
|
- grimmory_fuse.stat.ischr
|
||||||
|
|
||||||
|
- name: Configure Docker storage driver for unprivileged LXC
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/docker/daemon.json
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
{
|
||||||
|
"storage-driver": "fuse-overlayfs"
|
||||||
|
}
|
||||||
|
register: grimmory_docker_config
|
||||||
|
|
||||||
|
- name: Enable Docker service
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: docker
|
||||||
|
enabled: true
|
||||||
|
state: "{{ 'restarted' if grimmory_docker_config.changed else 'started' }}"
|
||||||
|
|
||||||
|
- name: Allow SSH from the HomeLab LAN
|
||||||
|
community.general.ufw:
|
||||||
|
rule: allow
|
||||||
|
port: "22"
|
||||||
|
proto: tcp
|
||||||
|
src: "{{ homelab_lan_cidr }}"
|
||||||
|
|
||||||
|
- name: Allow SSH from the OpenVPN network
|
||||||
|
community.general.ufw:
|
||||||
|
rule: allow
|
||||||
|
port: "22"
|
||||||
|
proto: tcp
|
||||||
|
src: "{{ openvpn_network_cidr }}"
|
||||||
|
|
||||||
|
- name: Allow Grimmory from the HomeLab LAN
|
||||||
|
community.general.ufw:
|
||||||
|
rule: allow
|
||||||
|
port: "6060"
|
||||||
|
proto: tcp
|
||||||
|
src: "{{ homelab_lan_cidr }}"
|
||||||
|
|
||||||
|
- name: Allow Grimmory from the OpenVPN network
|
||||||
|
community.general.ufw:
|
||||||
|
rule: allow
|
||||||
|
port: "6060"
|
||||||
|
proto: tcp
|
||||||
|
src: "{{ openvpn_network_cidr }}"
|
||||||
|
|
||||||
|
- name: Allow Node Exporter from the monitoring LXC
|
||||||
|
community.general.ufw:
|
||||||
|
rule: allow
|
||||||
|
port: "9100"
|
||||||
|
proto: tcp
|
||||||
|
src: 192.168.1.30
|
||||||
|
|
||||||
|
- name: Enable restrictive Grimmory firewall
|
||||||
|
community.general.ufw:
|
||||||
|
state: enabled
|
||||||
|
policy: deny
|
||||||
|
direction: incoming
|
||||||
|
|
||||||
|
- name: Create Grimmory directories
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item.path }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ item.owner }}"
|
||||||
|
group: "{{ item.group }}"
|
||||||
|
mode: "{{ item.mode }}"
|
||||||
|
loop:
|
||||||
|
- { path: "{{ grimmory_root }}", owner: root, group: root, mode: "0750" }
|
||||||
|
- { path: "{{ grimmory_root }}/data", owner: "1000", group: "1000", mode: "0750" }
|
||||||
|
- { path: "{{ grimmory_root }}/books", owner: "1000", group: "1000", mode: "0750" }
|
||||||
|
- { path: "{{ grimmory_root }}/bookdrop", owner: "1000", group: "1000", mode: "0750" }
|
||||||
|
- { path: "{{ grimmory_root }}/mariadb", owner: "1000", group: "1000", mode: "0750" }
|
||||||
|
- { path: "{{ grimmory_root }}/backup-staging", owner: root, group: root, mode: "0700" }
|
||||||
|
|
||||||
|
- name: Generate Grimmory secrets once
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
if [ -e {{ grimmory_root }}/.env ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
db_password=$(openssl rand -hex 32)
|
||||||
|
root_password=$(openssl rand -hex 32)
|
||||||
|
cat > {{ grimmory_root }}/.env <<EOF
|
||||||
|
TZ=Europe/Moscow
|
||||||
|
APP_USER_ID=1000
|
||||||
|
APP_GROUP_ID=1000
|
||||||
|
DB_USER_ID=1000
|
||||||
|
DB_GROUP_ID=1000
|
||||||
|
DB_PASSWORD=$db_password
|
||||||
|
MYSQL_ROOT_PASSWORD=$root_password
|
||||||
|
EOF
|
||||||
|
printf created
|
||||||
|
args:
|
||||||
|
executable: /bin/sh
|
||||||
|
register: grimmory_secrets
|
||||||
|
changed_when: grimmory_secrets.stdout == 'created'
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Enforce Grimmory secret file permissions
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ grimmory_root }}/.env"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
- name: Install Grimmory environment example
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ grimmory_root }}/.env.example"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
TZ=Europe/Moscow
|
||||||
|
APP_USER_ID=1000
|
||||||
|
APP_GROUP_ID=1000
|
||||||
|
DB_USER_ID=1000
|
||||||
|
DB_GROUP_ID=1000
|
||||||
|
DB_PASSWORD=replace-with-random-password
|
||||||
|
MYSQL_ROOT_PASSWORD=replace-with-separate-random-password
|
||||||
|
|
||||||
|
- name: Install Grimmory Compose configuration
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ grimmory_root }}/compose.yml"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
services:
|
||||||
|
grimmory:
|
||||||
|
image: {{ grimmory_image }}
|
||||||
|
container_name: grimmory
|
||||||
|
environment:
|
||||||
|
USER_ID: "${APP_USER_ID}"
|
||||||
|
GROUP_ID: "${APP_GROUP_ID}"
|
||||||
|
TZ: "${TZ}"
|
||||||
|
DATABASE_URL: jdbc:mariadb://mariadb:3306/grimmory
|
||||||
|
DATABASE_USERNAME: grimmory
|
||||||
|
DATABASE_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
DISK_TYPE: LOCAL
|
||||||
|
ALLOWED_ORIGINS: https://books.ada-dev.ru
|
||||||
|
depends_on:
|
||||||
|
mariadb:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "{{ grimmory_bind_ip }}:6060:6060"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./books:/books
|
||||||
|
- ./bookdrop:/bookdrop
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mariadb:
|
||||||
|
image: {{ grimmory_mariadb_image }}
|
||||||
|
container_name: grimmory-mariadb
|
||||||
|
environment:
|
||||||
|
PUID: "${DB_USER_ID}"
|
||||||
|
PGID: "${DB_GROUP_ID}"
|
||||||
|
TZ: "${TZ}"
|
||||||
|
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
|
||||||
|
MYSQL_DATABASE: grimmory
|
||||||
|
MYSQL_USER: grimmory
|
||||||
|
MYSQL_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
volumes:
|
||||||
|
- ./mariadb:/config
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mariadb-admin", "ping", "-h", "localhost"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
restart: unless-stopped
|
||||||
|
register: grimmory_compose
|
||||||
|
|
||||||
|
- name: Install Docker firewall script
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /usr/local/sbin/grimmory-docker-firewall
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
content: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
iptables -N GRIMMORY-FILTER 2>/dev/null || true
|
||||||
|
iptables -F GRIMMORY-FILTER
|
||||||
|
iptables -A GRIMMORY-FILTER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||||
|
iptables -A GRIMMORY-FILTER -s {{ homelab_lan_cidr }} -p tcp -m conntrack --ctorigdst {{ grimmory_bind_ip }} --ctorigdstport 6060 -j ACCEPT
|
||||||
|
iptables -A GRIMMORY-FILTER -s {{ openvpn_network_cidr }} -p tcp -m conntrack --ctorigdst {{ grimmory_bind_ip }} --ctorigdstport 6060 -j ACCEPT
|
||||||
|
iptables -A GRIMMORY-FILTER -p tcp -m conntrack --ctorigdst {{ grimmory_bind_ip }} --ctorigdstport 6060 -j DROP
|
||||||
|
iptables -A GRIMMORY-FILTER -j RETURN
|
||||||
|
iptables -C DOCKER-USER -j GRIMMORY-FILTER 2>/dev/null || iptables -I DOCKER-USER 1 -j GRIMMORY-FILTER
|
||||||
|
register: grimmory_firewall_script
|
||||||
|
|
||||||
|
- name: Install Docker firewall service
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/systemd/system/grimmory-docker-firewall.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Restrict Grimmory Docker published port
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
Before=grimmory.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/grimmory-docker-firewall
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
register: grimmory_firewall_unit
|
||||||
|
|
||||||
|
- name: Install Grimmory systemd unit
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/systemd/system/grimmory.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Grimmory Compose stack
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target docker.service grimmory-docker-firewall.service
|
||||||
|
Requires=docker.service grimmory-docker-firewall.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
WorkingDirectory={{ grimmory_root }}
|
||||||
|
ExecStart=/usr/bin/docker compose -f {{ grimmory_root }}/compose.yml up -d --remove-orphans
|
||||||
|
ExecStop=/usr/bin/docker compose -f {{ grimmory_root }}/compose.yml down
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
register: grimmory_unit
|
||||||
|
|
||||||
|
- name: Reload systemd for Grimmory units
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
daemon_reload: true
|
||||||
|
when: grimmory_firewall_unit.changed or grimmory_unit.changed
|
||||||
|
|
||||||
|
- name: Enable and apply Docker firewall
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: grimmory-docker-firewall
|
||||||
|
enabled: true
|
||||||
|
state: "{{ 'restarted' if grimmory_firewall_script.changed or grimmory_firewall_unit.changed else 'started' }}"
|
||||||
|
|
||||||
|
- name: Validate Grimmory Compose configuration
|
||||||
|
ansible.builtin.command: docker compose -f {{ grimmory_root }}/compose.yml config --quiet
|
||||||
|
args:
|
||||||
|
chdir: "{{ grimmory_root }}"
|
||||||
|
changed_when: false
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Enable and start Grimmory
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: grimmory
|
||||||
|
enabled: true
|
||||||
|
state: "{{ 'restarted' if grimmory_compose.changed or grimmory_unit.changed else 'started' }}"
|
||||||
|
|
||||||
|
- name: Wait for Grimmory health endpoint
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: http://{{ grimmory_bind_ip }}:6060/api/v1/healthcheck
|
||||||
|
status_code: 200
|
||||||
|
register: grimmory_health
|
||||||
|
retries: 120
|
||||||
|
delay: 5
|
||||||
|
until: grimmory_health.status == 200
|
||||||
|
|
||||||
|
- name: Verify Docker storage driver
|
||||||
|
ansible.builtin.command: docker info --format '{{ "{{" }}.Driver{{ "}}" }}'
|
||||||
|
register: grimmory_docker_driver
|
||||||
|
changed_when: false
|
||||||
|
failed_when: grimmory_docker_driver.stdout != 'fuse-overlayfs'
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
---
|
||||||
|
- name: Guard Gyro VMID before API updates
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
pve_gyro_legacy_provisioning_enabled: "{{ homelab_services['gyro'].provisioner != 'tofu' }}"
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy Gyro provisioning after cutover
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_gyro_legacy_provisioning_enabled | bool)
|
||||||
|
tasks:
|
||||||
|
- name: Read existing VMID 150 configuration
|
||||||
|
ansible.builtin.command: pct config 150
|
||||||
|
register: gyro_existing_vmid
|
||||||
|
check_mode: false
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Refuse to modify a foreign VMID 150
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- gyro_existing_vmid.rc != 0 or gyro_existing_hostname == 'gyro'
|
||||||
|
fail_msg: VMID 150 already exists and is not the Gyro container.
|
||||||
|
vars:
|
||||||
|
gyro_existing_hostname: >-
|
||||||
|
{{ gyro_existing_vmid.stdout_lines
|
||||||
|
| select('match', '^hostname: ')
|
||||||
|
| map('regex_replace', '^hostname: ', '')
|
||||||
|
| first
|
||||||
|
| default('') }}
|
||||||
|
|
||||||
|
- name: Create Gyro LXC on mini-pc
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
become: false
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
pve_gyro_legacy_provisioning_enabled: "{{ homelab_services['gyro'].provisioner != 'tofu' }}"
|
||||||
|
ansible_become: false
|
||||||
|
ansible_python_interpreter: "{{ ansible_playbook_python }}"
|
||||||
|
pve_lxc_vmid: 150
|
||||||
|
pve_lxc_node: mini-pc
|
||||||
|
pve_lxc_hostname: gyro
|
||||||
|
pve_lxc_ip: 192.168.1.35/24
|
||||||
|
pve_lxc_gateway: 192.168.1.1
|
||||||
|
pve_lxc_disk: local-lvm:2
|
||||||
|
pve_lxc_cores: 1
|
||||||
|
pve_lxc_memory: 512
|
||||||
|
pve_lxc_swap: 256
|
||||||
|
pve_lxc_startup: order=80
|
||||||
|
pve_lxc_unprivileged: true
|
||||||
|
pve_lxc_update: false
|
||||||
|
pve_lxc_features: []
|
||||||
|
pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy Gyro provisioning after cutover
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_gyro_legacy_provisioning_enabled | bool)
|
||||||
|
roles:
|
||||||
|
- role: pve_lxc
|
||||||
|
|
||||||
|
- name: Configure Gyro LXC isolation
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
pve_gyro_legacy_provisioning_enabled: "{{ homelab_services['gyro'].provisioner != 'tofu' }}"
|
||||||
|
gyro_vmid: 150
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy Gyro provisioning after cutover
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_gyro_legacy_provisioning_enabled | bool)
|
||||||
|
tasks:
|
||||||
|
- name: Read Gyro LXC configuration
|
||||||
|
ansible.builtin.command: "pct config {{ gyro_vmid }}"
|
||||||
|
register: gyro_lxc_config
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Require expected Gyro LXC properties
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- "'hostname: gyro' in gyro_lxc_config.stdout"
|
||||||
|
- "'unprivileged: 1' in gyro_lxc_config.stdout"
|
||||||
|
- "'rootfs: local-lvm:' in gyro_lxc_config.stdout"
|
||||||
|
- "'onboot: 1' in gyro_lxc_config.stdout"
|
||||||
|
|
||||||
|
- name: Check for an existing Gyro firewall file
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "/etc/pve/firewall/{{ gyro_vmid }}.fw"
|
||||||
|
register: gyro_firewall_file
|
||||||
|
|
||||||
|
- name: Preserve the existing Gyro firewall file
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: "/etc/pve/firewall/{{ gyro_vmid }}.fw"
|
||||||
|
register: gyro_previous_firewall
|
||||||
|
when: gyro_firewall_file.stat.exists
|
||||||
|
|
||||||
|
- name: Apply and verify Gyro firewall
|
||||||
|
block:
|
||||||
|
- name: Render Proxmox firewall for Gyro
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "/tmp/gyro-{{ gyro_vmid }}.fw"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0640"
|
||||||
|
content: |
|
||||||
|
[OPTIONS]
|
||||||
|
enable: 1
|
||||||
|
policy_in: DROP
|
||||||
|
policy_out: ACCEPT
|
||||||
|
|
||||||
|
[RULES]
|
||||||
|
IN ACCEPT -source {{ homelab_lan_cidr }} -p tcp -dport 22 -log nolog
|
||||||
|
IN ACCEPT -source {{ openvpn_network_cidr }} -p tcp -dport 22 -log nolog
|
||||||
|
OUT ACCEPT -dest 192.168.1.27 -p tcp -dport 7890 -log nolog
|
||||||
|
OUT DROP -dest {{ homelab_lan_cidr }} -log nolog
|
||||||
|
register: gyro_rendered_firewall
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Compare rendered and active Gyro firewall
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
cmp -s /tmp/gyro-{{ gyro_vmid }}.fw /etc/pve/firewall/{{ gyro_vmid }}.fw
|
||||||
|
register: gyro_firewall_comparison
|
||||||
|
changed_when: false
|
||||||
|
failed_when: gyro_firewall_comparison.rc not in [0, 1]
|
||||||
|
when: gyro_firewall_file.stat.exists
|
||||||
|
|
||||||
|
- name: Install Proxmox firewall for Gyro
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
cp /tmp/gyro-{{ gyro_vmid }}.fw /etc/pve/firewall/{{ gyro_vmid }}.fw
|
||||||
|
when: not gyro_firewall_file.stat.exists or gyro_firewall_comparison.rc != 0
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Validate Proxmox firewall configuration
|
||||||
|
ansible.builtin.command: pve-firewall compile
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Read cluster firewall options
|
||||||
|
ansible.builtin.command: pvesh get /cluster/firewall/options --output-format json
|
||||||
|
register: gyro_cluster_firewall_options
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Report staged Proxmox firewall state
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >-
|
||||||
|
{{ 'Gyro Proxmox firewall is active.'
|
||||||
|
if (gyro_cluster_firewall_options.stdout | from_json).enable | default(0) | int == 1
|
||||||
|
else 'Gyro Proxmox firewall is staged but inactive because the cluster firewall is disabled; UFW remains the enforced isolation layer.' }}
|
||||||
|
|
||||||
|
- name: Verify SSH remains reachable through the firewall
|
||||||
|
ansible.builtin.wait_for_connection:
|
||||||
|
timeout: 180
|
||||||
|
delegate_to: gyro
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
rescue:
|
||||||
|
- name: Restore the previous Gyro firewall file
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "/tmp/gyro-{{ gyro_vmid }}-previous.fw"
|
||||||
|
content: "{{ gyro_previous_firewall.content | b64decode }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0640"
|
||||||
|
when: gyro_firewall_file.stat.exists
|
||||||
|
|
||||||
|
- name: Reinstall the previous Gyro firewall file
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
cp /tmp/gyro-{{ gyro_vmid }}-previous.fw /etc/pve/firewall/{{ gyro_vmid }}.fw
|
||||||
|
when: gyro_firewall_file.stat.exists
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Remove the failed new Gyro firewall file
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "/etc/pve/firewall/{{ gyro_vmid }}.fw"
|
||||||
|
state: absent
|
||||||
|
when: not gyro_firewall_file.stat.exists
|
||||||
|
|
||||||
|
- name: Recompile restored Proxmox firewall configuration
|
||||||
|
ansible.builtin.command: pve-firewall compile
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Remove temporary Gyro firewall files after rollback
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: absent
|
||||||
|
loop:
|
||||||
|
- "/tmp/gyro-{{ gyro_vmid }}.fw"
|
||||||
|
- "/tmp/gyro-{{ gyro_vmid }}-previous.fw"
|
||||||
|
|
||||||
|
- name: Stop after rolling back the Gyro firewall
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: Gyro firewall validation or SSH reachability failed; the previous firewall state was restored.
|
||||||
|
|
||||||
|
- name: Remove temporary Gyro firewall file
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "/tmp/gyro-{{ gyro_vmid }}.fw"
|
||||||
|
state: absent
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Wait for Gyro SSH
|
||||||
|
hosts: gyro
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Wait for Gyro to accept SSH connections
|
||||||
|
ansible.builtin.wait_for_connection:
|
||||||
|
timeout: 180
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
---
|
||||||
|
- name: Create Hermes AI LXC on cloud-pc
|
||||||
|
hosts: cloud-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
hermes_ai_vmid: 147
|
||||||
|
hermes_ai_hostname: hermes-ai
|
||||||
|
hermes_ai_ip: 192.168.1.31/24
|
||||||
|
hermes_ai_gateway: 192.168.1.1
|
||||||
|
hermes_ai_rootfs: data:24
|
||||||
|
hermes_ai_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
hermes_ai_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
|
||||||
|
tasks:
|
||||||
|
- name: Check if Hermes AI LXC exists
|
||||||
|
ansible.builtin.command: "pct config {{ hermes_ai_vmid }}"
|
||||||
|
register: hermes_ai_pct_config
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Install Hermes AI LXC SSH public key on PVE host
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /tmp/hermes-ai-lxc.pub
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
content: "{{ lookup('file', hermes_ai_pubkey_file) }}\n"
|
||||||
|
when: hermes_ai_pct_config.rc != 0
|
||||||
|
|
||||||
|
- name: Create Hermes AI LXC
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pct create {{ hermes_ai_vmid }} {{ hermes_ai_ostemplate }}
|
||||||
|
--hostname {{ hermes_ai_hostname }}
|
||||||
|
--rootfs {{ hermes_ai_rootfs }}
|
||||||
|
--cores 2
|
||||||
|
--memory 4096
|
||||||
|
--swap 512
|
||||||
|
--net0 name=eth0,bridge=vmbr0,gw={{ hermes_ai_gateway }},ip={{ hermes_ai_ip }},firewall=1
|
||||||
|
--nameserver 1.1.1.1
|
||||||
|
--unprivileged 1
|
||||||
|
--features nesting=1,keyctl=1
|
||||||
|
--onboot 1
|
||||||
|
--startup order=90
|
||||||
|
--cmode shell
|
||||||
|
--ssh-public-keys /tmp/hermes-ai-lxc.pub
|
||||||
|
when: hermes_ai_pct_config.rc != 0
|
||||||
|
|
||||||
|
- name: Start Hermes AI LXC
|
||||||
|
ansible.builtin.command: "pct start {{ hermes_ai_vmid }}"
|
||||||
|
register: hermes_ai_pct_start
|
||||||
|
changed_when: hermes_ai_pct_start.rc == 0
|
||||||
|
failed_when: hermes_ai_pct_start.rc not in [0, 255]
|
||||||
|
|
||||||
|
- name: Configure Hermes AI runtime
|
||||||
|
hosts: cloud-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
hermes_ai_vmid: 147
|
||||||
|
handlers:
|
||||||
|
- name: restart Hermes AI LXC
|
||||||
|
ansible.builtin.command: "pct reboot {{ hermes_ai_vmid }}"
|
||||||
|
changed_when: true
|
||||||
|
tasks:
|
||||||
|
- name: Read Hermes AI LXC configuration
|
||||||
|
ansible.builtin.command: "pct config {{ hermes_ai_vmid }}"
|
||||||
|
register: hermes_ai_lxc_config
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Enable keyctl for Docker in Hermes AI LXC
|
||||||
|
ansible.builtin.command: "pct set {{ hermes_ai_vmid }} --features nesting=1,keyctl=1"
|
||||||
|
when: "'keyctl=1' not in hermes_ai_lxc_config.stdout"
|
||||||
|
notify: restart Hermes AI LXC
|
||||||
|
|
||||||
|
- name: Allow FUSE device in Hermes AI LXC config
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
|
||||||
|
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
|
||||||
|
state: present
|
||||||
|
notify: restart Hermes AI LXC
|
||||||
|
|
||||||
|
- name: Bind mount FUSE device in Hermes AI LXC config
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
|
||||||
|
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
|
||||||
|
state: present
|
||||||
|
notify: restart Hermes AI LXC
|
||||||
|
|
||||||
|
- name: Check TUN device on PVE host
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /dev/net/tun
|
||||||
|
register: hermes_ai_tun_device
|
||||||
|
|
||||||
|
- name: Require TUN device on PVE host
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- hermes_ai_tun_device.stat.exists
|
||||||
|
- hermes_ai_tun_device.stat.ischr
|
||||||
|
fail_msg: /dev/net/tun must exist on cloud-pc before enabling the Hermes AI transparent proxy.
|
||||||
|
|
||||||
|
- name: Allow TUN device in Hermes AI LXC config
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
|
||||||
|
line: "lxc.cgroup2.devices.allow: c 10:200 rwm"
|
||||||
|
state: present
|
||||||
|
notify: restart Hermes AI LXC
|
||||||
|
|
||||||
|
- name: Bind mount TUN device in Hermes AI LXC config
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
|
||||||
|
line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
|
||||||
|
state: present
|
||||||
|
notify: restart Hermes AI LXC
|
||||||
|
|
||||||
|
- name: Apply pending Hermes AI LXC configuration
|
||||||
|
ansible.builtin.meta: flush_handlers
|
||||||
|
|
||||||
|
- name: Wait for Hermes AI SSH through ru-vps
|
||||||
|
ansible.builtin.wait_for_connection:
|
||||||
|
timeout: 120
|
||||||
|
delegate_to: hermes-ai
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
|
||||||
|
- name: Install Hermes AI base runtime
|
||||||
|
hosts: hermes-ai
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
hermes_ai_root: /opt/hermes-ai
|
||||||
|
hermes_ai_state: /srv/hermes-ai
|
||||||
|
hermes_ai_proxy_dir: /opt/hermes-ai/tun-proxy
|
||||||
|
hermes_ai_proxy_image: metacubex/mihomo@sha256:e6acd921addecfd59a8e2d38203f88356d635b54de6c0673db0e015139989312
|
||||||
|
hermes_ai_proxy_name: hermes-ai-tun-proxy
|
||||||
|
tasks:
|
||||||
|
- name: Install Hermes AI runtime packages
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- ca-certificates
|
||||||
|
- curl
|
||||||
|
- docker-compose
|
||||||
|
- docker.io
|
||||||
|
- fuse-overlayfs
|
||||||
|
- git
|
||||||
|
- ufw
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Configure Docker storage driver for unprivileged LXC
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/docker/daemon.json
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
{
|
||||||
|
"storage-driver": "fuse-overlayfs"
|
||||||
|
}
|
||||||
|
register: hermes_ai_docker_config
|
||||||
|
|
||||||
|
- name: Enable Docker service
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: docker
|
||||||
|
enabled: true
|
||||||
|
state: "{{ 'restarted' if hermes_ai_docker_config.changed else 'started' }}"
|
||||||
|
|
||||||
|
- name: Allow SSH only from the HomeLab LAN
|
||||||
|
community.general.ufw:
|
||||||
|
rule: allow
|
||||||
|
port: "22"
|
||||||
|
proto: tcp
|
||||||
|
src: "{{ homelab_lan_cidr }}"
|
||||||
|
|
||||||
|
- name: Allow SSH from the OpenVPN network
|
||||||
|
community.general.ufw:
|
||||||
|
rule: allow
|
||||||
|
port: "22"
|
||||||
|
proto: tcp
|
||||||
|
src: "{{ openvpn_network_cidr }}"
|
||||||
|
|
||||||
|
- name: Enable restrictive Hermes AI firewall
|
||||||
|
community.general.ufw:
|
||||||
|
state: enabled
|
||||||
|
policy: deny
|
||||||
|
direction: incoming
|
||||||
|
|
||||||
|
- name: Create Hermes AI application directories
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0750"
|
||||||
|
loop:
|
||||||
|
- "{{ hermes_ai_root }}"
|
||||||
|
- "{{ hermes_ai_state }}"
|
||||||
|
- "{{ hermes_ai_proxy_dir }}"
|
||||||
|
|
||||||
|
- name: Configure Hermes AI transparent proxy
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ hermes_ai_proxy_dir }}/config.yaml"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0640"
|
||||||
|
content: |
|
||||||
|
ipv6: false
|
||||||
|
|
||||||
|
tun:
|
||||||
|
enable: true
|
||||||
|
stack: system
|
||||||
|
device: hermes-tun
|
||||||
|
auto-route: true
|
||||||
|
auto-redirect: true
|
||||||
|
auto-detect-interface: true
|
||||||
|
route-exclude-address:
|
||||||
|
- 192.168.1.27/32
|
||||||
|
dns-hijack:
|
||||||
|
- any:53
|
||||||
|
|
||||||
|
dns:
|
||||||
|
enable: true
|
||||||
|
enhanced-mode: redir-host
|
||||||
|
nameserver:
|
||||||
|
- https://cloudflare-dns.com/dns-query
|
||||||
|
|
||||||
|
sniffer:
|
||||||
|
enable: true
|
||||||
|
force-dns-mapping: true
|
||||||
|
parse-pure-ip: true
|
||||||
|
|
||||||
|
proxies:
|
||||||
|
- name: mihomo-lan
|
||||||
|
type: socks5
|
||||||
|
server: 192.168.1.27
|
||||||
|
port: 7891
|
||||||
|
|
||||||
|
proxy-groups:
|
||||||
|
- name: PROXY
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- mihomo-lan
|
||||||
|
|
||||||
|
rules:
|
||||||
|
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
|
||||||
|
- IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
|
||||||
|
- IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
|
||||||
|
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
|
||||||
|
- IP-CIDR,169.254.0.0/16,DIRECT,no-resolve
|
||||||
|
- MATCH,PROXY
|
||||||
|
register: hermes_ai_proxy_config
|
||||||
|
|
||||||
|
- name: Wait for Mihomo SOCKS5 upstream
|
||||||
|
ansible.builtin.wait_for:
|
||||||
|
host: 192.168.1.27
|
||||||
|
port: 7891
|
||||||
|
timeout: 15
|
||||||
|
|
||||||
|
- name: Validate Hermes AI transparent proxy config
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
docker run --rm --network none
|
||||||
|
-v {{ hermes_ai_proxy_dir }}:/root/.config/mihomo:ro
|
||||||
|
{{ hermes_ai_proxy_image }} -t -d /root/.config/mihomo
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Install Hermes AI transparent proxy service
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/systemd/system/hermes-ai-tun-proxy.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Hermes AI transparent Mihomo proxy
|
||||||
|
After=docker.service network-online.target
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
ExecStartPre=-/usr/bin/docker rm -f {{ hermes_ai_proxy_name }}
|
||||||
|
ExecStart=/usr/bin/docker run --rm \
|
||||||
|
--name {{ hermes_ai_proxy_name }} \
|
||||||
|
--network host \
|
||||||
|
--cap-drop ALL \
|
||||||
|
--cap-add NET_ADMIN \
|
||||||
|
--security-opt no-new-privileges \
|
||||||
|
--device /dev/net/tun \
|
||||||
|
-v {{ hermes_ai_proxy_dir }}:/root/.config/mihomo:ro \
|
||||||
|
{{ hermes_ai_proxy_image }}
|
||||||
|
ExecStop=/usr/bin/docker stop {{ hermes_ai_proxy_name }}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
register: hermes_ai_proxy_unit
|
||||||
|
|
||||||
|
- name: Reload systemd when transparent proxy service changes
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
daemon_reload: true
|
||||||
|
when: hermes_ai_proxy_unit.changed
|
||||||
|
|
||||||
|
- name: Enable Hermes AI transparent proxy
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: hermes-ai-tun-proxy
|
||||||
|
enabled: true
|
||||||
|
state: "{{ 'restarted' if hermes_ai_proxy_config.changed or hermes_ai_proxy_unit.changed else 'started' }}"
|
||||||
|
|
||||||
|
- name: Check Hermes AI transparent proxy service
|
||||||
|
ansible.builtin.command: systemctl is-active hermes-ai-tun-proxy
|
||||||
|
register: hermes_ai_proxy_status
|
||||||
|
changed_when: false
|
||||||
|
failed_when: hermes_ai_proxy_status.stdout != 'active'
|
||||||
|
|
||||||
|
- name: Check Hermes AI external HTTPS access
|
||||||
|
ansible.builtin.command: curl --fail --silent --show-error --max-time 20 https://api.ipify.org
|
||||||
|
changed_when: false
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Document Hermes AI runtime layout
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ hermes_ai_root }}/README.md"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
# Hermes AI runtime
|
||||||
|
|
||||||
|
Deploy the Hermes application here. Keep runtime state and the real
|
||||||
|
`.env` file in /srv/hermes-ai. Do not commit Telegram or LLM tokens.
|
||||||
|
|
||||||
|
Docker is installed with fuse-overlayfs for this unprivileged LXC.
|
||||||
|
Hermes is a Telegram bot and needs no published Docker ports. Do not
|
||||||
|
use `ports:` or `-p` without adding an explicit firewall policy:
|
||||||
|
Docker port publishing can bypass UFW.
|
||||||
|
|
||||||
|
The hermes-ai-tun-proxy service routes external traffic through
|
||||||
|
Mihomo at 192.168.1.27:7890. LAN traffic remains direct.
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
---
|
||||||
|
- name: Create mihomo LXC on mini-pc
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
# После переезда на OpenTofu (tofu/svc-mihomo.tf, VMID 159) этот play —
|
||||||
|
# legacy: он таргетит СТАРЫЙ VMID 143 и его handler сделал бы pct start 143,
|
||||||
|
# подняв остановленный откат на боевом адресе. Пропускаем, пока реестр
|
||||||
|
# говорит provisioner: tofu. Для намеренного legacy rollback:
|
||||||
|
# -e pve_mihomo_legacy_provisioning_enabled=true
|
||||||
|
pve_mihomo_legacy_provisioning_enabled: >-
|
||||||
|
{{ homelab_services['mihomo'].provisioner != 'tofu' }}
|
||||||
|
mihomo_vmid: 143
|
||||||
|
mihomo_hostname: mihomo
|
||||||
|
mihomo_ip: 192.168.1.27/24
|
||||||
|
mihomo_gateway: 192.168.1.1
|
||||||
|
mihomo_bridge: vmbr0
|
||||||
|
mihomo_rootfs: local-lvm:8
|
||||||
|
mihomo_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
mihomo_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
|
||||||
|
handlers:
|
||||||
|
- name: restart mihomo lxc
|
||||||
|
ansible.builtin.shell: "pct stop {{ mihomo_vmid }} || true; pct start {{ mihomo_vmid }}"
|
||||||
|
changed_when: true
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy mihomo provisioning after cutover
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_mihomo_legacy_provisioning_enabled | bool)
|
||||||
|
tasks:
|
||||||
|
- name: Check if mihomo LXC exists
|
||||||
|
ansible.builtin.command: "pct config {{ mihomo_vmid }}"
|
||||||
|
register: mihomo_pct_config
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Refuse to modify a foreign VMID 143
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- mihomo_pct_config.rc != 0 or mihomo_existing_hostname == mihomo_hostname
|
||||||
|
fail_msg: VMID 143 already exists and is not the Mihomo container.
|
||||||
|
vars:
|
||||||
|
mihomo_existing_hostname: >-
|
||||||
|
{{ mihomo_pct_config.stdout_lines
|
||||||
|
| select('match', '^hostname: ')
|
||||||
|
| map('regex_replace', '^hostname: ', '')
|
||||||
|
| first
|
||||||
|
| default('') }}
|
||||||
|
|
||||||
|
- name: Install mihomo LXC SSH public key on PVE host
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /tmp/mihomo-lxc.pub
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
content: "{{ lookup('file', mihomo_pubkey_file) }}\n"
|
||||||
|
when: mihomo_pct_config.rc != 0
|
||||||
|
|
||||||
|
- name: Create mihomo LXC
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
pct create {{ mihomo_vmid }} {{ mihomo_ostemplate }}
|
||||||
|
--hostname {{ mihomo_hostname }}
|
||||||
|
--rootfs {{ mihomo_rootfs }}
|
||||||
|
--cores 1
|
||||||
|
--memory 512
|
||||||
|
--swap 512
|
||||||
|
--net0 name=eth0,bridge={{ mihomo_bridge }},gw={{ mihomo_gateway }},ip={{ mihomo_ip }},firewall=1
|
||||||
|
--nameserver 1.1.1.1
|
||||||
|
--unprivileged 1
|
||||||
|
--features nesting=1,keyctl=1
|
||||||
|
--onboot 1
|
||||||
|
--startup order=70
|
||||||
|
--cmode shell
|
||||||
|
--ssh-public-keys /tmp/mihomo-lxc.pub
|
||||||
|
when: mihomo_pct_config.rc != 0
|
||||||
|
|
||||||
|
- name: Start mihomo LXC
|
||||||
|
ansible.builtin.command: "pct start {{ mihomo_vmid }}"
|
||||||
|
register: mihomo_pct_start
|
||||||
|
changed_when: mihomo_pct_start.rc == 0
|
||||||
|
failed_when: mihomo_pct_start.rc not in [0, 255]
|
||||||
|
|
||||||
|
- name: Allow FUSE device in mihomo LXC config
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
|
||||||
|
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
|
||||||
|
state: present
|
||||||
|
notify: restart mihomo lxc
|
||||||
|
|
||||||
|
- name: Bind mount FUSE device in mihomo LXC config
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
|
||||||
|
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
|
||||||
|
state: present
|
||||||
|
notify: restart mihomo lxc
|
||||||
|
|
||||||
|
- name: Allow TUN device in mihomo LXC config
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
|
||||||
|
line: "lxc.cgroup2.devices.allow: c 10:200 rwm"
|
||||||
|
state: present
|
||||||
|
notify: restart mihomo lxc
|
||||||
|
|
||||||
|
- name: Bind mount TUN device in mihomo LXC config
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
|
||||||
|
line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
|
||||||
|
state: present
|
||||||
|
notify: restart mihomo lxc
|
||||||
|
|
||||||
|
- name: Apply pending LXC config changes
|
||||||
|
ansible.builtin.meta: flush_handlers
|
||||||
|
|
||||||
|
- name: Wait for mihomo SSH through ru-vps
|
||||||
|
ansible.builtin.wait_for_connection:
|
||||||
|
timeout: 120
|
||||||
|
delegate_to: mihomo
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
|
||||||
|
- name: Prepare mihomo runtime host
|
||||||
|
# Таргет переопределяем ради blue-green переезда на OpenTofu: во время
|
||||||
|
# миграции этот play нужно прогнать против нового контейнера на временном
|
||||||
|
# адресе. Просто `--limit mihomo-new` для этого НЕ годится — лимит
|
||||||
|
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
|
||||||
|
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
|
||||||
|
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
|
||||||
|
# ansible-playbook playbooks/pve-mihomo.yml \
|
||||||
|
# -e pve_config_target=mihomo-new --limit mihomo-new
|
||||||
|
# По умолчанию поведение не меняется.
|
||||||
|
hosts: "{{ pve_config_target | default('mihomo') }}"
|
||||||
|
gather_facts: true
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
mihomo_config_dir: /opt/mihomo/config
|
||||||
|
mihomo_ui_dir: /opt/mihomo/ui
|
||||||
|
mihomo_image: metacubex/mihomo:v1.19.29@sha256:5e7bcc5e7a866afcc8b007ef827c9ba773f2f34b6d7311b6d39ed1751f37cfd5
|
||||||
|
mihomo_ui_image: ghcr.io/metacubex/metacubexd:v1.270.6@sha256:156d55be885d4ba6254d840bd781b715c20c00afee6e6c24c76be4cfe5eb89d4
|
||||||
|
mihomo_container_name: mihomo
|
||||||
|
mihomo_ui_container_name: mihomo-ui
|
||||||
|
tasks:
|
||||||
|
- name: Install Docker and mihomo runtime packages
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- docker.io
|
||||||
|
- fuse-overlayfs
|
||||||
|
- ca-certificates
|
||||||
|
- curl
|
||||||
|
- git
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Ensure Docker config directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/docker
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Configure Docker storage driver for unprivileged LXC
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/docker/daemon.json
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
{
|
||||||
|
"storage-driver": "fuse-overlayfs"
|
||||||
|
}
|
||||||
|
register: docker_daemon_config
|
||||||
|
|
||||||
|
- name: Ensure Docker service is enabled and running
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: docker
|
||||||
|
state: "{{ 'restarted' if docker_daemon_config.changed else 'started' }}"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- name: Ensure mihomo directories exist
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0750"
|
||||||
|
loop:
|
||||||
|
- /opt/mihomo
|
||||||
|
- "{{ mihomo_config_dir }}"
|
||||||
|
- "{{ mihomo_ui_dir }}"
|
||||||
|
|
||||||
|
- name: Check if the configured Mihomo image is present
|
||||||
|
ansible.builtin.command: "docker image inspect {{ mihomo_image }}"
|
||||||
|
register: mihomo_image_inspect
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Pull the configured Mihomo image
|
||||||
|
ansible.builtin.command: "docker pull {{ mihomo_image }}"
|
||||||
|
when: mihomo_image_inspect.rc != 0
|
||||||
|
register: mihomo_image_pull
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Check if the configured Mihomo UI image is present
|
||||||
|
ansible.builtin.command: "docker image inspect {{ mihomo_ui_image }}"
|
||||||
|
register: mihomo_ui_image_inspect
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Pull the configured Mihomo UI image
|
||||||
|
ansible.builtin.command: "docker pull {{ mihomo_ui_image }}"
|
||||||
|
when: mihomo_ui_image_inspect.rc != 0
|
||||||
|
register: mihomo_ui_image_pull
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Install default mihomo config if missing
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ mihomo_config_dir }}/config.yaml"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0640"
|
||||||
|
force: false
|
||||||
|
content: |
|
||||||
|
mixed-port: 7890
|
||||||
|
socks-port: 7891
|
||||||
|
allow-lan: true
|
||||||
|
bind-address: 0.0.0.0
|
||||||
|
mode: rule
|
||||||
|
log-level: info
|
||||||
|
external-controller: 0.0.0.0:9090
|
||||||
|
|
||||||
|
dns:
|
||||||
|
enable: true
|
||||||
|
listen: 0.0.0.0:1053
|
||||||
|
enhanced-mode: fake-ip
|
||||||
|
nameserver:
|
||||||
|
- 1.1.1.1
|
||||||
|
- 8.8.8.8
|
||||||
|
|
||||||
|
proxies: []
|
||||||
|
proxy-groups:
|
||||||
|
- name: PROXY
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- DIRECT
|
||||||
|
|
||||||
|
rules:
|
||||||
|
- MATCH,DIRECT
|
||||||
|
register: mihomo_config
|
||||||
|
|
||||||
|
- name: Install mihomo systemd unit
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/systemd/system/mihomo.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Mihomo proxy container
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
ExecStartPre=-/usr/bin/docker rm -f {{ mihomo_container_name }}
|
||||||
|
ExecStart=/usr/bin/docker run --rm \
|
||||||
|
--name {{ mihomo_container_name }} \
|
||||||
|
--pull never \
|
||||||
|
--cap-add NET_ADMIN \
|
||||||
|
--device /dev/net/tun \
|
||||||
|
-p 7890:7890 \
|
||||||
|
-p 7891:7891 \
|
||||||
|
-p 9090:9090 \
|
||||||
|
-v {{ mihomo_config_dir }}:/root/.config/mihomo \
|
||||||
|
{{ mihomo_image }}
|
||||||
|
ExecStop=/usr/bin/docker stop {{ mihomo_container_name }}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
register: mihomo_unit
|
||||||
|
|
||||||
|
- name: Install mihomo UI systemd unit
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/systemd/system/mihomo-ui.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Mihomo MetaCubeXD web UI container
|
||||||
|
After=docker.service mihomo.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
ExecStartPre=-/usr/bin/docker rm -f {{ mihomo_ui_container_name }}
|
||||||
|
ExecStart=/usr/bin/docker run --rm \
|
||||||
|
--name {{ mihomo_ui_container_name }} \
|
||||||
|
--pull never \
|
||||||
|
-p 8080:80 \
|
||||||
|
{{ mihomo_ui_image }}
|
||||||
|
ExecStop=/usr/bin/docker stop {{ mihomo_ui_container_name }}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
register: mihomo_ui_unit
|
||||||
|
|
||||||
|
- name: Reload systemd when mihomo units change
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
daemon_reload: true
|
||||||
|
when: mihomo_unit.changed or mihomo_ui_unit.changed
|
||||||
|
|
||||||
|
- name: Enable and start mihomo
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: mihomo
|
||||||
|
enabled: true
|
||||||
|
state: "{{ 'restarted' if mihomo_config.changed or mihomo_unit.changed or mihomo_image_pull is changed else 'started' }}"
|
||||||
|
|
||||||
|
- name: Enable and start mihomo UI
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: mihomo-ui
|
||||||
|
enabled: true
|
||||||
|
state: "{{ 'restarted' if mihomo_ui_unit.changed or mihomo_ui_image_pull is changed else 'started' }}"
|
||||||
|
|
||||||
|
- name: Wait for Mihomo proxy TCP ports
|
||||||
|
ansible.builtin.wait_for:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: "{{ item }}"
|
||||||
|
state: started
|
||||||
|
timeout: 120
|
||||||
|
loop:
|
||||||
|
- 7890
|
||||||
|
- 7891
|
||||||
|
- 9090
|
||||||
|
|
||||||
|
- name: Check Mihomo UI endpoint
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: http://127.0.0.1:8080/
|
||||||
|
status_code: 200
|
||||||
|
register: mihomo_ui_health
|
||||||
|
retries: 24
|
||||||
|
delay: 5
|
||||||
|
until: mihomo_ui_health.status == 200
|
||||||
|
|
||||||
|
- name: Check Mihomo controller /version endpoint
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: http://127.0.0.1:9090/version
|
||||||
|
status_code: 200
|
||||||
|
register: mihomo_controller_health
|
||||||
|
retries: 24
|
||||||
|
delay: 5
|
||||||
|
until: mihomo_controller_health.status == 200
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
# После переезда на OpenTofu (tofu/svc-monitoring.tf, VMID 155) обе play здесь —
|
||||||
|
# legacy: они таргетят СТАРЫЙ VMID 146 на боевом адресе 192.168.1.30, а вторая
|
||||||
|
# делает pct reboot 146. Пропускаем, пока реестр говорит provisioner: tofu.
|
||||||
|
# Конфигурация (Uptime Kuma) в этот плейбук не входит — она в
|
||||||
|
# playbooks/uptime-kuma.yml. Замороженный Prometheus-стек в новый контейнер не
|
||||||
|
# разворачивался. Для намеренного legacy rollback:
|
||||||
|
# -e pve_monitoring_legacy_provisioning_enabled=true
|
||||||
|
|
||||||
|
- name: Create monitoring LXC on cloud-pc
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
become: false
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
ansible_python_interpreter: "{{ ansible_playbook_python }}"
|
||||||
|
pve_monitoring_legacy_provisioning_enabled: >-
|
||||||
|
{{ homelab_services['monitoring'].provisioner != 'tofu' }}
|
||||||
|
pve_lxc_vmid: 146
|
||||||
|
pve_lxc_node: cloud-pc
|
||||||
|
pve_lxc_hostname: monitoring
|
||||||
|
pve_lxc_ip: 192.168.1.30/24
|
||||||
|
pve_lxc_gateway: 192.168.1.1
|
||||||
|
pve_lxc_disk: data:24
|
||||||
|
pve_lxc_cores: 2
|
||||||
|
pve_lxc_memory: 4096
|
||||||
|
pve_lxc_swap: 512
|
||||||
|
pve_lxc_startup: order=80
|
||||||
|
pve_lxc_features:
|
||||||
|
- nesting=1
|
||||||
|
pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy monitoring provisioning after cutover
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_monitoring_legacy_provisioning_enabled | bool)
|
||||||
|
roles:
|
||||||
|
- role: pve_lxc
|
||||||
|
|
||||||
|
- name: Enable Docker keyctl feature for monitoring LXC
|
||||||
|
hosts: cloud-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
pve_monitoring_legacy_provisioning_enabled: >-
|
||||||
|
{{ homelab_services['monitoring'].provisioner != 'tofu' }}
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy monitoring provisioning after cutover
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_monitoring_legacy_provisioning_enabled | bool)
|
||||||
|
tasks:
|
||||||
|
- name: Read monitoring LXC configuration
|
||||||
|
ansible.builtin.command: pct config 146
|
||||||
|
register: monitoring_lxc_config
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Enable keyctl for Docker in monitoring LXC
|
||||||
|
ansible.builtin.command: pct set 146 --features nesting=1,keyctl=1
|
||||||
|
when: "'keyctl=1' not in monitoring_lxc_config.stdout"
|
||||||
|
register: monitoring_lxc_keyctl
|
||||||
|
|
||||||
|
- name: Restart monitoring LXC after feature change
|
||||||
|
ansible.builtin.command: pct reboot 146
|
||||||
|
when: monitoring_lxc_keyctl.changed
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
# После переезда на OpenTofu (tofu/svc-ovpn-mini.tf, VMID 160) обе play здесь —
|
||||||
|
# legacy: они таргетят СТАРЫЙ VMID 132, а их handlers сделали бы pct start 132,
|
||||||
|
# подняв остановленный откат на боевом адресе 192.168.1.23. Пропускаем, пока
|
||||||
|
# реестр говорит provisioner: tofu. Конфигурация OpenVPN-шлюза в этот плейбук
|
||||||
|
# никогда не входила — она в playbooks/openvpn-vps-mini.yml (роль
|
||||||
|
# openvpn_gateway, группа vpn_openvpn). Для намеренного legacy rollback:
|
||||||
|
# -e pve_ovpn_mini_legacy_provisioning_enabled=true
|
||||||
|
|
||||||
|
- name: Create ovpn-mini LXC on mini-pc
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
pve_ovpn_mini_legacy_provisioning_enabled: >-
|
||||||
|
{{ homelab_services['ovpn-mini'].provisioner != 'tofu' }}
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy ovpn-mini provisioning after cutover
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_ovpn_mini_legacy_provisioning_enabled | bool)
|
||||||
|
roles:
|
||||||
|
- role: pve_lxc
|
||||||
|
vars:
|
||||||
|
pve_lxc_vmid: 132
|
||||||
|
pve_lxc_node: mini-pc
|
||||||
|
pve_lxc_hostname: ovpn-mini
|
||||||
|
pve_lxc_ip: 192.168.1.23/24
|
||||||
|
pve_lxc_gateway: 192.168.1.1
|
||||||
|
pve_lxc_storage: local-lvm
|
||||||
|
pve_lxc_disk: local-lvm:8
|
||||||
|
pve_lxc_ostemplate: "{{ lookup('env', 'PVE_LXC_OSTEMPLATE') | default('local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst', true) }}"
|
||||||
|
|
||||||
|
- name: Allow TUN device in ovpn-mini LXC config
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
become: true
|
||||||
|
vars:
|
||||||
|
pve_ovpn_mini_legacy_provisioning_enabled: >-
|
||||||
|
{{ homelab_services['ovpn-mini'].provisioner != 'tofu' }}
|
||||||
|
handlers:
|
||||||
|
- name: restart ovpn-mini lxc
|
||||||
|
ansible.builtin.shell: pct stop 132 || true; pct start 132
|
||||||
|
changed_when: true
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy ovpn-mini provisioning after cutover
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_ovpn_mini_legacy_provisioning_enabled | bool)
|
||||||
|
tasks:
|
||||||
|
- name: Allow /dev/net/tun device
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/pve/lxc/132.conf
|
||||||
|
line: "lxc.cgroup2.devices.allow: c 10:200 rwm"
|
||||||
|
state: present
|
||||||
|
notify: restart ovpn-mini lxc
|
||||||
|
|
||||||
|
- name: Bind mount /dev/net/tun
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/pve/lxc/132.conf
|
||||||
|
line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
|
||||||
|
state: present
|
||||||
|
notify: restart ovpn-mini lxc
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
- name: Remove storage-level PBS prune policy from pbs storage
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Read current PBS storage configuration
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- pvesh
|
||||||
|
- get
|
||||||
|
- /storage/pbs
|
||||||
|
- --output-format
|
||||||
|
- json
|
||||||
|
register: pbs_storage_current_raw
|
||||||
|
changed_when: false
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Parse current PBS storage configuration
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
pbs_storage_current: "{{ pbs_storage_current_raw.stdout | from_json }}"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Assert current PBS storage is safe to modify
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- pbs_storage_current.storage | default('') == 'pbs'
|
||||||
|
- pbs_storage_current.type | default('') == 'pbs'
|
||||||
|
- pbs_storage_current.digest | default('') | length > 0
|
||||||
|
fail_msg: Refusing to modify /storage/pbs because the returned storage metadata is unexpected or missing a digest.
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Remove storage-level prune policy from PBS storage
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- pvesh
|
||||||
|
- set
|
||||||
|
- /storage/pbs
|
||||||
|
- --delete
|
||||||
|
- prune-backups
|
||||||
|
- --digest
|
||||||
|
- "{{ pbs_storage_current.digest }}"
|
||||||
|
when: pbs_storage_current.get('prune-backups') is not none
|
||||||
|
changed_when: true
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Read back PBS storage configuration
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- pvesh
|
||||||
|
- get
|
||||||
|
- /storage/pbs
|
||||||
|
- --output-format
|
||||||
|
- json
|
||||||
|
register: pbs_storage_after_raw
|
||||||
|
changed_when: false
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Parse read-back PBS storage configuration
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
pbs_storage_after: "{{ pbs_storage_after_raw.stdout | from_json }}"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Assert storage-level prune policy is absent
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- pbs_storage_after.storage | default('') == 'pbs'
|
||||||
|
- pbs_storage_after.type | default('') == 'pbs'
|
||||||
|
- pbs_storage_after.get('prune-backups') is none
|
||||||
|
fail_msg: Storage-level prune policy still exists on /storage/pbs after the declarative fix.
|
||||||
|
no_log: true
|
||||||
@@ -1,6 +1,38 @@
|
|||||||
|
---
|
||||||
|
- name: Guard Vaultwarden VMID before API updates
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy Vaultwarden provisioning for runtime-only calls
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_provisioning_enabled | default(true) | bool)
|
||||||
|
tasks:
|
||||||
|
- name: Read existing VMID 140 configuration
|
||||||
|
ansible.builtin.command: pct config 140
|
||||||
|
register: vaultwarden_existing_vmid
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Refuse to modify a foreign VMID 140
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- vaultwarden_existing_vmid.rc != 0 or vaultwarden_existing_hostname == 'vaultwarden'
|
||||||
|
fail_msg: VMID 140 already exists and is not the Vaultwarden container.
|
||||||
|
vars:
|
||||||
|
vaultwarden_existing_hostname: >-
|
||||||
|
{{ vaultwarden_existing_vmid.stdout_lines
|
||||||
|
| select('match', '^hostname: ')
|
||||||
|
| map('regex_replace', '^hostname: ', '')
|
||||||
|
| first
|
||||||
|
| default('') }}
|
||||||
|
|
||||||
- name: Create Vaultwarden LXC on mini-pc
|
- name: Create Vaultwarden LXC on mini-pc
|
||||||
hosts: mini-pc
|
hosts: mini-pc
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
|
pre_tasks:
|
||||||
|
- name: Skip legacy Vaultwarden provisioning for runtime-only calls
|
||||||
|
ansible.builtin.meta: end_play
|
||||||
|
when: not (pve_provisioning_enabled | default(true) | bool)
|
||||||
vars:
|
vars:
|
||||||
vaultwarden_vmid: 140
|
vaultwarden_vmid: 140
|
||||||
vaultwarden_hostname: vaultwarden
|
vaultwarden_hostname: vaultwarden
|
||||||
@@ -79,12 +111,21 @@
|
|||||||
ansible_become: false
|
ansible_become: false
|
||||||
|
|
||||||
- name: Configure Docker and Vaultwarden inside LXC
|
- name: Configure Docker and Vaultwarden inside LXC
|
||||||
hosts: vaultwarden
|
# Таргет переопределяем ради blue-green переезда на OpenTofu: во время
|
||||||
|
# миграции этот play нужно прогнать против нового контейнера на временном
|
||||||
|
# адресе. Просто `--limit vaultwarden-new` для этого НЕ годится — лимит
|
||||||
|
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
|
||||||
|
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
|
||||||
|
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
|
||||||
|
# ansible-playbook playbooks/pve-vaultwarden.yml \
|
||||||
|
# -e pve_config_target=vaultwarden-new --limit vaultwarden-new
|
||||||
|
# По умолчанию поведение не меняется.
|
||||||
|
hosts: "{{ pve_config_target | default('vaultwarden') }}"
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
vars:
|
vars:
|
||||||
ansible_become: false
|
ansible_become: false
|
||||||
vaultwarden_data_dir: /opt/vaultwarden/data
|
vaultwarden_data_dir: /opt/vaultwarden/data
|
||||||
vaultwarden_image: vaultwarden/server:latest
|
vaultwarden_image: vaultwarden/server:1.37.1@sha256:e9efdf001bf0d68c21f2cbfb8e1d9b5961a7ca9c85e0a7e58bf51a13b997d744
|
||||||
vaultwarden_container_name: vaultwarden
|
vaultwarden_container_name: vaultwarden
|
||||||
vaultwarden_http_port: 80
|
vaultwarden_http_port: 80
|
||||||
tasks:
|
tasks:
|
||||||
@@ -133,6 +174,18 @@
|
|||||||
group: root
|
group: root
|
||||||
mode: "0750"
|
mode: "0750"
|
||||||
|
|
||||||
|
- name: Check if the configured Vaultwarden image is present
|
||||||
|
ansible.builtin.command: "docker image inspect {{ vaultwarden_image }}"
|
||||||
|
register: vaultwarden_image_inspect
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Pull the configured Vaultwarden image
|
||||||
|
ansible.builtin.command: "docker pull {{ vaultwarden_image }}"
|
||||||
|
when: vaultwarden_image_inspect.rc != 0
|
||||||
|
register: vaultwarden_image_pull
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
- name: Install Vaultwarden systemd unit
|
- name: Install Vaultwarden systemd unit
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: /etc/systemd/system/vaultwarden.service
|
dest: /etc/systemd/system/vaultwarden.service
|
||||||
@@ -151,7 +204,7 @@
|
|||||||
ExecStartPre=-/usr/bin/docker rm -f {{ vaultwarden_container_name }}
|
ExecStartPre=-/usr/bin/docker rm -f {{ vaultwarden_container_name }}
|
||||||
ExecStart=/usr/bin/docker run --rm \
|
ExecStart=/usr/bin/docker run --rm \
|
||||||
--name {{ vaultwarden_container_name }} \
|
--name {{ vaultwarden_container_name }} \
|
||||||
--pull always \
|
--pull never \
|
||||||
-p {{ vaultwarden_http_port }}:80 \
|
-p {{ vaultwarden_http_port }}:80 \
|
||||||
-v {{ vaultwarden_data_dir }}:/data \
|
-v {{ vaultwarden_data_dir }}:/data \
|
||||||
-e WEBSOCKET_ENABLED=true \
|
-e WEBSOCKET_ENABLED=true \
|
||||||
@@ -170,5 +223,20 @@
|
|||||||
- name: Enable and start Vaultwarden
|
- name: Enable and start Vaultwarden
|
||||||
ansible.builtin.systemd:
|
ansible.builtin.systemd:
|
||||||
name: vaultwarden
|
name: vaultwarden
|
||||||
state: started
|
state: "{{ 'restarted' if vaultwarden_unit.changed or vaultwarden_image_pull.changed else 'started' }}"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
- name: Wait for Vaultwarden HTTP endpoint
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "http://127.0.0.1:{{ vaultwarden_http_port }}/"
|
||||||
|
status_code: 200
|
||||||
|
register: vaultwarden_health
|
||||||
|
retries: 24
|
||||||
|
delay: 5
|
||||||
|
until: vaultwarden_health.status == 200
|
||||||
|
|
||||||
|
- name: Verify Docker storage driver
|
||||||
|
ansible.builtin.command: docker info --format '{{ "{{" }}.Driver{{ "}}" }}'
|
||||||
|
register: vaultwarden_docker_driver
|
||||||
|
changed_when: false
|
||||||
|
failed_when: vaultwarden_docker_driver.stdout != 'fuse-overlayfs'
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
- name: Create wg-mini LXC on mini-pc
|
|
||||||
hosts: localhost
|
|
||||||
connection: local
|
|
||||||
gather_facts: false
|
|
||||||
roles:
|
|
||||||
- role: pve_lxc
|
|
||||||
vars:
|
|
||||||
pve_lxc_vmid: 132
|
|
||||||
pve_lxc_node: mini-pc
|
|
||||||
pve_lxc_hostname: wg-mini
|
|
||||||
pve_lxc_ip: 192.168.1.23/24
|
|
||||||
pve_lxc_gateway: 192.168.1.1
|
|
||||||
pve_lxc_storage: local-lvm
|
|
||||||
pve_lxc_disk: local-lvm:8
|
|
||||||
pve_lxc_ostemplate: "{{ lookup('env', 'PVE_LXC_OSTEMPLATE') | default('local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst', true) }}"
|
|
||||||
|
|
||||||
- name: Allow TUN device in wg-mini LXC config
|
|
||||||
hosts: mini-pc
|
|
||||||
gather_facts: false
|
|
||||||
become: true
|
|
||||||
handlers:
|
|
||||||
- name: restart wg-mini lxc
|
|
||||||
ansible.builtin.shell: pct stop 132 || true; pct start 132
|
|
||||||
changed_when: true
|
|
||||||
tasks:
|
|
||||||
- name: Allow /dev/net/tun device
|
|
||||||
ansible.builtin.lineinfile:
|
|
||||||
path: /etc/pve/lxc/132.conf
|
|
||||||
line: "lxc.cgroup2.devices.allow: c 10:200 rwm"
|
|
||||||
state: present
|
|
||||||
notify: restart wg-mini lxc
|
|
||||||
|
|
||||||
- name: Bind mount /dev/net/tun
|
|
||||||
ansible.builtin.lineinfile:
|
|
||||||
path: /etc/pve/lxc/132.conf
|
|
||||||
line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
|
|
||||||
state: present
|
|
||||||
notify: restart wg-mini lxc
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
- name: Configure Gitea reverse proxy on ru-vps
|
|
||||||
hosts: ru-vps
|
|
||||||
gather_facts: false
|
|
||||||
vars:
|
|
||||||
caddy_dir: /opt/services/ru-vps/caddy
|
|
||||||
caddyfile_path: /opt/services/ru-vps/caddy/Caddyfile
|
|
||||||
gitea_domain: git.ada-dev.ru
|
|
||||||
gitea_upstream: 192.168.1.25:3000
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- name: Ensure Caddy service directory exists
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ caddy_dir }}"
|
|
||||||
state: directory
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0755"
|
|
||||||
|
|
||||||
- name: Configure Gitea Caddy site
|
|
||||||
ansible.builtin.blockinfile:
|
|
||||||
path: "{{ caddyfile_path }}"
|
|
||||||
create: true
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0644"
|
|
||||||
marker: "# {mark} ANSIBLE MANAGED GITEA SITE"
|
|
||||||
block: |
|
|
||||||
{{ gitea_domain }} {
|
|
||||||
reverse_proxy {{ gitea_upstream }}
|
|
||||||
}
|
|
||||||
register: gitea_caddy_site
|
|
||||||
|
|
||||||
- name: Remove legacy unmanaged Gitea Caddy site
|
|
||||||
ansible.builtin.replace:
|
|
||||||
path: "{{ caddyfile_path }}"
|
|
||||||
regexp: '(?ms)^git\.ada-dev\.ru \{\n\s*reverse_proxy 10\.122\.62\.51:3002\n\}\n+'
|
|
||||||
replace: ''
|
|
||||||
register: gitea_legacy_site
|
|
||||||
|
|
||||||
- name: Validate host Caddy config
|
|
||||||
ansible.builtin.command: caddy validate --config {{ caddyfile_path }}
|
|
||||||
changed_when: false
|
|
||||||
|
|
||||||
- name: Check Caddy container mounted config
|
|
||||||
ansible.builtin.command: docker exec caddy grep -F 'reverse_proxy {{ gitea_upstream }}' /etc/caddy/Caddyfile
|
|
||||||
register: gitea_container_caddyfile
|
|
||||||
changed_when: false
|
|
||||||
failed_when: false
|
|
||||||
|
|
||||||
- name: Restart Caddy when config changed or bind mount is stale
|
|
||||||
ansible.builtin.command: docker restart caddy
|
|
||||||
when: >-
|
|
||||||
gitea_caddy_site.changed or
|
|
||||||
gitea_legacy_site.changed or
|
|
||||||
gitea_container_caddyfile.rc != 0
|
|
||||||
|
|
||||||
- name: Validate Caddy container config after restart
|
|
||||||
ansible.builtin.command: docker exec caddy caddy validate --config /etc/caddy/Caddyfile
|
|
||||||
changed_when: false
|
|
||||||
|
|
||||||
- name: Check Gitea upstream from ru-vps
|
|
||||||
ansible.builtin.command: curl -fsS -o /dev/null -w '%{http_code}' http://{{ gitea_upstream }}/
|
|
||||||
register: gitea_upstream_http
|
|
||||||
changed_when: false
|
|
||||||
failed_when: gitea_upstream_http.stdout != '200'
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
- name: Configure Vaultwarden reverse proxy on ru-vps
|
|
||||||
hosts: ru-vps
|
|
||||||
gather_facts: false
|
|
||||||
vars:
|
|
||||||
caddy_dir: /opt/services/ru-vps/caddy
|
|
||||||
caddyfile_path: /opt/services/ru-vps/caddy/Caddyfile
|
|
||||||
vaultwarden_domain: pass.ada-dev.ru
|
|
||||||
vaultwarden_upstream: 192.168.1.24:80
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- name: Ensure Caddy service directory exists
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ caddy_dir }}"
|
|
||||||
state: directory
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0755"
|
|
||||||
|
|
||||||
- name: Configure Vaultwarden Caddy site
|
|
||||||
ansible.builtin.blockinfile:
|
|
||||||
path: "{{ caddyfile_path }}"
|
|
||||||
create: true
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0644"
|
|
||||||
marker: "# {mark} ANSIBLE MANAGED VAULTWARDEN SITE"
|
|
||||||
block: |
|
|
||||||
{{ vaultwarden_domain }} {
|
|
||||||
reverse_proxy {{ vaultwarden_upstream }}
|
|
||||||
}
|
|
||||||
register: vaultwarden_caddy_site
|
|
||||||
|
|
||||||
- name: Remove legacy unmanaged Vaultwarden Caddy site
|
|
||||||
ansible.builtin.replace:
|
|
||||||
path: "{{ caddyfile_path }}"
|
|
||||||
regexp: '(?ms)^pass\.ada-dev\.ru \{\n\s*reverse_proxy 10\.122\.62\.95:10380\n\}\n+'
|
|
||||||
replace: ''
|
|
||||||
register: vaultwarden_legacy_site
|
|
||||||
|
|
||||||
- name: Validate host Caddy config
|
|
||||||
ansible.builtin.command: caddy validate --config {{ caddyfile_path }}
|
|
||||||
changed_when: false
|
|
||||||
|
|
||||||
- name: Check Caddy container mounted config
|
|
||||||
ansible.builtin.command: docker exec caddy grep -F 'reverse_proxy {{ vaultwarden_upstream }}' /etc/caddy/Caddyfile
|
|
||||||
register: vaultwarden_container_caddyfile
|
|
||||||
changed_when: false
|
|
||||||
failed_when: false
|
|
||||||
|
|
||||||
- name: Restart Caddy when config changed or bind mount is stale
|
|
||||||
ansible.builtin.command: docker restart caddy
|
|
||||||
when: >-
|
|
||||||
vaultwarden_caddy_site.changed or
|
|
||||||
vaultwarden_legacy_site.changed or
|
|
||||||
vaultwarden_container_caddyfile.rc != 0
|
|
||||||
|
|
||||||
- name: Validate Caddy container config after restart
|
|
||||||
ansible.builtin.command: docker exec caddy caddy validate --config /etc/caddy/Caddyfile
|
|
||||||
changed_when: false
|
|
||||||
|
|
||||||
- name: Check Vaultwarden upstream from ru-vps
|
|
||||||
ansible.builtin.command: curl -fsS -o /dev/null -w '%{http_code}' http://{{ vaultwarden_upstream }}/
|
|
||||||
register: vaultwarden_upstream_http
|
|
||||||
changed_when: false
|
|
||||||
failed_when: vaultwarden_upstream_http.stdout != '200'
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
---
|
||||||
|
# ============================================================================
|
||||||
|
# Единый плейбук публикации сервисов через Caddy на ru-vps.
|
||||||
|
#
|
||||||
|
# Заменяет reverse-proxy-gitea.yml, reverse-proxy-vaultwarden.yml и
|
||||||
|
# reverse-proxy-grimmory.yml. Источник данных — реестр homelab_services
|
||||||
|
# (ansible/inventory/group_vars/all/services.yml): обрабатываются все сервисы,
|
||||||
|
# у которых определён блок `proxy`.
|
||||||
|
#
|
||||||
|
# Отличие от трёх старых плейбуков: контейнер Caddy перезапускается ОДИН раз
|
||||||
|
# в конце, если изменился хотя бы один сайт (раньше — по разу на плейбук).
|
||||||
|
#
|
||||||
|
# Запуск:
|
||||||
|
# ansible-playbook playbooks/reverse-proxy.yml
|
||||||
|
# ansible-playbook playbooks/reverse-proxy.yml -e 'reverse_proxy_only=[gitea]'
|
||||||
|
# ============================================================================
|
||||||
|
- name: Configure public reverse proxy sites on ru-vps
|
||||||
|
hosts: ru-vps
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
caddy_dir: "{{ homelab_reverse_proxy_dir | default('/opt/services/ru-vps/caddy') }}"
|
||||||
|
caddyfile_path: "{{ homelab_reverse_proxy_caddyfile | default('/opt/services/ru-vps/caddy/Caddyfile') }}"
|
||||||
|
caddy_container: "{{ homelab_reverse_proxy_container | default('caddy') }}"
|
||||||
|
caddyfile_container_path: /etc/caddy/Caddyfile
|
||||||
|
# Ограничить прогон подмножеством сервисов: -e 'reverse_proxy_only=[gitea]'
|
||||||
|
reverse_proxy_only: []
|
||||||
|
reverse_proxy_services: >-
|
||||||
|
{{ homelab_services | dict2items
|
||||||
|
| selectattr('value.proxy', 'defined')
|
||||||
|
| selectattr('key', 'in', reverse_proxy_only) | list
|
||||||
|
if reverse_proxy_only | length > 0
|
||||||
|
else homelab_services | dict2items
|
||||||
|
| selectattr('value.proxy', 'defined') | list }}
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Require at least one service with a published domain
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- reverse_proxy_services | length > 0
|
||||||
|
fail_msg: >-
|
||||||
|
В homelab_services нет ни одного сервиса с блоком `proxy`
|
||||||
|
(или reverse_proxy_only отфильтровал всё).
|
||||||
|
|
||||||
|
# Циклы по именам, а не по записям целиком: иначе assert печатает весь
|
||||||
|
# словарь сервиса в вывод при каждом прогоне.
|
||||||
|
- name: Require every proxied service to declare domain and upstream
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- svc.domain | default('') | length > 0
|
||||||
|
- svc.upstream | default('') | length > 0
|
||||||
|
- svc.caddy_marker | default('') is search('{mark}')
|
||||||
|
fail_msg: >-
|
||||||
|
Сервис {{ item }}: в proxy обязаны быть domain, upstream и
|
||||||
|
caddy_marker с плейсхолдером {mark}.
|
||||||
|
quiet: true
|
||||||
|
loop: "{{ reverse_proxy_services | map(attribute='key') | list }}"
|
||||||
|
vars:
|
||||||
|
svc: "{{ homelab_services[item].proxy }}"
|
||||||
|
|
||||||
|
- name: Require a custom Caddy body to point at the declared upstream
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- svc.upstream in svc.caddy_body
|
||||||
|
fail_msg: >-
|
||||||
|
Сервис {{ item }}: caddy_body не содержит апстрим
|
||||||
|
{{ svc.upstream }} — реестр рассинхронизирован.
|
||||||
|
quiet: true
|
||||||
|
loop: >-
|
||||||
|
{{ reverse_proxy_services
|
||||||
|
| selectattr('value.proxy.caddy_body', 'defined')
|
||||||
|
| map(attribute='key') | list }}
|
||||||
|
vars:
|
||||||
|
svc: "{{ homelab_services[item].proxy }}"
|
||||||
|
|
||||||
|
- name: Ensure Caddy service directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ caddy_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Configure Caddy sites
|
||||||
|
ansible.builtin.blockinfile:
|
||||||
|
path: "{{ caddyfile_path }}"
|
||||||
|
create: true
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
marker: "{{ item.value.proxy.caddy_marker }}"
|
||||||
|
block: |
|
||||||
|
{{ item.value.proxy.domain }} {
|
||||||
|
{% if item.value.proxy.caddy_body is defined %}
|
||||||
|
{{ item.value.proxy.caddy_body | trim | indent(2, first=True) }}
|
||||||
|
{% else %}
|
||||||
|
reverse_proxy {{ item.value.proxy.upstream }}
|
||||||
|
{% endif %}
|
||||||
|
}
|
||||||
|
loop: "{{ reverse_proxy_services }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.key }} ({{ item.value.proxy.domain }})"
|
||||||
|
register: caddy_sites
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# МИГРАЦИОННАЯ СЕКЦИЯ. Удаляет неуправляемые секции Caddyfile, оставшиеся
|
||||||
|
# от прежней инсталляции на 10.122.62.0/24. Держится только ради миграции:
|
||||||
|
# после подтверждённого прогона на ru-vps эту задачу и поля
|
||||||
|
# proxy.caddy_legacy_regexp в services.yml можно удалить.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Remove legacy unmanaged Caddy sites
|
||||||
|
ansible.builtin.replace:
|
||||||
|
path: "{{ caddyfile_path }}"
|
||||||
|
regexp: "{{ item.value.proxy.caddy_legacy_regexp }}"
|
||||||
|
replace: ''
|
||||||
|
loop: "{{ reverse_proxy_services | selectattr('value.proxy.caddy_legacy_regexp', 'defined') | list }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.key }}"
|
||||||
|
register: caddy_legacy
|
||||||
|
|
||||||
|
- name: Validate host Caddy config
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- caddy
|
||||||
|
- validate
|
||||||
|
- --config
|
||||||
|
- "{{ caddyfile_path }}"
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Check Caddy container mounted config
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- docker
|
||||||
|
- exec
|
||||||
|
- "{{ caddy_container }}"
|
||||||
|
- grep
|
||||||
|
- -F
|
||||||
|
- >-
|
||||||
|
{{ item.value.proxy.caddy_container_check
|
||||||
|
| default('reverse_proxy ' ~ item.value.proxy.upstream) }}
|
||||||
|
- "{{ caddyfile_container_path }}"
|
||||||
|
loop: "{{ reverse_proxy_services }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.key }}"
|
||||||
|
register: caddy_container_checks
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Restart Caddy once when any site changed or a bind mount is stale
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- docker
|
||||||
|
- restart
|
||||||
|
- "{{ caddy_container }}"
|
||||||
|
when: >-
|
||||||
|
caddy_sites.changed or
|
||||||
|
caddy_legacy.changed or
|
||||||
|
(caddy_container_checks.results
|
||||||
|
| selectattr('rc', 'defined')
|
||||||
|
| rejectattr('rc', 'equalto', 0)
|
||||||
|
| list | length > 0)
|
||||||
|
|
||||||
|
- name: Validate Caddy container config after restart
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- docker
|
||||||
|
- exec
|
||||||
|
- "{{ caddy_container }}"
|
||||||
|
- caddy
|
||||||
|
- validate
|
||||||
|
- --config
|
||||||
|
- "{{ caddyfile_container_path }}"
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Check service upstreams from ru-vps
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "http://{{ item.value.proxy.upstream }}{{ item.value.proxy.health.path | default('/') }}"
|
||||||
|
status_code: "{{ item.value.proxy.health.status_code | default([200]) }}"
|
||||||
|
follow_redirects: "{{ item.value.proxy.health.follow_redirects | default('none') }}"
|
||||||
|
return_content: false
|
||||||
|
loop: "{{ reverse_proxy_services }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.key }} -> {{ item.value.proxy.upstream }}"
|
||||||
@@ -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,280 @@
|
|||||||
|
---
|
||||||
|
- name: Harden live Mihomo on ru-vps
|
||||||
|
hosts: ru-vps
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
ru_vps_mihomo_harden_confirm: false
|
||||||
|
mihomo_root: /opt/services/ru-vps/mihomo
|
||||||
|
mihomo_config_path: "{{ mihomo_root }}/config/config.yaml"
|
||||||
|
mihomo_compose_path: "{{ mihomo_root }}/docker-compose.yml"
|
||||||
|
mihomo_backup_dir: /var/backups/ru-vps-mihomo
|
||||||
|
mihomo_state_dir: /var/lib/ru-vps-mihomo
|
||||||
|
mihomo_state_path: "{{ mihomo_state_dir }}/auth.json"
|
||||||
|
mihomo_harden_script_path: /usr/local/sbin/ru-vps-mihomo-harden
|
||||||
|
mihomo_harden_script_src: "{{ playbook_dir }}/../files/ru-vps-mihomo-harden.py"
|
||||||
|
mihomo_backup_path: "{{ mihomo_backup_dir }}/config.yaml.{{ lookup('pipe', 'date -u +%Y%m%d%H%M%S') }}"
|
||||||
|
pre_tasks:
|
||||||
|
- name: Require explicit Mihomo hardening confirmation
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- ru_vps_mihomo_harden_confirm | bool
|
||||||
|
fail_msg: Run with -e ru_vps_mihomo_harden_confirm=true.
|
||||||
|
|
||||||
|
- name: Preflight Mihomo config file exists
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ mihomo_config_path }}"
|
||||||
|
register: mihomo_config_stat
|
||||||
|
|
||||||
|
- name: Preflight Mihomo compose file exists
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ mihomo_compose_path }}"
|
||||||
|
register: mihomo_compose_stat
|
||||||
|
|
||||||
|
- name: Preflight Mihomo state file exists
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ mihomo_state_path }}"
|
||||||
|
register: mihomo_state_stat
|
||||||
|
|
||||||
|
- name: Refuse to run without live Mihomo config and compose files
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- mihomo_config_stat.stat.exists
|
||||||
|
- mihomo_compose_stat.stat.exists
|
||||||
|
fail_msg: Live Mihomo config or compose file is missing.
|
||||||
|
|
||||||
|
- name: Preflight Mihomo container is running
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- docker
|
||||||
|
- inspect
|
||||||
|
- -f
|
||||||
|
- '{{ "{{" }}.State.Running{{ "}}" }}'
|
||||||
|
- mihomo
|
||||||
|
register: mihomo_container_state
|
||||||
|
changed_when: false
|
||||||
|
failed_when: mihomo_container_state.stdout.strip() != 'true'
|
||||||
|
|
||||||
|
- name: Preflight PyYAML is available on the target
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- python3
|
||||||
|
- -c
|
||||||
|
- import yaml
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Preflight current Mihomo config validates
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- docker
|
||||||
|
- compose
|
||||||
|
- -f
|
||||||
|
- "{{ mihomo_compose_path }}"
|
||||||
|
- exec
|
||||||
|
- -T
|
||||||
|
- mihomo
|
||||||
|
- /mihomo
|
||||||
|
- -t
|
||||||
|
- -d
|
||||||
|
- /root/.config/mihomo
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Ensure Mihomo backup directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ mihomo_backup_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
- name: Install root-only Mihomo hardening helper
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ mihomo_harden_script_src }}"
|
||||||
|
dest: "{{ mihomo_harden_script_path }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
- name: Create fresh backup of the live Mihomo config after validation
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ mihomo_config_path }}"
|
||||||
|
dest: "{{ mihomo_backup_path }}"
|
||||||
|
remote_src: true
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- block:
|
||||||
|
- name: Apply Mihomo hardening in place
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- "{{ mihomo_harden_script_path }}"
|
||||||
|
- apply
|
||||||
|
- --config
|
||||||
|
- "{{ mihomo_config_path }}"
|
||||||
|
- --state
|
||||||
|
- "{{ mihomo_state_path }}"
|
||||||
|
register: mihomo_harden_apply
|
||||||
|
changed_when: (mihomo_harden_apply.stdout | from_json).changed
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Validate hardened Mihomo config in the running container
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- docker
|
||||||
|
- compose
|
||||||
|
- -f
|
||||||
|
- "{{ mihomo_compose_path }}"
|
||||||
|
- exec
|
||||||
|
- -T
|
||||||
|
- mihomo
|
||||||
|
- /mihomo
|
||||||
|
- -t
|
||||||
|
- -d
|
||||||
|
- /root/.config/mihomo
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Recreate Mihomo container after config hardening
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- docker
|
||||||
|
- compose
|
||||||
|
- -f
|
||||||
|
- "{{ mihomo_compose_path }}"
|
||||||
|
- up
|
||||||
|
- -d
|
||||||
|
- --force-recreate
|
||||||
|
- mihomo
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Wait for hardened Mihomo listeners on loopback
|
||||||
|
ansible.builtin.wait_for:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: "{{ item }}"
|
||||||
|
state: started
|
||||||
|
timeout: 30
|
||||||
|
loop:
|
||||||
|
- 7890
|
||||||
|
- 7891
|
||||||
|
|
||||||
|
- name: Run authenticated Mihomo SOCKS probe to Telegram
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- "{{ mihomo_harden_script_path }}"
|
||||||
|
- probe
|
||||||
|
- --config
|
||||||
|
- "{{ mihomo_config_path }}"
|
||||||
|
- --state
|
||||||
|
- "{{ mihomo_state_path }}"
|
||||||
|
register: mihomo_harden_probe
|
||||||
|
changed_when: false
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Check anonymous SOCKS access fails
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- curl
|
||||||
|
- --silent
|
||||||
|
- --show-error
|
||||||
|
- --connect-timeout
|
||||||
|
- "5"
|
||||||
|
- --max-time
|
||||||
|
- "15"
|
||||||
|
- --proxy
|
||||||
|
- socks5h://127.0.0.1:7891
|
||||||
|
- https://api.telegram.org
|
||||||
|
- --output
|
||||||
|
- /dev/null
|
||||||
|
register: mihomo_anon_probe
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Remove public Mihomo UFW rules after successful hardening
|
||||||
|
community.general.ufw:
|
||||||
|
rule: allow
|
||||||
|
port: "{{ item.port }}"
|
||||||
|
proto: "{{ item.proto }}"
|
||||||
|
delete: true
|
||||||
|
loop:
|
||||||
|
- { port: 7890, proto: tcp }
|
||||||
|
- { port: 7890, proto: udp }
|
||||||
|
- { port: 7891, proto: tcp }
|
||||||
|
- { port: 7891, proto: udp }
|
||||||
|
|
||||||
|
- name: Verify Mihomo listeners are loopback only
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- ss
|
||||||
|
- -H
|
||||||
|
- -ltnp
|
||||||
|
- '( sport = :7890 or sport = :7891 )'
|
||||||
|
register: mihomo_ss
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Confirm hardened Mihomo is bound to loopback only and anonymous access fails
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- mihomo_ss.stdout is search('127\\.0\\.0\\.1:7890')
|
||||||
|
- mihomo_ss.stdout is search('127\\.0\\.0\\.1:7891')
|
||||||
|
- mihomo_ss.stdout is not search('0\\.0\\.0\\.0:7890|:::7890|0\\.0\\.0\\.0:7891|:::7891')
|
||||||
|
- mihomo_anon_probe.rc != 0
|
||||||
|
fail_msg: Hardened Mihomo must listen on loopback only and reject anonymous SOCKS access.
|
||||||
|
|
||||||
|
rescue:
|
||||||
|
- name: Restore the live Mihomo config from backup
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ mihomo_backup_path }}"
|
||||||
|
dest: "{{ mihomo_config_path }}"
|
||||||
|
remote_src: true
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0640"
|
||||||
|
|
||||||
|
- name: Recreate Mihomo container after rollback
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- docker
|
||||||
|
- compose
|
||||||
|
- -f
|
||||||
|
- "{{ mihomo_compose_path }}"
|
||||||
|
- up
|
||||||
|
- -d
|
||||||
|
- --force-recreate
|
||||||
|
- mihomo
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Remove newly created Mihomo credentials state after rollback
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ mihomo_state_path }}"
|
||||||
|
state: absent
|
||||||
|
when: not mihomo_state_stat.stat.exists
|
||||||
|
|
||||||
|
- name: Wait for restored Mihomo listeners on loopback
|
||||||
|
ansible.builtin.wait_for:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: "{{ item }}"
|
||||||
|
state: started
|
||||||
|
timeout: 30
|
||||||
|
loop:
|
||||||
|
- 7890
|
||||||
|
- 7891
|
||||||
|
|
||||||
|
- name: Validate restored Mihomo config in the running container
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- docker
|
||||||
|
- compose
|
||||||
|
- -f
|
||||||
|
- "{{ mihomo_compose_path }}"
|
||||||
|
- exec
|
||||||
|
- -T
|
||||||
|
- mihomo
|
||||||
|
- /mihomo
|
||||||
|
- -t
|
||||||
|
- -d
|
||||||
|
- /root/.config/mihomo
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Fail Mihomo hardening after restoring the backup
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: Mihomo hardening failed and the live config was restored from backup.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
---
|
||||||
|
# One-shot, human-readable "what is my HomeLab doing right now" report.
|
||||||
|
#
|
||||||
|
# ansible-playbook playbooks/status.yml --ask-vault-pass
|
||||||
|
# ansible-playbook playbooks/status.yml --limit lxc_infra
|
||||||
|
# ansible-playbook playbooks/status.yml --check # same output, changes nothing
|
||||||
|
#
|
||||||
|
# Note: inventory/host_vars/gyro/vault.yml is Ansible Vault encrypted, so any run
|
||||||
|
# that includes the `gyro` host needs --ask-vault-pass / --vault-password-file
|
||||||
|
# (same as playbooks/check.yml). Without the password use --limit '!gyro'.
|
||||||
|
#
|
||||||
|
# Strictly READ-ONLY: every command is `changed_when: false` + `check_mode: false`,
|
||||||
|
# nothing is started, installed or written. An unreachable host is a *result*
|
||||||
|
# (reported as DOWN), not a playbook failure.
|
||||||
|
|
||||||
|
- name: Collect HomeLab status
|
||||||
|
hosts: servers
|
||||||
|
gather_facts: false
|
||||||
|
ignore_unreachable: true
|
||||||
|
vars:
|
||||||
|
# Application units per host. Every name below is taken from the playbook or
|
||||||
|
# role that installs it:
|
||||||
|
# adguard.service playbooks/pve-adguard.yml
|
||||||
|
# emergency-bot.service roles/emergency_bot
|
||||||
|
# gitea.service playbooks/pve-gitea.yml
|
||||||
|
# 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
|
||||||
|
# mihomo{,-ui}.service playbooks/pve-mihomo.yml
|
||||||
|
# uptime-kuma.service roles/uptime_kuma
|
||||||
|
# vaultwarden.service playbooks/pve-vaultwarden.yml
|
||||||
|
# docker.service installed by each of the compose-based playbooks
|
||||||
|
status_service_units:
|
||||||
|
adguard: [docker.service, adguard.service]
|
||||||
|
docker-test: [docker.service]
|
||||||
|
emergency-bot: [emergency-bot.service]
|
||||||
|
gitea: [docker.service, gitea.service]
|
||||||
|
grimmory: [docker.service, grimmory.service, grimmory-docker-firewall.service]
|
||||||
|
gyro: [gyro.timer]
|
||||||
|
hermes-ai: [docker.service, hermes-ai-tun-proxy.service]
|
||||||
|
mihomo: [docker.service, mihomo.service, mihomo-ui.service]
|
||||||
|
monitoring: [docker.service, uptime-kuma.service]
|
||||||
|
ru-vps: [docker.service]
|
||||||
|
vaultwarden: [docker.service, vaultwarden.service]
|
||||||
|
|
||||||
|
# Oneshot / timer-driven jobs. They are normally "inactive", so instead of
|
||||||
|
# is-active we report the last run (ExecMainExitTimestamp + Result).
|
||||||
|
# Running the audit scripts themselves (roles/backup_audit) would hit PBS and
|
||||||
|
# Yandex Disk over the network, so we only read what systemd already knows.
|
||||||
|
status_job_units:
|
||||||
|
cloud-pc:
|
||||||
|
- homelab-backup-audit-gitea.service
|
||||||
|
gitea:
|
||||||
|
- homelab-restic-offsite-gitea.service
|
||||||
|
grimmory:
|
||||||
|
- homelab-restic-offsite-grimmory.service
|
||||||
|
- homelab-backup-audit-grimmory.service
|
||||||
|
gyro: [gyro.service]
|
||||||
|
mini-pc: [homelab-backup-audit-pbs.service]
|
||||||
|
ru-vps: [homelab-monitoring-push.service]
|
||||||
|
vaultwarden:
|
||||||
|
- homelab-restic-offsite-vaultwarden.service
|
||||||
|
- homelab-backup-audit-vaultwarden.service
|
||||||
|
|
||||||
|
# Group-driven units: derived from inventory groups, not hardcoded per host.
|
||||||
|
status_units: >-
|
||||||
|
{{ (status_service_units[inventory_hostname] | default([]))
|
||||||
|
+ (['prometheus-node-exporter.service']
|
||||||
|
if inventory_hostname in (groups['monitoring_exporters'] | default([])) else [])
|
||||||
|
+ (['homelab-smartctl-metrics.timer']
|
||||||
|
if inventory_hostname in (groups['monitoring_smart_exporters'] | default([])) else [])
|
||||||
|
+ ([(openvpn_service_name | default('homelab-openvpn')) ~ '.service']
|
||||||
|
if inventory_hostname in (groups['vpn_openvpn'] | default([])) else []) }}
|
||||||
|
|
||||||
|
status_jobs: "{{ status_job_units[inventory_hostname] | default([]) }}"
|
||||||
|
|
||||||
|
# Root plus whatever extra storage the inventory declares for this host.
|
||||||
|
status_disk_paths: "{{ ['/'] + (storage_mounts | default([]) | map(attribute='path') | list) }}"
|
||||||
|
|
||||||
|
# Same idea as playbooks/openvpn-check.yml, but built from the inventory:
|
||||||
|
# PVE web UI on every pve_nodes member, PBS web UI on pbs.
|
||||||
|
status_vpn_probe_targets: >-
|
||||||
|
{{ (groups['pve_nodes'] | default([]) | map('extract', hostvars, 'ansible_host')
|
||||||
|
| map('regex_replace', '^(.+)$', '\1:8006') | list)
|
||||||
|
+ (groups['lxc_infra'] | default([]) | select('eq', 'pbs')
|
||||||
|
| map('extract', hostvars, 'ansible_host')
|
||||||
|
| map('regex_replace', '^(.+)$', '\1:8007') | list) }}
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Probe host reachability
|
||||||
|
ansible.builtin.ping:
|
||||||
|
register: status_ping
|
||||||
|
ignore_unreachable: true
|
||||||
|
ignore_errors: true
|
||||||
|
check_mode: false
|
||||||
|
|
||||||
|
- name: Record reachability
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_reachable: >-
|
||||||
|
{{ (not (status_ping.unreachable | default(false)))
|
||||||
|
and (not (status_ping.failed | default(false))) }}
|
||||||
|
|
||||||
|
- name: Collect host state
|
||||||
|
when: status_reachable | bool
|
||||||
|
check_mode: false
|
||||||
|
ignore_unreachable: true
|
||||||
|
block:
|
||||||
|
- name: Gather hardware facts (uptime, memory)
|
||||||
|
ansible.builtin.setup:
|
||||||
|
gather_subset:
|
||||||
|
- hardware
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Read root filesystem usage
|
||||||
|
ansible.builtin.shell:
|
||||||
|
cmd: >-
|
||||||
|
LC_ALL=C df -hP / | awk 'NR == 2 {print $5 " used, " $4 " free"}'
|
||||||
|
register: status_root_df
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Read declared storage mount usage
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: "{{ ['df', '-h', '--output=target,size,used,avail,pcent'] + status_disk_paths }}"
|
||||||
|
register: status_disk_df
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
when: status_disk_paths | length > 1
|
||||||
|
|
||||||
|
- name: Check service unit states
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: "{{ ['systemctl', 'is-active'] + status_units }}"
|
||||||
|
register: status_units_state
|
||||||
|
changed_when: false
|
||||||
|
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: >-
|
||||||
|
systemctl list-units --state=failed --no-legend --plain --no-pager
|
||||||
|
| awk '{print $1}' | head -n 5
|
||||||
|
register: status_failed_units
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Read last run of backup and oneshot jobs
|
||||||
|
ansible.builtin.shell:
|
||||||
|
cmd: |
|
||||||
|
set -u
|
||||||
|
now=$(date +%s)
|
||||||
|
for unit in {{ status_jobs | map('quote') | join(' ') }}; do
|
||||||
|
ts=$(systemctl show -p ExecMainExitTimestamp --value "$unit" 2>/dev/null || true)
|
||||||
|
res=$(systemctl show -p Result --value "$unit" 2>/dev/null || true)
|
||||||
|
if [ -n "$ts" ]; then
|
||||||
|
epoch=$(date -d "$ts" +%s 2>/dev/null || true)
|
||||||
|
if [ -n "${epoch:-}" ]; then
|
||||||
|
age="$(( (now - epoch) / 3600 ))h ago"
|
||||||
|
else
|
||||||
|
age="?"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ts="never"
|
||||||
|
age="-"
|
||||||
|
res="-"
|
||||||
|
fi
|
||||||
|
printf '%-44s %-32s %-10s %s\n' "$unit" "$ts" "$age" "${res:-unknown}"
|
||||||
|
done
|
||||||
|
register: status_job_state
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
when: status_jobs | length > 0
|
||||||
|
|
||||||
|
- name: List Proxmox containers
|
||||||
|
ansible.builtin.shell:
|
||||||
|
cmd: >-
|
||||||
|
pct list | awk 'NR > 1 {printf "%-6s %-9s %s\n", $1, $2, $NF}'
|
||||||
|
register: status_pct
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
when: inventory_hostname in (groups['pve_nodes'] | default([]))
|
||||||
|
|
||||||
|
- name: Ping the OpenVPN peer
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: [ping, -c, '2', -W, '2', "{{ openvpn_peer_ip | default('') }}"]
|
||||||
|
register: status_vpn_ping
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
when:
|
||||||
|
- inventory_hostname in (groups['vpn_openvpn'] | default([]))
|
||||||
|
- openvpn_peer_ip is defined
|
||||||
|
|
||||||
|
- name: Probe LAN services through the OpenVPN tunnel
|
||||||
|
ansible.builtin.shell:
|
||||||
|
cmd: |
|
||||||
|
for target in {{ status_vpn_probe_targets | map('quote') | join(' ') }}; do
|
||||||
|
host=${target%:*}
|
||||||
|
port=${target##*:}
|
||||||
|
if nc -z -w 3 "$host" "$port" 2>/dev/null; then
|
||||||
|
printf '%-24s OK\n' "$target"
|
||||||
|
else
|
||||||
|
printf '%-24s FAIL\n' "$target"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
register: status_vpn_probe
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
when:
|
||||||
|
- (openvpn_role | default('')) == 'server'
|
||||||
|
- status_vpn_probe_targets | length > 0
|
||||||
|
|
||||||
|
- name: Build status record
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_record:
|
||||||
|
state: "{{ 'UP' if (status_reachable | bool) else 'DOWN' }}"
|
||||||
|
uptime: >-
|
||||||
|
{{ ((ansible_facts.uptime_seconds | int) // 86400) ~ 'd '
|
||||||
|
~ (((ansible_facts.uptime_seconds | int) % 86400) // 3600) ~ 'h'
|
||||||
|
if ansible_facts.uptime_seconds is defined else '-' }}
|
||||||
|
root_disk: "{{ status_root_df.stdout | default('-') | trim }}"
|
||||||
|
units: >-
|
||||||
|
{{ status_units
|
||||||
|
| zip(status_units_state.stdout_lines | default([]))
|
||||||
|
| map('join', '=') | join(' ') }}
|
||||||
|
failed_units: "{{ status_failed_units.stdout_lines | default([]) }}"
|
||||||
|
jobs: "{{ status_job_state.stdout_lines | default([]) }}"
|
||||||
|
disk_lines: "{{ status_disk_df.stdout_lines | default([]) }}"
|
||||||
|
containers: "{{ status_pct.stdout_lines | default([]) }}"
|
||||||
|
vpn_peer: >-
|
||||||
|
{{ ('OK' if (status_vpn_ping.rc | default(1)) == 0 else 'FAIL')
|
||||||
|
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
|
||||||
|
# Runs once, on the controller, so it also works with --limit.
|
||||||
|
hosts: servers
|
||||||
|
gather_facts: false
|
||||||
|
become: false
|
||||||
|
ignore_unreachable: true
|
||||||
|
tasks:
|
||||||
|
- name: Render the summary
|
||||||
|
run_once: true
|
||||||
|
delegate_to: localhost
|
||||||
|
block:
|
||||||
|
- name: Select hosts that produced a record
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_hosts: >-
|
||||||
|
{{ ansible_play_hosts_all | sort
|
||||||
|
| map('extract', hostvars)
|
||||||
|
| selectattr('status_record', 'defined')
|
||||||
|
| map(attribute='inventory_hostname') | list }}
|
||||||
|
status_states: >-
|
||||||
|
{{ ansible_play_hosts_all | sort
|
||||||
|
| map('extract', hostvars)
|
||||||
|
| selectattr('status_record', 'defined')
|
||||||
|
| map(attribute='status_record.state') | list }}
|
||||||
|
|
||||||
|
- name: Start the report
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_report:
|
||||||
|
- ""
|
||||||
|
- "================ HOMELAB STATUS ================================================"
|
||||||
|
- >-
|
||||||
|
hosts: {{ status_hosts | length }}
|
||||||
|
up: {{ status_states | select('equalto', 'UP') | list | length }}
|
||||||
|
down: {{ status_states | select('equalto', 'DOWN') | list | length }}
|
||||||
|
- ""
|
||||||
|
- "{{ '%-13s %-5s %-9s %-24s %s' | format('HOST', 'STATE', 'UPTIME', 'ROOT FS', 'UNITS') }}"
|
||||||
|
- "{{ '-' * 80 }}"
|
||||||
|
|
||||||
|
- name: Add one line per host
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_report: "{{ status_report + [line] }}"
|
||||||
|
vars:
|
||||||
|
rec: "{{ hostvars[item].status_record }}"
|
||||||
|
line: >-
|
||||||
|
{{ '%-13s %-5s %-9s %-24s %s' | format(
|
||||||
|
item,
|
||||||
|
rec.state,
|
||||||
|
rec.uptime | trim | default('-', true),
|
||||||
|
rec.root_disk | trim | default('-', true),
|
||||||
|
rec.units | trim | default('-', true)) }}
|
||||||
|
loop: "{{ status_hosts }}"
|
||||||
|
|
||||||
|
- name: Add Proxmox container section header
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_report: "{{ status_report + ['', 'PROXMOX CONTAINERS (pct list)', '-' * 80] }}"
|
||||||
|
|
||||||
|
- name: Add containers per Proxmox node
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_report: >-
|
||||||
|
{{ status_report
|
||||||
|
+ [' ' ~ item ~ ': running=' ~ (running | length)
|
||||||
|
~ ' other=' ~ (stopped | length)]
|
||||||
|
+ (stopped | map('regex_replace', '^\s*', ' not running: ') | list) }}
|
||||||
|
vars:
|
||||||
|
lines: "{{ hostvars[item].status_record.containers }}"
|
||||||
|
running: "{{ lines | select('search', ' running ') | list }}"
|
||||||
|
stopped: "{{ lines | reject('search', ' running ') | list }}"
|
||||||
|
loop: "{{ status_hosts }}"
|
||||||
|
when: hostvars[item].status_record.containers | length > 0
|
||||||
|
|
||||||
|
- name: Add storage section header
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_report: "{{ status_report + ['', 'STORAGE (root + declared mounts)', '-' * 80] }}"
|
||||||
|
|
||||||
|
- name: Add storage lines per host
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_report: >-
|
||||||
|
{{ status_report + [' ' ~ item ~ ':']
|
||||||
|
+ (hostvars[item].status_record.disk_lines
|
||||||
|
| map('regex_replace', '^', ' ') | list) }}
|
||||||
|
loop: "{{ status_hosts }}"
|
||||||
|
when: hostvars[item].status_record.disk_lines | length > 0
|
||||||
|
|
||||||
|
- name: Add OpenVPN transport section header
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_report: "{{ status_report + ['', 'OPENVPN TRANSPORT', '-' * 80] }}"
|
||||||
|
|
||||||
|
- name: Add OpenVPN peer results
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_report: >-
|
||||||
|
{{ status_report
|
||||||
|
+ [' ' ~ item ~ ' -> peer ' ~ hostvars[item].status_record.vpn_peer_ip
|
||||||
|
~ ' : ' ~ hostvars[item].status_record.vpn_peer]
|
||||||
|
+ (hostvars[item].status_record.vpn_probes
|
||||||
|
| map('regex_replace', '^', ' via tunnel: ') | list) }}
|
||||||
|
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: >-
|
||||||
|
{{ status_report + ['', 'BACKUP / SCHEDULED JOBS (last run, from systemd)', '-' * 80] }}
|
||||||
|
|
||||||
|
- name: Add job lines per host
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_report: >-
|
||||||
|
{{ status_report + [' ' ~ item ~ ':']
|
||||||
|
+ (hostvars[item].status_record.jobs
|
||||||
|
| map('regex_replace', '^', ' ') | list) }}
|
||||||
|
loop: "{{ status_hosts }}"
|
||||||
|
when: hostvars[item].status_record.jobs | length > 0
|
||||||
|
|
||||||
|
- name: Add failed units section header
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_report: "{{ status_report + ['', 'FAILED SYSTEMD UNITS', '-' * 80] }}"
|
||||||
|
|
||||||
|
- name: Add failed units per host
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_report: >-
|
||||||
|
{{ status_report + [' ' ~ item ~ ': '
|
||||||
|
~ (hostvars[item].status_record.failed_units | join(', '))] }}
|
||||||
|
loop: "{{ status_hosts }}"
|
||||||
|
when: hostvars[item].status_record.failed_units | length > 0
|
||||||
|
|
||||||
|
- name: Note when nothing failed
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_report: "{{ status_report + [' none'] }}"
|
||||||
|
when: >-
|
||||||
|
(status_hosts | map('extract', hostvars)
|
||||||
|
| map(attribute='status_record.failed_units', default=[])
|
||||||
|
| flatten | length) == 0
|
||||||
|
|
||||||
|
- name: Add unreachable section
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
status_report: >-
|
||||||
|
{{ status_report + ['', 'UNREACHABLE', '-' * 80]
|
||||||
|
+ (down_hosts | map('regex_replace', '^', ' ') | list
|
||||||
|
if (down_hosts | length) > 0 else [' none'])
|
||||||
|
+ ['', '=' * 80, ''] }}
|
||||||
|
vars:
|
||||||
|
down_hosts: >-
|
||||||
|
{{ status_hosts | map('extract', hostvars)
|
||||||
|
| selectattr('status_record.state', 'equalto', 'DOWN')
|
||||||
|
| map(attribute='inventory_hostname') | list }}
|
||||||
|
|
||||||
|
- name: Print HomeLab status
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ status_report | join('\n') }}"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#jinja2: trim_blocks: True, lstrip_blocks: True
|
||||||
|
# {{ ansible_managed }}
|
||||||
|
# Статические админ-ссылки (не из реестра). Рендерится playbooks/dashboard.yml.
|
||||||
|
- Управление:
|
||||||
|
- Proxmox cloud-pc:
|
||||||
|
- abbr: PVE
|
||||||
|
href: https://192.168.1.5:8006
|
||||||
|
- Proxmox mini-pc:
|
||||||
|
- abbr: PVE
|
||||||
|
href: https://192.168.1.10:8006
|
||||||
|
- Proxmox Backup Server:
|
||||||
|
- abbr: PBS
|
||||||
|
href: https://192.168.1.20:8007
|
||||||
|
- Uptime Kuma:
|
||||||
|
- abbr: UK
|
||||||
|
href: http://{{ dash_bind }}:3001
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#jinja2: trim_blocks: True, lstrip_blocks: True
|
||||||
|
# {{ ansible_managed }}
|
||||||
|
# Рендерится playbooks/dashboard.yml (роль compose_service). Правь шаблон,
|
||||||
|
# а не файл на хосте: следующий прогон перезапишет.
|
||||||
|
services:
|
||||||
|
homepage:
|
||||||
|
image: {{ dash_image }}
|
||||||
|
container_name: homelab-homepage
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./homepage.env
|
||||||
|
environment:
|
||||||
|
HOMEPAGE_ALLOWED_HOSTS: "{{ dash_allowed_hosts | trim }}"
|
||||||
|
ports:
|
||||||
|
# Только LAN-адрес CT 155 -> контейнерный 3000. Наружу не публикуется.
|
||||||
|
- "{{ dash_bind }}:{{ dash_port }}:3000"
|
||||||
|
volumes:
|
||||||
|
- ./config:/app/config
|
||||||
|
# Docker-сокет намеренно НЕ монтируется: сервисы живут на других хостах,
|
||||||
|
# а лишний доступ к сокету на LAN-видимом контейнере не нужен.
|
||||||
|
networks: {}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#jinja2: trim_blocks: True, lstrip_blocks: True
|
||||||
|
# {{ ansible_managed }}
|
||||||
|
# Рендерится playbooks/dashboard.yml ИЗ реестра homelab_services. НЕ править
|
||||||
|
# на хосте: меняй сервисы в ansible/inventory/group_vars/all/services.yml,
|
||||||
|
# затем `make dashboard`.
|
||||||
|
#
|
||||||
|
# Правило ссылки на сервис:
|
||||||
|
# * есть proxy.domain -> https://<domain> (+ siteMonitor)
|
||||||
|
# * иначе первый веб-порт -> http(s)://<ip>:<port> (+ siteMonitor для http)
|
||||||
|
# приоритет имён портов: http > ui > setup > uptime-kuma > grafana >
|
||||||
|
# controller > pbs-api
|
||||||
|
# * ни того, ни другого -> группа Headless, только ICMP ping по ip
|
||||||
|
{% set port_priority = ['http', 'ui', 'setup', 'uptime-kuma', 'grafana', 'controller', 'pbs-api'] %}
|
||||||
|
{% if homelab_dashboard_proxmox_url | default('') | length > 0 %}
|
||||||
|
- Infrastructure:
|
||||||
|
- Proxmox кластер:
|
||||||
|
description: cloud-pc + mini-pc
|
||||||
|
widget:
|
||||||
|
type: proxmox
|
||||||
|
url: {{ homelab_dashboard_proxmox_url }}
|
||||||
|
username: "{{ '{{HOMEPAGE_VAR_PROXMOX_USER}}' }}"
|
||||||
|
password: "{{ '{{HOMEPAGE_VAR_PROXMOX_TOKEN}}' }}"
|
||||||
|
{% endif %}
|
||||||
|
{% for node in dashboard_nodes %}
|
||||||
|
- {{ node }}:
|
||||||
|
{% for name, svc in homelab_services | dictsort %}
|
||||||
|
{% if svc.node == node %}
|
||||||
|
{% set proxy = svc.proxy | default({}) %}
|
||||||
|
{% set ns = namespace(scheme='', port=0) %}
|
||||||
|
{% for pref in port_priority %}
|
||||||
|
{% for p in svc.ports | default([]) %}
|
||||||
|
{% if ns.port == 0 and p.name == pref %}
|
||||||
|
{% set ns.port = p.port %}
|
||||||
|
{% set ns.scheme = 'https' if pref == 'pbs-api' else 'http' %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if proxy.domain is defined %}
|
||||||
|
- {{ name }}:
|
||||||
|
href: https://{{ proxy.domain }}
|
||||||
|
description: {{ svc.role }}
|
||||||
|
siteMonitor: https://{{ proxy.domain }}
|
||||||
|
{% elif ns.port > 0 %}
|
||||||
|
- {{ name }}:
|
||||||
|
href: {{ ns.scheme }}://{{ svc.ip }}:{{ ns.port }}
|
||||||
|
description: {{ svc.role }}
|
||||||
|
{% if ns.scheme == 'http' %}
|
||||||
|
siteMonitor: http://{{ svc.ip }}:{{ ns.port }}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
- Headless:
|
||||||
|
{% for name, svc in homelab_services | dictsort %}
|
||||||
|
{% set proxy = svc.proxy | default({}) %}
|
||||||
|
{% set ns = namespace(web=false) %}
|
||||||
|
{% for pref in port_priority %}
|
||||||
|
{% for p in svc.ports | default([]) %}
|
||||||
|
{% if p.name == pref %}
|
||||||
|
{% set ns.web = true %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if proxy.domain is not defined and not ns.web %}
|
||||||
|
- {{ name }}:
|
||||||
|
description: {{ svc.role }}
|
||||||
|
ping: {{ svc.ip }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#jinja2: trim_blocks: True, lstrip_blocks: True
|
||||||
|
# {{ ansible_managed }}
|
||||||
|
# Рендерится playbooks/dashboard.yml. Порядок и имена групп ОБЯЗАНЫ совпадать
|
||||||
|
# с группами в services.yaml — иначе Homepage покажет их в алфавитном порядке.
|
||||||
|
title: HomeLab
|
||||||
|
headerStyle: boxed
|
||||||
|
color: slate
|
||||||
|
layout:
|
||||||
|
{% if homelab_dashboard_proxmox_url | default('') | length > 0 %}
|
||||||
|
Infrastructure:
|
||||||
|
style: row
|
||||||
|
columns: 3
|
||||||
|
{% endif %}
|
||||||
|
{% for node in dashboard_nodes %}
|
||||||
|
{{ node }}:
|
||||||
|
style: row
|
||||||
|
columns: 4
|
||||||
|
{% endfor %}
|
||||||
|
Headless:
|
||||||
|
style: row
|
||||||
|
columns: 4
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#jinja2: trim_blocks: True, lstrip_blocks: True
|
||||||
|
# {{ ansible_managed }}
|
||||||
|
# Верхняя панель Homepage. Рендерится playbooks/dashboard.yml.
|
||||||
|
- datetime:
|
||||||
|
text_size: xl
|
||||||
|
format:
|
||||||
|
timeStyle: short
|
||||||
|
dateStyle: short
|
||||||
|
- resources:
|
||||||
|
label: monitoring CT
|
||||||
|
cpu: true
|
||||||
|
memory: true
|
||||||
|
disk: /
|
||||||
|
- uptimekuma:
|
||||||
|
# Требует опубликованной Status Page в Uptime Kuma с этим slug
|
||||||
|
# (мониторы и статус-страницы Kuma живут только в её UI).
|
||||||
|
url: http://{{ dash_bind }}:3001
|
||||||
|
slug: {{ homelab_dashboard_kuma_slug | default('homelab') }}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#jinja2: trim_blocks: True, lstrip_blocks: True
|
||||||
|
# {{ ansible_managed }}
|
||||||
|
# Секреты виджетов Homepage. mode 0600, в Git не попадает. Значения берутся
|
||||||
|
# из корневого .env через lookup('env', ...) в playbooks/dashboard.yml.
|
||||||
|
# Homepage подставляет их в widgets/services по имени {{ '{{HOMEPAGE_VAR_*}}' }}.
|
||||||
|
HOMEPAGE_VAR_PROXMOX_URL={{ homelab_dashboard_proxmox_url | default('') }}
|
||||||
|
{% if dash_pve_user | length > 0 and dash_pve_token_id | length > 0 %}
|
||||||
|
HOMEPAGE_VAR_PROXMOX_USER={{ dash_pve_user }}!{{ dash_pve_token_id }}
|
||||||
|
{% else %}
|
||||||
|
HOMEPAGE_VAR_PROXMOX_USER=
|
||||||
|
{% endif %}
|
||||||
|
HOMEPAGE_VAR_PROXMOX_TOKEN={{ dash_pve_token_secret | default('') }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
- name: Freeze Prometheus monitoring and configure Uptime Kuma
|
||||||
|
# Таргет переопределяем ради blue-green переезда на OpenTofu: конфигурацию
|
||||||
|
# нужно прогнать против нового контейнера на временном адресе. `--limit`
|
||||||
|
# сам по себе не перенацеливает play, а обнуляет его (проверено 2026-09-02).
|
||||||
|
# Использовать вместе с --limit:
|
||||||
|
# ansible-playbook playbooks/uptime-kuma.yml \
|
||||||
|
# -e pve_config_target=monitoring-new --limit monitoring-new
|
||||||
|
# По умолчанию поведение не меняется.
|
||||||
|
hosts: "{{ pve_config_target | default('monitoring_server') }}"
|
||||||
|
gather_facts: false
|
||||||
|
roles:
|
||||||
|
- role: uptime_kuma
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
- name: Install user SSH public key on managed hosts
|
||||||
|
hosts: servers
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
homelab_user_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
|
||||||
|
homelab_user_pubkey: "{{ lookup('file', homelab_user_pubkey_file) }}"
|
||||||
|
tasks:
|
||||||
|
- name: Install user SSH key for connection user
|
||||||
|
ansible.posix.authorized_key:
|
||||||
|
user: "{{ ansible_user }}"
|
||||||
|
key: "{{ homelab_user_pubkey }}"
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Install user SSH key for root
|
||||||
|
ansible.posix.authorized_key:
|
||||||
|
user: root
|
||||||
|
key: "{{ homelab_user_pubkey }}"
|
||||||
|
state: present
|
||||||
|
become: true
|
||||||
|
when: ansible_user != 'root'
|
||||||
|
|
||||||
|
- name: Bootstrap user SSH public key into legacy LXC containers via Proxmox
|
||||||
|
hosts: pve_nodes
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
homelab_user_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
|
||||||
|
homelab_user_pubkey: "{{ lookup('file', homelab_user_pubkey_file) }}"
|
||||||
|
legacy_lxc_key_targets:
|
||||||
|
- node: cloud-pc
|
||||||
|
vmid: 120
|
||||||
|
name: pbs
|
||||||
|
tasks:
|
||||||
|
- name: Install user SSH key for root inside legacy LXC
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
pct exec {{ item.vmid }} -- sh -c '
|
||||||
|
key=$(printf "%s" {{ homelab_user_pubkey | b64encode | quote }} | base64 -d)
|
||||||
|
mkdir -p /root/.ssh
|
||||||
|
chmod 700 /root/.ssh
|
||||||
|
touch /root/.ssh/authorized_keys
|
||||||
|
if grep -qxF "$key" /root/.ssh/authorized_keys; then
|
||||||
|
echo present
|
||||||
|
else
|
||||||
|
printf "%s\n" "$key" >> /root/.ssh/authorized_keys
|
||||||
|
echo added
|
||||||
|
fi
|
||||||
|
chmod 600 /root/.ssh/authorized_keys
|
||||||
|
'
|
||||||
|
loop: "{{ legacy_lxc_key_targets }}"
|
||||||
|
when: item.node == inventory_hostname
|
||||||
|
register: legacy_lxc_key_install
|
||||||
|
changed_when: "'added' in legacy_lxc_key_install.stdout"
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
# Дрейф между реестром homelab_services и фактическим состоянием Proxmox.
|
||||||
|
#
|
||||||
|
# ЗАЧЕМ
|
||||||
|
# Реестр — источник правды, но программно его потребляют лишь несколько мест.
|
||||||
|
# VMID, адрес и ресурсы легко разъезжаются с реальностью, и заметить это
|
||||||
|
# раньше было нечем: `make status` показывает состояние, но не сверяет его
|
||||||
|
# с задекларированным, и его exit code намеренно не является gate'ом.
|
||||||
|
#
|
||||||
|
# ОТЛИЧИЕ ОТ status.yml
|
||||||
|
# Этот плейбук — именно gate: при любом расхождении он завершается ошибкой.
|
||||||
|
# Годится для cron и CI. Строго read-only: только `pct config`.
|
||||||
|
#
|
||||||
|
# make validate
|
||||||
|
# make validate EXTRA="--limit cloud-pc"
|
||||||
|
|
||||||
|
- name: Read actual LXC configuration from the Proxmox nodes
|
||||||
|
hosts: pve_nodes
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Read pct config for the services declared on this node
|
||||||
|
ansible.builtin.command: "pct config {{ item.value.vmid }}"
|
||||||
|
loop: >-
|
||||||
|
{{ homelab_services | dict2items
|
||||||
|
| selectattr('value.node', 'eq', inventory_hostname) | list }}
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.key }} ({{ item.value.vmid }})"
|
||||||
|
register: validate_pct
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
check_mode: false
|
||||||
|
|
||||||
|
# Сравниваются только однозначные поля. rootfs намеренно пропущен: в реестре
|
||||||
|
# он записан как "data:32", а pct отдаёт "data:vm-141-disk-0,size=32G" —
|
||||||
|
# это разные представления, и их сверка требует отдельного парсера.
|
||||||
|
- name: Collect drift for this node
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
validate_drift: "{{ validate_drift | default([]) + item_drift }}"
|
||||||
|
loop: "{{ validate_pct.results }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.item.key }}"
|
||||||
|
vars:
|
||||||
|
svc: "{{ item.item.value }}"
|
||||||
|
sname: "{{ item.item.key }}"
|
||||||
|
gone: "{{ item.rc | default(1) != 0 }}"
|
||||||
|
out: "{{ item.stdout | default('') }}"
|
||||||
|
a_host: "{{ out | regex_search('(?m)^hostname: (\\S+)', '\\1') | default([''], true) | first }}"
|
||||||
|
a_cores: "{{ out | regex_search('(?m)^cores: (\\d+)', '\\1') | default([''], true) | first }}"
|
||||||
|
a_mem: "{{ out | regex_search('(?m)^memory: (\\d+)', '\\1') | default([''], true) | first }}"
|
||||||
|
a_swap: "{{ out | regex_search('(?m)^swap: (\\d+)', '\\1') | default([''], true) | first }}"
|
||||||
|
a_ip: "{{ out | regex_search('ip=([0-9.]+)', '\\1') | default([''], true) | first }}"
|
||||||
|
item_drift: >-
|
||||||
|
{{ ([sname ~ ': VMID ' ~ svc.vmid ~ ' отсутствует на узле ' ~ inventory_hostname] if gone else [])
|
||||||
|
+ ([sname ~ ': hostname=' ~ a_host ~ ', в реестре ' ~ svc.hostname]
|
||||||
|
if (not gone and a_host != svc.hostname) else [])
|
||||||
|
+ ([sname ~ ': ip=' ~ a_ip ~ ', в реестре ' ~ svc.ip]
|
||||||
|
if (not gone and a_ip != svc.ip) else [])
|
||||||
|
+ ([sname ~ ': cores=' ~ a_cores ~ ', в реестре ' ~ svc.lxc.cores]
|
||||||
|
if (not gone and svc.lxc.cores is not none and a_cores | string != svc.lxc.cores | string) else [])
|
||||||
|
+ ([sname ~ ': memory=' ~ a_mem ~ ', в реестре ' ~ svc.lxc.memory]
|
||||||
|
if (not gone and svc.lxc.memory is not none and a_mem | string != svc.lxc.memory | string) else [])
|
||||||
|
+ ([sname ~ ': swap=' ~ a_swap ~ ', в реестре ' ~ svc.lxc.swap]
|
||||||
|
if (not gone and svc.lxc.swap is not none and a_swap | string != svc.lxc.swap | string) else []) }}
|
||||||
|
|
||||||
|
- name: Report registry drift
|
||||||
|
hosts: pve_nodes
|
||||||
|
gather_facts: false
|
||||||
|
run_once: true
|
||||||
|
tasks:
|
||||||
|
- name: Fail when the registry disagrees with Proxmox
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- all_drift | length == 0
|
||||||
|
success_msg: >-
|
||||||
|
Реестр совпадает с Proxmox: проверено сервисов —
|
||||||
|
{{ homelab_services | dict2items
|
||||||
|
| selectattr('value.node', 'in', groups['pve_nodes']) | list | length }}.
|
||||||
|
fail_msg: >-
|
||||||
|
{{ ['Реестр разошёлся с Proxmox:']
|
||||||
|
+ (all_drift | map('regex_replace', '^', ' - ') | list) }}
|
||||||
|
vars:
|
||||||
|
all_drift: >-
|
||||||
|
{{ groups['pve_nodes']
|
||||||
|
| map('extract', hostvars, 'validate_drift')
|
||||||
|
| select('defined') | flatten }}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
- name: Create and verify Vaultwarden backup before update
|
||||||
|
hosts: vaultwarden
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Create a fresh Vaultwarden offsite backup
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- systemctl
|
||||||
|
- start
|
||||||
|
- --wait
|
||||||
|
- homelab-restic-offsite-vaultwarden.service
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Run Vaultwarden offsite backup audit
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- systemctl
|
||||||
|
- start
|
||||||
|
- --wait
|
||||||
|
- homelab-backup-audit-vaultwarden.service
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Create and verify Vaultwarden PBS backup before update
|
||||||
|
hosts: mini-pc
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
vaultwarden_vmid: "{{ homelab_services['vaultwarden'].vmid }}"
|
||||||
|
tasks:
|
||||||
|
- name: Read existing Vaultwarden VMID configuration
|
||||||
|
ansible.builtin.command: "pct config {{ vaultwarden_vmid }}"
|
||||||
|
register: vaultwarden_existing_vmid
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Refuse to modify a foreign Vaultwarden VMID
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- vaultwarden_existing_vmid.rc == 0
|
||||||
|
- vaultwarden_existing_hostname == 'vaultwarden'
|
||||||
|
fail_msg: >-
|
||||||
|
VMID {{ vaultwarden_vmid }} from the service registry is not the
|
||||||
|
Vaultwarden container.
|
||||||
|
vars:
|
||||||
|
vaultwarden_existing_hostname: >-
|
||||||
|
{{ vaultwarden_existing_vmid.stdout_lines
|
||||||
|
| select('match', '^hostname: ')
|
||||||
|
| map('regex_replace', '^hostname: ', '')
|
||||||
|
| first
|
||||||
|
| default('') }}
|
||||||
|
|
||||||
|
- name: Check for active Proxmox backup before Vaultwarden PBS backup
|
||||||
|
ansible.builtin.command: pgrep -x vzdump
|
||||||
|
register: vaultwarden_vzdump_preflight
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Require no active Proxmox backup before Vaultwarden PBS backup
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- vaultwarden_vzdump_preflight.rc != 0
|
||||||
|
fail_msg: >-
|
||||||
|
A Proxmox backup is already running on mini-pc.
|
||||||
|
Retry after the existing backup completes.
|
||||||
|
|
||||||
|
- name: Create a fresh Vaultwarden PBS backup
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- vzdump
|
||||||
|
- "{{ vaultwarden_vmid }}"
|
||||||
|
- --storage
|
||||||
|
- pbs
|
||||||
|
- --mode
|
||||||
|
- snapshot
|
||||||
|
- --exclude-path
|
||||||
|
- /var/lib/docker/fuse-overlayfs/*/merged
|
||||||
|
|
||||||
|
- name: Run current PBS backup audit on mini-pc
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- systemctl
|
||||||
|
- start
|
||||||
|
- --wait
|
||||||
|
- homelab-backup-audit-pbs.service
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- import_playbook: pve-vaultwarden.yml
|
||||||
|
vars:
|
||||||
|
pve_provisioning_enabled: false
|
||||||
|
|
||||||
|
- name: Verify Vaultwarden public endpoint after update
|
||||||
|
hosts: ru-vps
|
||||||
|
gather_facts: false
|
||||||
|
tasks:
|
||||||
|
- name: Check Vaultwarden HTTPS endpoint
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: https://pass.ada-dev.ru/
|
||||||
|
status_code: 200
|
||||||
|
return_content: false
|
||||||
|
register: vaultwarden_public_health
|
||||||
|
retries: 24
|
||||||
|
delay: 5
|
||||||
|
until: vaultwarden_public_health.status == 200
|
||||||
@@ -4,3 +4,5 @@ collections:
|
|||||||
version: ">=2.0.0"
|
version: ">=2.0.0"
|
||||||
- name: community.proxmox
|
- name: community.proxmox
|
||||||
version: ">=2.0.0"
|
version: ">=2.0.0"
|
||||||
|
- name: community.general
|
||||||
|
version: ">=10.0.0"
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Роли Ansible
|
||||||
|
|
||||||
|
| Роль | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `backup_audit` | Аудит бэкапов PBS и restic, метрики в node-exporter |
|
||||||
|
| `bash_config` | Единый bash-конфиг для shell-хостов |
|
||||||
|
| `compose_service` | **(новая)** Docker Compose стек под systemd oneshot-юнитом |
|
||||||
|
| `emergency_access` | Аварийный доступ |
|
||||||
|
| `emergency_bot` | Telegram-бот аварийного доступа |
|
||||||
|
| `gyro` | Сервис gyro в изолированном LXC |
|
||||||
|
| `lxc_docker_host` | **(новая)** Подготовка непривилегированного LXC под Docker |
|
||||||
|
| `monitoring_blackbox` | Внешние HTTP-пробы с ru-vps |
|
||||||
|
| `monitoring_exporter` | node-exporter / smartctl-exporter |
|
||||||
|
| `monitoring_server` | Prometheus + Alertmanager + Grafana |
|
||||||
|
| `openvpn_gateway` | OpenVPN сервер/клиент |
|
||||||
|
| `pve_lxc` | Создание LXC через Proxmox API |
|
||||||
|
| `uptime_kuma` | Uptime Kuma |
|
||||||
|
| `base`, `docker`, `ufw` | Пустые каталоги, оставшиеся от ранней структуры |
|
||||||
|
|
||||||
|
## Новые роли
|
||||||
|
|
||||||
|
### `lxc_docker_host`
|
||||||
|
|
||||||
|
Пакеты, проверка `/dev/fuse`, `daemon.json` со `storage-driver:
|
||||||
|
fuse-overlayfs`, запуск docker, базовые правила UFW (SSH из LAN и OpenVPN,
|
||||||
|
node-exporter 9100 с 192.168.1.30, `deny incoming`), проверка эффективного
|
||||||
|
драйвера хранилища. Вынесено из восьми `pve-*.yml` — около 350 строк
|
||||||
|
копипасты. Подробности и пример: [`lxc_docker_host/README.md`](lxc_docker_host/README.md).
|
||||||
|
|
||||||
|
### `compose_service`
|
||||||
|
|
||||||
|
`compose.yml` + `.env` (генерация секретов один раз, `no_log`, `0600`) +
|
||||||
|
systemd-юнит `Type=oneshot` с `docker compose up -d --remove-orphans`,
|
||||||
|
`daemon-reload` через handler, валидация `docker compose config --quiet`,
|
||||||
|
рестарт только при изменениях, health-check по URL с `retries`/`until`.
|
||||||
|
Подробности и пример плейбука Gitea на новых ролях:
|
||||||
|
[`compose_service/README.md`](compose_service/README.md).
|
||||||
|
|
||||||
|
**Статус:** `compose_service` подключён в `playbooks/ru-vps-base.yml` (стек
|
||||||
|
Caddy на ru-vps) — это его первый и пока единственный потребитель.
|
||||||
|
`lxc_docker_host` не подключён нигде. Перевод `pve-*.yml` на обе роли —
|
||||||
|
отдельный этап.
|
||||||
|
|
||||||
|
## Источник данных
|
||||||
|
|
||||||
|
Факты о сервисах (vmid, узел, адрес, порты, домен, образы с digest, ресурсы,
|
||||||
|
бэкап, мониторинг, порядок автозапуска) собраны в реестре
|
||||||
|
`ansible/inventory/group_vars/all/services.yml` (`homelab_services`).
|
||||||
|
Программные потребители реестра:
|
||||||
|
|
||||||
|
- `playbooks/reverse-proxy.yml` — сборка Caddyfile;
|
||||||
|
- `playbooks/ru-vps-base.yml` — стек Caddy и его закреплённый образ;
|
||||||
|
- `playbooks/pve-backup-jobs.yml` — списки VMID заданий PBS (поле `backup.job`);
|
||||||
|
- `roles/backup_audit` — VMID для аудита (флаг `monitoring.backup_audit_vmid`);
|
||||||
|
- `playbooks/validate.yml` — сверка реестра с фактическим состоянием Proxmox.
|
||||||
|
|
||||||
|
Остальное (`pve-*.yml`, `status.yml`, monitoring, SSH config) по-прежнему
|
||||||
|
дублирует значения и должно меняться согласованно.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
backup_audit_log_file: /var/log/homelab-backup-audit.log
|
||||||
|
backup_audit_timer_oncalendar: "*-*-* 06:00:00"
|
||||||
|
backup_audit_timer_randomized_delay: 10m
|
||||||
|
|
||||||
|
# Единый порог свежести для всех PBS-снапшотов. Вынесен отдельно, потому что
|
||||||
|
# сам список VMID теперь выводится из реестра и per-vmid значения в нём нет.
|
||||||
|
backup_audit_pbs_max_age_hours: 48
|
||||||
|
|
||||||
|
# VMID берутся из homelab_services по флагу monitoring.backup_audit_vmid, а не
|
||||||
|
# перечисляются вручную. Форма записи ({vmid, max_age_hours}) сохранена, чтобы
|
||||||
|
# вызывающий playbook при необходимости мог передать свой список с иными
|
||||||
|
# порогами — шаблон audit-pbs.sh.j2 читает именно эти два поля.
|
||||||
|
backup_audit_pbs_vmids: >-
|
||||||
|
{{ homelab_services.values()
|
||||||
|
| selectattr('monitoring.backup_audit_vmid', 'defined')
|
||||||
|
| selectattr('monitoring.backup_audit_vmid')
|
||||||
|
| map(attribute='vmid') | sort
|
||||||
|
| map('community.general.dict_kv', 'vmid')
|
||||||
|
| map('combine', {'max_age_hours': backup_audit_pbs_max_age_hours})
|
||||||
|
| list }}
|
||||||
|
|
||||||
|
backup_audit_restic_profiles: []
|
||||||
|
backup_audit_metrics_dir: /var/lib/node_exporter/textfile_collector
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
---
|
||||||
|
- name: Install audit dependencies
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- jq
|
||||||
|
- sqlite3
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Ensure audit directories exist
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
loop:
|
||||||
|
- /etc/homelab-backup-audit
|
||||||
|
- /var/lib/homelab-backup-audit
|
||||||
|
- "{{ backup_audit_metrics_dir }}"
|
||||||
|
|
||||||
|
- name: Install PBS audit script
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: audit-pbs.sh.j2
|
||||||
|
dest: /usr/local/sbin/homelab-backup-audit-pbs
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
when: backup_audit_type | default('') == 'pbs'
|
||||||
|
|
||||||
|
- name: Install restic audit script
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: audit-restic.sh.j2
|
||||||
|
dest: /usr/local/sbin/homelab-backup-audit-restic
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
when: backup_audit_type | default('') == 'restic'
|
||||||
|
|
||||||
|
- name: Install restic audit env files
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "/etc/homelab-backup-audit/{{ item.name }}.env"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
content: |
|
||||||
|
HOMELAB_AUDIT_PROFILE={{ item.name }}
|
||||||
|
HOMELAB_AUDIT_MAX_AGE_HOURS={{ item.max_age_hours | default(36) }}
|
||||||
|
HOMELAB_AUDIT_SQLITE_NAME={{ item.sqlite_name | default('') }}
|
||||||
|
HOMELAB_AUDIT_EXPECTED_NAME={{ item.expected_name | default('') }}
|
||||||
|
loop: "{{ backup_audit_restic_profiles }}"
|
||||||
|
when: backup_audit_type | default('') == 'restic'
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Install PBS audit systemd service
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/systemd/system/homelab-backup-audit-pbs.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=HomeLab PBS backup audit
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/homelab-backup-audit-pbs
|
||||||
|
when: backup_audit_type | default('') == 'pbs'
|
||||||
|
|
||||||
|
- name: Install restic audit systemd services
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "/etc/systemd/system/homelab-backup-audit-{{ item.name }}.service"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=HomeLab restic offsite backup audit ({{ item.name }})
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/flock -w 1800 /var/lock/homelab-restic-{{ item.name }}.lock /usr/local/sbin/homelab-backup-audit-restic {{ item.name }}
|
||||||
|
loop: "{{ backup_audit_restic_profiles }}"
|
||||||
|
when: backup_audit_type | default('') == 'restic'
|
||||||
|
|
||||||
|
- name: Install PBS audit systemd timer
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/systemd/system/homelab-backup-audit-pbs.timer
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Run HomeLab PBS backup audit
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar={{ backup_audit_timer_oncalendar }}
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec={{ backup_audit_timer_randomized_delay }}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
when: backup_audit_type | default('') == 'pbs'
|
||||||
|
|
||||||
|
- name: Install restic audit systemd timers
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "/etc/systemd/system/homelab-backup-audit-{{ item.name }}.timer"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Run HomeLab restic backup audit ({{ item.name }})
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar={{ backup_audit_timer_oncalendar }}
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec={{ backup_audit_timer_randomized_delay }}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
loop: "{{ backup_audit_restic_profiles }}"
|
||||||
|
when: backup_audit_type | default('') == 'restic'
|
||||||
|
|
||||||
|
- name: Reload systemd
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
daemon_reload: true
|
||||||
|
|
||||||
|
- name: Enable PBS audit timer
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: homelab-backup-audit-pbs.timer
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
when: backup_audit_type | default('') == 'pbs'
|
||||||
|
|
||||||
|
- name: Enable restic audit timers
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: "homelab-backup-audit-{{ item.name }}.timer"
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
loop: "{{ backup_audit_restic_profiles }}"
|
||||||
|
when: backup_audit_type | default('') == 'restic'
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Managed by Ansible: HomeLab PBS backup audit (L0 freshness)
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
LOG_FILE="{{ backup_audit_log_file }}"
|
||||||
|
METRICS_DIR="{{ backup_audit_metrics_dir }}"
|
||||||
|
METRICS_FILE="$METRICS_DIR/homelab_backup_audit_pbs.prom"
|
||||||
|
METRICS_TMP=$(mktemp "$METRICS_FILE.XXXXXX")
|
||||||
|
STATUS=OK
|
||||||
|
NOW_EPOCH=$(date +%s)
|
||||||
|
|
||||||
|
trap 'rm -f "$METRICS_TMP"' EXIT
|
||||||
|
|
||||||
|
publish_metrics() {
|
||||||
|
chmod 0644 "$METRICS_TMP"
|
||||||
|
mv "$METRICS_TMP" "$METRICS_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
|
||||||
|
log() { echo "[$(ts)] [pbs] $*" | tee -a "$LOG_FILE"; }
|
||||||
|
|
||||||
|
get_latest_snapshot() {
|
||||||
|
pvesm list pbs --vmid "$1" 2>/dev/null \
|
||||||
|
| tail -n +2 \
|
||||||
|
| awk '{print $1}' \
|
||||||
|
| sort -t/ -k4 \
|
||||||
|
| tail -1
|
||||||
|
}
|
||||||
|
|
||||||
|
{% for entry in backup_audit_pbs_vmids %}
|
||||||
|
audit_vmid_{{ entry.vmid }}() {
|
||||||
|
vmid={{ entry.vmid }}
|
||||||
|
max_age={{ entry.max_age_hours }}
|
||||||
|
latest=$(get_latest_snapshot "$vmid")
|
||||||
|
if [ -z "$latest" ]; then
|
||||||
|
log "FAIL L0: vmid $vmid — no snapshots in PBS"
|
||||||
|
printf 'homelab_backup_audit_snapshot_age_hours{profile="pbs",vmid="%s"} -1\n' "$vmid" >> "$METRICS_TMP"
|
||||||
|
printf 'homelab_backup_audit_snapshot_success{profile="pbs",vmid="%s"} 0\n' "$vmid" >> "$METRICS_TMP"
|
||||||
|
STATUS=FAIL
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
ts_str=$(printf '%s' "$latest" | sed -n 's#.*/\([0-9T:Z-]*\)$#\1#p')
|
||||||
|
snap_epoch=$(date -d "$ts_str" +%s 2>/dev/null || echo 0)
|
||||||
|
age_hours=$(( (NOW_EPOCH - snap_epoch) / 3600 ))
|
||||||
|
if [ "$age_hours" -gt "$max_age" ]; then
|
||||||
|
log "FAIL L0: vmid $vmid — latest snapshot age ${age_hours}h > ${max_age}h (snapshot: $ts_str)"
|
||||||
|
printf 'homelab_backup_audit_snapshot_success{profile="pbs",vmid="%s"} 0\n' "$vmid" >> "$METRICS_TMP"
|
||||||
|
STATUS=FAIL
|
||||||
|
else
|
||||||
|
log "OK L0: vmid $vmid — latest snapshot age ${age_hours}h"
|
||||||
|
printf 'homelab_backup_audit_snapshot_success{profile="pbs",vmid="%s"} 1\n' "$vmid" >> "$METRICS_TMP"
|
||||||
|
fi
|
||||||
|
printf 'homelab_backup_audit_snapshot_age_hours{profile="pbs",vmid="%s"} %s\n' "$vmid" "$age_hours" >> "$METRICS_TMP"
|
||||||
|
}
|
||||||
|
audit_vmid_{{ entry.vmid }}
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
if [ "$STATUS" = "OK" ]; then
|
||||||
|
log "AUDIT PASSED"
|
||||||
|
printf 'homelab_backup_audit_success{profile="pbs"} 1\n' >> "$METRICS_TMP"
|
||||||
|
printf 'homelab_backup_audit_timestamp_seconds{profile="pbs"} %s\n' "$NOW_EPOCH" >> "$METRICS_TMP"
|
||||||
|
publish_metrics
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
log "AUDIT FAILED"
|
||||||
|
printf 'homelab_backup_audit_success{profile="pbs"} 0\n' >> "$METRICS_TMP"
|
||||||
|
printf 'homelab_backup_audit_timestamp_seconds{profile="pbs"} %s\n' "$NOW_EPOCH" >> "$METRICS_TMP"
|
||||||
|
publish_metrics
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Managed by Ansible: HomeLab restic offsite backup audit (L0+L1+L2)
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$#" -ne 1 ]; then
|
||||||
|
echo "usage: $0 <profile>" >&2
|
||||||
|
exit 64
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROFILE="$1"
|
||||||
|
case "$PROFILE" in
|
||||||
|
''|*[!A-Za-z0-9_-]*)
|
||||||
|
echo "invalid profile name" >&2
|
||||||
|
exit 64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
RESTIC_ENV="/etc/homelab-restic/${PROFILE}.env"
|
||||||
|
AUDIT_ENV="/etc/homelab-backup-audit/${PROFILE}.env"
|
||||||
|
LOG_FILE="{{ backup_audit_log_file }}"
|
||||||
|
TMP="/var/lib/homelab-backup-audit/${PROFILE}"
|
||||||
|
METRICS_DIR="{{ backup_audit_metrics_dir }}"
|
||||||
|
METRICS_FILE="$METRICS_DIR/homelab_backup_audit_${PROFILE}.prom"
|
||||||
|
METRICS_TMP=$(mktemp "$METRICS_FILE.XXXXXX")
|
||||||
|
STATUS=OK
|
||||||
|
|
||||||
|
trap 'rm -f "$METRICS_TMP"' EXIT
|
||||||
|
|
||||||
|
publish_metrics() {
|
||||||
|
chmod 0644 "$METRICS_TMP"
|
||||||
|
mv "$METRICS_TMP" "$METRICS_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
for f in "$RESTIC_ENV" "$AUDIT_ENV"; do
|
||||||
|
if [ ! -f "$f" ]; then
|
||||||
|
echo "[$(date '+%Y-%m-%dT%H:%M:%S%z')] [${PROFILE}] FAIL: missing env file $f" | tee -a "$LOG_FILE"
|
||||||
|
printf 'homelab_backup_audit_success{profile="%s"} 0\n' "$PROFILE" > "$METRICS_TMP"
|
||||||
|
printf 'homelab_backup_audit_timestamp_seconds{profile="%s"} %s\n' "$PROFILE" "$(date +%s)" >> "$METRICS_TMP"
|
||||||
|
publish_metrics
|
||||||
|
exit 66
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
set -a
|
||||||
|
. "$RESTIC_ENV"
|
||||||
|
. "$AUDIT_ENV"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
export RESTIC_REPOSITORY RESTIC_PASSWORD_FILE RCLONE_CONFIG
|
||||||
|
|
||||||
|
ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
|
||||||
|
log() { echo "[$(ts)] [${PROFILE}] $*" | tee -a "$LOG_FILE"; }
|
||||||
|
|
||||||
|
# ── L0: freshness ──────────────────────────────────────────────────────────
|
||||||
|
latest_time=$(restic snapshots --latest 1 --json 2>/dev/null | jq -r '.[0].time // empty')
|
||||||
|
if [ -z "$latest_time" ]; then
|
||||||
|
log "FAIL L0: no snapshots found in repository"
|
||||||
|
printf 'homelab_backup_audit_success{profile="%s"} 0\n' "$PROFILE" > "$METRICS_TMP"
|
||||||
|
printf 'homelab_backup_audit_timestamp_seconds{profile="%s"} %s\n' "$PROFILE" "$(date +%s)" >> "$METRICS_TMP"
|
||||||
|
publish_metrics
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
now_epoch=$(date +%s)
|
||||||
|
snap_epoch=$(date -d "$latest_time" +%s 2>/dev/null || echo 0)
|
||||||
|
age_hours=$(( (now_epoch - snap_epoch) / 3600 ))
|
||||||
|
max_age="${HOMELAB_AUDIT_MAX_AGE_HOURS:-36}"
|
||||||
|
|
||||||
|
if [ "$age_hours" -gt "$max_age" ]; then
|
||||||
|
log "FAIL L0: latest snapshot age ${age_hours}h > ${max_age}h (snapshot: $latest_time)"
|
||||||
|
STATUS=FAIL
|
||||||
|
else
|
||||||
|
log "OK L0: latest snapshot age ${age_hours}h"
|
||||||
|
fi
|
||||||
|
printf 'homelab_backup_audit_snapshot_age_hours{profile="%s"} %s\n' "$PROFILE" "$age_hours" >> "$METRICS_TMP"
|
||||||
|
|
||||||
|
# ── L1: repository integrity ───────────────────────────────────────────────
|
||||||
|
if restic check 2>&1 | tee -a "$LOG_FILE"; then
|
||||||
|
log "OK L1: restic check passed"
|
||||||
|
printf 'homelab_backup_audit_level_success{profile="%s",level="l1"} 1\n' "$PROFILE" >> "$METRICS_TMP"
|
||||||
|
else
|
||||||
|
log "FAIL L1: restic check failed"
|
||||||
|
printf 'homelab_backup_audit_level_success{profile="%s",level="l1"} 0\n' "$PROFILE" >> "$METRICS_TMP"
|
||||||
|
STATUS=FAIL
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── L2: SQLite restore + integrity ─────────────────────────────────────────
|
||||||
|
if [ -n "${HOMELAB_AUDIT_SQLITE_NAME:-}" ]; then
|
||||||
|
case "$TMP" in
|
||||||
|
/var/lib/homelab-backup-audit/[A-Za-z0-9_-]*) ;;
|
||||||
|
*)
|
||||||
|
log "FAIL L2: unsafe temporary path"
|
||||||
|
exit 64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
rm -rf "$TMP"
|
||||||
|
mkdir -p "$TMP"
|
||||||
|
|
||||||
|
if restic restore latest --target "$TMP" --include "**/${HOMELAB_AUDIT_SQLITE_NAME}" 2>&1 | tee -a "$LOG_FILE"; then
|
||||||
|
db=$(find "$TMP" -name "$HOMELAB_AUDIT_SQLITE_NAME" -type f | head -1)
|
||||||
|
if [ -z "$db" ] || [ ! -f "$db" ]; then
|
||||||
|
log "FAIL L2: $HOMELAB_AUDIT_SQLITE_NAME not found in restored data"
|
||||||
|
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 0\n' "$PROFILE" >> "$METRICS_TMP"
|
||||||
|
STATUS=FAIL
|
||||||
|
else
|
||||||
|
check=$(sqlite3 "$db" "PRAGMA integrity_check;" 2>&1)
|
||||||
|
if [ "$check" = "ok" ]; then
|
||||||
|
log "OK L2: SQLite integrity_check ok ($db)"
|
||||||
|
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 1\n' "$PROFILE" >> "$METRICS_TMP"
|
||||||
|
else
|
||||||
|
log "FAIL L2: SQLite integrity_check: $check"
|
||||||
|
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 0\n' "$PROFILE" >> "$METRICS_TMP"
|
||||||
|
STATUS=FAIL
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "FAIL L2: restic restore failed for $HOMELAB_AUDIT_SQLITE_NAME"
|
||||||
|
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 0\n' "$PROFILE" >> "$METRICS_TMP"
|
||||||
|
STATUS=FAIL
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$TMP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── L2: expected file restore ───────────────────────────────────────────────
|
||||||
|
if [ -n "${HOMELAB_AUDIT_EXPECTED_NAME:-}" ]; then
|
||||||
|
case "$TMP" in
|
||||||
|
/var/lib/homelab-backup-audit/[A-Za-z0-9_-]*) ;;
|
||||||
|
*)
|
||||||
|
log "FAIL L2: unsafe temporary path"
|
||||||
|
exit 64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
rm -rf "$TMP"
|
||||||
|
mkdir -p "$TMP"
|
||||||
|
|
||||||
|
if restic restore latest --target "$TMP" --include "**/${HOMELAB_AUDIT_EXPECTED_NAME}" 2>&1 | tee -a "$LOG_FILE"; then
|
||||||
|
expected=$(find "$TMP" -name "$HOMELAB_AUDIT_EXPECTED_NAME" -type f | head -1)
|
||||||
|
if [ -n "$expected" ] && [ -s "$expected" ]; then
|
||||||
|
log "OK L2: restored non-empty expected file ($expected)"
|
||||||
|
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 1\n' "$PROFILE" >> "$METRICS_TMP"
|
||||||
|
else
|
||||||
|
log "FAIL L2: $HOMELAB_AUDIT_EXPECTED_NAME not found or empty"
|
||||||
|
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 0\n' "$PROFILE" >> "$METRICS_TMP"
|
||||||
|
STATUS=FAIL
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "FAIL L2: restic restore failed for $HOMELAB_AUDIT_EXPECTED_NAME"
|
||||||
|
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 0\n' "$PROFILE" >> "$METRICS_TMP"
|
||||||
|
STATUS=FAIL
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$TMP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$STATUS" = "OK" ]; then
|
||||||
|
log "AUDIT PASSED"
|
||||||
|
printf 'homelab_backup_audit_success{profile="%s"} 1\n' "$PROFILE" >> "$METRICS_TMP"
|
||||||
|
printf 'homelab_backup_audit_timestamp_seconds{profile="%s"} %s\n' "$PROFILE" "$(date +%s)" >> "$METRICS_TMP"
|
||||||
|
publish_metrics
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
log "AUDIT FAILED"
|
||||||
|
printf 'homelab_backup_audit_success{profile="%s"} 0\n' "$PROFILE" >> "$METRICS_TMP"
|
||||||
|
printf 'homelab_backup_audit_timestamp_seconds{profile="%s"} %s\n' "$PROFILE" "$(date +%s)" >> "$METRICS_TMP"
|
||||||
|
publish_metrics
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -30,6 +30,23 @@ alias gs='git status --short --branch'
|
|||||||
alias gd='git diff'
|
alias gd='git diff'
|
||||||
alias gl='git log --oneline --decorate -10'
|
alias gl='git log --oneline --decorate -10'
|
||||||
|
|
||||||
|
{% if bash_config_proxy_http_url | default('') | length > 0 %}
|
||||||
|
proxy_on() {
|
||||||
|
export http_proxy="{{ bash_config_proxy_http_url }}"
|
||||||
|
export https_proxy="{{ bash_config_proxy_http_url }}"
|
||||||
|
export all_proxy="{{ bash_config_proxy_socks_url }}"
|
||||||
|
export HTTP_PROXY="$http_proxy"
|
||||||
|
export HTTPS_PROXY="$https_proxy"
|
||||||
|
export ALL_PROXY="$all_proxy"
|
||||||
|
export no_proxy="localhost,127.0.0.1,::1,192.168.1.0/24"
|
||||||
|
export NO_PROXY="$no_proxy"
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_off() {
|
||||||
|
unset http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY no_proxy NO_PROXY
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
if command -v systemctl >/dev/null 2>&1; then
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
alias sctl='systemctl'
|
alias sctl='systemctl'
|
||||||
alias jctl='journalctl'
|
alias jctl='journalctl'
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# compose_service
|
||||||
|
|
||||||
|
Раскладывает Docker Compose стек и systemd-юнит, который им управляет.
|
||||||
|
Обобщение того, что делает `playbooks/pve-grimmory.yml`; такой же по форме
|
||||||
|
код продублирован в `roles/monitoring_server` и `roles/uptime_kuma`
|
||||||
|
(`grep -rn "up -d --remove-orphans"` — четыре копии одного юнита).
|
||||||
|
|
||||||
|
## Что делает
|
||||||
|
|
||||||
|
1. Создаёт корневой каталог сервиса и, при необходимости, каталоги данных
|
||||||
|
с нужными owner/group/mode.
|
||||||
|
2. Генерирует `.env` **один раз** под `umask 077`: статические пары из
|
||||||
|
`compose_service_env_static` и случайные секреты
|
||||||
|
(`openssl rand -hex`) для имён из `compose_service_env_generated`.
|
||||||
|
Задача целиком под `no_log: true`; права форсируются в `0600`.
|
||||||
|
Повторный прогон существующий `.env` не перетирает — иначе поменялся бы
|
||||||
|
пароль работающей БД.
|
||||||
|
3. Кладёт `compose.yml` из inline-строки или из Jinja-шаблона.
|
||||||
|
4. Ставит systemd-юнит `Type=oneshot`, `RemainAfterExit=yes`,
|
||||||
|
`ExecStart=docker compose up -d --remove-orphans`,
|
||||||
|
`ExecStop=docker compose down`.
|
||||||
|
5. `daemon-reload` через handler + немедленный `meta: flush_handlers`
|
||||||
|
(перечитать юнит надо ДО `systemctl start`, а не в конце play).
|
||||||
|
6. Валидирует конфигурацию: `docker compose config --quiet`, `no_log: true`
|
||||||
|
— при ошибке вывод подставляет значения из `.env`.
|
||||||
|
7. Стартует сервис, перезапуская его только если изменился `compose.yml`,
|
||||||
|
юнит или сработал внешний триггер (`compose_service_restart_triggers`).
|
||||||
|
8. Ждёт health-endpoint через `uri` с `retries`/`until`.
|
||||||
|
|
||||||
|
## Переменные
|
||||||
|
|
||||||
|
Полный список — в `defaults/main.yml`. Обязательные: `compose_service_name`
|
||||||
|
и ровно один из `compose_service_compose_content` /
|
||||||
|
`compose_service_compose_template` (проверяется `assert` в начале роли).
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `compose_service_name` | — | имя сервиса и systemd-юнита |
|
||||||
|
| `compose_service_root` | `/opt/<name>` | корень стека |
|
||||||
|
| `compose_service_compose_content` | `""` | inline `compose.yml` |
|
||||||
|
| `compose_service_compose_template` | `""` | путь к Jinja-шаблону |
|
||||||
|
| `compose_service_directories` | `[]` | каталоги данных |
|
||||||
|
| `compose_service_env_static` | `{}` | пары для `.env` |
|
||||||
|
| `compose_service_env_generated` | `[]` | имена случайных секретов |
|
||||||
|
| `compose_service_after` / `_requires` | `[]` | доп. юниты в `After=`/`Requires=` |
|
||||||
|
| `compose_service_restart_triggers` | `[]` | внешние причины рестарта |
|
||||||
|
| `compose_service_health_url` | `""` | URL health-check (пусто — пропустить) |
|
||||||
|
|
||||||
|
## Пример использования — как выглядел бы Gitea на новых ролях
|
||||||
|
|
||||||
|
`playbooks/pve-gitea.yml` сейчас 231 строка. На ролях `pve_lxc` +
|
||||||
|
`lxc_docker_host` + `compose_service` содержательная часть сводится
|
||||||
|
примерно к такому (сам стек Gitea пока НЕ мигрирован — это следующий этап):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
- name: Create the Gitea LXC on cloud-pc
|
||||||
|
hosts: localhost
|
||||||
|
connection: local
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
gitea: "{{ homelab_services.gitea }}"
|
||||||
|
roles:
|
||||||
|
- role: pve_lxc
|
||||||
|
pve_lxc_vmid: "{{ gitea.vmid }}"
|
||||||
|
pve_lxc_node: "{{ gitea.node }}"
|
||||||
|
pve_lxc_hostname: "{{ gitea.hostname }}"
|
||||||
|
pve_lxc_ip: "{{ gitea.ip }}/24"
|
||||||
|
pve_lxc_disk: "{{ gitea.lxc.disk }}"
|
||||||
|
pve_lxc_cores: "{{ gitea.lxc.cores }}"
|
||||||
|
pve_lxc_memory: "{{ gitea.lxc.memory }}"
|
||||||
|
pve_lxc_swap: "{{ gitea.lxc.swap }}"
|
||||||
|
pve_lxc_startup: "{{ gitea.lxc.startup }}"
|
||||||
|
pve_lxc_ostemplate: "{{ gitea.lxc.ostemplate }}"
|
||||||
|
|
||||||
|
- name: Configure the Gitea service
|
||||||
|
hosts: gitea
|
||||||
|
gather_facts: true
|
||||||
|
vars:
|
||||||
|
gitea: "{{ homelab_services.gitea }}"
|
||||||
|
roles:
|
||||||
|
- role: lxc_docker_host
|
||||||
|
lxc_docker_host_extra_packages: [sqlite3, rsync]
|
||||||
|
lxc_docker_host_ufw_service_rules:
|
||||||
|
- port: "3000"
|
||||||
|
sources: ["{{ homelab_lan_cidr }}", "{{ openvpn_network_cidr }}"]
|
||||||
|
- port: "2222"
|
||||||
|
sources: ["{{ homelab_lan_cidr }}", "{{ openvpn_network_cidr }}"]
|
||||||
|
|
||||||
|
- role: compose_service
|
||||||
|
compose_service_name: gitea
|
||||||
|
compose_service_root: /opt/gitea
|
||||||
|
compose_service_description: Gitea Compose stack
|
||||||
|
compose_service_directories:
|
||||||
|
- {path: /opt/gitea/data, owner: "1000", group: "1000", mode: "0750"}
|
||||||
|
compose_service_env_static:
|
||||||
|
USER_UID: "1000"
|
||||||
|
USER_GID: "1000"
|
||||||
|
compose_service_compose_content: |
|
||||||
|
services:
|
||||||
|
gitea:
|
||||||
|
image: {{ gitea.images[0] }}
|
||||||
|
container_name: gitea
|
||||||
|
environment:
|
||||||
|
USER_UID: "${USER_UID}"
|
||||||
|
USER_GID: "${USER_GID}"
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
- "2222:22"
|
||||||
|
volumes:
|
||||||
|
- /opt/gitea/data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
compose_service_health_url: http://127.0.0.1:3000/api/healthz
|
||||||
|
compose_service_health_retries: 24
|
||||||
|
compose_service_health_delay: 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Около 30 строк `vars` вместо 231 строки процедурного кода, и все факты
|
||||||
|
(vmid, узел, адрес, ресурсы, digest образа) берутся из реестра
|
||||||
|
`homelab_services`, а не дублируются в плейбуке.
|
||||||
|
|
||||||
|
### Что при такой миграции меняется на живом хосте
|
||||||
|
|
||||||
|
Это не чистый рефакторинг, поэтому мигрировать нужно осознанно:
|
||||||
|
|
||||||
|
* `docker run` в `ExecStart=` заменяется на compose-стек — контейнер
|
||||||
|
пересоздаётся, юнит `gitea.service` меняет тип на `oneshot`.
|
||||||
|
* появляется `/opt/gitea/.env`, которого раньше не было;
|
||||||
|
* `--pull never` и явный `docker pull` по digest заменяются на `image:`
|
||||||
|
в compose — политику закрепления образов надо перенести отдельно.
|
||||||
|
|
||||||
|
Поэтому перевод существующих сервисов вынесен в отдельный этап и делается
|
||||||
|
по одному сервису, с бэкапом и `--check --diff` перед реальным прогоном.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
# ============================================================================
|
||||||
|
# roles/compose_service — раскладка Docker Compose стека + systemd-юнита.
|
||||||
|
# Обобщает то, что делает pve-grimmory.yml (compose.yml, .env с секретами,
|
||||||
|
# oneshot-юнит, валидация, health-check).
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# --- Обязательное ----------------------------------------------------------
|
||||||
|
# Имя сервиса. Оно же имя systemd-юнита (<name>.service) и имя каталога
|
||||||
|
# по умолчанию. Роль падает с понятным сообщением, если не задано.
|
||||||
|
compose_service_name: ""
|
||||||
|
|
||||||
|
# Содержимое compose.yml. Ровно один из двух способов:
|
||||||
|
# compose_service_compose_content — готовая строка (можно собрать в vars);
|
||||||
|
# compose_service_compose_template — путь к Jinja-шаблону в вызывающей роли
|
||||||
|
# или в playbooks/templates.
|
||||||
|
compose_service_compose_content: ""
|
||||||
|
compose_service_compose_template: ""
|
||||||
|
|
||||||
|
# --- Раскладка на диске ----------------------------------------------------
|
||||||
|
compose_service_root: "/opt/{{ compose_service_name }}"
|
||||||
|
compose_service_root_mode: "0750"
|
||||||
|
compose_service_owner: root
|
||||||
|
compose_service_group: root
|
||||||
|
compose_service_compose_file: compose.yml
|
||||||
|
compose_service_compose_mode: "0644"
|
||||||
|
|
||||||
|
# Дополнительные каталоги данных. Формат:
|
||||||
|
# - {path: /opt/grimmory/data, owner: "1000", group: "1000", mode: "0750"}
|
||||||
|
compose_service_directories: []
|
||||||
|
|
||||||
|
# --- Файл окружения --------------------------------------------------------
|
||||||
|
compose_service_env_path: "{{ compose_service_root }}/.env"
|
||||||
|
compose_service_env_mode: "0600"
|
||||||
|
|
||||||
|
# Пары ключ-значение, записываемые в .env как есть.
|
||||||
|
compose_service_env_static: {}
|
||||||
|
# Имена переменных, значения которых генерируются `openssl rand -hex` ОДИН РАЗ.
|
||||||
|
# Файл .env создаётся только если его ещё нет: повторный прогон не перетирает
|
||||||
|
# уже используемые пароли. Чтобы поменять секрет — удалите .env вручную.
|
||||||
|
compose_service_env_generated: []
|
||||||
|
compose_service_env_secret_bytes: 32
|
||||||
|
|
||||||
|
# Необязательный .env.example для документирования формата (без секретов!).
|
||||||
|
compose_service_env_example: ""
|
||||||
|
compose_service_env_example_mode: "0644"
|
||||||
|
|
||||||
|
# --- systemd ---------------------------------------------------------------
|
||||||
|
compose_service_description: "{{ compose_service_name }} Compose stack"
|
||||||
|
# Дополнительные юниты в After= / Requires= (docker.service уже включён).
|
||||||
|
compose_service_after: []
|
||||||
|
compose_service_requires: []
|
||||||
|
compose_service_unit_path: "/etc/systemd/system/{{ compose_service_name }}.service"
|
||||||
|
compose_service_docker_binary: /usr/bin/docker
|
||||||
|
compose_service_enabled: true
|
||||||
|
# Дополнительные условия рестарта: список булевых значений от вызывающего
|
||||||
|
# (например, результат pull образа).
|
||||||
|
compose_service_restart_triggers: []
|
||||||
|
|
||||||
|
# --- Валидация и health-check ---------------------------------------------
|
||||||
|
# `docker compose config --quiet` перед стартом. Выполняется с no_log,
|
||||||
|
# потому что вывод при ошибке может содержать значения из .env.
|
||||||
|
compose_service_validate: true
|
||||||
|
|
||||||
|
# URL health-check после старта. Пустая строка — проверка пропускается.
|
||||||
|
compose_service_health_url: ""
|
||||||
|
compose_service_health_status: [200]
|
||||||
|
compose_service_health_retries: 24
|
||||||
|
compose_service_health_delay: 5
|
||||||
|
compose_service_health_follow_redirects: safe
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
# Юнит должен быть перечитан ДО задачи enable/start, поэтому вызывающая роль
|
||||||
|
# сразу после установки юнита делает `meta: flush_handlers`.
|
||||||
|
- name: Reload systemd for compose services
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
daemon_reload: true
|
||||||
|
listen: compose_service_daemon_reload
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
# Зависимостей у роли нет: подготовка хоста и раскладка стека независимы
|
||||||
|
# и подключаются в нужном порядке из плейбука.
|
||||||
|
dependencies: []
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
---
|
||||||
|
- name: Validate compose_service parameters
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- compose_service_name | length > 0
|
||||||
|
- (compose_service_compose_content | length > 0)
|
||||||
|
!= (compose_service_compose_template | length > 0)
|
||||||
|
fail_msg: >-
|
||||||
|
Задайте compose_service_name и ровно один из
|
||||||
|
compose_service_compose_content / compose_service_compose_template.
|
||||||
|
|
||||||
|
- name: Ensure the service root directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ compose_service_root }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ compose_service_owner }}"
|
||||||
|
group: "{{ compose_service_group }}"
|
||||||
|
mode: "{{ compose_service_root_mode }}"
|
||||||
|
|
||||||
|
- name: Ensure the service data directories exist
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item.path }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ item.owner | default(compose_service_owner) }}"
|
||||||
|
group: "{{ item.group | default(compose_service_group) }}"
|
||||||
|
mode: "{{ item.mode | default('0750') }}"
|
||||||
|
loop: "{{ compose_service_directories }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.path }}"
|
||||||
|
|
||||||
|
# --- Файл окружения --------------------------------------------------------
|
||||||
|
# Создаётся один раз под umask 077. Секреты никогда не попадают в вывод:
|
||||||
|
# задача целиком под no_log.
|
||||||
|
- name: Generate the environment file once
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
if [ -e {{ compose_service_env_path | quote }} ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
: > {{ compose_service_env_path | quote }}
|
||||||
|
{% for key, value in compose_service_env_static.items() %}
|
||||||
|
printf '%s\n' {{ (key ~ '=' ~ value) | quote }} >> {{ compose_service_env_path | quote }}
|
||||||
|
{% endfor %}
|
||||||
|
{% for key in compose_service_env_generated %}
|
||||||
|
printf '%s=%s\n' {{ key | quote }} "$(openssl rand -hex {{ compose_service_env_secret_bytes }})" >> {{ compose_service_env_path | quote }}
|
||||||
|
{% endfor %}
|
||||||
|
printf created
|
||||||
|
args:
|
||||||
|
executable: /bin/sh
|
||||||
|
register: compose_service_env_result
|
||||||
|
changed_when: compose_service_env_result.stdout == 'created'
|
||||||
|
no_log: true
|
||||||
|
when: >-
|
||||||
|
compose_service_env_static | length > 0 or
|
||||||
|
compose_service_env_generated | length > 0
|
||||||
|
|
||||||
|
- name: Enforce the environment file permissions
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ compose_service_env_path }}"
|
||||||
|
owner: "{{ compose_service_owner }}"
|
||||||
|
group: "{{ compose_service_group }}"
|
||||||
|
mode: "{{ compose_service_env_mode }}"
|
||||||
|
when: >-
|
||||||
|
compose_service_env_static | length > 0 or
|
||||||
|
compose_service_env_generated | length > 0
|
||||||
|
|
||||||
|
- name: Install the environment example
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ compose_service_env_path }}.example"
|
||||||
|
owner: "{{ compose_service_owner }}"
|
||||||
|
group: "{{ compose_service_group }}"
|
||||||
|
mode: "{{ compose_service_env_example_mode }}"
|
||||||
|
content: "{{ compose_service_env_example }}"
|
||||||
|
when: compose_service_env_example | length > 0
|
||||||
|
|
||||||
|
# --- compose.yml -----------------------------------------------------------
|
||||||
|
- name: Install the Compose configuration from inline content
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ compose_service_root }}/{{ compose_service_compose_file }}"
|
||||||
|
owner: "{{ compose_service_owner }}"
|
||||||
|
group: "{{ compose_service_group }}"
|
||||||
|
mode: "{{ compose_service_compose_mode }}"
|
||||||
|
content: "{{ compose_service_compose_content }}"
|
||||||
|
when: compose_service_compose_content | length > 0
|
||||||
|
register: compose_service_compose_inline
|
||||||
|
|
||||||
|
- name: Install the Compose configuration from a template
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: "{{ compose_service_compose_template }}"
|
||||||
|
dest: "{{ compose_service_root }}/{{ compose_service_compose_file }}"
|
||||||
|
owner: "{{ compose_service_owner }}"
|
||||||
|
group: "{{ compose_service_group }}"
|
||||||
|
mode: "{{ compose_service_compose_mode }}"
|
||||||
|
when: compose_service_compose_template | length > 0
|
||||||
|
register: compose_service_compose_templated
|
||||||
|
|
||||||
|
- name: Record whether the Compose configuration changed
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
compose_service_compose_changed: >-
|
||||||
|
{{ (compose_service_compose_inline.changed | default(false)) or
|
||||||
|
(compose_service_compose_templated.changed | default(false)) }}
|
||||||
|
|
||||||
|
# --- systemd ---------------------------------------------------------------
|
||||||
|
- name: Install the systemd unit
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ compose_service_unit_path }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description={{ compose_service_description }}
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target docker.service{{ (' ' ~ compose_service_after | join(' ')) if compose_service_after else '' }}
|
||||||
|
Requires=docker.service{{ (' ' ~ compose_service_requires | join(' ')) if compose_service_requires else '' }}
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
WorkingDirectory={{ compose_service_root }}
|
||||||
|
ExecStart={{ compose_service_docker_binary }} compose -f {{ compose_service_root }}/{{ compose_service_compose_file }} up -d --remove-orphans
|
||||||
|
ExecStop={{ compose_service_docker_binary }} compose -f {{ compose_service_root }}/{{ compose_service_compose_file }} down
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
register: compose_service_unit
|
||||||
|
notify: compose_service_daemon_reload
|
||||||
|
|
||||||
|
- name: Apply the pending systemd daemon reload
|
||||||
|
ansible.builtin.meta: flush_handlers
|
||||||
|
|
||||||
|
# --- Валидация -------------------------------------------------------------
|
||||||
|
# no_log: сообщение об ошибке `docker compose config` подставляет значения
|
||||||
|
# переменных из .env.
|
||||||
|
- name: Validate the Compose configuration
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- "{{ compose_service_docker_binary }}"
|
||||||
|
- compose
|
||||||
|
- -f
|
||||||
|
- "{{ compose_service_root }}/{{ compose_service_compose_file }}"
|
||||||
|
- config
|
||||||
|
- --quiet
|
||||||
|
args:
|
||||||
|
chdir: "{{ compose_service_root }}"
|
||||||
|
changed_when: false
|
||||||
|
no_log: true
|
||||||
|
when: compose_service_validate
|
||||||
|
|
||||||
|
# --- Запуск ----------------------------------------------------------------
|
||||||
|
- name: Enable and start the service
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: "{{ compose_service_name }}"
|
||||||
|
enabled: "{{ compose_service_enabled }}"
|
||||||
|
state: "{{ 'restarted' if compose_service_needs_restart else 'started' }}"
|
||||||
|
vars:
|
||||||
|
compose_service_needs_restart: >-
|
||||||
|
{{ (compose_service_compose_changed | bool) or
|
||||||
|
(compose_service_unit.changed | default(false)) or
|
||||||
|
(compose_service_restart_triggers | select | list | length > 0) }}
|
||||||
|
|
||||||
|
- name: Wait for the service health endpoint
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "{{ compose_service_health_url }}"
|
||||||
|
status_code: "{{ compose_service_health_status }}"
|
||||||
|
follow_redirects: "{{ compose_service_health_follow_redirects }}"
|
||||||
|
return_content: false
|
||||||
|
register: compose_service_health
|
||||||
|
retries: "{{ compose_service_health_retries }}"
|
||||||
|
delay: "{{ compose_service_health_delay }}"
|
||||||
|
until: compose_service_health.status in (compose_service_health_status | map('int') | list)
|
||||||
|
when: compose_service_health_url | length > 0
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
emergency_reverse_user: homelab-rescue
|
||||||
|
emergency_reverse_port: 22010
|
||||||
|
emergency_reverse_bind_address: 127.0.0.1
|
||||||
|
emergency_reverse_tunnel_user: emergency-tunnel
|
||||||
|
emergency_control_user: emergency-control
|
||||||
|
emergency_control_key_path: /etc/emergency-access/control_ed25519
|
||||||
|
emergency_tunnel_state_dir: /var/lib/emergency-reverse-ssh
|
||||||
|
emergency_reverse_key_path: /var/lib/emergency-reverse-ssh/reverse_ed25519
|
||||||
|
emergency_access_dir: /etc/emergency-access
|
||||||
|
emergency_tunnel_ttl: 60m
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
- name: Reload systemd
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
daemon_reload: true
|
||||||
|
|
||||||
|
- name: Validate and reload SSH
|
||||||
|
ansible.builtin.shell: sshd -t && systemctl reload ssh
|
||||||
|
changed_when: true
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
---
|
||||||
|
- name: Validate emergency access client inputs
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- emergency_vps_host_key is match('^ssh-(ed25519|rsa|ecdsa-[^ ]+) [A-Za-z0-9+/=]+( [A-Za-z0-9@._:-]+)?$')
|
||||||
|
- emergency_bot_control_public_key | length > 0
|
||||||
|
fail_msg: Set EMERGENCY_VPS_HOST_KEY and provision the emergency-bot control key first.
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Install reverse SSH client
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- openssh-client
|
||||||
|
- sudo
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Create emergency access service accounts
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: "{{ item }}"
|
||||||
|
system: true
|
||||||
|
# SSHD must start the forced command under a valid shell; authorized_keys
|
||||||
|
# still prevents this account from receiving an arbitrary command or PTY.
|
||||||
|
shell: "{{ '/bin/sh' if item == emergency_control_user else '/usr/sbin/nologin' }}"
|
||||||
|
create_home: true
|
||||||
|
loop:
|
||||||
|
- "{{ emergency_reverse_tunnel_user }}"
|
||||||
|
- "{{ emergency_control_user }}"
|
||||||
|
|
||||||
|
- name: Create emergency access configuration directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ emergency_access_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Create reverse SSH state directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ emergency_tunnel_state_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ emergency_reverse_tunnel_user }}"
|
||||||
|
group: "{{ emergency_reverse_tunnel_user }}"
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
- name: Generate dedicated reverse SSH key
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: "ssh-keygen -q -t ed25519 -N '' -f {{ emergency_reverse_key_path }}"
|
||||||
|
creates: "{{ emergency_reverse_key_path }}"
|
||||||
|
become_user: "{{ emergency_reverse_tunnel_user }}"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Read reverse SSH public key
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: "{{ emergency_reverse_key_path }}.pub"
|
||||||
|
register: emergency_reverse_key
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Store reverse SSH public key for VPS configuration
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
emergency_reverse_public_key: "{{ emergency_reverse_key.content | b64decode | trim }}"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Pin ru-vps SSH host key for the tunnel user
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ emergency_tunnel_state_dir }}/known_hosts"
|
||||||
|
content: "ru-vps-emergency {{ emergency_vps_host_key }}\n"
|
||||||
|
owner: "{{ emergency_reverse_tunnel_user }}"
|
||||||
|
group: "{{ emergency_reverse_tunnel_user }}"
|
||||||
|
mode: "0600"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Install reverse SSH systemd unit
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: emergency-reverse-ssh.service.j2
|
||||||
|
dest: /etc/systemd/system/emergency-reverse-ssh.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: Reload systemd
|
||||||
|
|
||||||
|
- name: Install emergency tunnel expiry unit
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: emergency-reverse-ssh-expire.service.j2
|
||||||
|
dest: /etc/systemd/system/emergency-reverse-ssh-expire.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: Reload systemd
|
||||||
|
|
||||||
|
- name: Install emergency tunnel expiry timer
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: emergency-reverse-ssh.timer.j2
|
||||||
|
dest: /etc/systemd/system/emergency-reverse-ssh.timer
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: Reload systemd
|
||||||
|
|
||||||
|
- name: Install restricted emergency control helper
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: emergency-control.j2
|
||||||
|
dest: /usr/local/libexec/emergency-control
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Install emergency control sudo policy
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: emergency-control.sudoers.j2
|
||||||
|
dest: /etc/sudoers.d/emergency-control
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0440"
|
||||||
|
validate: visudo -cf %s
|
||||||
|
|
||||||
|
- name: Authorize emergency-bot control key with forced command
|
||||||
|
ansible.posix.authorized_key:
|
||||||
|
user: "{{ emergency_control_user }}"
|
||||||
|
key: "command=\"/usr/local/libexec/emergency-control\",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty {{ emergency_bot_control_public_key }}"
|
||||||
|
state: present
|
||||||
|
exclusive: true
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Reload systemd before managing emergency units
|
||||||
|
ansible.builtin.meta: flush_handlers
|
||||||
|
|
||||||
|
- name: Keep emergency tunnel disabled at boot
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: "{{ item }}"
|
||||||
|
enabled: false
|
||||||
|
loop:
|
||||||
|
- emergency-reverse-ssh.service
|
||||||
|
- emergency-reverse-ssh.timer
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
- name: Validate emergency access endpoint input
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- emergency_reverse_public_key | length > 0
|
||||||
|
fail_msg: Configure the mini-pc reverse SSH client before ru-vps.
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Create restricted reverse SSH endpoint user
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: "{{ emergency_reverse_user }}"
|
||||||
|
system: true
|
||||||
|
# Remote forwarding is accepted before a session exists. A valid shell is
|
||||||
|
# required by SSHD; the forced command below rejects every session.
|
||||||
|
shell: /bin/sh
|
||||||
|
create_home: true
|
||||||
|
|
||||||
|
- name: Authorize only the dedicated reverse SSH key
|
||||||
|
ansible.posix.authorized_key:
|
||||||
|
user: "{{ emergency_reverse_user }}"
|
||||||
|
key: "command=\"/usr/bin/false\",restrict,port-forwarding,permitlisten=\"{{ emergency_reverse_bind_address }}:{{ emergency_reverse_port }}\" {{ emergency_reverse_public_key }}"
|
||||||
|
state: present
|
||||||
|
exclusive: true
|
||||||
|
no_log: true
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
case "${SSH_ORIGINAL_COMMAND:-}" in
|
||||||
|
start)
|
||||||
|
/usr/bin/sudo /usr/bin/systemctl start emergency-reverse-ssh.service
|
||||||
|
/usr/bin/sudo /usr/bin/systemctl restart emergency-reverse-ssh.timer
|
||||||
|
/usr/bin/systemctl is-active --quiet emergency-reverse-ssh.service
|
||||||
|
printf 'started; expires in {{ emergency_tunnel_ttl }}\nConnect:\nssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -J vps -p 22010 ansible@127.0.0.1\n'
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
/usr/bin/sudo /usr/bin/systemctl stop emergency-reverse-ssh.timer
|
||||||
|
/usr/bin/sudo /usr/bin/systemctl stop emergency-reverse-ssh.service
|
||||||
|
printf 'stopped\n'
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
if /usr/bin/systemctl is-active --quiet emergency-reverse-ssh.service; then
|
||||||
|
/usr/bin/systemctl show --property=ActiveState --property=ActiveEnterTimestamp --value emergency-reverse-ssh.service
|
||||||
|
else
|
||||||
|
printf 'stopped\n'
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'unsupported command\n' >&2
|
||||||
|
exit 64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{ emergency_control_user }} ALL=(root) NOPASSWD: /usr/bin/systemctl start emergency-reverse-ssh.service, /usr/bin/systemctl restart emergency-reverse-ssh.timer, /usr/bin/systemctl stop emergency-reverse-ssh.timer, /usr/bin/systemctl stop emergency-reverse-ssh.service
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Close expired HomeLab reverse SSH rescue tunnel
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/systemctl stop emergency-reverse-ssh.service
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Temporary HomeLab reverse SSH rescue tunnel
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User={{ emergency_reverse_tunnel_user }}
|
||||||
|
ExecStart=/usr/bin/ssh -N -i {{ emergency_reverse_key_path }} -o BatchMode=yes -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o StrictHostKeyChecking=yes -o UserKnownHostsFile={{ emergency_tunnel_state_dir }}/known_hosts -o HostKeyAlias=ru-vps-emergency -p 3422 -R {{ emergency_reverse_bind_address }}:{{ emergency_reverse_port }}:127.0.0.1:22 {{ emergency_reverse_user }}@157.22.231.198
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ReadWritePaths={{ emergency_tunnel_state_dir }}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Expire HomeLab reverse SSH rescue tunnel after {{ emergency_tunnel_ttl }}
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnActiveSec={{ emergency_tunnel_ttl }}
|
||||||
|
AccuracySec=1s
|
||||||
|
Unit=emergency-reverse-ssh-expire.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
emergency_bot_user: emergency-bot
|
||||||
|
emergency_bot_state_dir: /var/lib/emergency-bot
|
||||||
|
emergency_bot_config_dir: /etc/emergency-bot
|
||||||
|
emergency_bot_control_key_path: /var/lib/emergency-bot/control_ed25519
|
||||||
|
emergency_bot_mini_pc_host: 192.168.1.10
|
||||||
|
emergency_bot_mini_pc_user: emergency-control
|
||||||
|
emergency_telegram_proxy: http://192.168.1.27:7890
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
- name: Reload systemd
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
daemon_reload: true
|
||||||
|
|
||||||
|
- name: Restart emergency bot
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: emergency-bot.service
|
||||||
|
state: restarted
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
- name: Create emergency bot user
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: "{{ emergency_bot_user }}"
|
||||||
|
system: true
|
||||||
|
shell: /usr/sbin/nologin
|
||||||
|
create_home: false
|
||||||
|
|
||||||
|
- name: Create emergency bot state directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ emergency_bot_state_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ emergency_bot_user }}"
|
||||||
|
group: "{{ emergency_bot_user }}"
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
- name: Generate emergency bot control key
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: "ssh-keygen -q -t ed25519 -N '' -f {{ emergency_bot_control_key_path }}"
|
||||||
|
creates: "{{ emergency_bot_control_key_path }}"
|
||||||
|
become: true
|
||||||
|
become_user: "{{ emergency_bot_user }}"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Set emergency bot control key ownership
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item.path }}"
|
||||||
|
state: file
|
||||||
|
owner: "{{ emergency_bot_user }}"
|
||||||
|
group: "{{ emergency_bot_user }}"
|
||||||
|
mode: "{{ item.mode }}"
|
||||||
|
loop:
|
||||||
|
- path: "{{ emergency_bot_control_key_path }}"
|
||||||
|
mode: "0600"
|
||||||
|
- path: "{{ emergency_bot_control_key_path }}.pub"
|
||||||
|
mode: "0644"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Read emergency bot control public key
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: "{{ emergency_bot_control_key_path }}.pub"
|
||||||
|
register: emergency_bot_control_key
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Store emergency bot control public key for mini-pc configuration
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
emergency_bot_control_public_key: "{{ emergency_bot_control_key.content | b64decode | trim }}"
|
||||||
|
no_log: true
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
- name: Bootstrap emergency bot control identity
|
||||||
|
ansible.builtin.import_tasks: bootstrap.yml
|
||||||
|
|
||||||
|
- name: Validate emergency bot inputs
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- emergency_bot_token is match('^[0-9]+:[A-Za-z0-9_-]+$')
|
||||||
|
- emergency_bot_allowed_user_ids is match('^[0-9]+(,[0-9]+)*$')
|
||||||
|
- emergency_mini_pc_host_key is match('^ssh-(ed25519|rsa|ecdsa-[^ ]+) [A-Za-z0-9+/=]+( [A-Za-z0-9@._:-]+)?$')
|
||||||
|
fail_msg: Set EMERGENCY_BOT_TOKEN, EMERGENCY_ALLOWED_USER_IDS and EMERGENCY_MINI_PC_HOST_KEY.
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Install emergency bot dependencies
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- ca-certificates
|
||||||
|
- openssh-client
|
||||||
|
- python3
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Create emergency bot configuration directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ emergency_bot_config_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Pin mini-pc SSH host key
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ emergency_bot_state_dir }}/known_hosts"
|
||||||
|
content: "mini-pc-emergency {{ emergency_mini_pc_host_key }}\n"
|
||||||
|
owner: "{{ emergency_bot_user }}"
|
||||||
|
group: "{{ emergency_bot_user }}"
|
||||||
|
mode: "0600"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Install emergency bot runtime
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: emergency_bot.py.j2
|
||||||
|
dest: /usr/local/libexec/emergency-bot
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
notify: Restart emergency bot
|
||||||
|
|
||||||
|
- name: Install emergency bot environment
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: emergency-bot.env.j2
|
||||||
|
dest: "{{ emergency_bot_config_dir }}/bot.env"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
no_log: true
|
||||||
|
notify: Restart emergency bot
|
||||||
|
|
||||||
|
- name: Install emergency bot systemd unit
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: emergency-bot.service.j2
|
||||||
|
dest: /etc/systemd/system/emergency-bot.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify:
|
||||||
|
- Reload systemd
|
||||||
|
- Restart emergency bot
|
||||||
|
|
||||||
|
- name: Enable and start emergency bot
|
||||||
|
ansible.builtin.meta: flush_handlers
|
||||||
|
|
||||||
|
- name: Enable and start emergency bot
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: emergency-bot.service
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user