Compare commits

..
8 Commits
Author SHA1 Message Date
DmitryandClaude Opus 5 7700ed5a88 Sync documentation with the actual infrastructure
lint / yamllint + ansible-lint + syntax-check (push) Canceled after 0s
The host table listed 9 hosts against 16 in the inventory, the role tree
did not match roles/, and the documented setup path used a venv that no
longer works.

Describe the current entry points instead: nix develop, make, and the
ssh_config include that makes `ssh gitea` work by hand. Point at
`make docs` as the way to regenerate the host table rather than editing
it, since that is what drifted.

Also record what is deliberately incomplete: lxc_docker_host and
compose_service exist but are not wired into any playbook, and LXC
creation is still split between direct pct create over SSH and the
pve_lxc API role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
2026-08-26 22:10:38 +03:00
DmitryandClaude Opus 5 ef234b17f5 Add Makefile as the entry point for manual operation
Knowing how to run something required reading ansible/README.md and
remembering to source .env first. Provide `make help` instead, with
targets grouped by purpose and pattern rules for the repetitive families:
deploy-%, dry-%, update-% and play-%.

.env is sourced automatically; targets that need Proxmox credentials fail
with an actionable message when it is missing. Destructive targets --
mihomo-harden, which rotates live credentials, the frozen monitoring
stack, and update-all -- require CONFIRM=1.

The interpreter is resolved at runtime rather than hardcoded to .venv:
the repository's venv is currently broken, so the Makefile falls back to
whatever is on PATH, which is what the Nix devshell provides.

gen-inventory-docs.py prints the host and group tables from
ansible-inventory, so documentation can be regenerated instead of being
maintained by hand and drifting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
2026-08-26 22:10:29 +03:00
DmitryandClaude Opus 5 d535ef2d32 Add read-only infrastructure status playbook
One command to see the state of everything: reachability, uptime, disk
usage, service unit states, failed units, pct list on the Proxmox nodes,
OpenVPN transport health, and the last run of each backup job.

An unreachable host is reported as data, not as a run failure, so a
single host being down still produces a full summary. Every command is
changed_when: false with check_mode: false, so the playbook is read-only
and works under --check. Backup freshness is read from what systemd
already recorded rather than by invoking the audit scripts, which would
hit PBS and Yandex Disk and take locks.

Service units are derived from inventory groups where possible; only
app-specific units need the per-host map, and each was taken from the
playbook or role that installs it.

Verified against live infrastructure: 15 hosts, changed=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
2026-08-26 22:10:29 +03:00
DmitryandClaude Opus 5 9725d3ea7c Add service registry, shared roles and unified reverse proxy
Collect the facts about all 14 services -- VMID, node, address, ports,
domain, pinned images, resources, backup and monitoring participation --
into group_vars/all/services.yml. Values are taken from the existing
playbooks; gaps are marked null rather than invented.

Replace reverse-proxy-{gitea,vaultwarden,grimmory}.yml with a single
playbook iterating over registry entries that declare a domain. It keeps
every check the three had, preserves grimmory's richer Caddy block
byte-for-byte, and restarts Caddy once when any site changed instead of
up to three times. Verified with --check --diff against ru-vps: ok=6
changed=0, so it reproduces the current Caddyfile exactly.

Add two roles factoring out the skeleton duplicated across the pve-*
playbooks: lxc_docker_host (packages, /dev/fuse assertion, fuse-overlayfs
storage driver, UFW baseline) and compose_service (compose file, systemd
unit, config validation, health check). They are not wired into any
playbook yet -- migrating a live service is a separate, per-service step;
compose_service/README.md shows the Gitea example and spells out what
actually changes on the host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
2026-08-26 22:10:16 +03:00
DmitryandClaude Opus 5 ec3b736250 Move SSH transport to ssh_config and shared group_vars
hosts.yml repeated the same authentication block for 13 LXC hosts and
carried 13 byte-identical copies of the ru-vps ProxyCommand. Describe the
transport once in ansible/ssh_config instead: jump host, per-host users,
keys, and the fact that pbs and ovpn-mini are reached directly rather
than through ru-vps.

Ansible loads that file through ansible_ssh_common_args in
group_vars/all/main.yml, where the path is derived from inventory_dir so
it depends on neither the current directory nor the clone location.
The same file makes `ssh gitea` work from a plain terminal once
~/.ssh/config includes it.

hosts.yml drops from 209 to 137 lines and now holds only addresses and
per-host facts. Verified equivalent: ansible-inventory --list before and
after differ only by the removed ansible_ssh_common_args, with group
membership and ordering byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
2026-08-26 22:10:16 +03:00
DmitryandClaude Opus 5 a7b0635830 Add lint configuration and Gitea Actions CI
Configure yamllint and ansible-lint, plus a workflow running yamllint,
ansible-lint and ansible-playbook --syntax-check over every playbook.

ansible-lint uses the moderate profile: on the current code it reports
exactly the same violations as basic, so it costs nothing today while
holding a higher bar for new code. skip_list is empty; noisy legacy
rules go to warn_list with a comment on why and when to restore them.
Correctness and safety rules stay fatal.

Two constraints are encoded in the workflow: syntax-check must run from
ansible/ because roles_path is relative, and ansible-lint needs absolute
ANSIBLE_ROLES_PATH/ANSIBLE_COLLECTIONS_PATH when run from the root.

The runner is not registered yet; registration notes are in the workflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
2026-08-26 22:09:59 +03:00
DmitryandClaude Opus 5 b953909e0a Add reproducible Nix dev environment
Replace the Python venv with a Nix devshell pinning ansible-core 2.21.3,
ansible-lint, yamllint and a Python with proxmoxer/requests. The Python
dependencies share the interpreter that runs ansible, so pve-*.yml plays
on implicit localhost can import proxmoxer without inventory changes.

The shellHook exports absolute ANSIBLE_CONFIG, ANSIBLE_INVENTORY,
ANSIBLE_ROLES_PATH and ANSIBLE_COLLECTIONS_PATH, so commands work from
the repository root as well as from ansible/.

Also un-ignore .envrc, which the global gitignore hides, and ignore the
stray .ansible/ runtime directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
2026-08-26 22:09:59 +03:00
DmitryandClaude Opus 5 c676be81ec Capture current Ansible control plane state
Commit the accumulated infrastructure work that was living only in the
working tree: monitoring stack, emergency access/bot, gyro allocator,
grimmory, adguard, backup audit and the OpenCode agent definitions.

Also ignore Python bytecode, local archives and Nix/direnv artifacts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
2026-08-26 21:39:28 +03:00
152 changed files with 13427 additions and 214 deletions
+85
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# direnv: load the Nix dev shell defined in flake.nix
# Enable once per checkout with: direnv allow
use flake
+134
View File
@@ -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"
+20
View File
@@ -6,3 +6,23 @@ 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/
@@ -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.
+24
View File
@@ -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.
+24
View File
@@ -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.
+23
View File
@@ -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.
+201
View File
@@ -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.
+22
View File
@@ -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.
+23
View File
@@ -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.
+27
View File
@@ -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.
+69
View File
@@ -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
+361
View File
@@ -0,0 +1,361 @@
# AGENTS.md
## Project Summary
**HomeLab infras** is a GitOps-like repository for managing home infrastructure through Ansible.
- Management: `ansible/` (playbooks, roles, inventory)
- Documentation: Obsidian vault `/home/ada/Documents/Vaults/SecondBrain/02 Projects/HomeLab/`
- Active infrastructure: Proxmox VE cluster (`cloud-pc`, `mini-pc`), PBS, OpenVPN, ZeroTier
- Archive: `archive/2026-07-proxmox-migration/` (historical NixOS/Docker configs, reference only)
## Design Goal
**Ansible-first infrastructure**: HomeLab configuration is managed through Ansible inventory, roles and playbooks. Direct server changes are allowed only for break-glass recovery or read-only diagnostics; afterwards the intended state must be captured in Ansible.
## Obsidian Integration
Project documentation lives in `/home/ada/Documents/Vaults/SecondBrain/02 Projects/HomeLab/`. Use it as wiki:
- **Read** before making infra changes — context, decisions, constraints.
- **Update** after making changes — document non-obvious decisions, new patterns, lessons learned.
- Key files: `HomeLab.md`, `Notes/Текущее состояние HomeLab после миграции на Proxmox.md`, `Log.md`.
When introducing infra changes, update the relevant Obsidian note to keep documentation in sync.
## Operating Model
- User: passwords, SSH access, base network reachability.
- Agent: infrastructure changes via `ansible/` files.
- Prefer adding/updating a role or playbook over ad-hoc commands.
- For every new VM, LXC container, or managed host, provision the user's SSH public key by default unless explicitly told otherwise.
- Run smallest safe Ansible check before broader changes.
- Do not commit secrets (`.env`, tokens, keys, real passwords).
## Quick Start
Окружение собрано в Nix — venv и системные пакеты не нужны.
```bash
nix develop # или один раз: direnv allow
# Один раз на клон: galaxy-коллекции
ansible-galaxy collection install -r ansible/requirements.yml -p ansible/collections
```
Дальше всё управление идёт через `make` из каталога `ansible/`:
```bash
cd ansible
make help # список всех целей — начинать отсюда
make check # связность и ожидаемые IP
make status # сводное состояние всей инфраструктуры (read-only)
make lint # ansible-lint + yamllint
make docs # актуальная таблица хостов из inventory
make deploy-gitea # playbooks/pve-gitea.yml, .env подхватывается сам
make dry-gitea # то же в режиме --check --diff
make update-gitea # бэкап -> обновление -> health-check
```
`make` без аргументов печатает `help`. Опасные цели (`mihomo-harden`, `monitoring`,
`update-all`) требуют явного `CONFIRM=1`. Дополнительные флаги — через `EXTRA`:
```bash
make status EXTRA="--limit '!gyro'"
```
`gyro` использует шифрованный `host_vars/gyro/vault.yml`, поэтому для него нужен
`--ask-vault-pass` (цель `make gyro` добавляет флаг сама).
### SSH руками
Транспорт описан в `ansible/ssh_config` — один источник правды и для Ansible,
и для терминала. Чтобы заработал `ssh gitea`, добавь в `~/.ssh/config`:
```
Include /home/ada/Documents/Projects/HomeLab/infras/ansible/ssh_config
```
SSH берёт первое совпадение: Include в начале файла — побеждают настройки репозитория,
в конце — личные записи из `~/.ssh/config.d/`. На Ansible порядок не влияет, он ходит
с явным `-F`.
## Active Infrastructure
Каноничный источник — `ansible/inventory/hosts.yml` и реестр сервисов
`ansible/inventory/group_vars/all/services.yml`. Актуальную таблицу всегда можно
получить командой `make docs`, поэтому здесь — только опорная картина.
### Nodes
| Name | Role | LAN IP | Notes |
|---|---|---:|---|
| `ru-vps` | Public VPS, JumpHost, qdevice, OpenVPN server, Caddy | `157.22.231.198:3422` | OpenVPN `10.78.0.1` |
| `cloud-pc` | Proxmox VE node, storage | `192.168.1.5` | Main node |
| `mini-pc` | Proxmox VE node | `192.168.1.10` | Secondary node |
| `pbs` | Proxmox Backup Server LXC (CT 120) | `192.168.1.20` | Прямой доступ, без ProxyJump |
| `ovpn-mini` | OpenVPN gateway LXC (CT 132) | `192.168.1.23` | OpenVPN `10.78.0.2`, без ProxyJump |
| `vaultwarden` | Vaultwarden LXC (CT 140) | `192.168.1.24` | `pass.ada-dev.ru` |
| `gitea` | Gitea LXC (CT 141) | `192.168.1.25` | `git.ada-dev.ru`, SSH `:2222` |
| `memoir-bot` | Telegram memoir bot LXC (CT 142) | `192.168.1.26` | образ собирается локально |
| `mihomo` | Local proxy/UI LXC (CT 143) | `192.168.1.27` | UI `:8080`, API `:9090`, proxy `:7890/:7891` |
| `adguard` | AdGuard Home LXC (CT 144) | `192.168.1.28` | DNS `:53`, UI `:3000` |
| `docker-test` | Песочница/кандидат в CI-раннеры (CT 145) | `192.168.1.29` | |
| `monitoring` | Monitoring LXC (CT 146) | `192.168.1.30` | Uptime Kuma активен, Prometheus заморожен |
| `hermes-ai` | AI-хост (CT 147) | `192.168.1.31` | ходит наружу через mihomo |
| `emergency-bot` | Telegram-бот break-glass доступа (CT 148) | `192.168.1.32` | |
| `grimmory` | Grimmory + MariaDB (CT 149) | `192.168.1.34` | `books.ada-dev.ru` |
| `gyro` | Investment allocator (CT 150) | `192.168.1.35` | vault-переменные, outbound-only |
### Inventory Groups
- `homelab` — ru-vps
- `pve_nodes` — cloud-pc, mini-pc
- `lxc_infra` — все LXC (13 шт.)
- `monitoring_server` — monitoring
- `monitoring_exporters` — хосты с Node Exporter
- `monitoring_smart_exporters` — cloud-pc, mini-pc
- `vpn_openvpn` — ru-vps, ovpn-mini
- `shell_hosts` — ru-vps, cloud-pc, mini-pc, hermes-ai
- `servers` — все управляемые хосты
Общие переменные живут в `inventory/group_vars/`, а не в `hosts.yml`:
`group_vars/all/main.yml` (сеть, доступ, OpenVPN), `group_vars/lxc_infra/main.yml`
(LXC ходят под root без sudo), `group_vars/all/services.yml` (реестр сервисов).
### Transport
- **OpenVPN**: ru-vps (10.78.0.1) ↔ ovpn-mini (10.78.0.2), порт 8443/tcp
- **JumpHost**: SSH к нодам и LXC через ProxyJump `ru-vps`; исключения — `pbs` и
`ovpn-mini`, они доступны напрямую по LAN
- Всё это описано в `ansible/ssh_config`; Ansible подключает его через
`ansible_ssh_common_args` в `group_vars/all/main.yml` (путь считается от
`inventory_dir`, поэтому не зависит от cwd и места клона)
## Directory Structure
```
flake.nix / .envrc # Nix dev-окружение (ansible, линтеры, python-зависимости)
.ansible-lint / .yamllint # Конфигурация линтеров
.gitea/workflows/lint.yml # CI: yamllint + ansible-lint + syntax-check
ansible/
├── Makefile # ЕДИНАЯ точка входа для ручного управления (make help)
├── ansible.cfg
├── ssh_config # Транспорт: ProxyJump, пользователи, ключи
├── scripts/
│ └── gen-inventory-docs.py
├── inventory/
│ ├── hosts.yml # Только адреса и индивидуальные факты хостов
│ ├── group_vars/
│ │ ├── all/main.yml # Общие переменные
│ │ ├── all/services.yml # Реестр сервисов homelab_services
│ │ └── lxc_infra/main.yml
│ └── host_vars/gyro/ # main.yml + шифрованный vault.yml
├── playbooks/
│ ├── check.yml # Связность и ожидаемые IP
│ ├── status.yml # Read-only сводка по всей инфраструктуре
│ ├── reverse-proxy.yml # Единый Caddy-плейбук по реестру сервисов
│ ├── pve-*.yml # Создание LXC (нужен .env)
│ ├── *-update.yml # Обновления: бэкап -> апдейт -> health-check
│ └── openvpn-*.yml
├── roles/
│ ├── lxc_docker_host/ # LXC под Docker: пакеты, fuse-overlayfs, ufw
│ ├── compose_service/ # compose.yml + systemd-юнит + health-check
│ ├── pve_lxc/ # Создание LXC через Proxmox API
│ ├── backup_audit/ # Аудит PBS и restic
│ ├── monitoring_*/ # Prometheus-стек (заморожен), экспортеры
│ ├── uptime_kuma/ # Активный мониторинг
│ ├── emergency_access/ # Break-glass reverse SSH
│ ├── emergency_bot/ # Telegram-бот break-glass
│ ├── gyro/ # Investment allocator
│ ├── openvpn_gateway/ # OpenVPN транспорт
│ └── bash_config/ # Единый bash-конфиг
└── tasks/
archive/2026-07-proxmox-migration/ # Историческое, только как справка
```
Роли `lxc_docker_host` и `compose_service` созданы, но пока не подключены ни к одному
плейбуку — миграция `pve-*.yml` на них не выполнена. Пример использования и перечень
того, что меняется на живом хосте при переходе, — в `roles/compose_service/README.md`.
## Common Playbooks
Предпочитай `make` — он сам грузит `.env` и защищает опасные цели.
```bash
make check # связность и ожидаемые IP
make status # read-only сводка: хосты, юниты, диски, VPN, бэкапы
make deploy-<service> # playbooks/pve-<service>.yml
make dry-<service> # то же с --check --diff
make update-<service> # gitea | vaultwarden | adguard | mihomo | grimmory
make reverse-proxy # Caddy на ru-vps по реестру сервисов
make backup-audit # аудит PBS + offsite restic
make openvpn-check # проверка транспорта ru-vps <-> ovpn-mini
make bash-config # единый bash-конфиг на shell_hosts
make user-ssh-key # разложить публичный ключ на все хосты
```
Обновления всегда идут в порядке: свежий бэкап/аудит -> обновление -> health-check.
Образы закреплены по `tag@sha256:digest`; floating-теги и авто-апдейтеры не используются.
## Proxmox API Tasks
Плейбукам `pve-*.yml` нужны переменные Proxmox API. Держи их в `ansible/.env`
(файл в `.gitignore`, шаблон — `.env.example`):
```bash
PROXMOX_HOST=<host>
PROXMOX_USER=<user@realm>
PROXMOX_TOKEN_ID=<token_id>
PROXMOX_TOKEN_SECRET=<secret>
PROXMOX_VALIDATE_CERTS=false
```
`make` подхватывает `.env` сам и падает с внятным сообщением, если его нет —
руками `. ./.env` делать не нужно:
```bash
make env-check # проверить, что .env на месте и заполнен
make dry-gitea # сначала всегда --check --diff
make deploy-gitea
```
Выпустить токен, если его ещё нет:
```bash
make bootstrap-pve-token # прямо с ноды, нужен sudo
make bootstrap-monitoring-token # отдельный read-only токен для мониторинга
```
## Secrets Policy
- Never commit real secrets to git.
- Use `.env` files (in `.gitignore`), Ansible vault, or runtime prompt.
- Store `.env.example` templates in repo.
- Keep: passwords, tokens, private keys, PBS secrets, TLS keys outside repo.
## Workflow
1. **Read Obsidian** — understand context, constraints, prior decisions.
2. **Make change** — edit/add role/playbook in `ansible/`.
3. **Test safe** — run smallest applicable check/playbook.
4. **Update Obsidian** — document non-obvious decisions and changes.
## Archive Policy
- `archive/2026-07-proxmox-migration/` contains historical NixOS, Docker Compose, GitOps configs.
- Use archived files only as reference when porting behavior to Ansible.
- Do not edit archived files for active infrastructure.
- Restore services via new Ansible roles/playbooks, not by moving old files back.
## Token Economy
Принципы минимизации токенов при управлении инфраструктурой.
### Delegate Heavy Reading
Когда нужно анализировать большие логи или выводы — делегируй субагенту:
```
agent("Read this log and summarize key errors")
```
**Субагент использует для:**
- Анализа логов (journalctl, docker logs, PBS logs)
- Summarization больших файлов
- Поиска паттернов в выводах
- Диагностики статусов
**Основной агент для:**
- Принятия решений на основе конспекта
- Сложных рассуждений над findings
- Изменения кода и архитектуры
### Prefer Scripts Over LLM
Для повторяющихся задач — скрипты, а не объяснения:
```bash
# Вместо: "проверь статус всех LXC"
ansible/scripts/check-lxc-status.sh
# Вместо: "верифицируй PBS backup jobs"
ansible/scripts/check-pbs-backups.sh
```
Паттерн: написать один раз → переиспользовать. LLM только пишет или вызывает.
### Focused Context — читай только нужное
Вместо чтения всего файла — только релевантные части:
```python
# Вместо всего inventory.yml
Read(file_path, offset=1, limit=50) # только hosts vars
# Или grep для конкретного хоста
grep("mini-pc", "inventory/hosts.yml")
```
### Tool Limits — ограничивай вывод команд
```bash
# Вместо: journalctl -u openvpn (тысячи строк)
journalctl -u openvpn --since "1 hour ago" -n 100
# Вместо: docker logs (весь лог)
docker logs --tail 50 gitea
```
Всегда ограничивай вывод: `--tail`, `--since`, `-n`, `head`.
### Batch Operations — группируй задачи
Вместо нескольких запусков — один с несколькими role:
```python
# Плохо
ansible-playbook playbooks/bash-config.yml
ansible-playbook playbooks/docker.yml
ansible-playbook playbooks/ufw.yml
# Хорошо — один плейбук
ansible-playbook playbooks/base-setup.yml # включает bash, docker, ufw
```
### State Caching — не перечитывай статичное
Структура инфры меняется редко. Не перечитывай `hosts.yml`, если структура не изменилась. Кэшируй в memory стабильные данные: network topology, storage layout.
### Structured Output — избегай повторного парсинга
Когда субагент анализирует логи — проси сразу JSON с findings:
```json
{
"summary": "OpenVPN handshake fails due to certificate expired",
"findings": ["cert expired 2026-07-01", "client retries every 5s"],
"relevant_lines": ["Jul 08 10:23:01 TLS auth error"]
}
```
Работа со структурированным результатом вместо повторного чтения логов.
### Declarative Over Imperative
Описывай желаемое состояние, а не шаги:
```yaml
# Плохо: каждый раз перечислять шаги
"Создай LXC, установи Docker, добавь пользователя..."
# Хорошо: декларативно
lxc_container:
name: "vaultwarden"
features: ["docker", "autostart"]
user: "ansible"
```
Ansible/Terraform сами разберут шаги.
+7
View File
@@ -0,0 +1,7 @@
# CLAUDE.md
Этот файл содержит адаптер инструкций для Claude Code (claude.ai/code) при работе с этим репозиторием.
Канонические проектные инструкции находятся в [AGENTS.md](./AGENTS.md). Не дублируй их здесь, чтобы `CLAUDE.md` и `AGENTS.md` не расходились.
@AGENTS.md
+55 -14
View File
@@ -1,26 +1,67 @@
# HomeLab Infrastructure # HomeLab Infrastructure
Active HomeLab infrastructure is managed through Ansible. Активная инфраструктура домашней лаборатории управляется через Ansible.
Каноничные инструкции для людей и агентов — в [AGENTS.md](./AGENTS.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` с Proxmox-токенами подхватывается автоматически. Опасные цели требуют `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
- `archive/2026-07-proxmox-migration/` — исторические NixOS/Docker конфиги, только как справка
## Grimmory MCP
`tools/grimmory-mcp/` содержит read-only интеграцию с Grimmory API для OpenCode
и явно вызываемые инструменты синхронизации с Obsidian.
```bash
npm install --prefix tools/grimmory-mcp
npm run configure --prefix tools/grimmory-mcp
npm test --prefix tools/grimmory-mcp
```
OpenCode регистрирует сервер глобально. После перезапуска OpenCode используй
`/grimmory-sync` для обновления заметок книг в `90 Library/Books` и обложек
в `99 System/Export/Grimmory/Covers`.
+17
View File
@@ -3,6 +3,23 @@ export PROXMOX_USER='ansible@pve'
export PROXMOX_TOKEN_ID='homelab' export PROXMOX_TOKEN_ID='homelab'
export PROXMOX_TOKEN_SECRET='replace-me' export PROXMOX_TOKEN_SECRET='replace-me'
export PROXMOX_VALIDATE_CERTS=false 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. # Override if the downloaded template name differs.
export PVE_LXC_OSTEMPLATE='local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst' 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'
+241
View File
@@ -0,0 +1,241 @@
# 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)))
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
ENV_FILE ?= .env
# Дополнительные аргументы ansible-playbook для любой цели.
EXTRA ?=
# Спрашивать sudo-пароль там, где нужен become. `make openvpn ASK_BECOME=` отключает.
ASK_BECOME ?= -K
# Спрашивать пароль Ansible Vault. `make gyro ASK_VAULT=` отключает.
ASK_VAULT ?= --ask-vault-pass
# Строгая загрузка ansible/.env: обязательна для Proxmox API и секретов.
# Каждая строка рецепта make — отдельный шелл, поэтому source и запуск идут одной строкой.
REQUIRE_ENV = if [ ! -f '$(ENV_FILE)' ]; then \
printf 'ОШИБКА: не найден %s/%s\n' '$(ANSIBLE_DIR)' '$(ENV_FILE)' >&2; \
printf 'Создай его и заполни реальными значениями:\n' >&2; \
printf ' cp .env.example .env\n' >&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 " Плейбуки Proxmox API сами подхватывают ./.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)
.PHONY: lint
lint: ## Прогнать ansible-lint и yamllint по репозиторию
$(ANSIBLE_LINT)
$(YAMLLINT) .
.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: 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: 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-ansible-user
bootstrap-ansible-user: ## Завести сервисный аккаунт ansible на shell-хостах (нужен sudo)
$(ANSIBLE_PLAYBOOK) playbooks/bootstrap-ansible-user.yml $(ASK_BECOME) $(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-аллокатор в CT 150 (спрашивает пароль 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: 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
##@ Опасное (только осознанно, требует 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: monitoring
monitoring: ## ЗАМОРОЖЕН: стек Prometheus. Запускать только при восстановлении мониторинга
@$(REQUIRE_CONFIRM)
@printf 'monitoring.yml заморожен, пока используется Uptime Kuma.\n' >&2
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/monitoring.yml $(EXTRA)
+106 -4
View File
@@ -17,10 +17,13 @@ Ansible is the control plane for HomeLab infrastructure changes.
## 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.
- `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`. - `shell_hosts` — hosts with unified bash config: `ru-vps`, `cloud-pc`, `mini-pc`.
- `servers` — all managed hosts. - `servers` — all managed hosts.
@@ -41,12 +44,44 @@ Run from `ansible/`:
ansible-playbook playbooks/check.yml ansible-playbook playbooks/check.yml
``` ```
## Controlled Updates
Service updates are manual and use pinned `tag@sha256:digest` image references only; floating tags and auto-update agents are not used.
Run the dedicated playbook for the target service from `ansible/`:
```bash
.venv/bin/ansible-playbook playbooks/vaultwarden-update.yml
.venv/bin/ansible-playbook playbooks/gitea-update.yml
.venv/bin/ansible-playbook playbooks/adguard-update.yml
.venv/bin/ansible-playbook playbooks/mihomo-update.yml
.venv/bin/ansible-playbook playbooks/grimmory-update.yml
```
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
.venv/bin/ansible-playbook playbooks/ru-vps-mihomo-harden.yml -e ru_vps_mihomo_harden_confirm=true
```
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` from `.env.example` and load it: For Proxmox API playbooks, create ignored `.env` from `.env.example` and load it:
```bash ```bash
cp .env.example .env cp .env.example .env
. ./.env . ./.env
.venv/bin/ansible-playbook playbooks/pve-wg-mini.yml .venv/bin/ansible-playbook playbooks/pve-ovpn-mini.yml
``` ```
Or bootstrap the token from `mini-pc` with sudo: Or bootstrap the token from `mini-pc` with sudo:
@@ -55,6 +90,12 @@ Or bootstrap the token from `mini-pc` with sudo:
.venv/bin/ansible-playbook playbooks/bootstrap-pve-api-token.yml -K .venv/bin/ansible-playbook playbooks/bootstrap-pve-api-token.yml -K
``` ```
Create the separate read-only PVE token used by the monitoring exporter:
```bash
.venv/bin/ansible-playbook playbooks/bootstrap-monitoring-pve-token.yml
```
OpenVPN transport: OpenVPN transport:
```bash ```bash
@@ -62,6 +103,67 @@ OpenVPN transport:
.venv/bin/ansible-playbook playbooks/openvpn-check.yml .venv/bin/ansible-playbook playbooks/openvpn-check.yml
``` ```
Monitoring is provisioned in two steps after loading the monitoring secrets from ignored `.env` or Ansible Vault:
```bash
. ./.env
.venv/bin/ansible-playbook playbooks/pve-monitoring.yml
.venv/bin/ansible-playbook playbooks/monitoring.yml
```
`pve-monitoring.yml` creates CT `146` (`monitoring`, `192.168.1.30`) on `cloud-pc`. `monitoring.yml` configures exporters, the `ru-vps` probe vantage point, and the central Prometheus stack. It is frozen while Uptime Kuma is in use; do not run it unless restoring Prometheus monitoring.
## Gyro Investment Allocator
`pve-gyro.yml` creates unprivileged CT `150` (`gyro`, `192.168.1.35`) on `mini-pc`. `gyro.yml` installs Python 3.13+, pinned `uv`, the `gyro` service user, a container-local GitHub deploy key, restrictive firewall rules, and a weekday systemd timer.
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 `150` Proxmox firewall is staged, but the cluster-wide PVE firewall remains disabled; 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
. ./.env
.venv/bin/ansible-playbook playbooks/pve-gyro.yml
.venv/bin/ansible-playbook playbooks/gyro.yml --ask-vault-pass
```
The timer runs at 11:00 Europe/Moscow from Monday through Friday and uses `OnFailure=` for a best-effort Telegram alert. CT `150` is included in the daily mini-pc PBS job and backup freshness audit.
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
.venv/bin/ansible-playbook playbooks/uptime-kuma.yml
```
## 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
. ./.env
.venv/bin/ansible-playbook playbooks/pve-emergency-bot.yml
.venv/bin/ansible-playbook playbooks/emergency-access.yml
```
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
+3
View File
@@ -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
+152
View File
@@ -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())
+23
View File
@@ -0,0 +1,23 @@
---
# Общие переменные для всех управляемых хостов.
# 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
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,605 @@
---
# ============================================================================
# homelab_services — декларативный реестр сервисов HomeLab
# ============================================================================
#
# ЧТО ЭТО
# Единственное место, где собраны факты о каждом сервисе домашней лаборатории:
# VMID, узел Proxmox, адрес, порты, публичный домен, закреплённые образы
# с digest, ресурсы LXC, схема резервного копирования, мониторинг и порядок
# автозапуска.
#
# ВСЕ значения взяты из существующего кода (ansible/playbooks/*.yml,
# ansible/roles/*, ansible/inventory/hosts.yml). Ничего не выдумано.
# Там, где факта в коде нет, стоит комментарий "# нет в коде", а не догадка.
#
# КТО ЭТО ПОТРЕБЛЯЕТ (на текущем этапе)
# * ansible/playbooks/reverse-proxy.yml — итерируется по сервисам,
# у которых задан блок `proxy`, и собирает Caddyfile на ru-vps.
# * Человек — как справочник вместо чтения семи 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
# ВНИМАНИЕ: сам контейнер Caddy на ru-vps ansible'ом НЕ управляется —
# в репозитории нет плейбука его установки. Управляется только Caddyfile.
homelab_reverse_proxy_container: caddy
# Адрес центрального хоста мониторинга (источник scrape для node-exporter).
homelab_monitoring_host_ip: 192.168.1.30
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: 132
node: mini-pc
ip: 192.168.1.23
hostname: ovpn-mini
role: OpenVPN-шлюз в LAN (клиент ru-vps)
provisioner: pve_lxc # playbooks/pve-ovpn-mini.yml
lxc:
cores: 1 # из roles/pve_lxc/defaults
memory: 256 # из roles/pve_lxc/defaults
swap: 128 # из roles/pve_lxc/defaults
disk: local-lvm:8
startup: order=30 # из roles/pve_lxc/defaults
features: "nesting=1"
unprivileged: true
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: 140
node: mini-pc
ip: 192.168.1.24
hostname: vaultwarden
role: Менеджер паролей Vaultwarden
provisioner: pct_ssh # playbooks/pve-vaultwarden.yml
lxc:
cores: 2
memory: 1024
swap: 512
disk: local-lvm:16
startup: order=40
features: "nesting=1,keyctl=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: 141
node: cloud-pc
ip: 192.168.1.25
hostname: gitea
role: Git-хостинг Gitea
provisioner: pct_ssh # playbooks/pve-gitea.yml
lxc:
cores: 2
memory: 2048
swap: 1024
disk: data:32
startup: order=50
features: "nesting=1,keyctl=1"
unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse"]
mounts:
# mp0 на cloud-pc; каталог на хосте принадлежит uid/gid 101000
- {host_path: /opt/data/gitea, container_path: /opt/gitea/data, host_uid: 101000, host_gid: 101000}
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
# restic-профиль выполняется на cloud-pc, а не внутри LXC
run_on: cloud-pc
repository: rclone:yadisk:System/Backups/HomeLab/restic/gitea
source_path: /opt/data/gitea
schedule: "*-*-* 04:15:00"
sqlite_db: /opt/data/gitea/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 в старом плейбуке
# --------------------------------------------------------------------------
memoir-bot:
vmid: 142
node: mini-pc
ip: 192.168.1.26
hostname: memoir-bot
role: Telegram-бот дневника в Obsidian-хранилище
provisioner: pct_ssh # playbooks/pve-memoir-bot.yml
lxc:
cores: 1
memory: 512
swap: 512
disk: local-lvm:16
startup: order=60
features: "nesting=1,keyctl=1"
unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse"]
data_dir: /srv/memoir-bot
ports: [] # портов не публикует, только исходящие подключения к Telegram
images:
- memoir-bot:local # собирается на месте `docker build`, digest отсутствует
runtime: docker-run-systemd # /etc/systemd/system/memoir-bot.service
backup:
kind: pbs
job: homelab-pbs-daily-mini
schedule: "02:40"
storage: pbs
monitoring:
node_exporter: true
blackbox: false
backup_audit_vmid: true
# --------------------------------------------------------------------------
mihomo:
vmid: 143
node: mini-pc
ip: 192.168.1.27
hostname: mihomo
role: Локальный прокси Mihomo + веб-интерфейс MetaCubeXD
provisioner: pct_ssh # playbooks/pve-mihomo.yml
lxc:
cores: 1
memory: 512
swap: 512
disk: local-lvm:8
startup: order=70
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: /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: 144
node: mini-pc
ip: 192.168.1.28
hostname: adguard
role: AdGuard Home — DNS с фильтрацией
provisioner: pct_ssh # playbooks/pve-adguard.yml
lxc:
cores: 1
memory: 512
swap: 512
disk: local-lvm:8
startup: order=40
features: "nesting=1,keyctl=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: 145
node: cloud-pc
ip: 192.168.1.29
hostname: docker-test
role: Песочница для проверки Docker в непривилегированном LXC
provisioner: pct_ssh # playbooks/pve-docker-test.yml
lxc:
cores: 1
memory: 512
swap: 512
disk: data:8
startup: order=50
features: "nesting=1,keyctl=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: 146
node: cloud-pc
ip: 192.168.1.30
hostname: monitoring
role: Prometheus + Alertmanager + Grafana + blackbox + pve-exporter + Uptime Kuma
provisioner: pve_lxc # playbooks/pve-monitoring.yml
lxc:
cores: 2
memory: 4096
swap: 512
disk: data:24
startup: order=80
# создаётся с nesting=1, keyctl=1 добавляется отдельной задачей `pct set`
features: "nesting=1,keyctl=1"
unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
data_dir: /opt/monitoring
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: 148
node: mini-pc
ip: 192.168.1.32
hostname: emergency-bot
role: Telegram-бот аварийного доступа
provisioner: pve_lxc # playbooks/pve-emergency-bot.yml
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: 149
node: cloud-pc
ip: 192.168.1.34
hostname: grimmory
role: Библиотека книг Grimmory (+ MariaDB), OPDS/KOReader
provisioner: pve_lxc # playbooks/pve-grimmory.yml
lxc:
cores: 2
memory: 4096
swap: 1024
disk: data:64
startup: order=100
# создаётся с nesting=1, keyctl=1 добавляется отдельной задачей `pct set`
features: "nesting=1,keyctl=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: 150
node: mini-pc
ip: 192.168.1.35
hostname: gyro
role: Изолированный контейнер под задачу gyro (доступ в сеть только через mihomo)
provisioner: pve_lxc # playbooks/pve-gyro.yml
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/150.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
+75 -41
View File
@@ -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,104 @@ 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
memoir-bot:
ansible_host: 192.168.1.26
expected_lan_ip: 192.168.1.26
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:
memoir-bot:
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:
+110
View File
@@ -0,0 +1,110 @@
---
- name: Create and verify current PBS audit before AdGuard update
hosts: mini-pc
gather_facts: false
tasks:
- name: Verify AdGuard VMID ownership before backup
ansible.builtin.command: pct config 144
register: adguard_pct_config
changed_when: false
failed_when: false
- name: Refuse to back up a foreign VMID 144
ansible.builtin.assert:
that:
- adguard_pct_config.rc == 0
- adguard_existing_hostname == 'adguard'
fail_msg: VMID 144 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
- "144"
- --storage
- pbs
- --mode
- snapshot
- --prune-backups
- keep-all=1
- --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
+45
View File
@@ -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,76 @@
---
- 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
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
+42
View File
@@ -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
+45
View File
@@ -0,0 +1,45 @@
---
- name: Create and verify Gitea backup before update
hosts: cloud-pc
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 offsite backup audit
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-gitea.service
changed_when: true
- import_playbook: pve-gitea.yml
- 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
+106
View File
@@ -0,0 +1,106 @@
---
- 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
tasks:
- name: Read Grimmory LXC config
ansible.builtin.command:
argv:
- pct
- config
- "149"
register: grimmory_pct_config
changed_when: false
- name: Assert VMID 149 belongs to Grimmory
ansible.builtin.assert:
that:
- grimmory_pct_hostname_line != ""
- grimmory_pct_hostname == "grimmory"
fail_msg: >-
Refusing to run vzdump 149 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
- "149"
- --storage
- pbs
- --mode
- snapshot
- --prune-backups
- keep-all=1
- --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
- 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
+6
View File
@@ -0,0 +1,6 @@
---
- name: Configure Gyro investment allocator host
hosts: gyro
gather_facts: true
roles:
- role: gyro
+96
View File
@@ -0,0 +1,96 @@
---
- name: Verify Mihomo PBS audit before update
hosts: mini-pc
gather_facts: false
tasks:
- name: Read VMID 143 configuration
ansible.builtin.command: "pct config 143"
register: mihomo_pct_config
changed_when: false
failed_when: false
- name: Refuse to run backup unless VMID 143 is Mihomo
ansible.builtin.assert:
that:
- mihomo_pct_config.rc == 0
- mihomo_update_hostname == 'mihomo'
fail_msg: VMID 143 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
- "143"
- --storage
- pbs
- --mode
- snapshot
- --prune-backups
- keep-all=1
- --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
+26
View File
@@ -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,74 @@
- name: Configure Gitea offsite backup to Yandex Disk
hosts: cloud-pc
gather_facts: false
vars:
offsite_profile: gitea
offsite_repository: rclone:yadisk:System/Backups/HomeLab/restic/gitea
offsite_source_path: /opt/data/gitea
offsite_sqlite_db: /opt/data/gitea/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/data/gitea/gitea/gitea.db
- /opt/data/gitea/gitea/gitea.db-shm
- /opt/data/gitea/gitea/gitea.db-wal
- /opt/data/gitea/gitea/log/**
- /opt/data/gitea/gitea/sessions/**
- /opt/data/gitea/gitea/queues/**
- /opt/data/gitea/gitea/tmp/**
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
+12
View File
@@ -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
+247
View File
@@ -0,0 +1,247 @@
---
- name: Create AdGuard Home LXC on mini-pc
hosts: mini-pc
gather_facts: false
vars:
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
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
hosts: 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
+114
View File
@@ -0,0 +1,114 @@
- 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: 141,145,146,147,149
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: 132,140,142,143,144,150
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: 120
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
+125
View File
@@ -0,0 +1,125 @@
---
- name: Create Docker test LXC on cloud-pc
hosts: cloud-pc
gather_facts: false
vars:
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
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
hosts: 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
+30
View File
@@ -0,0 +1,30 @@
---
- 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_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) }}"
roles:
- role: pve_lxc
- name: Wait for emergency-bot SSH
hosts: emergency-bot
gather_facts: false
tasks:
- name: Wait for emergency-bot to accept SSH connections
ansible.builtin.wait_for_connection:
timeout: 120
+37 -3
View File
@@ -25,6 +25,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
@@ -103,7 +116,7 @@
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 +167,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 +197,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 +218,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
+431
View File
@@ -0,0 +1,431 @@
---
- name: Guard Grimmory VMID before API updates
hosts: cloud-pc
gather_facts: false
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
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
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
hosts: 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
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:
- "192.168.1.34: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 192.168.1.34 --ctorigdstport 6060 -j ACCEPT
iptables -A GRIMMORY-FILTER -s {{ openvpn_network_cidr }} -p tcp -m conntrack --ctorigdst 192.168.1.34 --ctorigdstport 6060 -j ACCEPT
iptables -A GRIMMORY-FILTER -p tcp -m conntrack --ctorigdst 192.168.1.34 --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://192.168.1.34: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'
+189
View File
@@ -0,0 +1,189 @@
---
- name: Guard Gyro VMID before API updates
hosts: mini-pc
gather_facts: false
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:
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
roles:
- role: pve_lxc
- name: Configure Gyro LXC isolation
hosts: mini-pc
gather_facts: false
vars:
gyro_vmid: 150
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
+334
View File
@@ -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.
+305
View File
@@ -0,0 +1,305 @@
---
- name: Create memoir-bot LXC on mini-pc
hosts: mini-pc
gather_facts: false
vars:
memoir_bot_vmid: 142
memoir_bot_hostname: memoir-bot
memoir_bot_ip: 192.168.1.26/24
memoir_bot_gateway: 192.168.1.1
memoir_bot_bridge: vmbr0
memoir_bot_rootfs: local-lvm:16
memoir_bot_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
memoir_bot_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
handlers:
- name: restart memoir-bot lxc
ansible.builtin.shell: "pct stop {{ memoir_bot_vmid }} || true; pct start {{ memoir_bot_vmid }}"
changed_when: true
tasks:
- name: Check if memoir-bot LXC exists
ansible.builtin.command: "pct config {{ memoir_bot_vmid }}"
register: memoir_bot_pct_config
changed_when: false
failed_when: false
- name: Install memoir-bot LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/memoir-bot-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', memoir_bot_pubkey_file) }}\n"
when: memoir_bot_pct_config.rc != 0
- name: Create memoir-bot LXC
ansible.builtin.command: >-
pct create {{ memoir_bot_vmid }} {{ memoir_bot_ostemplate }}
--hostname {{ memoir_bot_hostname }}
--rootfs {{ memoir_bot_rootfs }}
--cores 1
--memory 512
--swap 512
--net0 name=eth0,bridge={{ memoir_bot_bridge }},gw={{ memoir_bot_gateway }},ip={{ memoir_bot_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=60
--cmode shell
--ssh-public-keys /tmp/memoir-bot-lxc.pub
when: memoir_bot_pct_config.rc != 0
- name: Start memoir-bot LXC
ansible.builtin.command: "pct start {{ memoir_bot_vmid }}"
register: memoir_bot_pct_start
changed_when: memoir_bot_pct_start.rc == 0
failed_when: memoir_bot_pct_start.rc not in [0, 255]
- name: Allow FUSE device in memoir-bot LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ memoir_bot_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart memoir-bot lxc
- name: Bind mount FUSE device in memoir-bot LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ memoir_bot_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart memoir-bot lxc
- name: Apply pending LXC config changes
ansible.builtin.meta: flush_handlers
- name: Wait for memoir-bot SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: memoir-bot
vars:
ansible_become: false
- name: Configure Docker and memoir-bot inside LXC
hosts: memoir-bot
gather_facts: true
vars:
ansible_become: false
memoir_bot_source_dir: /home/ada/Documents/Projects/Other/memoir_bot/
memoir_bot_app_dir: /opt/memoir-bot/app
memoir_bot_state_dir: /srv/memoir-bot
memoir_bot_env_file: /srv/memoir-bot/.env
memoir_bot_env_source: /home/ada/Documents/Projects/Other/memoir_bot/.env
memoir_bot_ssh_private_key_file: ~/.ssh/id_ed25519
memoir_bot_ssh_public_key_file: ~/.ssh/id_ed25519.pub
memoir_bot_vault_repo: git@github.com:ada-dmitry/SecondBrain.git
memoir_bot_vault_dir: /srv/memoir-bot/vault
memoir_bot_image: memoir-bot:local
memoir_bot_container_name: memoir-bot
tasks:
- name: Install Docker and deploy dependencies
ansible.builtin.apt:
name:
- docker.io
- fuse-overlayfs
- git
- openssh-client
- rsync
- 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_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 memoir-bot directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ memoir_bot_app_dir }}"
- "{{ memoir_bot_state_dir }}"
- "{{ memoir_bot_state_dir }}/ssh"
- name: Copy memoir-bot env example
ansible.builtin.copy:
dest: "{{ memoir_bot_state_dir }}/.env.example"
owner: root
group: root
mode: "0640"
content: |
BOT_TOKEN=123456:telegram-token
ALLOWED_USER_IDS=123456789
OBSIDIAN_VAULT_PATH=/srv/obsidian/vault
DAILY_NOTES_DIR=03 Journal
NOTE_PATH_FORMAT=%Y/%m/%d.%m.%y.md
MESSAGE_TIME_FORMAT=%H:%M
TELEGRAM_PROXY=
REMINDER_MIN_HOURS=2
REMINDER_MAX_HOURS=3
REMINDER_START_HOUR=7
REMINDER_END_HOUR=23
REMINDER_TIMEZONE=Europe/Moscow
- name: Copy memoir-bot env file when present locally
ansible.builtin.copy:
src: "{{ memoir_bot_env_source }}"
dest: "{{ memoir_bot_env_file }}"
owner: root
group: root
mode: "0600"
when: lookup('ansible.builtin.fileglob', memoir_bot_env_source) | length > 0
no_log: true
- name: Install GitHub SSH private key for memoir-bot
ansible.builtin.copy:
src: "{{ memoir_bot_ssh_private_key_file }}"
dest: "{{ memoir_bot_state_dir }}/ssh/id_ed25519"
owner: root
group: root
mode: "0600"
no_log: true
- name: Install GitHub SSH public key for memoir-bot when present locally
ansible.builtin.copy:
src: "{{ memoir_bot_ssh_public_key_file }}"
dest: "{{ memoir_bot_state_dir }}/ssh/id_ed25519.pub"
owner: root
group: root
mode: "0644"
when: lookup('ansible.builtin.fileglob', memoir_bot_ssh_public_key_file) | length > 0
- name: Scan GitHub SSH host key
ansible.builtin.command: ssh-keyscan github.com
register: memoir_bot_github_host_key
changed_when: false
- name: Trust GitHub SSH host key for memoir-bot
ansible.builtin.known_hosts:
path: "{{ memoir_bot_state_dir }}/ssh/known_hosts"
name: github.com
key: "{{ memoir_bot_github_host_key.stdout }}"
state: present
- name: Clone SecondBrain vault
ansible.builtin.git:
repo: "{{ memoir_bot_vault_repo }}"
dest: "{{ memoir_bot_vault_dir }}"
key_file: "{{ memoir_bot_state_dir }}/ssh/id_ed25519"
accept_hostkey: true
update: true
version: main
register: memoir_bot_vault_checkout
- name: Configure memoir-bot vault Git author name
ansible.builtin.command: git config user.name memoir-bot
args:
chdir: "{{ memoir_bot_vault_dir }}"
changed_when: false
- name: Configure memoir-bot vault Git author email
ansible.builtin.command: git config user.email memoir-bot@homelab.local
args:
chdir: "{{ memoir_bot_vault_dir }}"
changed_when: false
- name: Sync memoir-bot source code
ansible.posix.synchronize:
src: "{{ memoir_bot_source_dir }}"
dest: "{{ memoir_bot_app_dir }}/"
delete: true
rsync_opts:
- "--exclude=.env"
- "--exclude=.git"
- "--exclude=.venv"
- "--exclude=__pycache__"
- "--exclude=*.pyc"
register: memoir_bot_source_sync
- name: Check if memoir-bot image exists
ansible.builtin.command: "docker image inspect {{ memoir_bot_image }}"
register: memoir_bot_image_inspect
changed_when: false
failed_when: false
- name: Build memoir-bot image
ansible.builtin.command: "docker build -t {{ memoir_bot_image }} {{ memoir_bot_app_dir }}"
when: memoir_bot_source_sync.changed or memoir_bot_image_inspect.rc != 0
register: memoir_bot_image_build
changed_when: memoir_bot_image_build.rc == 0
- name: Install memoir-bot systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/memoir-bot.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Memoir Telegram bot container
After=docker.service
Requires=docker.service
ConditionPathExists={{ memoir_bot_env_file }}
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f {{ memoir_bot_container_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ memoir_bot_container_name }} \
--env-file {{ memoir_bot_env_file }} \
-v {{ memoir_bot_state_dir }}/vault:/srv/obsidian/vault \
-v {{ memoir_bot_state_dir }}/ssh:/root/.ssh:ro \
{{ memoir_bot_image }}
ExecStop=/usr/bin/docker stop {{ memoir_bot_container_name }}
[Install]
WantedBy=multi-user.target
register: memoir_bot_unit
- name: Reload systemd when memoir-bot unit changes
ansible.builtin.systemd:
daemon_reload: true
when: memoir_bot_unit.changed
- name: Check memoir-bot env file
ansible.builtin.stat:
path: "{{ memoir_bot_env_file }}"
register: memoir_bot_env
- name: Enable memoir-bot service
ansible.builtin.systemd:
name: memoir-bot
enabled: true
- name: Start memoir-bot when env file exists
ansible.builtin.systemd:
name: memoir-bot
state: "{{ 'restarted' if memoir_bot_source_sync.changed or memoir_bot_image_build.changed or memoir_bot_unit.changed or memoir_bot_vault_checkout.changed else 'started' }}"
when: memoir_bot_env.stat.exists
+332
View File
@@ -0,0 +1,332 @@
---
- name: Create mihomo LXC on mini-pc
hosts: mini-pc
gather_facts: false
vars:
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
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
hosts: 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
+42
View File
@@ -0,0 +1,42 @@
---
- 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_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
roles:
- role: pve_lxc
- name: Enable Docker keyctl feature for monitoring LXC
hosts: cloud-pc
gather_facts: false
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
@@ -1,4 +1,4 @@
- name: Create wg-mini LXC on mini-pc - name: Create ovpn-mini LXC on mini-pc
hosts: localhost hosts: localhost
connection: local connection: local
gather_facts: false gather_facts: false
@@ -7,19 +7,19 @@
vars: vars:
pve_lxc_vmid: 132 pve_lxc_vmid: 132
pve_lxc_node: mini-pc pve_lxc_node: mini-pc
pve_lxc_hostname: wg-mini pve_lxc_hostname: ovpn-mini
pve_lxc_ip: 192.168.1.23/24 pve_lxc_ip: 192.168.1.23/24
pve_lxc_gateway: 192.168.1.1 pve_lxc_gateway: 192.168.1.1
pve_lxc_storage: local-lvm pve_lxc_storage: local-lvm
pve_lxc_disk: local-lvm:8 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) }}" 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 - name: Allow TUN device in ovpn-mini LXC config
hosts: mini-pc hosts: mini-pc
gather_facts: false gather_facts: false
become: true become: true
handlers: handlers:
- name: restart wg-mini lxc - name: restart ovpn-mini lxc
ansible.builtin.shell: pct stop 132 || true; pct start 132 ansible.builtin.shell: pct stop 132 || true; pct start 132
changed_when: true changed_when: true
tasks: tasks:
@@ -28,11 +28,11 @@
path: /etc/pve/lxc/132.conf path: /etc/pve/lxc/132.conf
line: "lxc.cgroup2.devices.allow: c 10:200 rwm" line: "lxc.cgroup2.devices.allow: c 10:200 rwm"
state: present state: present
notify: restart wg-mini lxc notify: restart ovpn-mini lxc
- name: Bind mount /dev/net/tun - name: Bind mount /dev/net/tun
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
path: /etc/pve/lxc/132.conf path: /etc/pve/lxc/132.conf
line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file" line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
state: present state: present
notify: restart wg-mini lxc notify: restart ovpn-mini lxc
+54 -3
View File
@@ -1,3 +1,27 @@
---
- name: Guard Vaultwarden VMID before API updates
hosts: mini-pc
gather_facts: false
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
@@ -84,7 +108,7 @@
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 +157,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 +187,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 +206,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'
-65
View File
@@ -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'
+182
View File
@@ -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 }}"
+280
View File
@@ -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.
+383
View File
@@ -0,0 +1,383 @@
---
# 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
# memoir-bot.service playbooks/pve-memoir-bot.yml
# mihomo{,-ui}.service playbooks/pve-mihomo.yml
# uptime-kuma.service roles/uptime_kuma
# vaultwarden.service playbooks/pve-vaultwarden.yml
# 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]
memoir-bot: [docker.service, memoir-bot.service]
mihomo: [docker.service, mihomo.service, mihomo-ui.service]
monitoring: [docker.service, uptime-kuma.service]
ru-vps: [docker.service]
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-restic-offsite-gitea.service
- homelab-backup-audit-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
- 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([]) }}"
- 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 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') }}"
+6
View File
@@ -0,0 +1,6 @@
---
- name: Freeze Prometheus monitoring and configure Uptime Kuma
hosts: monitoring_server
gather_facts: false
roles:
- role: uptime_kuma
+52
View File
@@ -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"
+98
View File
@@ -0,0 +1,98 @@
---
- 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
tasks:
- name: Read existing VMID 140 configuration
ansible.builtin.command: pct config 140
register: vaultwarden_existing_vmid
changed_when: false
- name: Refuse to modify a foreign VMID 140
ansible.builtin.assert:
that:
- vaultwarden_existing_vmid.rc == 0
- 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: 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
- "140"
- --storage
- pbs
- --mode
- snapshot
- --prune-backups
- keep-all=1
- --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
- 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
+2
View File
@@ -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"
+47
View File
@@ -0,0 +1,47 @@
# Роли 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).
**Статус:** роли созданы и проверены синтаксически, но пока не подключены ни
к одному живому сервису. Перевод `pve-*.yml` на них — отдельный этап.
## Источник данных
Факты о сервисах (vmid, узел, адрес, порты, домен, образы с digest, ресурсы,
бэкап, мониторинг, порядок автозапуска) собраны в реестре
`ansible/inventory/group_vars/all/services.yml` (`homelab_services`).
Его уже потребляет `playbooks/reverse-proxy.yml`.
@@ -0,0 +1,31 @@
---
backup_audit_log_file: /var/log/homelab-backup-audit.log
backup_audit_timer_oncalendar: "*-*-* 06:00:00"
backup_audit_timer_randomized_delay: 10m
backup_audit_pbs_vmids:
- vmid: 132
max_age_hours: 48
- vmid: 140
max_age_hours: 48
- vmid: 141
max_age_hours: 48
- vmid: 142
max_age_hours: 48
- vmid: 143
max_age_hours: 48
- vmid: 144
max_age_hours: 48
- vmid: 145
max_age_hours: 48
- vmid: 146
max_age_hours: 48
- vmid: 147
max_age_hours: 48
- vmid: 149
max_age_hours: 48
- vmid: 150
max_age_hours: 48
backup_audit_restic_profiles: []
backup_audit_metrics_dir: /var/lib/node_exporter/textfile_collector
+146
View File
@@ -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'
+133
View File
@@ -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
@@ -0,0 +1,7 @@
EMERGENCY_BOT_TOKEN={{ emergency_bot_token }}
EMERGENCY_ALLOWED_USER_IDS={{ emergency_bot_allowed_user_ids }}
EMERGENCY_MINI_PC_HOST={{ emergency_bot_mini_pc_host }}
EMERGENCY_MINI_PC_USER={{ emergency_bot_mini_pc_user }}
EMERGENCY_CONTROL_KEY={{ emergency_bot_control_key_path }}
EMERGENCY_KNOWN_HOSTS={{ emergency_bot_state_dir }}/known_hosts
EMERGENCY_TELEGRAM_PROXY={{ emergency_telegram_proxy }}
@@ -0,0 +1,20 @@
[Unit]
Description=HomeLab emergency access Telegram bot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ emergency_bot_user }}
EnvironmentFile={{ emergency_bot_config_dir }}/bot.env
ExecStart=/usr/local/libexec/emergency-bot
Restart=always
RestartSec=10
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths={{ emergency_bot_state_dir }}
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import time
import urllib.parse
import urllib.request
API = "https://api.telegram.org/bot" + os.environ["EMERGENCY_BOT_TOKEN"]
ALLOWED_USERS = {item.strip() for item in os.environ["EMERGENCY_ALLOWED_USER_IDS"].split(",") if item.strip()}
STATE_FILE = "{{ emergency_bot_state_dir }}/updates.json"
BUTTONS = {
"inline_keyboard": [[
{"text": "Enable SSH", "callback_data": "start"},
{"text": "Status", "callback_data": "status"},
{"text": "Stop", "callback_data": "stop"},
]]
}
HTTP = urllib.request.build_opener(
urllib.request.ProxyHandler({"https": os.environ["EMERGENCY_TELEGRAM_PROXY"]})
)
def api(method, payload=None, timeout=35):
if payload is not None:
payload = {
key: json.dumps(value) if isinstance(value, (dict, list)) else value
for key, value in payload.items()
}
data = None if payload is None else urllib.parse.urlencode(payload).encode()
with HTTP.open(API + "/" + method, data=data, timeout=timeout) as response:
result = json.load(response)
if not result.get("ok"):
raise RuntimeError(result.get("description", "Telegram API request failed"))
return result["result"]
def control(command):
args = [
"/usr/bin/ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
"-o", "HostKeyAlias=mini-pc-emergency", "-o", "UserKnownHostsFile=" + os.environ["EMERGENCY_KNOWN_HOSTS"],
"-i", os.environ["EMERGENCY_CONTROL_KEY"],
os.environ["EMERGENCY_MINI_PC_USER"] + "@" + os.environ["EMERGENCY_MINI_PC_HOST"], command,
]
result = subprocess.run(args, capture_output=True, text=True, timeout=25, check=False)
output = (result.stdout or result.stderr).strip()
if result.returncode:
return "Control command failed: " + (output or "unknown error")[:1000]
return output or "ok"
def reply(chat_id, text):
payload = {"chat_id": chat_id, "text": text[:3500], "reply_markup": BUTTONS}
if text.startswith("started; expires in") and "\nConnect:\n" in text:
status, command = text.split("\nConnect:\n", 1)
payload["text"] = status + "\nConnect:\n`" + command + "`"
payload["parse_mode"] = "Markdown"
api("sendMessage", payload)
def allowed(chat, sender):
return chat.get("type") == "private" and str(sender.get("id")) in ALLOWED_USERS
def handle_command(chat_id, command):
if command == "help":
reply(chat_id, "Choose an action or use /emergency ssh, /emergency status, /emergency stop")
elif command:
print("accepted emergency command: " + command, file=sys.stderr, flush=True)
reply(chat_id, control(command))
def load_offset():
try:
with open(STATE_FILE, encoding="utf-8") as state_file:
return int(json.load(state_file).get("offset", 0))
except (FileNotFoundError, ValueError, json.JSONDecodeError):
return 0
def save_offset(offset):
temporary = STATE_FILE + ".tmp"
with open(temporary, "w", encoding="utf-8") as state_file:
json.dump({"offset": offset}, state_file)
os.replace(temporary, STATE_FILE)
def command_for(text):
parts = text.strip().split()
if not parts:
return None
command_name = parts[0].split("@", 1)[0]
if command_name == "/start":
return "help"
if command_name != "/emergency":
return None
if len(parts) != 2:
return "help"
return {"ssh": "start", "stop": "stop", "status": "status"}.get(parts[1], "help")
def run():
offset = load_offset()
while True:
try:
for update in api("getUpdates", {"offset": offset, "timeout": 30}, timeout=40):
offset = update["update_id"] + 1
save_offset(offset)
callback = update.get("callback_query")
if callback:
callback_message = callback.get("message", {})
chat = callback_message.get("chat", {})
sender = callback.get("from", {})
command = callback.get("data")
if not allowed(chat, sender) or command not in {"start", "status", "stop"}:
print("ignored callback: chat_type=" + str(chat.get("type")) + " sender=" + str(sender.get("id")), file=sys.stderr, flush=True)
api("answerCallbackQuery", {"callback_query_id": callback["id"], "text": "Not authorized", "show_alert": True})
else:
api("answerCallbackQuery", {"callback_query_id": callback["id"]})
handle_command(chat["id"], command)
continue
message = update.get("message", {})
chat = message.get("chat", {})
sender = message.get("from", {})
text = message.get("text", "")
if not allowed(chat, sender):
print("ignored update: chat_type=" + str(chat.get("type")) + " sender=" + str(sender.get("id")), file=sys.stderr, flush=True)
continue
command = command_for(text)
handle_command(chat["id"], command)
except Exception as error:
print("emergency-bot error: " + str(error), file=sys.stderr, flush=True)
time.sleep(10)
if __name__ == "__main__":
run()
+28
View File
@@ -0,0 +1,28 @@
---
gyro_user: gyro
gyro_group: gyro
gyro_home: /home/gyro
gyro_app_dir: /opt/gyro/app
gyro_config_dir: /etc/gyro
gyro_cache_dir: /var/cache/gyro
gyro_uv_venv: /opt/uv
gyro_uv_version: 0.12.5
gyro_python_min_version: "3.13"
gyro_timezone: Europe/Moscow
gyro_repo_url: ""
gyro_repo_version: main
gyro_git_known_hosts_name: ""
gyro_git_host_key: ""
gyro_deploy_enabled: false
gyro_tinvest_token: ""
gyro_tinvest_account_id: ""
gyro_telegram_bot_token: ""
gyro_telegram_user_id: ""
gyro_telegram_proxy: ""
gyro_dry_run_override: "true"
gyro_secrets_configured: false
gyro_timer_enabled: false
gyro_timer_on_calendar: "Mon..Fri *-*-* 11:00:00 Europe/Moscow"
@@ -0,0 +1,37 @@
#!/usr/bin/python3
import os
import socket
import sys
import urllib.parse
import urllib.request
def main() -> int:
token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
user_id = os.environ.get("TELEGRAM_USER_ID", "").strip()
if not token or not user_id:
print("Gyro failure notification skipped: Telegram credentials are absent")
return 1
proxy = os.environ.get("TELEGRAM_PROXY", "").strip()
handlers = [urllib.request.ProxyHandler({"http": proxy, "https": proxy})] if proxy else []
opener = urllib.request.build_opener(*handlers)
failed_unit = sys.argv[1] if len(sys.argv) > 1 else "gyro.service"
message = f"Gyro job failed on {socket.gethostname()}: {failed_unit}. Check journalctl -u gyro.service."
body = urllib.parse.urlencode({"chat_id": user_id, "text": message}).encode()
request = urllib.request.Request(
f"https://api.telegram.org/bot{token}/sendMessage",
data=body,
method="POST",
)
try:
with opener.open(request, timeout=20) as response:
return 0 if response.status == 200 else 1
except Exception as exc:
print(f"Gyro failure notification failed: {type(exc).__name__}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -0,0 +1,4 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
+365
View File
@@ -0,0 +1,365 @@
---
- name: Install Gyro runtime packages
ansible.builtin.apt:
name:
- ca-certificates
- git
- openssh-client
- python3
- python3-packaging
- python3-venv
- sudo
- ufw
state: present
update_cache: true
- name: Configure Gyro timezone
community.general.timezone:
name: "{{ gyro_timezone }}"
- name: Create Gyro service group
ansible.builtin.group:
name: "{{ gyro_group }}"
system: true
- name: Create Gyro service user
ansible.builtin.user:
name: "{{ gyro_user }}"
group: "{{ gyro_group }}"
home: "{{ gyro_home }}"
shell: /bin/bash
system: true
create_home: true
- name: Create Gyro directories
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
group: "{{ item.group }}"
mode: "{{ item.mode }}"
loop:
- { path: "{{ gyro_app_dir }}", owner: "{{ gyro_user }}", group: "{{ gyro_group }}", mode: "0750" }
- { path: "{{ gyro_config_dir }}", owner: root, group: root, mode: "0700" }
- { path: "{{ gyro_cache_dir }}", owner: "{{ gyro_user }}", group: "{{ gyro_group }}", mode: "0700" }
- { path: "{{ gyro_home }}/.ssh", owner: "{{ gyro_user }}", group: "{{ gyro_group }}", mode: "0700" }
- name: Read system Python version
ansible.builtin.command: python3 -c "import platform; print(platform.python_version())"
register: gyro_python_version
changed_when: false
- name: Require Python 3.13 or newer
ansible.builtin.assert:
that:
- gyro_python_version.stdout is version(gyro_python_min_version, '>=')
fail_msg: "Gyro requires Python {{ gyro_python_min_version }} or newer; found {{ gyro_python_version.stdout }}."
- name: Create isolated uv installation environment
ansible.builtin.command:
argv:
- python3
- -m
- venv
- "{{ gyro_uv_venv }}"
creates: "{{ gyro_uv_venv }}/bin/pip"
- name: Install pinned uv version
ansible.builtin.pip:
name: "uv=={{ gyro_uv_version }}"
executable: "{{ gyro_uv_venv }}/bin/pip"
- name: Link uv into the system path
ansible.builtin.file:
src: "{{ gyro_uv_venv }}/bin/uv"
dest: /usr/local/bin/uv
state: link
- name: Generate Git deploy key on the container
ansible.builtin.command:
argv:
- ssh-keygen
- -q
- -t
- ed25519
- -N
- ""
- -C
- gyro@homelab
- -f
- "{{ gyro_home }}/.ssh/id_ed25519_gitea"
creates: "{{ gyro_home }}/.ssh/id_ed25519_gitea"
become: true
become_user: "{{ gyro_user }}"
vars:
ansible_become: true
- name: Secure Git deploy key ownership
ansible.builtin.file:
path: "{{ item.path }}"
owner: "{{ gyro_user }}"
group: "{{ gyro_group }}"
mode: "{{ item.mode }}"
loop:
- { path: "{{ gyro_home }}/.ssh/id_ed25519_gitea", mode: "0600" }
- { path: "{{ gyro_home }}/.ssh/id_ed25519_gitea.pub", mode: "0644" }
- name: Read Git deploy public key
ansible.builtin.slurp:
src: "{{ gyro_home }}/.ssh/id_ed25519_gitea.pub"
register: gyro_deploy_public_key
- name: Show Git deploy public key
ansible.builtin.debug:
msg: "{{ gyro_deploy_public_key.content | b64decode | trim }}"
- 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: Remove obsolete outbound Gitea SSH allowance
community.general.ufw:
rule: allow
delete: true
direction: out
dest: 192.168.1.25
port: "2222"
proto: tcp
- name: Allow outbound Telegram proxy access
community.general.ufw:
rule: allow
direction: out
dest: 192.168.1.27
port: "7890"
proto: tcp
- name: Deny other outbound HomeLab LAN access
community.general.ufw:
rule: deny
direction: out
dest: "{{ homelab_lan_cidr }}"
- name: Enable restrictive Gyro firewall
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Mask mount units already provided by unprivileged LXC
ansible.builtin.systemd:
name: "{{ item }}"
enabled: false
masked: true
state: stopped
loop:
- dev-mqueue.mount
- run-lock.mount
- tmp.mount
- name: Clear stale failures from masked LXC mount units
ansible.builtin.command: >-
systemctl reset-failed dev-mqueue.mount run-lock.mount tmp.mount
changed_when: false
- name: Create locked placeholder environment file
ansible.builtin.copy:
dest: "{{ gyro_config_dir }}/gyro.env"
owner: root
group: root
mode: "0600"
force: false
content: |
# Managed by Ansible after gyro_secrets_configured is enabled.
DRY_RUN_OVERRIDE=true
- name: Validate deployment inputs
ansible.builtin.assert:
that:
- gyro_repo_url | length > 0
- gyro_git_known_hosts_name | length > 0
- gyro_git_host_key | length > 0
fail_msg: Set the repository URL and verified SSH host key before enabling deployment.
when: gyro_deploy_enabled | bool
- name: Remove obsolete Git SSH alias
ansible.builtin.file:
path: "{{ gyro_home }}/.ssh/config"
state: absent
when: gyro_deploy_enabled | bool
- name: Remove obsolete Gitea known host
ansible.builtin.known_hosts:
path: "{{ gyro_home }}/.ssh/known_hosts"
name: "[192.168.1.25]:2222"
state: absent
when: gyro_deploy_enabled | bool
- name: Pin verified Git host key
ansible.builtin.known_hosts:
path: "{{ gyro_home }}/.ssh/known_hosts"
name: "{{ gyro_git_known_hosts_name }}"
key: "{{ gyro_git_host_key }}"
when: gyro_deploy_enabled | bool
- name: Secure Git known hosts file
ansible.builtin.file:
path: "{{ gyro_home }}/.ssh/known_hosts"
owner: "{{ gyro_user }}"
group: "{{ gyro_group }}"
mode: "0600"
when: gyro_deploy_enabled | bool
- name: Ensure Gyro checkout belongs to the service user
ansible.builtin.file:
path: "{{ gyro_app_dir }}"
owner: "{{ gyro_user }}"
group: "{{ gyro_group }}"
recurse: true
when: gyro_deploy_enabled | bool
- name: Clone Gyro from Git remote
ansible.builtin.git:
repo: "{{ gyro_repo_url }}"
dest: "{{ gyro_app_dir }}"
version: "{{ gyro_repo_version }}"
key_file: "{{ gyro_home }}/.ssh/id_ed25519_gitea"
accept_hostkey: false
ssh_opts: >-
-o UserKnownHostsFile={{ gyro_home }}/.ssh/known_hosts
-o StrictHostKeyChecking=yes
-o IdentitiesOnly=yes
update: true
become: true
become_user: "{{ gyro_user }}"
vars:
ansible_become: true
when: gyro_deploy_enabled | bool
- name: Synchronize locked Gyro dependencies
ansible.builtin.command:
argv:
- /usr/local/bin/uv
- sync
- --frozen
args:
chdir: "{{ gyro_app_dir }}"
environment:
UV_CACHE_DIR: "{{ gyro_cache_dir }}/uv"
become: true
become_user: "{{ gyro_user }}"
vars:
ansible_become: true
register: gyro_uv_sync
changed_when: "'Installed' in gyro_uv_sync.stderr or 'Uninstalled' in gyro_uv_sync.stderr"
when: gyro_deploy_enabled | bool
- name: Verify bundled T-Invest CA file
ansible.builtin.stat:
path: "{{ gyro_app_dir }}/config/certs/russian_ca.pem"
register: gyro_ca_bundle
when: gyro_deploy_enabled | bool
- name: Require complete Gyro checkout
ansible.builtin.assert:
that:
- gyro_ca_bundle.stat.exists
- gyro_ca_bundle.stat.isreg | default(false)
fail_msg: The Git checkout does not contain config/certs/russian_ca.pem.
when: gyro_deploy_enabled | bool
- name: Run Gyro unit tests
ansible.builtin.command:
argv:
- /usr/local/bin/uv
- run
- --frozen
- --no-sync
- python
- -m
- unittest
- discover
- -s
- tests
args:
chdir: "{{ gyro_app_dir }}"
environment:
UV_CACHE_DIR: "{{ gyro_cache_dir }}/uv"
become: true
become_user: "{{ gyro_user }}"
vars:
ansible_become: true
changed_when: false
when: gyro_deploy_enabled | bool
- name: Validate Gyro Vault secrets
ansible.builtin.assert:
that:
- gyro_tinvest_token | length > 0
- gyro_tinvest_account_id | length > 0
- gyro_telegram_bot_token | length > 0
- (gyro_telegram_user_id | string | length) > 0
- gyro_dry_run_override in ['true', 'false']
fail_msg: Populate and encrypt inventory/host_vars/gyro/vault.yml before enabling secrets.
no_log: true
when: gyro_secrets_configured | bool
- name: Install Gyro environment file from Vault
ansible.builtin.template:
src: gyro.env.j2
dest: "{{ gyro_config_dir }}/gyro.env"
owner: root
group: root
mode: "0600"
no_log: true
when: gyro_secrets_configured | bool
- name: Install Gyro failure notifier
ansible.builtin.copy:
src: gyro-failure-notify.py
dest: /usr/local/libexec/gyro-failure-notify
owner: root
group: root
mode: "0755"
- name: Install Gyro systemd units
ansible.builtin.template:
src: "{{ item.src }}"
dest: "/etc/systemd/system/{{ item.dest }}"
owner: root
group: root
mode: "0644"
loop:
- { src: gyro.service.j2, dest: gyro.service }
- { src: gyro.timer.j2, dest: gyro.timer }
- { src: gyro-failure@.service.j2, dest: "gyro-failure@.service" }
notify: Reload systemd
- name: Apply systemd unit changes
ansible.builtin.meta: flush_handlers
- name: Enable Gyro timer only after deployment and secret setup
ansible.builtin.systemd:
name: gyro.timer
enabled: "{{ gyro_timer_ready }}"
state: "{{ 'started' if gyro_timer_ready else 'stopped' }}"
vars:
gyro_timer_ready: "{{ gyro_timer_enabled | bool and gyro_deploy_enabled | bool and gyro_secrets_configured | bool }}"
- name: Verify Gyro unit definitions
ansible.builtin.command: >-
systemd-analyze verify
/etc/systemd/system/gyro.service
/etc/systemd/system/gyro.timer
/etc/systemd/system/gyro-failure@.service
changed_when: false
@@ -0,0 +1,15 @@
[Unit]
Description=Notify Telegram about failed Gyro unit %i
[Service]
Type=oneshot
User={{ gyro_user }}
Group={{ gyro_group }}
EnvironmentFile={{ gyro_config_dir }}/gyro.env
ExecStart=/usr/local/libexec/gyro-failure-notify %i
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
+6
View File
@@ -0,0 +1,6 @@
TINVEST_TOKEN={{ gyro_tinvest_token | string | to_json }}
TINVEST_ACCOUNT_ID={{ gyro_tinvest_account_id | string | to_json }}
TELEGRAM_BOT_TOKEN={{ gyro_telegram_bot_token | string | to_json }}
TELEGRAM_USER_ID={{ gyro_telegram_user_id | string | to_json }}
TELEGRAM_PROXY={{ gyro_telegram_proxy | string | to_json }}
DRY_RUN_OVERRIDE={{ gyro_dry_run_override | string | to_json }}
@@ -0,0 +1,31 @@
[Unit]
Description=Gyro investment allocator
Wants=network-online.target
After=network-online.target
OnFailure=gyro-failure@%n.service
[Service]
Type=oneshot
User={{ gyro_user }}
Group={{ gyro_group }}
WorkingDirectory={{ gyro_app_dir }}
EnvironmentFile={{ gyro_config_dir }}/gyro.env
Environment=UV_CACHE_DIR={{ gyro_cache_dir }}/uv
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStart=/usr/local/bin/uv run --frozen --no-sync python main.py
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectClock=true
ProtectControlGroups=true
ProtectHome=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectSystem=strict
ReadWritePaths={{ gyro_cache_dir }}
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
@@ -0,0 +1,10 @@
[Unit]
Description=Run Gyro investment allocator on weekdays
[Timer]
OnCalendar={{ gyro_timer_on_calendar }}
Persistent=true
Unit=gyro.service
[Install]
WantedBy=timers.target
+83
View File
@@ -0,0 +1,83 @@
# lxc_docker_host
Приводит непривилегированный Proxmox LXC в состояние «здесь можно запускать
Docker»: пакеты, проверка `/dev/fuse`, `storage-driver: fuse-overlayfs`,
запуск демона и базовый UFW.
Роль вынесена из повторяющихся блоков `playbooks/pve-*.yml`
(gitea, vaultwarden, mihomo, adguard, memoir-bot, docker-test, grimmory,
hermes-ai) — суммарно около 350 строк копипасты.
## Что делает
1. Ставит `ca-certificates`, `curl`, `docker.io`, `fuse-overlayfs`
(+ `lxc_docker_host_extra_packages`, `ufw` при управлении фаерволом).
2. `stat` + `assert` на `/dev/fuse`: без символьного устройства
fuse-overlayfs не работает, и Docker молча деградирует до `vfs`.
Сообщение об ошибке подсказывает, какие строки добавить в
`/etc/pve/lxc/<vmid>.conf` на узле PVE.
3. Пишет `/etc/docker/daemon.json` со `storage-driver: fuse-overlayfs`.
4. `systemd: docker``enabled: true`, `restarted` при смене daemon.json,
иначе `started`.
5. UFW: SSH из `homelab_lan_cidr` и `openvpn_network_cidr`, node-exporter
9100/tcp с хоста мониторинга (192.168.1.30), произвольные порты сервиса
из `lxc_docker_host_ufw_service_rules`, затем `policy deny incoming`.
Разрешающие правила ставятся ДО включения политики — иначе прогон
обрывает собственную SSH-сессию.
6. Проверяет `docker info --format '{{.Driver}}'` и падает при расхождении.
## Переменные
Полный список с комментариями — в `defaults/main.yml`. Ключевые:
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `lxc_docker_host_extra_packages` | `[]` | доп. пакеты сервиса |
| `lxc_docker_host_require_fuse` | `true` | проверять `/dev/fuse` |
| `lxc_docker_host_storage_driver` | `fuse-overlayfs` | драйвер хранилища |
| `lxc_docker_host_manage_ufw` | `true` | трогать ли UFW вообще |
| `lxc_docker_host_ssh_sources` | LAN + OpenVPN | откуда разрешён SSH |
| `lxc_docker_host_allow_node_exporter` | `true` | 9100 с хоста мониторинга |
| `lxc_docker_host_ufw_service_rules` | `[]` | порты сервиса |
| `lxc_docker_host_ufw_enable` | `true` | включать `deny incoming` |
| `lxc_docker_host_verify_storage_driver` | `true` | финальная проверка |
## Пример: как выглядела бы подготовка хоста Grimmory
```yaml
- name: Prepare the Grimmory Docker host
hosts: grimmory
gather_facts: true
vars:
ansible_become: false
roles:
- role: lxc_docker_host
lxc_docker_host_extra_packages:
- mariadb-client
- openssl
lxc_docker_host_install_node_exporter: true
lxc_docker_host_ufw_service_rules:
- port: "{{ homelab_services.grimmory.ports[0].port }}"
proto: tcp
comment: Grimmory HTTP
sources:
- "{{ homelab_lan_cidr }}"
- "{{ openvpn_network_cidr }}"
```
Эти 12 строк заменяют 86 строк из `playbooks/pve-grimmory.yml`.
## Чего роль НЕ делает
* Не создаёт LXC и не правит `/etc/pve/lxc/<vmid>.conf` — это роль `pve_lxc`
и соответствующий `pve-*.yml`. Роль только проверяет результат.
* Не публикует порты Docker в обход UFW. Помните: `-p` в Docker обходит UFW,
поэтому для публикуемых портов нужны правила в цепочке `DOCKER-USER`
(см. `grimmory-docker-firewall` в `playbooks/pve-grimmory.yml`).
* Не ставит node-exporter по умолчанию — этим занимается `monitoring_exporter`.
## Handlers
Их нет намеренно. Docker перезапускается прямо в задаче через
`state: restarted if changed else started`: handler отработал бы в конце
play, уже после того как compose-стек стартовал на старом драйвере хранилища.
@@ -0,0 +1,82 @@
---
# ============================================================================
# roles/lxc_docker_host — подготовка непривилегированного LXC под Docker.
# Все переменные документированы; значения по умолчанию соответствуют тому,
# что фактически делают playbooks/pve-*.yml.
# ============================================================================
# --- Пакеты ----------------------------------------------------------------
# Базовый набор, общий для всех Docker-хостов HomeLab.
lxc_docker_host_packages:
- ca-certificates
- curl
- docker.io
- fuse-overlayfs
# Дополнительные пакеты конкретного сервиса.
# Примеры из существующих плейбуков:
# gitea: [sqlite3, rsync]
# adguard: [dnsutils]
# mihomo: [git]
# memoir-bot: [git, openssh-client, rsync]
# grimmory: [mariadb-client, openssl]
lxc_docker_host_extra_packages: []
# Ставить пакет docker-compose (в pve-grimmory.yml и pve-hermes-ai.yml он есть).
# Юниты используют плагин `docker compose` из docker.io, поэтому по умолчанию
# отдельный пакет не нужен.
lxc_docker_host_install_compose_package: false
# Ставить prometheus-node-exporter локально (так делает pve-grimmory.yml).
# Обычно экспортёром управляет роль monitoring_exporter.
lxc_docker_host_install_node_exporter: false
# Обновлять кеш apt перед установкой.
lxc_docker_host_update_cache: true
# --- FUSE ------------------------------------------------------------------
# Проверять наличие символьного устройства /dev/fuse и падать, если его нет.
# Без него fuse-overlayfs не заработает, а Docker молча деградирует.
lxc_docker_host_require_fuse: true
lxc_docker_host_fuse_device: /dev/fuse
# --- Docker daemon ---------------------------------------------------------
lxc_docker_host_storage_driver: fuse-overlayfs
# Итоговое содержимое /etc/docker/daemon.json. Расширяемо: можно передать
# дополнительные ключи, storage-driver подставляется отсюда.
lxc_docker_host_daemon_config:
storage-driver: "{{ lxc_docker_host_storage_driver }}"
lxc_docker_host_daemon_config_path: /etc/docker/daemon.json
# Проверить `docker info --format {{.Driver}}` в конце и упасть при расхождении.
lxc_docker_host_verify_storage_driver: true
# --- UFW -------------------------------------------------------------------
# Управлять ли фаерволом вообще. false — роль не трогает ufw.
lxc_docker_host_manage_ufw: true
# Ставить пакет ufw, если управление включено.
lxc_docker_host_install_ufw: true
# Источники, которым разрешён SSH.
lxc_docker_host_ssh_port: 22
lxc_docker_host_ssh_sources:
- "{{ homelab_lan_cidr }}"
- "{{ openvpn_network_cidr }}"
# Node exporter: скрейп разрешён только с хоста мониторинга.
lxc_docker_host_allow_node_exporter: true
lxc_docker_host_node_exporter_port: 9100
lxc_docker_host_monitoring_host: "{{ homelab_monitoring_host_ip | default('192.168.1.30') }}"
# Дополнительные порты сервиса. Формат:
# - port: "6060"
# proto: tcp # необязательно, по умолчанию tcp
# comment: "Grimmory" # необязательно
# sources:
# - "{{ homelab_lan_cidr }}"
# - "{{ openvpn_network_cidr }}"
lxc_docker_host_ufw_service_rules: []
# Включить ufw с политикой deny incoming. Правила выше применяются ДО включения,
# чтобы не потерять SSH.
lxc_docker_host_ufw_enable: true
lxc_docker_host_ufw_policy: deny
@@ -0,0 +1,4 @@
---
# Зависимостей у роли нет: подготовка хоста и раскладка стека независимы
# и подключаются в нужном порядке из плейбука.
dependencies: []

Some files were not shown because too many files have changed in this diff Show More