From c676be81ec8f626bd4a971bde47bc06ccef49f30 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Wed, 26 Aug 2026 21:39:28 +0300 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71 --- .gitignore | 14 + .opencode/agents/ansible-safety-reviewer.md | 19 + .opencode/agents/ansible-verifier.md | 24 + .opencode/agents/backup-audit-reader.md | 24 + .opencode/agents/command-output-summarizer.md | 19 + .opencode/agents/diff-reviewer.md | 23 + .opencode/agents/homelab-recon.md | 201 +++ .opencode/agents/inventory-doc-auditor.md | 22 + .opencode/agents/log-diagnostician.md | 23 + .opencode/agents/network-diagnostician.md | 27 + .opencode/agents/obsidian-context-reader.md | 22 + AGENTS.md | 295 ++++ CLAUDE.md | 7 + README.md | 15 + ansible/.env.example | 17 + ansible/README.md | 110 +- ansible/files/ru-vps-mihomo-harden.py | 152 +++ ansible/inventory/host_vars/gyro/main.yml | 9 + .../host_vars/gyro/vault.yml.example | 10 + ansible/inventory/hosts.yml | 136 +- ansible/playbooks/adguard-update.yml | 110 ++ ansible/playbooks/backup-audit.yml | 45 + .../bootstrap-monitoring-pve-token.yml | 76 ++ ansible/playbooks/emergency-access.yml | 42 + ansible/playbooks/gitea-update.yml | 45 + ansible/playbooks/grimmory-update.yml | 106 ++ ansible/playbooks/gyro.yml | 6 + ansible/playbooks/mihomo-update.yml | 96 ++ ansible/playbooks/monitoring.yml | 26 + ansible/playbooks/offsite-restic-yadisk.yml | 74 ++ ansible/playbooks/openvpn-check.yml | 12 + ansible/playbooks/pve-adguard.yml | 247 ++++ ansible/playbooks/pve-backup-jobs.yml | 114 ++ ansible/playbooks/pve-docker-test.yml | 125 ++ ansible/playbooks/pve-emergency-bot.yml | 30 + ansible/playbooks/pve-gitea.yml | 40 +- ansible/playbooks/pve-grimmory.yml | 431 ++++++ ansible/playbooks/pve-gyro.yml | 189 +++ ansible/playbooks/pve-hermes-ai.yml | 334 +++++ ansible/playbooks/pve-memoir-bot.yml | 305 +++++ ansible/playbooks/pve-mihomo.yml | 332 +++++ ansible/playbooks/pve-monitoring.yml | 42 + .../{pve-wg-mini.yml => pve-ovpn-mini.yml} | 12 +- ansible/playbooks/pve-vaultwarden.yml | 57 +- ansible/playbooks/reverse-proxy-grimmory.yml | 88 ++ ansible/playbooks/ru-vps-mihomo-harden.yml | 280 ++++ ansible/playbooks/uptime-kuma.yml | 6 + ansible/playbooks/user-ssh-key.yml | 52 + ansible/playbooks/vaultwarden-update.yml | 98 ++ ansible/requirements.yml | 2 + ansible/roles/backup_audit/defaults/main.yml | 31 + ansible/roles/backup_audit/tasks/main.yml | 146 ++ .../backup_audit/templates/audit-pbs.sh.j2 | 70 + .../backup_audit/templates/audit-restic.sh.j2 | 167 +++ .../roles/bash_config/templates/homelab.sh.j2 | 17 + .../roles/emergency_access/defaults/main.yml | 11 + .../roles/emergency_access/handlers/main.yml | 8 + .../roles/emergency_access/tasks/client.yml | 134 ++ .../roles/emergency_access/tasks/endpoint.yml | 24 + .../templates/emergency-control.j2 | 27 + .../templates/emergency-control.sudoers.j2 | 1 + .../emergency-reverse-ssh-expire.service.j2 | 6 + .../emergency-reverse-ssh.service.j2 | 19 + .../templates/emergency-reverse-ssh.timer.j2 | 10 + ansible/roles/emergency_bot/defaults/main.yml | 8 + ansible/roles/emergency_bot/handlers/main.yml | 9 + .../roles/emergency_bot/tasks/bootstrap.yml | 48 + ansible/roles/emergency_bot/tasks/main.yml | 77 ++ .../templates/emergency-bot.env.j2 | 7 + .../templates/emergency-bot.service.j2 | 20 + .../templates/emergency_bot.py.j2 | 140 ++ ansible/roles/gyro/defaults/main.yml | 28 + .../roles/gyro/files/gyro-failure-notify.py | 37 + ansible/roles/gyro/handlers/main.yml | 4 + ansible/roles/gyro/tasks/main.yml | 365 +++++ .../gyro/templates/gyro-failure@.service.j2 | 15 + ansible/roles/gyro/templates/gyro.env.j2 | 6 + ansible/roles/gyro/templates/gyro.service.j2 | 31 + ansible/roles/gyro/templates/gyro.timer.j2 | 10 + .../monitoring_blackbox/defaults/main.yml | 3 + .../monitoring_blackbox/handlers/main.yml | 9 + .../roles/monitoring_blackbox/tasks/main.yml | 96 ++ .../templates/blackbox.yml.j2 | 13 + .../templates/push-metrics.sh.j2 | 37 + .../monitoring_exporter/defaults/main.yml | 6 + .../monitoring_exporter/handlers/main.yml | 9 + .../roles/monitoring_exporter/tasks/main.yml | 121 ++ .../templates/smartctl-metrics.sh.j2 | 30 + .../roles/monitoring_server/defaults/main.yml | 14 + .../files/dashboards/backups.json | 26 + .../files/dashboards/homelab-overview.json | 34 + .../files/dashboards/proxmox-storage.json | 26 + .../files/dashboards/services-network.json | 26 + .../files/grafana-dashboard-provider.yml | 11 + .../files/grafana-datasource.yml | 10 + .../roles/monitoring_server/handlers/main.yml | 9 + .../roles/monitoring_server/tasks/main.yml | 233 ++++ .../templates/alertmanager.yml.j2 | 40 + .../monitoring_server/templates/alerts.yml.j2 | 134 ++ .../templates/blackbox.yml.j2 | 10 + .../templates/compose.yml.j2 | 63 + .../templates/prometheus.yml.j2 | 94 ++ .../monitoring_server/templates/pve.yml.j2 | 5 + .../roles/openvpn_gateway/defaults/main.yml | 3 + ansible/roles/openvpn_gateway/tasks/main.yml | 12 +- .../openvpn_gateway/templates/down.sh.j2 | 15 +- .../openvpn_gateway/templates/homelab.conf.j2 | 8 + .../roles/openvpn_gateway/templates/up.sh.j2 | 24 +- ansible/roles/pve_lxc/defaults/main.yml | 4 +- ansible/roles/pve_lxc/tasks/main.yml | 6 +- ansible/roles/uptime_kuma/defaults/main.yml | 9 + ansible/roles/uptime_kuma/handlers/main.yml | 5 + ansible/roles/uptime_kuma/tasks/main.yml | 159 +++ ansible/tasks/offsite-restic-profile.yml | 219 +++ .../ru-vps/mihomo/config/config.yaml | 4 +- tasks/grimmory-deployment-plan.md | 551 ++++++++ tools/grimmory-mcp/README.md | 42 + tools/grimmory-mcp/package-lock.json | 1182 +++++++++++++++++ tools/grimmory-mcp/package.json | 19 + tools/grimmory-mcp/src/client.js | 311 +++++ tools/grimmory-mcp/src/configure.js | 42 + tools/grimmory-mcp/src/obsidian.js | 371 ++++++ tools/grimmory-mcp/src/server.js | 194 +++ tools/grimmory-mcp/test/client.test.js | 102 ++ tools/grimmory-mcp/test/obsidian.test.js | 192 +++ tools/grimmory-mcp/test/server.test.js | 27 + 126 files changed, 10583 insertions(+), 44 deletions(-) create mode 100644 .opencode/agents/ansible-safety-reviewer.md create mode 100644 .opencode/agents/ansible-verifier.md create mode 100644 .opencode/agents/backup-audit-reader.md create mode 100644 .opencode/agents/command-output-summarizer.md create mode 100644 .opencode/agents/diff-reviewer.md create mode 100644 .opencode/agents/homelab-recon.md create mode 100644 .opencode/agents/inventory-doc-auditor.md create mode 100644 .opencode/agents/log-diagnostician.md create mode 100644 .opencode/agents/network-diagnostician.md create mode 100644 .opencode/agents/obsidian-context-reader.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 ansible/files/ru-vps-mihomo-harden.py create mode 100644 ansible/inventory/host_vars/gyro/main.yml create mode 100644 ansible/inventory/host_vars/gyro/vault.yml.example create mode 100644 ansible/playbooks/adguard-update.yml create mode 100644 ansible/playbooks/backup-audit.yml create mode 100644 ansible/playbooks/bootstrap-monitoring-pve-token.yml create mode 100644 ansible/playbooks/emergency-access.yml create mode 100644 ansible/playbooks/gitea-update.yml create mode 100644 ansible/playbooks/grimmory-update.yml create mode 100644 ansible/playbooks/gyro.yml create mode 100644 ansible/playbooks/mihomo-update.yml create mode 100644 ansible/playbooks/monitoring.yml create mode 100644 ansible/playbooks/offsite-restic-yadisk.yml create mode 100644 ansible/playbooks/pve-adguard.yml create mode 100644 ansible/playbooks/pve-backup-jobs.yml create mode 100644 ansible/playbooks/pve-docker-test.yml create mode 100644 ansible/playbooks/pve-emergency-bot.yml create mode 100644 ansible/playbooks/pve-grimmory.yml create mode 100644 ansible/playbooks/pve-gyro.yml create mode 100644 ansible/playbooks/pve-hermes-ai.yml create mode 100644 ansible/playbooks/pve-memoir-bot.yml create mode 100644 ansible/playbooks/pve-mihomo.yml create mode 100644 ansible/playbooks/pve-monitoring.yml rename ansible/playbooks/{pve-wg-mini.yml => pve-ovpn-mini.yml} (81%) create mode 100644 ansible/playbooks/reverse-proxy-grimmory.yml create mode 100644 ansible/playbooks/ru-vps-mihomo-harden.yml create mode 100644 ansible/playbooks/uptime-kuma.yml create mode 100644 ansible/playbooks/user-ssh-key.yml create mode 100644 ansible/playbooks/vaultwarden-update.yml create mode 100644 ansible/roles/backup_audit/defaults/main.yml create mode 100644 ansible/roles/backup_audit/tasks/main.yml create mode 100644 ansible/roles/backup_audit/templates/audit-pbs.sh.j2 create mode 100644 ansible/roles/backup_audit/templates/audit-restic.sh.j2 create mode 100644 ansible/roles/emergency_access/defaults/main.yml create mode 100644 ansible/roles/emergency_access/handlers/main.yml create mode 100644 ansible/roles/emergency_access/tasks/client.yml create mode 100644 ansible/roles/emergency_access/tasks/endpoint.yml create mode 100644 ansible/roles/emergency_access/templates/emergency-control.j2 create mode 100644 ansible/roles/emergency_access/templates/emergency-control.sudoers.j2 create mode 100644 ansible/roles/emergency_access/templates/emergency-reverse-ssh-expire.service.j2 create mode 100644 ansible/roles/emergency_access/templates/emergency-reverse-ssh.service.j2 create mode 100644 ansible/roles/emergency_access/templates/emergency-reverse-ssh.timer.j2 create mode 100644 ansible/roles/emergency_bot/defaults/main.yml create mode 100644 ansible/roles/emergency_bot/handlers/main.yml create mode 100644 ansible/roles/emergency_bot/tasks/bootstrap.yml create mode 100644 ansible/roles/emergency_bot/tasks/main.yml create mode 100644 ansible/roles/emergency_bot/templates/emergency-bot.env.j2 create mode 100644 ansible/roles/emergency_bot/templates/emergency-bot.service.j2 create mode 100644 ansible/roles/emergency_bot/templates/emergency_bot.py.j2 create mode 100644 ansible/roles/gyro/defaults/main.yml create mode 100644 ansible/roles/gyro/files/gyro-failure-notify.py create mode 100644 ansible/roles/gyro/handlers/main.yml create mode 100644 ansible/roles/gyro/tasks/main.yml create mode 100644 ansible/roles/gyro/templates/gyro-failure@.service.j2 create mode 100644 ansible/roles/gyro/templates/gyro.env.j2 create mode 100644 ansible/roles/gyro/templates/gyro.service.j2 create mode 100644 ansible/roles/gyro/templates/gyro.timer.j2 create mode 100644 ansible/roles/monitoring_blackbox/defaults/main.yml create mode 100644 ansible/roles/monitoring_blackbox/handlers/main.yml create mode 100644 ansible/roles/monitoring_blackbox/tasks/main.yml create mode 100644 ansible/roles/monitoring_blackbox/templates/blackbox.yml.j2 create mode 100644 ansible/roles/monitoring_blackbox/templates/push-metrics.sh.j2 create mode 100644 ansible/roles/monitoring_exporter/defaults/main.yml create mode 100644 ansible/roles/monitoring_exporter/handlers/main.yml create mode 100644 ansible/roles/monitoring_exporter/tasks/main.yml create mode 100644 ansible/roles/monitoring_exporter/templates/smartctl-metrics.sh.j2 create mode 100644 ansible/roles/monitoring_server/defaults/main.yml create mode 100644 ansible/roles/monitoring_server/files/dashboards/backups.json create mode 100644 ansible/roles/monitoring_server/files/dashboards/homelab-overview.json create mode 100644 ansible/roles/monitoring_server/files/dashboards/proxmox-storage.json create mode 100644 ansible/roles/monitoring_server/files/dashboards/services-network.json create mode 100644 ansible/roles/monitoring_server/files/grafana-dashboard-provider.yml create mode 100644 ansible/roles/monitoring_server/files/grafana-datasource.yml create mode 100644 ansible/roles/monitoring_server/handlers/main.yml create mode 100644 ansible/roles/monitoring_server/tasks/main.yml create mode 100644 ansible/roles/monitoring_server/templates/alertmanager.yml.j2 create mode 100644 ansible/roles/monitoring_server/templates/alerts.yml.j2 create mode 100644 ansible/roles/monitoring_server/templates/blackbox.yml.j2 create mode 100644 ansible/roles/monitoring_server/templates/compose.yml.j2 create mode 100644 ansible/roles/monitoring_server/templates/prometheus.yml.j2 create mode 100644 ansible/roles/monitoring_server/templates/pve.yml.j2 create mode 100644 ansible/roles/uptime_kuma/defaults/main.yml create mode 100644 ansible/roles/uptime_kuma/handlers/main.yml create mode 100644 ansible/roles/uptime_kuma/tasks/main.yml create mode 100644 ansible/tasks/offsite-restic-profile.yml create mode 100644 tasks/grimmory-deployment-plan.md create mode 100644 tools/grimmory-mcp/README.md create mode 100644 tools/grimmory-mcp/package-lock.json create mode 100644 tools/grimmory-mcp/package.json create mode 100644 tools/grimmory-mcp/src/client.js create mode 100644 tools/grimmory-mcp/src/configure.js create mode 100644 tools/grimmory-mcp/src/obsidian.js create mode 100644 tools/grimmory-mcp/src/server.js create mode 100644 tools/grimmory-mcp/test/client.test.js create mode 100644 tools/grimmory-mcp/test/obsidian.test.js create mode 100644 tools/grimmory-mcp/test/server.test.js diff --git a/.gitignore b/.gitignore index 2060c5f..9ca00a3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,17 @@ passwd ansible/.venv/ ansible/collections/ 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-* diff --git a/.opencode/agents/ansible-safety-reviewer.md b/.opencode/agents/ansible-safety-reviewer.md new file mode 100644 index 0000000..bee09a9 --- /dev/null +++ b/.opencode/agents/ansible-safety-reviewer.md @@ -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. diff --git a/.opencode/agents/ansible-verifier.md b/.opencode/agents/ansible-verifier.md new file mode 100644 index 0000000..40a956d --- /dev/null +++ b/.opencode/agents/ansible-verifier.md @@ -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. diff --git a/.opencode/agents/backup-audit-reader.md b/.opencode/agents/backup-audit-reader.md new file mode 100644 index 0000000..a2e214a --- /dev/null +++ b/.opencode/agents/backup-audit-reader.md @@ -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. diff --git a/.opencode/agents/command-output-summarizer.md b/.opencode/agents/command-output-summarizer.md new file mode 100644 index 0000000..7444a57 --- /dev/null +++ b/.opencode/agents/command-output-summarizer.md @@ -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. diff --git a/.opencode/agents/diff-reviewer.md b/.opencode/agents/diff-reviewer.md new file mode 100644 index 0000000..1cc8017 --- /dev/null +++ b/.opencode/agents/diff-reviewer.md @@ -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. diff --git a/.opencode/agents/homelab-recon.md b/.opencode/agents/homelab-recon.md new file mode 100644 index 0000000..3faace9 --- /dev/null +++ b/.opencode/agents/homelab-recon.md @@ -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. + + + + + 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 анализа + репозитория и поиска существующих паттернов конфигурации." + + + + 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. + + + + + + + + + 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." + + + + 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. + + + + + + + + + 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-документации перед + предложением места изменения." + + + + 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. + + + + +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. diff --git a/.opencode/agents/inventory-doc-auditor.md b/.opencode/agents/inventory-doc-auditor.md new file mode 100644 index 0000000..b8e09e7 --- /dev/null +++ b/.opencode/agents/inventory-doc-auditor.md @@ -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. diff --git a/.opencode/agents/log-diagnostician.md b/.opencode/agents/log-diagnostician.md new file mode 100644 index 0000000..093e4f7 --- /dev/null +++ b/.opencode/agents/log-diagnostician.md @@ -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. diff --git a/.opencode/agents/network-diagnostician.md b/.opencode/agents/network-diagnostician.md new file mode 100644 index 0000000..6257e5d --- /dev/null +++ b/.opencode/agents/network-diagnostician.md @@ -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. diff --git a/.opencode/agents/obsidian-context-reader.md b/.opencode/agents/obsidian-context-reader.md new file mode 100644 index 0000000..648f091 --- /dev/null +++ b/.opencode/agents/obsidian-context-reader.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..864f8c6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,295 @@ +# 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 + +```bash +cd ansible + +# Setup (once) +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +ansible-galaxy collection install -r requirements.yml -p collections + +# Basic connectivity check +ansible-playbook playbooks/check.yml + +# Privileged tasks +ansible-playbook playbooks/.yml -K + +# Proxmox API tasks (from .env) +cp .env.example .env +# Edit .env with real values +. ./.env +ansible-playbook playbooks/pve-*.yml +``` + +## Active Infrastructure + +### Nodes + +| Name | Role | LAN IP | Notes | +|---|---|---:|---| +| `ru-vps` | Public VPS, JumpHost, qdevice, OpenVPN server | `157.22.231.198:3422` | OpenVPN `10.78.0.1` | +| `cloud-pc` | Proxmox VE node, PBS LXC, storage | `192.168.1.5` | Main node | +| `mini-pc` | Proxmox VE node | `192.168.1.10` | Secondary node | +| `pbs` | Proxmox Backup Server LXC | `192.168.1.20` | On cloud-pc | +| `ovpn-mini` | OpenVPN gateway LXC | `192.168.1.23` | OpenVPN `10.78.0.2` | +| `vaultwarden` | Vaultwarden LXC | `192.168.1.24` | | +| `gitea` | Gitea LXC | `192.168.1.25` | | +| `memoir-bot` | Telegram memoir bot LXC | `192.168.1.26` | | +| `mihomo` | Local proxy/UI LXC | `192.168.1.27` | UI `:8080`, API `:9090` | + +### Inventory Groups + +- `homelab` — ru-vps +- `pve_nodes` — cloud-pc, mini-pc +- `lxc_infra` — pbs, ovpn-mini, vaultwarden, gitea, memoir-bot, mihomo +- `vpn_openvpn` — ru-vps, ovpn-mini +- `shell_hosts` — ru-vps, cloud-pc, mini-pc +- `servers` — all managed hosts + +### Transport + +- **OpenVPN**: ru-vps (10.78.0.1) ↔ ovpn-mini (10.78.0.2), port 8443/tcp +- **JumpHost**: SSH to cloud-pc/mini-pc via ProxyJump ru-vps + +## Directory Structure + +``` +ansible/ +├── ansible.cfg # Ansible configuration +├── inventory/ +│ └── hosts.yml # Canonical host list and variables +├── playbooks/ # Entry points +│ ├── check.yml # Connectivity check +│ ├── pve-*.yml # Proxmox API tasks (need .env) +│ ├── openvpn-*.yml # OpenVPN transport setup +│ └── *.yml # Other tasks +├── roles/ # Reusable units +│ ├── backup_audit/ # PBS/restic backup audit +│ ├── bash_config/ # Unified bash config +│ ├── base/ # Base packages/config +│ ├── docker/ # Docker setup +│ ├── openvpn_gateway/ # OpenVPN client gateway +│ ├── pve_lxc/ # Proxmox LXC creation +│ └── ufw/ # Firewall rules +├── tasks/ # Task snippets +└── requirements.txt/yml # Dependencies + +archive/2026-07-proxmox-migration/ # Historical configs (reference only) +``` + +## Common Playbooks + +```bash +# Check connectivity and expected IPs +ansible-playbook playbooks/check.yml + +# Bootstrap Proxmox API token from mini-pc (requires sudo) +ansible-playbook playbooks/bootstrap-pve-api-token.yml -K + +# OpenVPN transport setup (requires privilege) +ansible-playbook playbooks/openvpn-vps-mini.yml -K +ansible-playbook playbooks/openvpn-check.yml + +# Unified bash config for shell hosts +ansible-playbook playbooks/bash-config.yml + +# Install user's SSH public key on all managed hosts +ansible-playbook playbooks/user-ssh-key.yml + +# Backup audit (PBS + restic offsite) +ansible-playbook playbooks/backup-audit.yml +``` + +## Proxmox API Tasks + +Proxmox playbooks require environment variables: + +```bash +# From .env file +PROXMOX_HOST= +PROXMOX_USER= +PROXMOX_TOKEN_ID= +PROXMOX_TOKEN_SECRET= +PROXMOX_VALIDATE_CERTS=false +``` + +Usage: + +```bash +. ./.env +ansible-playbook playbooks/pve-ovpn-mini.yml +ansible-playbook playbooks/pve-vaultwarden.yml +ansible-playbook playbooks/pve-gitea.yml +``` + +Or bootstrap token directly from node: + +```bash +ansible-playbook playbooks/bootstrap-pve-api-token.yml -K +``` + +## 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 сами разберут шаги. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b5608da --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# CLAUDE.md + +Этот файл содержит адаптер инструкций для Claude Code (claude.ai/code) при работе с этим репозиторием. + +Канонические проектные инструкции находятся в [AGENTS.md](./AGENTS.md). Не дублируй их здесь, чтобы `CLAUDE.md` и `AGENTS.md` не расходились. + +@AGENTS.md diff --git a/README.md b/README.md index f15f4a7..7f26d94 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,18 @@ It contains old NixOS configs, Docker Compose service definitions, Gitea workflo cd ansible ansible-playbook playbooks/check.yml ``` + +## Grimmory MCP + +`tools/grimmory-mcp/` contains the read-only Grimmory API integration for +OpenCode and the explicitly invoked Obsidian synchronization tools. + +```bash +npm install --prefix tools/grimmory-mcp +npm run configure --prefix tools/grimmory-mcp +npm test --prefix tools/grimmory-mcp +``` + +OpenCode registers the server globally. After restarting OpenCode, use +`/grimmory-sync` to update individual book notes under `90 Library/Books` and +covers under `99 System/Export/Grimmory/Covers`. diff --git a/ansible/.env.example b/ansible/.env.example index 0ac6361..bf16f15 100644 --- a/ansible/.env.example +++ b/ansible/.env.example @@ -3,6 +3,23 @@ export PROXMOX_USER='ansible@pve' export PROXMOX_TOKEN_ID='homelab' export PROXMOX_TOKEN_SECRET='replace-me' export PROXMOX_VALIDATE_CERTS=false +# Optional local SSH-forward port when the control node reaches PVE through ru-vps. +# export PROXMOX_PORT=18006 # Override if the downloaded template name differs. export PVE_LXC_OSTEMPLATE='local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst' + +# Monitoring secrets. Keep actual values in ignored ansible/.env or Ansible Vault. +export MONITORING_TELEGRAM_BOT_TOKEN='replace-me' +export MONITORING_TELEGRAM_CHAT_ID='replace-me' +export MONITORING_GRAFANA_ADMIN_PASSWORD='replace-me' +export MONITORING_PVE_API_USER='monitoring@pve' +export MONITORING_PVE_API_TOKEN_ID='prometheus' +export MONITORING_PVE_API_TOKEN_SECRET='replace-me' + +# Emergency reverse SSH Telegram bot. Keep actual values in ignored ansible/.env +# or Ansible Vault. Host keys are pinned public keys in the form "ssh-ed25519 AAAA...". +export EMERGENCY_BOT_TOKEN='replace-me' +export EMERGENCY_ALLOWED_USER_IDS='replace-me' +export EMERGENCY_VPS_HOST_KEY='ssh-ed25519 replace-me' +export EMERGENCY_MINI_PC_HOST_KEY='ssh-ed25519 replace-me' diff --git a/ansible/README.md b/ansible/README.md index 17c5f7d..5c25375 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -17,10 +17,13 @@ Ansible is the control plane for HomeLab infrastructure changes. ## 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`. -- `lxc_infra` — infrastructure LXC containers: `pbs`, `zt-cloud`, `zt-mini`. -- `vpn_openvpn` — OpenVPN transport hosts: `ru-vps`, `wg-mini`. +- `lxc_infra` — infrastructure LXC containers, including the outbound-only `gyro` investment allocator host. +- `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`. - `servers` — all managed hosts. @@ -41,12 +44,44 @@ Run from `ansible/`: 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: ```bash cp .env.example .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: @@ -55,6 +90,12 @@ Or bootstrap the token from `mini-pc` with sudo: .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: ```bash @@ -62,6 +103,67 @@ OpenVPN transport: .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: ```bash diff --git a/ansible/files/ru-vps-mihomo-harden.py b/ansible/files/ru-vps-mihomo-harden.py new file mode 100644 index 0000000..31d5aa1 --- /dev/null +++ b/ansible/files/ru-vps-mihomo-harden.py @@ -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()) diff --git a/ansible/inventory/host_vars/gyro/main.yml b/ansible/inventory/host_vars/gyro/main.yml new file mode 100644 index 0000000..3c75d82 --- /dev/null +++ b/ansible/inventory/host_vars/gyro/main.yml @@ -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" diff --git a/ansible/inventory/host_vars/gyro/vault.yml.example b/ansible/inventory/host_vars/gyro/vault.yml.example new file mode 100644 index 0000000..7295ad0 --- /dev/null +++ b/ansible/inventory/host_vars/gyro/vault.yml.example @@ -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 diff --git a/ansible/inventory/hosts.yml b/ansible/inventory/hosts.yml index f1b4c6b..2c4374c 100644 --- a/ansible/inventory/hosts.yml +++ b/ansible/inventory/hosts.yml @@ -6,15 +6,21 @@ all: 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 + openvpn_forwarded_services: + - name: satisfactory + protocol: udp + public_port: 7777 + target_host: 192.168.1.100 + target_port: 7777 children: homelab: hosts: ru-vps: ansible_host: vps + monitoring_exporter_node_listen_address: 127.0.0.1:9100 openvpn_local_ip: 10.78.0.1 openvpn_peer_ip: 10.78.0.2 openvpn_role: server @@ -44,29 +50,39 @@ all: hosts: pbs: ansible_host: 192.168.1.20 + ansible_user: root + ansible_become: false + ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab expected_lan_ip: 192.168.1.20 - zt-cloud: - 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: + ovpn-mini: ansible_host: 192.168.1.23 ansible_user: root + ansible_become: false ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab expected_lan_ip: 192.168.1.23 openvpn_local_ip: 10.78.0.2 openvpn_peer_ip: 10.78.0.1 openvpn_role: gateway + adguard: + ansible_host: 192.168.1.28 + 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.28 + + docker-test: + ansible_host: 192.168.1.29 + 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.29 + vaultwarden: ansible_host: 192.168.1.24 ansible_user: root @@ -85,16 +101,106 @@ all: -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 + memoir-bot: + ansible_host: 192.168.1.26 + 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.26 + + mihomo: + ansible_host: 192.168.1.27 + 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.27 + + monitoring: + ansible_host: 192.168.1.30 + 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.30 + + hermes-ai: + ansible_host: 192.168.1.31 + 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.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 + 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.32 + + grimmory: + ansible_host: 192.168.1.34 + 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.34 + + gyro: + ansible_host: 192.168.1.35 + 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.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: hosts: ru-vps: - wg-mini: + ovpn-mini: shell_hosts: hosts: ru-vps: cloud-pc: mini-pc: + hermes-ai: servers: children: diff --git a/ansible/playbooks/adguard-update.yml b/ansible/playbooks/adguard-update.yml new file mode 100644 index 0000000..1d2f472 --- /dev/null +++ b/ansible/playbooks/adguard-update.yml @@ -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 diff --git a/ansible/playbooks/backup-audit.yml b/ansible/playbooks/backup-audit.yml new file mode 100644 index 0000000..c117bcd --- /dev/null +++ b/ansible/playbooks/backup-audit.yml @@ -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 diff --git a/ansible/playbooks/bootstrap-monitoring-pve-token.yml b/ansible/playbooks/bootstrap-monitoring-pve-token.yml new file mode 100644 index 0000000..ed0d4db --- /dev/null +++ b/ansible/playbooks/bootstrap-monitoring-pve-token.yml @@ -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 diff --git a/ansible/playbooks/emergency-access.yml b/ansible/playbooks/emergency-access.yml new file mode 100644 index 0000000..b689ed1 --- /dev/null +++ b/ansible/playbooks/emergency-access.yml @@ -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 diff --git a/ansible/playbooks/gitea-update.yml b/ansible/playbooks/gitea-update.yml new file mode 100644 index 0000000..c0a44bd --- /dev/null +++ b/ansible/playbooks/gitea-update.yml @@ -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 diff --git a/ansible/playbooks/grimmory-update.yml b/ansible/playbooks/grimmory-update.yml new file mode 100644 index 0000000..f95b9b8 --- /dev/null +++ b/ansible/playbooks/grimmory-update.yml @@ -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 diff --git a/ansible/playbooks/gyro.yml b/ansible/playbooks/gyro.yml new file mode 100644 index 0000000..c1d3c83 --- /dev/null +++ b/ansible/playbooks/gyro.yml @@ -0,0 +1,6 @@ +--- +- name: Configure Gyro investment allocator host + hosts: gyro + gather_facts: true + roles: + - role: gyro diff --git a/ansible/playbooks/mihomo-update.yml b/ansible/playbooks/mihomo-update.yml new file mode 100644 index 0000000..6a56acc --- /dev/null +++ b/ansible/playbooks/mihomo-update.yml @@ -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 diff --git a/ansible/playbooks/monitoring.yml b/ansible/playbooks/monitoring.yml new file mode 100644 index 0000000..6489be7 --- /dev/null +++ b/ansible/playbooks/monitoring.yml @@ -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 diff --git a/ansible/playbooks/offsite-restic-yadisk.yml b/ansible/playbooks/offsite-restic-yadisk.yml new file mode 100644 index 0000000..366fc4c --- /dev/null +++ b/ansible/playbooks/offsite-restic-yadisk.yml @@ -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 diff --git a/ansible/playbooks/openvpn-check.yml b/ansible/playbooks/openvpn-check.yml index 8afa859..d2cdf87 100644 --- a/ansible/playbooks/openvpn-check.yml +++ b/ansible/playbooks/openvpn-check.yml @@ -10,10 +10,22 @@ ansible.builtin.command: nc -vz -w 5 192.168.1.10 8006 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 ansible.builtin.command: nc -vz -w 5 192.168.1.5 8006 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 ansible.builtin.command: nc -vz -w 5 192.168.1.20 8007 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 diff --git a/ansible/playbooks/pve-adguard.yml b/ansible/playbooks/pve-adguard.yml new file mode 100644 index 0000000..ec6c21e --- /dev/null +++ b/ansible/playbooks/pve-adguard.yml @@ -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 diff --git a/ansible/playbooks/pve-backup-jobs.yml b/ansible/playbooks/pve-backup-jobs.yml new file mode 100644 index 0000000..f58b14c --- /dev/null +++ b/ansible/playbooks/pve-backup-jobs.yml @@ -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 diff --git a/ansible/playbooks/pve-docker-test.yml b/ansible/playbooks/pve-docker-test.yml new file mode 100644 index 0000000..cff5852 --- /dev/null +++ b/ansible/playbooks/pve-docker-test.yml @@ -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 diff --git a/ansible/playbooks/pve-emergency-bot.yml b/ansible/playbooks/pve-emergency-bot.yml new file mode 100644 index 0000000..b63a4e5 --- /dev/null +++ b/ansible/playbooks/pve-emergency-bot.yml @@ -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 diff --git a/ansible/playbooks/pve-gitea.yml b/ansible/playbooks/pve-gitea.yml index c2aaa2f..5539246 100644 --- a/ansible/playbooks/pve-gitea.yml +++ b/ansible/playbooks/pve-gitea.yml @@ -25,6 +25,19 @@ changed_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 ansible.builtin.copy: dest: /tmp/gitea-lxc.pub @@ -103,7 +116,7 @@ vars: ansible_become: false 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_http_port: 3000 gitea_ssh_port: 2222 @@ -154,6 +167,18 @@ group: "1000" 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 ansible.builtin.copy: dest: /etc/systemd/system/gitea.service @@ -172,7 +197,7 @@ ExecStartPre=-/usr/bin/docker rm -f {{ gitea_container_name }} ExecStart=/usr/bin/docker run --rm \ --name {{ gitea_container_name }} \ - --pull always \ + --pull never \ -p {{ gitea_http_port }}:3000 \ -p {{ gitea_ssh_port }}:22 \ -v {{ gitea_data_dir }}:/data \ @@ -193,5 +218,14 @@ - name: Enable and start Gitea ansible.builtin.systemd: name: gitea - state: started + state: "{{ 'restarted' if gitea_unit.changed or gitea_image_pull.changed else 'started' }}" 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 diff --git a/ansible/playbooks/pve-grimmory.yml b/ansible/playbooks/pve-grimmory.yml new file mode 100644 index 0000000..fda55ca --- /dev/null +++ b/ansible/playbooks/pve-grimmory.yml @@ -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 </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' diff --git a/ansible/playbooks/pve-gyro.yml b/ansible/playbooks/pve-gyro.yml new file mode 100644 index 0000000..a55d9f1 --- /dev/null +++ b/ansible/playbooks/pve-gyro.yml @@ -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 diff --git a/ansible/playbooks/pve-hermes-ai.yml b/ansible/playbooks/pve-hermes-ai.yml new file mode 100644 index 0000000..997b6db --- /dev/null +++ b/ansible/playbooks/pve-hermes-ai.yml @@ -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. diff --git a/ansible/playbooks/pve-memoir-bot.yml b/ansible/playbooks/pve-memoir-bot.yml new file mode 100644 index 0000000..518dcbc --- /dev/null +++ b/ansible/playbooks/pve-memoir-bot.yml @@ -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 diff --git a/ansible/playbooks/pve-mihomo.yml b/ansible/playbooks/pve-mihomo.yml new file mode 100644 index 0000000..73421f2 --- /dev/null +++ b/ansible/playbooks/pve-mihomo.yml @@ -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 diff --git a/ansible/playbooks/pve-monitoring.yml b/ansible/playbooks/pve-monitoring.yml new file mode 100644 index 0000000..d27e45b --- /dev/null +++ b/ansible/playbooks/pve-monitoring.yml @@ -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 diff --git a/ansible/playbooks/pve-wg-mini.yml b/ansible/playbooks/pve-ovpn-mini.yml similarity index 81% rename from ansible/playbooks/pve-wg-mini.yml rename to ansible/playbooks/pve-ovpn-mini.yml index 159007f..2641513 100644 --- a/ansible/playbooks/pve-wg-mini.yml +++ b/ansible/playbooks/pve-ovpn-mini.yml @@ -1,4 +1,4 @@ -- name: Create wg-mini LXC on mini-pc +- name: Create ovpn-mini LXC on mini-pc hosts: localhost connection: local gather_facts: false @@ -7,19 +7,19 @@ vars: pve_lxc_vmid: 132 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_gateway: 192.168.1.1 pve_lxc_storage: local-lvm pve_lxc_disk: local-lvm:8 pve_lxc_ostemplate: "{{ lookup('env', 'PVE_LXC_OSTEMPLATE') | default('local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst', true) }}" -- name: Allow TUN device in wg-mini LXC config +- name: Allow TUN device in ovpn-mini LXC config hosts: mini-pc gather_facts: false become: true handlers: - - name: restart wg-mini lxc + - name: restart ovpn-mini lxc ansible.builtin.shell: pct stop 132 || true; pct start 132 changed_when: true tasks: @@ -28,11 +28,11 @@ path: /etc/pve/lxc/132.conf line: "lxc.cgroup2.devices.allow: c 10:200 rwm" state: present - notify: restart wg-mini lxc + notify: restart ovpn-mini lxc - name: Bind mount /dev/net/tun ansible.builtin.lineinfile: path: /etc/pve/lxc/132.conf line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file" state: present - notify: restart wg-mini lxc + notify: restart ovpn-mini lxc diff --git a/ansible/playbooks/pve-vaultwarden.yml b/ansible/playbooks/pve-vaultwarden.yml index 8351ec2..2cffe3d 100644 --- a/ansible/playbooks/pve-vaultwarden.yml +++ b/ansible/playbooks/pve-vaultwarden.yml @@ -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 hosts: mini-pc gather_facts: false @@ -84,7 +108,7 @@ vars: ansible_become: false 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_http_port: 80 tasks: @@ -133,6 +157,18 @@ group: root 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 ansible.builtin.copy: dest: /etc/systemd/system/vaultwarden.service @@ -151,7 +187,7 @@ ExecStartPre=-/usr/bin/docker rm -f {{ vaultwarden_container_name }} ExecStart=/usr/bin/docker run --rm \ --name {{ vaultwarden_container_name }} \ - --pull always \ + --pull never \ -p {{ vaultwarden_http_port }}:80 \ -v {{ vaultwarden_data_dir }}:/data \ -e WEBSOCKET_ENABLED=true \ @@ -170,5 +206,20 @@ - name: Enable and start Vaultwarden ansible.builtin.systemd: name: vaultwarden - state: started + state: "{{ 'restarted' if vaultwarden_unit.changed or vaultwarden_image_pull.changed else 'started' }}" 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' diff --git a/ansible/playbooks/reverse-proxy-grimmory.yml b/ansible/playbooks/reverse-proxy-grimmory.yml new file mode 100644 index 0000000..71684c9 --- /dev/null +++ b/ansible/playbooks/reverse-proxy-grimmory.yml @@ -0,0 +1,88 @@ +--- +- name: Configure Grimmory 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 + grimmory_domain: books.ada-dev.ru + grimmory_upstream: 192.168.1.34:6060 + tasks: + - name: Ensure Caddy service directory exists + ansible.builtin.file: + path: "{{ caddy_dir }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Configure Grimmory Caddy site + ansible.builtin.blockinfile: + path: "{{ caddyfile_path }}" + create: true + owner: root + group: root + mode: "0644" + marker: "# {mark} ANSIBLE MANAGED GRIMMORY SITE" + block: | + {{ grimmory_domain }} { + @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 {{ grimmory_upstream }} { + 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 {{ grimmory_upstream }} { + 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 {{ grimmory_upstream }} { + header_up Accept-Encoding identity + transport http { + compression off + } + } + } + + handle { + reverse_proxy {{ grimmory_upstream }} + } + } + register: grimmory_caddy_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 '@grimmory_device_api path /api/koreader' /etc/caddy/Caddyfile + register: grimmory_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: grimmory_caddy_site.changed or grimmory_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 Grimmory upstream from ru-vps + ansible.builtin.uri: + url: http://{{ grimmory_upstream }}/api/v1/healthcheck + status_code: 200 diff --git a/ansible/playbooks/ru-vps-mihomo-harden.yml b/ansible/playbooks/ru-vps-mihomo-harden.yml new file mode 100644 index 0000000..0190c5f --- /dev/null +++ b/ansible/playbooks/ru-vps-mihomo-harden.yml @@ -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. diff --git a/ansible/playbooks/uptime-kuma.yml b/ansible/playbooks/uptime-kuma.yml new file mode 100644 index 0000000..3ae72a5 --- /dev/null +++ b/ansible/playbooks/uptime-kuma.yml @@ -0,0 +1,6 @@ +--- +- name: Freeze Prometheus monitoring and configure Uptime Kuma + hosts: monitoring_server + gather_facts: false + roles: + - role: uptime_kuma diff --git a/ansible/playbooks/user-ssh-key.yml b/ansible/playbooks/user-ssh-key.yml new file mode 100644 index 0000000..b8961fc --- /dev/null +++ b/ansible/playbooks/user-ssh-key.yml @@ -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" diff --git a/ansible/playbooks/vaultwarden-update.yml b/ansible/playbooks/vaultwarden-update.yml new file mode 100644 index 0000000..9bb7b8e --- /dev/null +++ b/ansible/playbooks/vaultwarden-update.yml @@ -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 diff --git a/ansible/requirements.yml b/ansible/requirements.yml index ee30ab1..f387915 100644 --- a/ansible/requirements.yml +++ b/ansible/requirements.yml @@ -4,3 +4,5 @@ collections: version: ">=2.0.0" - name: community.proxmox version: ">=2.0.0" + - name: community.general + version: ">=10.0.0" diff --git a/ansible/roles/backup_audit/defaults/main.yml b/ansible/roles/backup_audit/defaults/main.yml new file mode 100644 index 0000000..6a9acdd --- /dev/null +++ b/ansible/roles/backup_audit/defaults/main.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 diff --git a/ansible/roles/backup_audit/tasks/main.yml b/ansible/roles/backup_audit/tasks/main.yml new file mode 100644 index 0000000..8b1bc8e --- /dev/null +++ b/ansible/roles/backup_audit/tasks/main.yml @@ -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' diff --git a/ansible/roles/backup_audit/templates/audit-pbs.sh.j2 b/ansible/roles/backup_audit/templates/audit-pbs.sh.j2 new file mode 100644 index 0000000..47dedfb --- /dev/null +++ b/ansible/roles/backup_audit/templates/audit-pbs.sh.j2 @@ -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 diff --git a/ansible/roles/backup_audit/templates/audit-restic.sh.j2 b/ansible/roles/backup_audit/templates/audit-restic.sh.j2 new file mode 100644 index 0000000..ff742bd --- /dev/null +++ b/ansible/roles/backup_audit/templates/audit-restic.sh.j2 @@ -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 " >&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 diff --git a/ansible/roles/bash_config/templates/homelab.sh.j2 b/ansible/roles/bash_config/templates/homelab.sh.j2 index 1cad202..2ce6ed6 100644 --- a/ansible/roles/bash_config/templates/homelab.sh.j2 +++ b/ansible/roles/bash_config/templates/homelab.sh.j2 @@ -30,6 +30,23 @@ alias gs='git status --short --branch' alias gd='git diff' 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 alias sctl='systemctl' alias jctl='journalctl' diff --git a/ansible/roles/emergency_access/defaults/main.yml b/ansible/roles/emergency_access/defaults/main.yml new file mode 100644 index 0000000..b10bd5b --- /dev/null +++ b/ansible/roles/emergency_access/defaults/main.yml @@ -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 diff --git a/ansible/roles/emergency_access/handlers/main.yml b/ansible/roles/emergency_access/handlers/main.yml new file mode 100644 index 0000000..81e25ac --- /dev/null +++ b/ansible/roles/emergency_access/handlers/main.yml @@ -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 diff --git a/ansible/roles/emergency_access/tasks/client.yml b/ansible/roles/emergency_access/tasks/client.yml new file mode 100644 index 0000000..efeb869 --- /dev/null +++ b/ansible/roles/emergency_access/tasks/client.yml @@ -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 diff --git a/ansible/roles/emergency_access/tasks/endpoint.yml b/ansible/roles/emergency_access/tasks/endpoint.yml new file mode 100644 index 0000000..8432628 --- /dev/null +++ b/ansible/roles/emergency_access/tasks/endpoint.yml @@ -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 diff --git a/ansible/roles/emergency_access/templates/emergency-control.j2 b/ansible/roles/emergency_access/templates/emergency-control.j2 new file mode 100644 index 0000000..18ebd1c --- /dev/null +++ b/ansible/roles/emergency_access/templates/emergency-control.j2 @@ -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 diff --git a/ansible/roles/emergency_access/templates/emergency-control.sudoers.j2 b/ansible/roles/emergency_access/templates/emergency-control.sudoers.j2 new file mode 100644 index 0000000..05f4750 --- /dev/null +++ b/ansible/roles/emergency_access/templates/emergency-control.sudoers.j2 @@ -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 diff --git a/ansible/roles/emergency_access/templates/emergency-reverse-ssh-expire.service.j2 b/ansible/roles/emergency_access/templates/emergency-reverse-ssh-expire.service.j2 new file mode 100644 index 0000000..717a282 --- /dev/null +++ b/ansible/roles/emergency_access/templates/emergency-reverse-ssh-expire.service.j2 @@ -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 diff --git a/ansible/roles/emergency_access/templates/emergency-reverse-ssh.service.j2 b/ansible/roles/emergency_access/templates/emergency-reverse-ssh.service.j2 new file mode 100644 index 0000000..b1f1624 --- /dev/null +++ b/ansible/roles/emergency_access/templates/emergency-reverse-ssh.service.j2 @@ -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 diff --git a/ansible/roles/emergency_access/templates/emergency-reverse-ssh.timer.j2 b/ansible/roles/emergency_access/templates/emergency-reverse-ssh.timer.j2 new file mode 100644 index 0000000..747008c --- /dev/null +++ b/ansible/roles/emergency_access/templates/emergency-reverse-ssh.timer.j2 @@ -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 diff --git a/ansible/roles/emergency_bot/defaults/main.yml b/ansible/roles/emergency_bot/defaults/main.yml new file mode 100644 index 0000000..c902048 --- /dev/null +++ b/ansible/roles/emergency_bot/defaults/main.yml @@ -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 diff --git a/ansible/roles/emergency_bot/handlers/main.yml b/ansible/roles/emergency_bot/handlers/main.yml new file mode 100644 index 0000000..388f4b6 --- /dev/null +++ b/ansible/roles/emergency_bot/handlers/main.yml @@ -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 diff --git a/ansible/roles/emergency_bot/tasks/bootstrap.yml b/ansible/roles/emergency_bot/tasks/bootstrap.yml new file mode 100644 index 0000000..93b5569 --- /dev/null +++ b/ansible/roles/emergency_bot/tasks/bootstrap.yml @@ -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 diff --git a/ansible/roles/emergency_bot/tasks/main.yml b/ansible/roles/emergency_bot/tasks/main.yml new file mode 100644 index 0000000..bf983e5 --- /dev/null +++ b/ansible/roles/emergency_bot/tasks/main.yml @@ -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 diff --git a/ansible/roles/emergency_bot/templates/emergency-bot.env.j2 b/ansible/roles/emergency_bot/templates/emergency-bot.env.j2 new file mode 100644 index 0000000..cf2baaf --- /dev/null +++ b/ansible/roles/emergency_bot/templates/emergency-bot.env.j2 @@ -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 }} diff --git a/ansible/roles/emergency_bot/templates/emergency-bot.service.j2 b/ansible/roles/emergency_bot/templates/emergency-bot.service.j2 new file mode 100644 index 0000000..e208ee5 --- /dev/null +++ b/ansible/roles/emergency_bot/templates/emergency-bot.service.j2 @@ -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 diff --git a/ansible/roles/emergency_bot/templates/emergency_bot.py.j2 b/ansible/roles/emergency_bot/templates/emergency_bot.py.j2 new file mode 100644 index 0000000..c741208 --- /dev/null +++ b/ansible/roles/emergency_bot/templates/emergency_bot.py.j2 @@ -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() diff --git a/ansible/roles/gyro/defaults/main.yml b/ansible/roles/gyro/defaults/main.yml new file mode 100644 index 0000000..7b4a977 --- /dev/null +++ b/ansible/roles/gyro/defaults/main.yml @@ -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" diff --git a/ansible/roles/gyro/files/gyro-failure-notify.py b/ansible/roles/gyro/files/gyro-failure-notify.py new file mode 100644 index 0000000..5ec724b --- /dev/null +++ b/ansible/roles/gyro/files/gyro-failure-notify.py @@ -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()) diff --git a/ansible/roles/gyro/handlers/main.yml b/ansible/roles/gyro/handlers/main.yml new file mode 100644 index 0000000..c28484f --- /dev/null +++ b/ansible/roles/gyro/handlers/main.yml @@ -0,0 +1,4 @@ +--- +- name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true diff --git a/ansible/roles/gyro/tasks/main.yml b/ansible/roles/gyro/tasks/main.yml new file mode 100644 index 0000000..ad9573e --- /dev/null +++ b/ansible/roles/gyro/tasks/main.yml @@ -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 diff --git a/ansible/roles/gyro/templates/gyro-failure@.service.j2 b/ansible/roles/gyro/templates/gyro-failure@.service.j2 new file mode 100644 index 0000000..b404b10 --- /dev/null +++ b/ansible/roles/gyro/templates/gyro-failure@.service.j2 @@ -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 diff --git a/ansible/roles/gyro/templates/gyro.env.j2 b/ansible/roles/gyro/templates/gyro.env.j2 new file mode 100644 index 0000000..469b9e3 --- /dev/null +++ b/ansible/roles/gyro/templates/gyro.env.j2 @@ -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 }} diff --git a/ansible/roles/gyro/templates/gyro.service.j2 b/ansible/roles/gyro/templates/gyro.service.j2 new file mode 100644 index 0000000..0235aa0 --- /dev/null +++ b/ansible/roles/gyro/templates/gyro.service.j2 @@ -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 diff --git a/ansible/roles/gyro/templates/gyro.timer.j2 b/ansible/roles/gyro/templates/gyro.timer.j2 new file mode 100644 index 0000000..2409efa --- /dev/null +++ b/ansible/roles/gyro/templates/gyro.timer.j2 @@ -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 diff --git a/ansible/roles/monitoring_blackbox/defaults/main.yml b/ansible/roles/monitoring_blackbox/defaults/main.yml new file mode 100644 index 0000000..23e6445 --- /dev/null +++ b/ansible/roles/monitoring_blackbox/defaults/main.yml @@ -0,0 +1,3 @@ +--- +monitoring_blackbox_listen_address: 127.0.0.1:9115 +monitoring_pushgateway_url: http://192.168.1.30:9091 diff --git a/ansible/roles/monitoring_blackbox/handlers/main.yml b/ansible/roles/monitoring_blackbox/handlers/main.yml new file mode 100644 index 0000000..35c356a --- /dev/null +++ b/ansible/roles/monitoring_blackbox/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: reload systemd + ansible.builtin.systemd: + daemon_reload: true + +- name: restart blackbox exporter + ansible.builtin.systemd: + name: prometheus-blackbox-exporter + state: restarted diff --git a/ansible/roles/monitoring_blackbox/tasks/main.yml b/ansible/roles/monitoring_blackbox/tasks/main.yml new file mode 100644 index 0000000..51cd9ba --- /dev/null +++ b/ansible/roles/monitoring_blackbox/tasks/main.yml @@ -0,0 +1,96 @@ +--- +- name: Install Blackbox Exporter + ansible.builtin.apt: + name: + - curl + - prometheus-blackbox-exporter + state: present + update_cache: true + +- name: Install external probe configuration + ansible.builtin.template: + src: blackbox.yml.j2 + dest: /etc/prometheus/blackbox.yml + owner: root + group: root + mode: "0644" + notify: restart blackbox exporter + +- name: Create Blackbox Exporter systemd override directory + ansible.builtin.file: + path: /etc/systemd/system/prometheus-blackbox-exporter.service.d + state: directory + owner: root + group: root + mode: "0755" + +- name: Configure Blackbox Exporter listen address + ansible.builtin.copy: + dest: /etc/systemd/system/prometheus-blackbox-exporter.service.d/override.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/bin/prometheus-blackbox-exporter --config.file=/etc/prometheus/blackbox.yml --web.listen-address={{ monitoring_blackbox_listen_address }} + notify: + - reload systemd + - restart blackbox exporter + +- name: Install ru-vps metrics push script + ansible.builtin.template: + src: push-metrics.sh.j2 + dest: /usr/local/sbin/homelab-monitoring-push + owner: root + group: root + mode: "0755" + +- name: Install ru-vps metrics push service + ansible.builtin.copy: + dest: /etc/systemd/system/homelab-monitoring-push.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Push ru-vps monitoring metrics through OpenVPN + Wants=network-online.target + After=network-online.target + + [Service] + Type=oneshot + ExecStart=/usr/local/sbin/homelab-monitoring-push + notify: reload systemd + +- name: Install ru-vps metrics push timer + ansible.builtin.copy: + dest: /etc/systemd/system/homelab-monitoring-push.timer + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Run ru-vps monitoring metric push every minute + + [Timer] + OnBootSec=2m + OnUnitActiveSec=1m + Persistent=true + + [Install] + WantedBy=timers.target + notify: reload systemd + +- name: Ensure Blackbox Exporter is enabled and running + ansible.builtin.systemd: + name: prometheus-blackbox-exporter + enabled: true + state: started + +- name: Enable ru-vps metrics push timer + ansible.builtin.systemd: + name: homelab-monitoring-push.timer + daemon_reload: true + enabled: true + state: started diff --git a/ansible/roles/monitoring_blackbox/templates/blackbox.yml.j2 b/ansible/roles/monitoring_blackbox/templates/blackbox.yml.j2 new file mode 100644 index 0000000..3c8ab56 --- /dev/null +++ b/ansible/roles/monitoring_blackbox/templates/blackbox.yml.j2 @@ -0,0 +1,13 @@ +modules: + http_2xx: + prober: http + timeout: 10s + http: + preferred_ip_protocol: ip4 + valid_status_codes: [200, 301, 302] + tcp_connect: + prober: tcp + timeout: 10s + icmp: + prober: icmp + timeout: 10s diff --git a/ansible/roles/monitoring_blackbox/templates/push-metrics.sh.j2 b/ansible/roles/monitoring_blackbox/templates/push-metrics.sh.j2 new file mode 100644 index 0000000..ea1374d --- /dev/null +++ b/ansible/roles/monitoring_blackbox/templates/push-metrics.sh.j2 @@ -0,0 +1,37 @@ +#!/bin/sh +# Managed by Ansible. Push local ru-vps metrics through the OpenVPN route. +set -eu + +PUSHGATEWAY_URL="{{ monitoring_pushgateway_url }}" +NOW=$(date +%s) +NODE_METRICS=$(mktemp) + +trap 'rm -f "$NODE_METRICS"' EXIT + +push_stdin() { + path="$1" + curl --fail --silent --show-error --data-binary @- "$PUSHGATEWAY_URL/metrics/$path" +} + +curl --fail --silent --show-error http://127.0.0.1:9100/metrics > "$NODE_METRICS" +push_stdin "job/node/instance/ru-vps" < "$NODE_METRICS" + +probe() { + name="$1" + module="$2" + target="$3" + payload=$(curl --fail --silent --show-error --get \ + --data-urlencode "module=$module" \ + --data-urlencode "target=$target" \ + http://127.0.0.1:9115/probe || printf 'probe_success 0\n') + payload=$(printf '%s\nhomelab_vps_probe_timestamp_seconds %s\n' "$payload" "$NOW") + printf '%s\n' "$payload" | push_stdin "job/blackbox-vps/probe_name/$name" +} + +probe vaultwarden http_2xx https://pass.ada-dev.ru +probe gitea http_2xx https://git.ada-dev.ru +probe grimmory http_2xx https://books.ada-dev.ru/api/v1/healthcheck +probe ovpn_gateway tcp_connect 10.78.0.2:22 +probe cloud_pve tcp_connect 192.168.1.5:8006 +probe mini_pve tcp_connect 192.168.1.10:8006 +probe pbs tcp_connect 192.168.1.20:8007 diff --git a/ansible/roles/monitoring_exporter/defaults/main.yml b/ansible/roles/monitoring_exporter/defaults/main.yml new file mode 100644 index 0000000..f10b3a0 --- /dev/null +++ b/ansible/roles/monitoring_exporter/defaults/main.yml @@ -0,0 +1,6 @@ +--- +monitoring_exporter_install_node: true +monitoring_exporter_install_smart: false +monitoring_exporter_textfile_dir: /var/lib/node_exporter/textfile_collector +monitoring_exporter_node_listen_address: 0.0.0.0:9100 +monitoring_exporter_smartctl_timer: "*:0/15" diff --git a/ansible/roles/monitoring_exporter/handlers/main.yml b/ansible/roles/monitoring_exporter/handlers/main.yml new file mode 100644 index 0000000..2a8227f --- /dev/null +++ b/ansible/roles/monitoring_exporter/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: reload systemd + ansible.builtin.systemd: + daemon_reload: true + +- name: restart node exporter + ansible.builtin.systemd: + name: prometheus-node-exporter + state: restarted diff --git a/ansible/roles/monitoring_exporter/tasks/main.yml b/ansible/roles/monitoring_exporter/tasks/main.yml new file mode 100644 index 0000000..8684c1b --- /dev/null +++ b/ansible/roles/monitoring_exporter/tasks/main.yml @@ -0,0 +1,121 @@ +--- +- name: Install Node Exporter + ansible.builtin.apt: + name: prometheus-node-exporter + state: present + when: monitoring_exporter_install_node + +- name: Create Node Exporter textfile directory + ansible.builtin.file: + path: "{{ monitoring_exporter_textfile_dir }}" + state: directory + owner: root + group: root + mode: "0755" + when: monitoring_exporter_install_node + +- name: Create Node Exporter systemd override directory + ansible.builtin.file: + path: /etc/systemd/system/prometheus-node-exporter.service.d + state: directory + owner: root + group: root + mode: "0755" + when: monitoring_exporter_install_node + +- name: Configure Node Exporter collectors + ansible.builtin.copy: + dest: /etc/systemd/system/prometheus-node-exporter.service.d/override.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/bin/prometheus-node-exporter --web.listen-address={{ monitoring_exporter_node_listen_address }} --collector.systemd --collector.textfile.directory={{ monitoring_exporter_textfile_dir }} + when: monitoring_exporter_install_node + notify: + - reload systemd + - restart node exporter + +- name: Ensure Node Exporter is enabled and running + ansible.builtin.systemd: + name: prometheus-node-exporter + enabled: true + state: started + when: monitoring_exporter_install_node + +- name: Install SMART Exporter dependencies + ansible.builtin.apt: + name: + - jq + - smartmontools + state: present + when: monitoring_exporter_install_smart + +- name: Remove obsolete SMART Exporter container unit + ansible.builtin.systemd: + name: homelab-smartctl-exporter + enabled: false + state: stopped + failed_when: false + when: monitoring_exporter_install_smart + +- name: Install SMART metric collection script + ansible.builtin.template: + src: smartctl-metrics.sh.j2 + dest: /usr/local/sbin/homelab-smartctl-metrics + owner: root + group: root + mode: "0755" + when: monitoring_exporter_install_smart + +- name: Install SMART metric collection service + ansible.builtin.copy: + dest: /etc/systemd/system/homelab-smartctl-metrics.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Publish SMART metrics for Node Exporter + + [Service] + Type=oneshot + ExecStart=/usr/local/sbin/homelab-smartctl-metrics + when: monitoring_exporter_install_smart + notify: reload systemd + +- name: Install SMART metric collection timer + ansible.builtin.copy: + dest: /etc/systemd/system/homelab-smartctl-metrics.timer + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Collect SMART metrics every 15 minutes + + [Timer] + OnBootSec=2m + OnUnitActiveSec=15m + Persistent=true + + [Install] + WantedBy=multi-user.target + when: monitoring_exporter_install_smart + notify: reload systemd + +- name: Enable SMART metric collection timer + ansible.builtin.systemd: + name: homelab-smartctl-metrics.timer + daemon_reload: true + enabled: true + state: started + when: monitoring_exporter_install_smart + +- name: Run initial SMART metric collection + ansible.builtin.systemd: + name: homelab-smartctl-metrics.service + state: started + when: monitoring_exporter_install_smart diff --git a/ansible/roles/monitoring_exporter/templates/smartctl-metrics.sh.j2 b/ansible/roles/monitoring_exporter/templates/smartctl-metrics.sh.j2 new file mode 100644 index 0000000..d27acf1 --- /dev/null +++ b/ansible/roles/monitoring_exporter/templates/smartctl-metrics.sh.j2 @@ -0,0 +1,30 @@ +#!/bin/sh +# Managed by Ansible. Export SMART health to Node Exporter's textfile collector. +set -eu + +METRICS_DIR="{{ monitoring_exporter_textfile_dir }}" +METRICS_FILE="$METRICS_DIR/homelab_smartctl.prom" +METRICS_TMP=$(mktemp "$METRICS_FILE.XXXXXX") + +trap 'rm -f "$METRICS_TMP"' EXIT + +printf '# HELP homelab_smart_device_healthy SMART overall health (1 healthy, 0 failed)\n' >> "$METRICS_TMP" +printf '# TYPE homelab_smart_device_healthy gauge\n' >> "$METRICS_TMP" + +lsblk -dn -o NAME,TYPE | while read -r name type; do + [ "$type" = "disk" ] || continue + device="/dev/$name" + json=$(smartctl -a -j "$device" 2>/dev/null || true) + [ -n "$json" ] || continue + + healthy=$(printf '%s' "$json" | jq -r 'if .smart_status.passed == true then 1 else 0 end') + temperature=$(printf '%s' "$json" | jq -r '.temperature.current // .nvme_smart_health_information_log.temperature // empty') + nvme_warning=$(printf '%s' "$json" | jq -r '.nvme_smart_health_information_log.critical_warning // empty') + + printf 'homelab_smart_device_healthy{device="%s"} %s\n' "$name" "$healthy" >> "$METRICS_TMP" + [ -z "$temperature" ] || printf 'homelab_smart_temperature_celsius{device="%s"} %s\n' "$name" "$temperature" >> "$METRICS_TMP" + [ -z "$nvme_warning" ] || printf 'homelab_smart_nvme_critical_warning{device="%s"} %s\n' "$name" "$nvme_warning" >> "$METRICS_TMP" +done + +chmod 0644 "$METRICS_TMP" +mv "$METRICS_TMP" "$METRICS_FILE" diff --git a/ansible/roles/monitoring_server/defaults/main.yml b/ansible/roles/monitoring_server/defaults/main.yml new file mode 100644 index 0000000..d8e21c7 --- /dev/null +++ b/ansible/roles/monitoring_server/defaults/main.yml @@ -0,0 +1,14 @@ +--- +monitoring_root: /opt/monitoring +monitoring_prometheus_retention: 30d +monitoring_prometheus_retention_size: 8GB +monitoring_grafana_admin_user: admin +monitoring_grafana_admin_password: "{{ lookup('env', 'MONITORING_GRAFANA_ADMIN_PASSWORD') }}" +monitoring_telegram_bot_token: "{{ lookup('env', 'MONITORING_TELEGRAM_BOT_TOKEN') }}" +monitoring_telegram_chat_id: "{{ lookup('env', 'MONITORING_TELEGRAM_CHAT_ID') }}" +monitoring_pve_api_user: "{{ lookup('env', 'MONITORING_PVE_API_USER') }}" +monitoring_pve_api_token_id: "{{ lookup('env', 'MONITORING_PVE_API_TOKEN_ID') }}" +monitoring_pve_api_token_secret: "{{ lookup('env', 'MONITORING_PVE_API_TOKEN_SECRET') }}" +monitoring_lan_cidr: 192.168.1.0/24 +monitoring_openvpn_gateway_ip: 192.168.1.23 +monitoring_docker_cidr: 172.16.0.0/12 diff --git a/ansible/roles/monitoring_server/files/dashboards/backups.json b/ansible/roles/monitoring_server/files/dashboards/backups.json new file mode 100644 index 0000000..a0127a3 --- /dev/null +++ b/ansible/roles/monitoring_server/files/dashboards/backups.json @@ -0,0 +1,26 @@ +{ + "title": "Backups", + "uid": "homelab-backups", + "schemaVersion": 39, + "version": 1, + "panels": [ + { + "type": "table", + "title": "Audit Result", + "gridPos": {"h": 10, "w": 12, "x": 0, "y": 0}, + "targets": [{"expr": "homelab_backup_audit_success", "format": "table", "instant": true, "refId": "A"}] + }, + { + "type": "timeseries", + "title": "Backup Age (Hours)", + "gridPos": {"h": 10, "w": 12, "x": 12, "y": 0}, + "targets": [{"expr": "homelab_backup_audit_snapshot_age_hours", "legendFormat": "{{profile}} {{vmid}}", "refId": "A"}] + }, + { + "type": "timeseries", + "title": "Audit Levels", + "gridPos": {"h": 10, "w": 24, "x": 0, "y": 10}, + "targets": [{"expr": "homelab_backup_audit_level_success", "legendFormat": "{{profile}} {{level}}", "refId": "A"}] + } + ] +} diff --git a/ansible/roles/monitoring_server/files/dashboards/homelab-overview.json b/ansible/roles/monitoring_server/files/dashboards/homelab-overview.json new file mode 100644 index 0000000..f628644 --- /dev/null +++ b/ansible/roles/monitoring_server/files/dashboards/homelab-overview.json @@ -0,0 +1,34 @@ +{ + "title": "HomeLab Overview", + "uid": "homelab-overview", + "schemaVersion": 39, + "version": 1, + "panels": [ + { + "type": "stat", + "title": "Active Alerts", + "gridPos": {"h": 8, "w": 6, "x": 0, "y": 0}, + "targets": [{"expr": "count(ALERTS{alertstate=\"firing\"})", "refId": "A"}], + "options": {"reduceOptions": {"calcs": ["lastNotNull"]}} + }, + { + "type": "stat", + "title": "Successful Probes", + "gridPos": {"h": 8, "w": 6, "x": 6, "y": 0}, + "targets": [{"expr": "sum(probe_success)", "refId": "A"}], + "options": {"reduceOptions": {"calcs": ["lastNotNull"]}} + }, + { + "type": "timeseries", + "title": "Filesystem Free Space", + "gridPos": {"h": 10, "w": 12, "x": 0, "y": 8}, + "targets": [{"expr": "node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay\"}", "legendFormat": "{{instance}} {{mountpoint}}", "refId": "A"}] + }, + { + "type": "timeseries", + "title": "Backup Audit Success", + "gridPos": {"h": 10, "w": 12, "x": 12, "y": 8}, + "targets": [{"expr": "homelab_backup_audit_success", "legendFormat": "{{profile}} {{instance}}", "refId": "A"}] + } + ] +} diff --git a/ansible/roles/monitoring_server/files/dashboards/proxmox-storage.json b/ansible/roles/monitoring_server/files/dashboards/proxmox-storage.json new file mode 100644 index 0000000..74a7c71 --- /dev/null +++ b/ansible/roles/monitoring_server/files/dashboards/proxmox-storage.json @@ -0,0 +1,26 @@ +{ + "title": "Proxmox and Storage", + "uid": "proxmox-storage", + "schemaVersion": 39, + "version": 1, + "panels": [ + { + "type": "timeseries", + "title": "Node CPU Usage", + "gridPos": {"h": 9, "w": 12, "x": 0, "y": 0}, + "targets": [{"expr": "1 - avg by(instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))", "legendFormat": "{{instance}}", "refId": "A"}] + }, + { + "type": "timeseries", + "title": "Node Memory Available", + "gridPos": {"h": 9, "w": 12, "x": 12, "y": 0}, + "targets": [{"expr": "node_memory_MemAvailable_bytes", "legendFormat": "{{instance}}", "refId": "A"}] + }, + { + "type": "timeseries", + "title": "Disk Read and Write", + "gridPos": {"h": 9, "w": 24, "x": 0, "y": 9}, + "targets": [{"expr": "rate(node_disk_read_bytes_total[5m])", "legendFormat": "read {{instance}} {{device}}", "refId": "A"}, {"expr": "rate(node_disk_written_bytes_total[5m])", "legendFormat": "write {{instance}} {{device}}", "refId": "B"}] + } + ] +} diff --git a/ansible/roles/monitoring_server/files/dashboards/services-network.json b/ansible/roles/monitoring_server/files/dashboards/services-network.json new file mode 100644 index 0000000..3331173 --- /dev/null +++ b/ansible/roles/monitoring_server/files/dashboards/services-network.json @@ -0,0 +1,26 @@ +{ + "title": "Services and Network", + "uid": "services-network", + "schemaVersion": 39, + "version": 1, + "panels": [ + { + "type": "table", + "title": "Probe State", + "gridPos": {"h": 10, "w": 12, "x": 0, "y": 0}, + "targets": [{"expr": "probe_success", "format": "table", "instant": true, "refId": "A"}] + }, + { + "type": "timeseries", + "title": "Probe Duration", + "gridPos": {"h": 10, "w": 12, "x": 12, "y": 0}, + "targets": [{"expr": "probe_duration_seconds", "legendFormat": "{{job}} {{instance}}", "refId": "A"}] + }, + { + "type": "timeseries", + "title": "TLS Certificate Remaining", + "gridPos": {"h": 10, "w": 24, "x": 0, "y": 10}, + "targets": [{"expr": "(probe_ssl_earliest_cert_expiry - time()) / 86400", "legendFormat": "{{instance}}", "refId": "A"}] + } + ] +} diff --git a/ansible/roles/monitoring_server/files/grafana-dashboard-provider.yml b/ansible/roles/monitoring_server/files/grafana-dashboard-provider.yml new file mode 100644 index 0000000..06fc8d7 --- /dev/null +++ b/ansible/roles/monitoring_server/files/grafana-dashboard-provider.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: HomeLab + orgId: 1 + folder: HomeLab + type: file + disableDeletion: true + editable: false + options: + path: /etc/grafana/dashboards diff --git a/ansible/roles/monitoring_server/files/grafana-datasource.yml b/ansible/roles/monitoring_server/files/grafana-datasource.yml new file mode 100644 index 0000000..00f9915 --- /dev/null +++ b/ansible/roles/monitoring_server/files/grafana-datasource.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false diff --git a/ansible/roles/monitoring_server/handlers/main.yml b/ansible/roles/monitoring_server/handlers/main.yml new file mode 100644 index 0000000..2eb3891 --- /dev/null +++ b/ansible/roles/monitoring_server/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: reload systemd + ansible.builtin.systemd: + daemon_reload: true + +- name: restart monitoring stack + ansible.builtin.systemd: + name: homelab-monitoring + state: restarted diff --git a/ansible/roles/monitoring_server/tasks/main.yml b/ansible/roles/monitoring_server/tasks/main.yml new file mode 100644 index 0000000..617b113 --- /dev/null +++ b/ansible/roles/monitoring_server/tasks/main.yml @@ -0,0 +1,233 @@ +--- +- name: Validate monitoring secrets + ansible.builtin.assert: + that: + - monitoring_telegram_bot_token | length > 0 + - monitoring_telegram_bot_token != 'replace-me' + - monitoring_telegram_chat_id | length > 0 + - monitoring_telegram_chat_id != 'replace-me' + - monitoring_pve_api_user | length > 0 + - monitoring_pve_api_token_id | length > 0 + - monitoring_pve_api_token_secret | length > 0 + - monitoring_pve_api_token_secret != 'replace-me' + - monitoring_grafana_admin_password | length > 0 + - monitoring_grafana_admin_password != 'change-me' + fail_msg: Load monitoring secrets from ignored ansible/.env or Ansible Vault before running this playbook. + no_log: true + +- name: Install monitoring runtime packages + ansible.builtin.apt: + name: + - ca-certificates + - docker.io + - docker-compose + - ufw + state: present + update_cache: true + +- name: Ensure Docker is enabled and running + ansible.builtin.systemd: + name: docker + enabled: true + state: started + +- name: Allow SSH and Grafana access from the LAN + community.general.ufw: + rule: allow + port: "{{ item }}" + proto: tcp + src: "{{ monitoring_lan_cidr }}" + loop: + - "22" + - "3000" + +- name: Allow Pushgateway only from the OpenVPN gateway + community.general.ufw: + rule: allow + port: "9091" + proto: tcp + src: "{{ monitoring_openvpn_gateway_ip }}" + +- name: Allow local Prometheus container to scrape Node Exporter + community.general.ufw: + rule: allow + port: "9100" + proto: tcp + src: "{{ monitoring_docker_cidr }}" + +- name: Enable monitoring LXC firewall + community.general.ufw: + state: enabled + policy: deny + direction: incoming + +- name: Create monitoring directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: "0755" + loop: + - "{{ monitoring_root }}" + - "{{ monitoring_root }}/prometheus" + - "{{ monitoring_root }}/alertmanager" + - "{{ monitoring_root }}/grafana" + - "{{ monitoring_root }}/grafana/provisioning" + - "{{ monitoring_root }}/grafana/provisioning/dashboards" + - "{{ monitoring_root }}/grafana/provisioning/datasources" + - "{{ monitoring_root }}/grafana/dashboards" + - "{{ monitoring_root }}/pve-exporter" + +- name: Grant Prometheus access to its data directory + ansible.builtin.file: + path: "{{ monitoring_root }}/prometheus/data" + state: directory + owner: "65534" + group: "65534" + mode: "0755" + +- name: Install monitoring compose definition + ansible.builtin.template: + src: compose.yml.j2 + dest: "{{ monitoring_root }}/compose.yml" + owner: root + group: root + mode: "0600" + no_log: true + notify: restart monitoring stack + +- name: Install Prometheus configuration + ansible.builtin.template: + src: prometheus.yml.j2 + dest: "{{ monitoring_root }}/prometheus/prometheus.yml" + owner: root + group: root + mode: "0644" + notify: restart monitoring stack + +- name: Install Prometheus alert rules + ansible.builtin.template: + src: alerts.yml.j2 + dest: "{{ monitoring_root }}/prometheus/alerts.yml" + owner: root + group: root + mode: "0644" + notify: restart monitoring stack + +- name: Install central Blackbox configuration + ansible.builtin.template: + src: blackbox.yml.j2 + dest: "{{ monitoring_root }}/blackbox.yml" + owner: root + group: root + mode: "0644" + notify: restart monitoring stack + +- name: Install Alertmanager configuration + ansible.builtin.template: + src: alertmanager.yml.j2 + dest: "{{ monitoring_root }}/alertmanager/alertmanager.yml" + owner: root + group: root + mode: "0640" + no_log: true + notify: restart monitoring stack + +- name: Install PVE exporter configuration + ansible.builtin.template: + src: pve.yml.j2 + dest: "{{ monitoring_root }}/pve-exporter/pve.yml" + owner: root + group: root + mode: "0600" + no_log: true + notify: restart monitoring stack + +- name: Provision Grafana datasource + ansible.builtin.copy: + src: grafana-datasource.yml + dest: "{{ monitoring_root }}/grafana/provisioning/datasources/prometheus.yml" + owner: root + group: root + mode: "0644" + notify: restart monitoring stack + +- name: Provision Grafana dashboard provider + ansible.builtin.copy: + src: grafana-dashboard-provider.yml + dest: "{{ monitoring_root }}/grafana/provisioning/dashboards/homelab.yml" + owner: root + group: root + mode: "0644" + notify: restart monitoring stack + +- name: Install Grafana dashboards + ansible.builtin.copy: + src: "dashboards/{{ item }}" + dest: "{{ monitoring_root }}/grafana/dashboards/{{ item }}" + owner: root + group: root + mode: "0644" + loop: + - homelab-overview.json + - proxmox-storage.json + - services-network.json + - backups.json + notify: restart monitoring stack + +- name: Validate Docker Compose configuration + ansible.builtin.command: >- + docker-compose -f {{ monitoring_root }}/compose.yml config --quiet + changed_when: false + no_log: true + +- name: Validate Prometheus configuration + ansible.builtin.command: >- + docker run --rm --network none + -v {{ monitoring_root }}/prometheus:/etc/prometheus:ro + --entrypoint promtool prom/prometheus:v3.2.1 + check config /etc/prometheus/prometheus.yml + changed_when: false + +- name: Validate Alertmanager configuration + ansible.builtin.command: >- + docker run --rm --network none + -v {{ monitoring_root }}/alertmanager:/etc/alertmanager:ro + --user 0:0 + --entrypoint amtool prom/alertmanager:v0.28.0 + check-config /etc/alertmanager/alertmanager.yml + changed_when: false + no_log: true + +- name: Install monitoring systemd unit + ansible.builtin.copy: + dest: /etc/systemd/system/homelab-monitoring.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=HomeLab monitoring stack + Requires=docker.service + After=docker.service + + [Service] + Type=oneshot + RemainAfterExit=yes + WorkingDirectory={{ monitoring_root }} + ExecStart=/usr/bin/docker-compose -f {{ monitoring_root }}/compose.yml up -d --remove-orphans + ExecStop=/usr/bin/docker-compose -f {{ monitoring_root }}/compose.yml down + + [Install] + WantedBy=multi-user.target + notify: + - reload systemd + - restart monitoring stack + +- name: Enable monitoring stack + ansible.builtin.systemd: + name: homelab-monitoring + daemon_reload: true + enabled: true + state: started diff --git a/ansible/roles/monitoring_server/templates/alertmanager.yml.j2 b/ansible/roles/monitoring_server/templates/alertmanager.yml.j2 new file mode 100644 index 0000000..2c77f64 --- /dev/null +++ b/ansible/roles/monitoring_server/templates/alertmanager.yml.j2 @@ -0,0 +1,40 @@ +global: + resolve_timeout: 5m + +route: + receiver: telegram-warning + group_by: [alertname, instance] + group_wait: 30s + group_interval: 10m + repeat_interval: 6h + routes: + - matchers: + - severity="critical" + receiver: telegram-critical + repeat_interval: 2h + +inhibit_rules: + - source_matchers: + - alertname="ExporterDown" + target_matchers: + - alertname="LanServiceUnavailable" + equal: [instance] + +receivers: + - name: telegram-warning + telegram_configs: + - bot_token: "{{ monitoring_telegram_bot_token }}" + chat_id: {{ monitoring_telegram_chat_id }} + send_resolved: true + message: |- + [{{ "{{" }} .Status | toUpper {{ "}}" }}] {{ "{{" }} .CommonLabels.alertname {{ "}}" }} + {{ "{{" }} .CommonAnnotations.summary {{ "}}" }} + + - name: telegram-critical + telegram_configs: + - bot_token: "{{ monitoring_telegram_bot_token }}" + chat_id: {{ monitoring_telegram_chat_id }} + send_resolved: true + message: |- + [{{ "{{" }} .Status | toUpper {{ "}}" }}] CRITICAL: {{ "{{" }} .CommonLabels.alertname {{ "}}" }} + {{ "{{" }} .CommonAnnotations.summary {{ "}}" }} diff --git a/ansible/roles/monitoring_server/templates/alerts.yml.j2 b/ansible/roles/monitoring_server/templates/alerts.yml.j2 new file mode 100644 index 0000000..38a70c2 --- /dev/null +++ b/ansible/roles/monitoring_server/templates/alerts.yml.j2 @@ -0,0 +1,134 @@ +groups: + - name: homelab-availability + rules: + - alert: ExporterDown + expr: up{job=~"node|smartctl|pve"} == 0 + for: 10m + labels: + severity: warning + annotations: + summary: "Exporter unavailable: {{ '{{' }} $labels.job {{ '}}' }} {{ '{{' }} $labels.instance {{ '}}' }}" + + - alert: CriticalServiceUnavailable + expr: probe_success{job="blackbox-vps"} == 0 + for: 5m + labels: + severity: critical + annotations: + summary: "External or VPN check failed: {{ '{{' }} $labels.probe_name {{ '}}' }}" + + - alert: RuVpsMonitoringStale + expr: time() - homelab_vps_probe_timestamp_seconds > 5 * 60 + for: 5m + labels: + severity: critical + annotations: + summary: "ru-vps has not pushed monitoring metrics through OpenVPN" + + - alert: RuVpsMonitoringMissing + expr: absent(homelab_vps_probe_timestamp_seconds{job="blackbox-vps"}) + for: 10m + labels: + severity: critical + annotations: + summary: "ru-vps has no monitoring metrics in Pushgateway" + + - alert: LanServiceUnavailable + expr: probe_success{job=~"blackbox-lan-http|blackbox-lan-tcp"} == 0 + for: 10m + labels: + severity: warning + annotations: + summary: "LAN service unavailable: {{ '{{' }} $labels.instance {{ '}}' }}" + + - alert: TLSCertificateExpiringSoon + expr: (probe_ssl_earliest_cert_expiry - time()) < 14 * 24 * 3600 + for: 1h + labels: + severity: warning + annotations: + summary: "TLS certificate expires within 14 days: {{ '{{' }} $labels.instance {{ '}}' }}" + + - name: homelab-capacity + rules: + - alert: FilesystemAlmostFull + expr: (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) < 0.15 + for: 15m + labels: + severity: warning + annotations: + summary: "Filesystem is more than 85% full: {{ '{{' }} $labels.instance {{ '}}' }} {{ '{{' }} $labels.mountpoint {{ '}}' }}" + + - alert: FilesystemWillFillSoon + expr: predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 7 * 24 * 3600) < 0 + for: 30m + labels: + severity: warning + annotations: + summary: "Filesystem may fill within seven days: {{ '{{' }} $labels.instance {{ '}}' }} {{ '{{' }} $labels.mountpoint {{ '}}' }}" + + - alert: HostMemoryPressure + expr: (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) < 0.1 + for: 20m + labels: + severity: warning + annotations: + summary: "Memory pressure on {{ '{{' }} $labels.instance {{ '}}' }}" + + - alert: SMARTDeviceFailed + expr: homelab_smart_device_healthy == 0 + for: 5m + labels: + severity: critical + annotations: + summary: "SMART health check failed: {{ '{{' }} $labels.instance {{ '}}' }} {{ '{{' }} $labels.device {{ '}}' }}" + + - alert: NVMECriticalWarning + expr: homelab_smart_nvme_critical_warning > 0 + for: 5m + labels: + severity: critical + annotations: + summary: "NVMe critical warning: {{ '{{' }} $labels.instance {{ '}}' }} {{ '{{' }} $labels.device {{ '}}' }}" + + - name: homelab-backups + rules: + - alert: BackupAuditFailed + expr: homelab_backup_audit_success == 0 + for: 5m + labels: + severity: critical + annotations: + summary: "Backup audit failed: {{ '{{' }} $labels.profile {{ '}}' }} on {{ '{{' }} $labels.instance {{ '}}' }}" + + - alert: BackupAuditStale + expr: time() - homelab_backup_audit_timestamp_seconds > 48 * 3600 + for: 15m + labels: + severity: critical + annotations: + summary: "Backup audit result is stale: {{ '{{' }} $labels.profile {{ '}}' }} on {{ '{{' }} $labels.instance {{ '}}' }}" + + - alert: BackupAuditMissing + expr: absent(homelab_backup_audit_timestamp_seconds{profile="pbs"}) or absent(homelab_backup_audit_timestamp_seconds{profile="gitea"}) or absent(homelab_backup_audit_timestamp_seconds{profile="vaultwarden"}) or absent(homelab_backup_audit_timestamp_seconds{profile="grimmory"}) + for: 12h + labels: + severity: critical + annotations: + summary: "Expected backup audit metric has not been published" + + - alert: ResticBackupTooOld + expr: homelab_backup_audit_snapshot_age_hours{profile=~"gitea|vaultwarden|grimmory"} > 36 + for: 15m + labels: + severity: critical + annotations: + summary: "Restic backup is older than 36 hours: {{ '{{' }} $labels.profile {{ '}}' }}" + + - alert: PBSBackupTooOld + expr: homelab_backup_audit_snapshot_age_hours{profile="pbs"} > 48 + for: 15m + labels: + severity: critical + annotations: + summary: "PBS backup is older than 48 hours: VMID {{ '{{' }} $labels.vmid {{ '}}' }}" diff --git a/ansible/roles/monitoring_server/templates/blackbox.yml.j2 b/ansible/roles/monitoring_server/templates/blackbox.yml.j2 new file mode 100644 index 0000000..0b1f905 --- /dev/null +++ b/ansible/roles/monitoring_server/templates/blackbox.yml.j2 @@ -0,0 +1,10 @@ +modules: + http_2xx: + prober: http + timeout: 10s + http: + preferred_ip_protocol: ip4 + valid_status_codes: [200, 301, 302] + tcp_connect: + prober: tcp + timeout: 10s diff --git a/ansible/roles/monitoring_server/templates/compose.yml.j2 b/ansible/roles/monitoring_server/templates/compose.yml.j2 new file mode 100644 index 0000000..c3994bc --- /dev/null +++ b/ansible/roles/monitoring_server/templates/compose.yml.j2 @@ -0,0 +1,63 @@ +services: + prometheus: + image: prom/prometheus:v3.2.1 + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --storage.tsdb.retention.time={{ monitoring_prometheus_retention }} + - --storage.tsdb.retention.size={{ monitoring_prometheus_retention_size }} + - --web.enable-lifecycle + volumes: + - ./prometheus:/etc/prometheus:ro + - ./prometheus/data:/prometheus + restart: unless-stopped + + alertmanager: + image: prom/alertmanager:v0.28.0 + user: "0:0" + command: + - --config.file=/etc/alertmanager/alertmanager.yml + - --storage.path=/alertmanager + volumes: + - ./alertmanager:/etc/alertmanager:ro + restart: unless-stopped + + grafana: + image: grafana/grafana:11.5.1 + environment: + GF_SECURITY_ADMIN_USER: {{ monitoring_grafana_admin_user | to_json }} + GF_SECURITY_ADMIN_PASSWORD: {{ monitoring_grafana_admin_password | to_json }} + GF_USERS_ALLOW_SIGN_UP: "false" + ports: + - "192.168.1.30:3000:3000" + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/etc/grafana/dashboards:ro + - grafana-data:/var/lib/grafana + restart: unless-stopped + + blackbox-exporter: + image: prom/blackbox-exporter:v0.25.0 + command: + - --config.file=/config/blackbox.yml + volumes: + - ./blackbox.yml:/config/blackbox.yml:ro + restart: unless-stopped + + pve-exporter: + image: prompve/prometheus-pve-exporter:3.5.5 + user: "0:0" + command: + - --config.file=/etc/pve-exporter/pve.yml + volumes: + - ./pve-exporter/pve.yml:/etc/pve-exporter/pve.yml:ro + restart: unless-stopped + + pushgateway: + image: prom/pushgateway:v1.11.0 + ports: + - "192.168.1.30:9091:9091" + restart: unless-stopped + +volumes: + grafana-data: diff --git a/ansible/roles/monitoring_server/templates/prometheus.yml.j2 b/ansible/roles/monitoring_server/templates/prometheus.yml.j2 new file mode 100644 index 0000000..b77aad1 --- /dev/null +++ b/ansible/roles/monitoring_server/templates/prometheus.yml.j2 @@ -0,0 +1,94 @@ +global: + scrape_interval: 30s + evaluation_interval: 30s + +alerting: + alertmanagers: + - static_configs: + - targets: ["alertmanager:9093"] + +rule_files: + - /etc/prometheus/alerts.yml + +scrape_configs: + - job_name: prometheus + static_configs: + - targets: ["localhost:9090"] + + - job_name: node + static_configs: + - targets: + - "192.168.1.5:9100" + - "192.168.1.10:9100" + - "192.168.1.20:9100" + - "192.168.1.23:9100" + - "192.168.1.24:9100" + - "192.168.1.25:9100" + - "192.168.1.26:9100" + - "192.168.1.27:9100" + - "192.168.1.28:9100" + - "192.168.1.30:9100" + - "192.168.1.34:9100" + + - job_name: pve + metrics_path: /pve + params: + module: [default] + static_configs: + - targets: + - "192.168.1.5" + - "192.168.1.10" + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: pve-exporter:9221 + + - job_name: blackbox-lan-http + metrics_path: /probe + params: + module: [http_2xx] + static_configs: + - targets: + - "http://192.168.1.24" + - "http://192.168.1.25:3000" + - "http://192.168.1.27:8080" + - "http://192.168.1.28" + - "http://192.168.1.34:6060/api/v1/healthcheck" + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox-exporter:9115 + - target_label: probe_location + replacement: lan + + - job_name: blackbox-lan-tcp + metrics_path: /probe + params: + module: [tcp_connect] + static_configs: + - targets: + - "192.168.1.5:8006" + - "192.168.1.10:8006" + - "192.168.1.20:8007" + - "192.168.1.25:2222" + - "192.168.1.27:9090" + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox-exporter:9115 + - target_label: probe_location + replacement: lan + + - job_name: pushgateway + honor_labels: true + static_configs: + - targets: ["pushgateway:9091"] diff --git a/ansible/roles/monitoring_server/templates/pve.yml.j2 b/ansible/roles/monitoring_server/templates/pve.yml.j2 new file mode 100644 index 0000000..a3e8a08 --- /dev/null +++ b/ansible/roles/monitoring_server/templates/pve.yml.j2 @@ -0,0 +1,5 @@ +default: + user: "{{ monitoring_pve_api_user }}" + token_name: "{{ monitoring_pve_api_token_id }}" + token_value: "{{ monitoring_pve_api_token_secret }}" + verify_ssl: false diff --git a/ansible/roles/openvpn_gateway/defaults/main.yml b/ansible/roles/openvpn_gateway/defaults/main.yml index b4d3898..34f790e 100644 --- a/ansible/roles/openvpn_gateway/defaults/main.yml +++ b/ansible/roles/openvpn_gateway/defaults/main.yml @@ -7,6 +7,9 @@ openvpn_config_dir: /etc/openvpn/homelab openvpn_config_path: /etc/openvpn/homelab/homelab.conf openvpn_static_key_path: /etc/openvpn/homelab/static.key openvpn_service_name: homelab-openvpn +openvpn_public_interface: "{{ ansible_facts['default_ipv4']['interface'] }}" +openvpn_lan_interface: eth0 +openvpn_forwarded_services: [] openvpn_home_routes: - network: 192.168.1.5 netmask: 255.255.255.255 diff --git a/ansible/roles/openvpn_gateway/tasks/main.yml b/ansible/roles/openvpn_gateway/tasks/main.yml index b10bdef..7f20616 100644 --- a/ansible/roles/openvpn_gateway/tasks/main.yml +++ b/ansible/roles/openvpn_gateway/tasks/main.yml @@ -41,13 +41,13 @@ no_log: true notify: restart openvpn -- name: Enable IPv4 forwarding on gateway +- name: Enable IPv4 forwarding for routed traffic ansible.posix.sysctl: name: net.ipv4.ip_forward value: "1" state: present reload: true - when: openvpn_role == 'gateway' + when: openvpn_role == 'gateway' or openvpn_forwarded_services | length > 0 - name: Allow OpenVPN TCP in UFW on server ansible.builtin.command: "ufw allow {{ openvpn_listen_port }}/tcp" @@ -65,7 +65,7 @@ mode: "0600" notify: restart openvpn -- name: Render OpenVPN gateway up script +- name: Render OpenVPN up script ansible.builtin.template: src: up.sh.j2 dest: "{{ openvpn_config_dir }}/up.sh" @@ -73,9 +73,9 @@ group: root mode: "0700" notify: restart openvpn - when: openvpn_role == 'gateway' + when: openvpn_role == 'gateway' or openvpn_forwarded_services | length > 0 -- name: Render OpenVPN gateway down script +- name: Render OpenVPN down script ansible.builtin.template: src: down.sh.j2 dest: "{{ openvpn_config_dir }}/down.sh" @@ -83,7 +83,7 @@ group: root mode: "0700" notify: restart openvpn - when: openvpn_role == 'gateway' + when: openvpn_role == 'gateway' or openvpn_forwarded_services | length > 0 - name: Render OpenVPN systemd service ansible.builtin.template: diff --git a/ansible/roles/openvpn_gateway/templates/down.sh.j2 b/ansible/roles/openvpn_gateway/templates/down.sh.j2 index b2a70ea..2c49ff8 100644 --- a/ansible/roles/openvpn_gateway/templates/down.sh.j2 +++ b/ansible/roles/openvpn_gateway/templates/down.sh.j2 @@ -1,2 +1,15 @@ #!/bin/sh -iptables -t nat -D POSTROUTING -s {{ openvpn_network_cidr }} -d {{ homelab_lan_cidr }} -o eth0 -j MASQUERADE 2>/dev/null || true +{% if openvpn_role == 'gateway' %} +iptables -t nat -D POSTROUTING -s {{ openvpn_network_cidr }} -d {{ homelab_lan_cidr }} -o {{ openvpn_lan_interface }} -j MASQUERADE 2>/dev/null || true +{% for service in openvpn_forwarded_services %} +iptables -D FORWARD -i {{ openvpn_interface }} -o {{ openvpn_lan_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT 2>/dev/null || true +iptables -D FORWARD -i {{ openvpn_lan_interface }} -o {{ openvpn_interface }} -p {{ service.protocol }} -s {{ service.target_host }} --sport {{ service.target_port }} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true +{% endfor %} +{% else %} +{% for service in openvpn_forwarded_services %} +iptables -t nat -D PREROUTING -i {{ openvpn_public_interface }} -p {{ service.protocol }} --dport {{ service.public_port }} -j DNAT --to-destination {{ service.target_host }}:{{ service.target_port }} 2>/dev/null || true +iptables -t nat -D POSTROUTING -o {{ openvpn_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -j SNAT --to-source {{ openvpn_local_ip }} 2>/dev/null || true +iptables -D FORWARD -i {{ openvpn_public_interface }} -o {{ openvpn_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT 2>/dev/null || true +iptables -D FORWARD -i {{ openvpn_interface }} -o {{ openvpn_public_interface }} -p {{ service.protocol }} -s {{ service.target_host }} --sport {{ service.target_port }} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true +{% endfor %} +{% endif %} diff --git a/ansible/roles/openvpn_gateway/templates/homelab.conf.j2 b/ansible/roles/openvpn_gateway/templates/homelab.conf.j2 index 5953d02..9aa1e81 100644 --- a/ansible/roles/openvpn_gateway/templates/homelab.conf.j2 +++ b/ansible/roles/openvpn_gateway/templates/homelab.conf.j2 @@ -13,6 +13,14 @@ port {{ openvpn_listen_port }} {% for route in openvpn_home_routes %} route {{ route.network }} {{ route.netmask }} {% endfor %} +{% for service in openvpn_forwarded_services %} +route {{ service.target_host }} 255.255.255.255 +{% endfor %} +{% if openvpn_forwarded_services | length > 0 %} +script-security 2 +up /etc/openvpn/homelab/up.sh +down /etc/openvpn/homelab/down.sh +{% endif %} {% endif %} {% if openvpn_role == 'gateway' %} proto {{ openvpn_proto_client }} diff --git a/ansible/roles/openvpn_gateway/templates/up.sh.j2 b/ansible/roles/openvpn_gateway/templates/up.sh.j2 index 4a42c11..7a172e3 100644 --- a/ansible/roles/openvpn_gateway/templates/up.sh.j2 +++ b/ansible/roles/openvpn_gateway/templates/up.sh.j2 @@ -1,3 +1,23 @@ #!/bin/sh -iptables -t nat -C POSTROUTING -s {{ openvpn_network_cidr }} -d {{ homelab_lan_cidr }} -o eth0 -j MASQUERADE 2>/dev/null || \ - iptables -t nat -A POSTROUTING -s {{ openvpn_network_cidr }} -d {{ homelab_lan_cidr }} -o eth0 -j MASQUERADE +sysctl -w net.ipv4.ip_forward=1 >/dev/null +{% if openvpn_role == 'gateway' %} +iptables -t nat -C POSTROUTING -s {{ openvpn_network_cidr }} -d {{ homelab_lan_cidr }} -o {{ openvpn_lan_interface }} -j MASQUERADE 2>/dev/null || \ + iptables -t nat -A POSTROUTING -s {{ openvpn_network_cidr }} -d {{ homelab_lan_cidr }} -o {{ openvpn_lan_interface }} -j MASQUERADE +{% for service in openvpn_forwarded_services %} +iptables -C FORWARD -i {{ openvpn_interface }} -o {{ openvpn_lan_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT 2>/dev/null || \ + iptables -I FORWARD 1 -i {{ openvpn_interface }} -o {{ openvpn_lan_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT +iptables -C FORWARD -i {{ openvpn_lan_interface }} -o {{ openvpn_interface }} -p {{ service.protocol }} -s {{ service.target_host }} --sport {{ service.target_port }} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || \ + iptables -I FORWARD 1 -i {{ openvpn_lan_interface }} -o {{ openvpn_interface }} -p {{ service.protocol }} -s {{ service.target_host }} --sport {{ service.target_port }} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT +{% endfor %} +{% else %} +{% for service in openvpn_forwarded_services %} +iptables -t nat -C PREROUTING -i {{ openvpn_public_interface }} -p {{ service.protocol }} --dport {{ service.public_port }} -j DNAT --to-destination {{ service.target_host }}:{{ service.target_port }} 2>/dev/null || \ + iptables -t nat -A PREROUTING -i {{ openvpn_public_interface }} -p {{ service.protocol }} --dport {{ service.public_port }} -j DNAT --to-destination {{ service.target_host }}:{{ service.target_port }} +iptables -t nat -C POSTROUTING -o {{ openvpn_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -j SNAT --to-source {{ openvpn_local_ip }} 2>/dev/null || \ + iptables -t nat -A POSTROUTING -o {{ openvpn_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -j SNAT --to-source {{ openvpn_local_ip }} +iptables -C FORWARD -i {{ openvpn_public_interface }} -o {{ openvpn_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT 2>/dev/null || \ + iptables -I FORWARD 1 -i {{ openvpn_public_interface }} -o {{ openvpn_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT +iptables -C FORWARD -i {{ openvpn_interface }} -o {{ openvpn_public_interface }} -p {{ service.protocol }} -s {{ service.target_host }} --sport {{ service.target_port }} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || \ + iptables -I FORWARD 1 -i {{ openvpn_interface }} -o {{ openvpn_public_interface }} -p {{ service.protocol }} -s {{ service.target_host }} --sport {{ service.target_port }} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT +{% endfor %} +{% endif %} diff --git a/ansible/roles/pve_lxc/defaults/main.yml b/ansible/roles/pve_lxc/defaults/main.yml index 8d08d7a..1108bc7 100644 --- a/ansible/roles/pve_lxc/defaults/main.yml +++ b/ansible/roles/pve_lxc/defaults/main.yml @@ -1,7 +1,7 @@ --- pve_lxc_vmid: 132 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_gateway: 192.168.1.1 pve_lxc_bridge: vmbr0 @@ -13,8 +13,10 @@ pve_lxc_swap: 128 pve_lxc_onboot: true pve_lxc_startup: order=30 pve_lxc_unprivileged: true +pve_lxc_update: true pve_lxc_features: - nesting=1 pve_lxc_nameserver: 1.1.1.1 pve_lxc_pubkey_file: ~/.ssh/id_ed25519_homelab.pub pve_lxc_cmode: shell +pve_lxc_api_port: "{{ lookup('env', 'PROXMOX_PORT') | default('8006', true) | int }}" diff --git a/ansible/roles/pve_lxc/tasks/main.yml b/ansible/roles/pve_lxc/tasks/main.yml index 82a7d4f..c224f55 100644 --- a/ansible/roles/pve_lxc/tasks/main.yml +++ b/ansible/roles/pve_lxc/tasks/main.yml @@ -11,6 +11,7 @@ - name: Create or update LXC container community.proxmox.proxmox: api_host: "{{ lookup('env', 'PROXMOX_HOST') }}" + api_port: "{{ pve_lxc_api_port }}" api_user: "{{ lookup('env', 'PROXMOX_USER') }}" api_token_id: "{{ lookup('env', 'PROXMOX_TOKEN_ID') }}" api_token_secret: "{{ lookup('env', 'PROXMOX_TOKEN_SECRET') }}" @@ -32,15 +33,18 @@ unprivileged: "{{ pve_lxc_unprivileged }}" onboot: "{{ pve_lxc_onboot }}" startup: "{{ pve_lxc_startup }}" - update: true + update: "{{ pve_lxc_update }}" state: present + no_log: true - name: Start LXC container community.proxmox.proxmox: api_host: "{{ lookup('env', 'PROXMOX_HOST') }}" + api_port: "{{ pve_lxc_api_port }}" api_user: "{{ lookup('env', 'PROXMOX_USER') }}" api_token_id: "{{ lookup('env', 'PROXMOX_TOKEN_ID') }}" api_token_secret: "{{ lookup('env', 'PROXMOX_TOKEN_SECRET') }}" validate_certs: "{{ lookup('env', 'PROXMOX_VALIDATE_CERTS') | default('false', true) | bool }}" vmid: "{{ pve_lxc_vmid }}" state: started + no_log: true diff --git a/ansible/roles/uptime_kuma/defaults/main.yml b/ansible/roles/uptime_kuma/defaults/main.yml new file mode 100644 index 0000000..a82bc22 --- /dev/null +++ b/ansible/roles/uptime_kuma/defaults/main.yml @@ -0,0 +1,9 @@ +--- +uptime_kuma_root: /opt/uptime-kuma +uptime_kuma_image: louislam/uptime-kuma:1.23.16@sha256:431fee3be822b04861cf0e35daf4beef6b7cb37391c5f26c3ad6e12ce280fe18 +uptime_kuma_port: 3001 +uptime_kuma_lan_cidr: 192.168.1.0/24 +uptime_kuma_listen_address: "{{ expected_lan_ip }}" +uptime_kuma_openvpn_gateway_ip: 192.168.1.23 +uptime_kuma_http_proxy: http://192.168.1.27:7890 +uptime_kuma_no_proxy: "localhost,127.0.0.1,::1,ada-dev.ru,.ada-dev.ru,192.168.1.5,192.168.1.10,192.168.1.20,192.168.1.23,192.168.1.24,192.168.1.25,192.168.1.26,192.168.1.27,192.168.1.28,192.168.1.29,192.168.1.30,192.168.1.31,192.168.1.32,192.168.1.34,192.168.1.100" diff --git a/ansible/roles/uptime_kuma/handlers/main.yml b/ansible/roles/uptime_kuma/handlers/main.yml new file mode 100644 index 0000000..db3afa7 --- /dev/null +++ b/ansible/roles/uptime_kuma/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart uptime kuma + ansible.builtin.systemd: + name: uptime-kuma + state: restarted diff --git a/ansible/roles/uptime_kuma/tasks/main.yml b/ansible/roles/uptime_kuma/tasks/main.yml new file mode 100644 index 0000000..fcf681b --- /dev/null +++ b/ansible/roles/uptime_kuma/tasks/main.yml @@ -0,0 +1,159 @@ +--- +- name: Install Uptime Kuma runtime packages + ansible.builtin.apt: + name: + - ca-certificates + - docker.io + - docker-compose + - ufw + state: present + update_cache: true + +- name: Ensure Docker is enabled and running + ansible.builtin.systemd: + name: docker + enabled: true + state: started + +- name: Allow SSH and Uptime Kuma access from the LAN + community.general.ufw: + rule: allow + port: "{{ item }}" + proto: tcp + src: "{{ uptime_kuma_lan_cidr }}" + loop: + - "22" + - "{{ uptime_kuma_port }}" + +- name: Allow SSH from the OpenVPN gateway + community.general.ufw: + rule: allow + port: "22" + proto: tcp + src: "{{ uptime_kuma_openvpn_gateway_ip }}" + +- name: Enable Uptime Kuma LXC firewall + community.general.ufw: + state: enabled + policy: deny + direction: incoming + +- name: Create Uptime Kuma directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: "0750" + loop: + - "{{ uptime_kuma_root }}" + - "{{ uptime_kuma_root }}/data" + +- name: Check if the configured Uptime Kuma image is present + ansible.builtin.command: "docker image inspect {{ uptime_kuma_image }}" + register: uptime_kuma_image_inspect + changed_when: false + failed_when: false + +- name: Pull the configured Uptime Kuma image + ansible.builtin.command: "docker pull {{ uptime_kuma_image }}" + when: uptime_kuma_image_inspect.rc != 0 + register: uptime_kuma_image_pull + changed_when: true + +- name: Install Uptime Kuma compose definition + ansible.builtin.copy: + dest: "{{ uptime_kuma_root }}/compose.yml" + owner: root + group: root + mode: "0640" + content: | + services: + uptime-kuma: + image: {{ uptime_kuma_image }} + container_name: uptime-kuma + restart: unless-stopped + network_mode: host + environment: + HTTP_PROXY: {{ uptime_kuma_http_proxy }} + HTTPS_PROXY: {{ uptime_kuma_http_proxy }} + http_proxy: {{ uptime_kuma_http_proxy }} + https_proxy: {{ uptime_kuma_http_proxy }} + NO_PROXY: {{ uptime_kuma_no_proxy }} + no_proxy: {{ uptime_kuma_no_proxy }} + volumes: + - {{ uptime_kuma_root }}/data:/app/data + notify: restart uptime kuma + +- name: Validate Uptime Kuma Compose configuration + ansible.builtin.command: >- + docker-compose -f {{ uptime_kuma_root }}/compose.yml config --quiet + changed_when: false + +- name: Install Uptime Kuma systemd unit + ansible.builtin.copy: + dest: /etc/systemd/system/uptime-kuma.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Uptime Kuma + Requires=docker.service + After=docker.service + + [Service] + Type=oneshot + RemainAfterExit=yes + WorkingDirectory={{ uptime_kuma_root }} + ExecStart=/usr/bin/docker-compose -f {{ uptime_kuma_root }}/compose.yml up -d --remove-orphans + ExecStop=/usr/bin/docker-compose -f {{ uptime_kuma_root }}/compose.yml down + + [Install] + WantedBy=multi-user.target + register: uptime_kuma_unit + notify: restart uptime kuma + +- name: Reload systemd when the Uptime Kuma unit changes + ansible.builtin.systemd: + daemon_reload: true + when: uptime_kuma_unit.changed + +- name: Enable and start Uptime Kuma + ansible.builtin.systemd: + name: uptime-kuma + enabled: true + state: "{{ 'restarted' if uptime_kuma_unit.changed or uptime_kuma_image_pull is changed else 'started' }}" + +- name: Apply pending Uptime Kuma configuration changes + ansible.builtin.meta: flush_handlers + +- name: Check Uptime Kuma web interface + ansible.builtin.uri: + url: "http://{{ uptime_kuma_listen_address }}:{{ uptime_kuma_port }}/" + status_code: 200 + register: uptime_kuma_health + retries: 24 + delay: 5 + until: uptime_kuma_health.status == 200 + +- name: Freeze the legacy Prometheus monitoring stack after Uptime Kuma is healthy + ansible.builtin.systemd: + name: homelab-monitoring + enabled: false + state: stopped + +- name: Remove legacy monitoring firewall rules + community.general.ufw: + rule: allow + port: "{{ item.port }}" + proto: tcp + src: "{{ item.src }}" + delete: true + loop: + - port: "3000" + src: "{{ uptime_kuma_lan_cidr }}" + - port: "9091" + src: "{{ uptime_kuma_openvpn_gateway_ip }}" + - port: "9100" + src: "172.16.0.0/12" diff --git a/ansible/tasks/offsite-restic-profile.yml b/ansible/tasks/offsite-restic-profile.yml new file mode 100644 index 0000000..d3798c7 --- /dev/null +++ b/ansible/tasks/offsite-restic-profile.yml @@ -0,0 +1,219 @@ +--- +- name: Install offsite backup packages + ansible.builtin.apt: + name: + - restic + - rclone + - sqlite3 + - ca-certificates + state: present + update_cache: true + +- name: Ensure rclone config directory exists + ansible.builtin.file: + path: /root/.config/rclone + state: directory + owner: root + group: root + mode: "0700" + +- name: Install rclone config + ansible.builtin.copy: + src: "{{ offsite_rclone_config_local }}" + dest: /root/.config/rclone/rclone.conf + owner: root + group: root + mode: "0600" + no_log: true + +- name: Ensure restic config directory exists + ansible.builtin.file: + path: /etc/restic + state: directory + owner: root + group: root + mode: "0700" + +- name: Install restic repository password + ansible.builtin.copy: + src: "{{ offsite_restic_password_local }}" + dest: "/etc/restic/{{ offsite_profile }}.password" + owner: root + group: root + mode: "0600" + no_log: true + +- name: Ensure offsite backup directories exist + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: "0700" + loop: + - /etc/homelab-restic + - /var/lib/homelab-restic + - /var/log/homelab-restic + +- name: Install restic exclude file + ansible.builtin.copy: + dest: "/etc/homelab-restic/{{ offsite_profile }}.exclude" + owner: root + group: root + mode: "0644" + content: | + {% for exclude in offsite_excludes %} + {{ exclude }} + {% endfor %} + +- name: Install restic environment file + ansible.builtin.copy: + dest: "/etc/homelab-restic/{{ offsite_profile }}.env" + owner: root + group: root + mode: "0600" + content: | + RESTIC_REPOSITORY={{ offsite_repository }} + RESTIC_PASSWORD_FILE=/etc/restic/{{ offsite_profile }}.password + RCLONE_CONFIG=/root/.config/rclone/rclone.conf + HOMELAB_RESTIC_PROFILE={{ offsite_profile }} + HOMELAB_RESTIC_SOURCE={{ offsite_source_path }} + HOMELAB_RESTIC_SQLITE_DB={{ offsite_sqlite_db | default('') }} + HOMELAB_RESTIC_MARIADB_CONTAINER={{ offsite_mariadb_container | default('') }} + HOMELAB_RESTIC_MARIADB_DATABASE={{ offsite_mariadb_database | default('') }} + HOMELAB_RESTIC_MARIADB_USER={{ offsite_mariadb_user | default('') }} + HOMELAB_RESTIC_MARIADB_ENV={{ offsite_mariadb_env | default('') }} + HOMELAB_RESTIC_MARIADB_DUMP_NAME={{ offsite_mariadb_dump_name | default('') }} + HOMELAB_RESTIC_EXCLUDE_FILE=/etc/homelab-restic/{{ offsite_profile }}.exclude + HOMELAB_RESTIC_STAGING=/var/lib/homelab-restic/{{ offsite_profile }} + HOMELAB_RESTIC_TAGS={{ offsite_backup_tag }} + HOMELAB_RESTIC_KEEP_DAILY=14 + HOMELAB_RESTIC_KEEP_WEEKLY=8 + HOMELAB_RESTIC_KEEP_MONTHLY=6 + +- name: Install restic offsite backup script + ansible.builtin.copy: + dest: /usr/local/sbin/homelab-restic-offsite-backup + owner: root + group: root + mode: "0755" + content: | + #!/bin/sh + set -eu + + if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 64 + fi + + env_file="/etc/homelab-restic/$1.env" + if [ ! -f "$env_file" ]; then + echo "missing env file: $env_file" >&2 + exit 66 + fi + + set -a + . "$env_file" + set +a + + export RESTIC_REPOSITORY RESTIC_PASSWORD_FILE RCLONE_CONFIG + + mkdir -p "$HOMELAB_RESTIC_STAGING" + rm -rf "$HOMELAB_RESTIC_STAGING"/* + + if [ -n "${HOMELAB_RESTIC_SQLITE_DB:-}" ] && [ -f "$HOMELAB_RESTIC_SQLITE_DB" ]; then + sqlite_backup="$HOMELAB_RESTIC_STAGING/$(basename "$HOMELAB_RESTIC_SQLITE_DB")" + sqlite3 -cmd 'PRAGMA busy_timeout=30000;' "$HOMELAB_RESTIC_SQLITE_DB" ".backup '$sqlite_backup'" + sqlite3 -cmd 'PRAGMA busy_timeout=30000;' "$sqlite_backup" "PRAGMA integrity_check;" | grep -qx ok + fi + + if [ -n "${HOMELAB_RESTIC_MARIADB_CONTAINER:-}" ]; then + if [ ! -f "$HOMELAB_RESTIC_MARIADB_ENV" ]; then + echo "missing MariaDB env file: $HOMELAB_RESTIC_MARIADB_ENV" >&2 + exit 66 + fi + set -a + . "$HOMELAB_RESTIC_MARIADB_ENV" + set +a + dump_tmp="$HOMELAB_RESTIC_STAGING/${HOMELAB_RESTIC_MARIADB_DUMP_NAME}.tmp" + dump_final="$HOMELAB_RESTIC_STAGING/$HOMELAB_RESTIC_MARIADB_DUMP_NAME" + docker exec -e MYSQL_PWD="$DB_PASSWORD" "$HOMELAB_RESTIC_MARIADB_CONTAINER" \ + mariadb-dump --user="$HOMELAB_RESTIC_MARIADB_USER" --single-transaction --routines --events \ + --databases "$HOMELAB_RESTIC_MARIADB_DATABASE" > "$dump_tmp" + test -s "$dump_tmp" + mv "$dump_tmp" "$dump_final" + { + printf '%s\n' 'grimmory_image=grimmory/grimmory:v3.2.4@sha256:dfa7afdfcf25d649fd664497a62385dd00cd9678c37546e182c172e41c8e80cb' + printf '%s\n' 'mariadb_image=lscr.io/linuxserver/mariadb:11.4.8@sha256:91de7f701bc7fc3a424b81beafca7a7c6c4c5b7c8be6afd2ae148698695c0b0c' + printf 'created_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } > "$HOMELAB_RESTIC_STAGING/manifest.txt" + fi + + restic snapshots >/dev/null 2>&1 || restic init + + tag_args="" + old_ifs="$IFS" + IFS=, + for tag in $HOMELAB_RESTIC_TAGS; do + tag_args="$tag_args --tag $tag" + done + IFS="$old_ifs" + + # shellcheck disable=SC2086 + restic backup "$HOMELAB_RESTIC_SOURCE" "$HOMELAB_RESTIC_STAGING" \ + --exclude-file "$HOMELAB_RESTIC_EXCLUDE_FILE" \ + --one-file-system \ + $tag_args + + restic forget --prune \ + --keep-daily "$HOMELAB_RESTIC_KEEP_DAILY" \ + --keep-weekly "$HOMELAB_RESTIC_KEEP_WEEKLY" \ + --keep-monthly "$HOMELAB_RESTIC_KEEP_MONTHLY" + + if [ -n "${HOMELAB_RESTIC_MARIADB_CONTAINER:-}" ]; then + rm -rf "$HOMELAB_RESTIC_STAGING"/* + fi + +- name: Install restic offsite systemd service + ansible.builtin.copy: + dest: "/etc/systemd/system/homelab-restic-offsite-{{ offsite_profile }}.service" + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=HomeLab restic offsite backup ({{ offsite_profile }}) + Wants=network-online.target + After=network-online.target + + [Service] + Type=oneshot + ExecStart=/usr/bin/flock -w 1800 /var/lock/homelab-restic-{{ offsite_profile }}.lock /usr/local/sbin/homelab-restic-offsite-backup {{ offsite_profile }} + +- name: Install restic offsite systemd timer + ansible.builtin.copy: + dest: "/etc/systemd/system/homelab-restic-offsite-{{ offsite_profile }}.timer" + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Run HomeLab restic offsite backup ({{ offsite_profile }}) + + [Timer] + OnCalendar={{ offsite_timer_oncalendar }} + Persistent=true + RandomizedDelaySec=15m + + [Install] + WantedBy=timers.target + +- name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true + +- name: Enable restic offsite timer + ansible.builtin.systemd: + name: "homelab-restic-offsite-{{ offsite_profile }}.timer" + enabled: true + state: started diff --git a/archive/2026-07-proxmox-migration/ru-vps/mihomo/config/config.yaml b/archive/2026-07-proxmox-migration/ru-vps/mihomo/config/config.yaml index 634f0a9..0ab5866 100644 --- a/archive/2026-07-proxmox-migration/ru-vps/mihomo/config/config.yaml +++ b/archive/2026-07-proxmox-migration/ru-vps/mihomo/config/config.yaml @@ -10,12 +10,12 @@ external-ui: /root/.config/mihomo/ui # secret: "changeme" authentication: - - "ada:Holivarr588856" + - "USERNAME:REDACTED_PASSWORD" proxy-providers: main: type: http - url: "https://491b5056.withblancvpn.online/s/c1b50e857b4e41a59ea80bcf6d1953d3" + url: "https://example.invalid/REDACTED_SUBSCRIPTION" interval: 3600 health-check: enable: true diff --git a/tasks/grimmory-deployment-plan.md b/tasks/grimmory-deployment-plan.md new file mode 100644 index 0000000..7168ccf --- /dev/null +++ b/tasks/grimmory-deployment-plan.md @@ -0,0 +1,551 @@ +# План развертывания Grimmory + +## Целевая архитектура + +- Отдельный unprivileged LXC `grimmory` на `cloud-pc`. +- Итоговые значения: CTID `149`, IP `192.168.1.34`. Первоначальный `.33` оказался занят после создания LXC. +- RootFS на storage `data`, первоначально `64 GB` с возможностью расширения. +- Ресурсы: `2 vCPU`, `4 GB RAM`, `1 GB swap`. +- Docker с `fuse-overlayfs` внутри LXC. +- Docker Compose: Grimmory и MariaDB. +- Публичный адрес: `https://books.ada-dev.ru`. +- Книги и данные находятся внутри диска LXC. +- Daily PBS snapshot всего CT. +- Offsite restic: дамп MariaDB, конфигурация и `/app/data`, но не книги. +- Образы фиксируются на версиях: + - `grimmory/grimmory:v3.2.4`; + - `lscr.io/linuxserver/mariadb:11.4.8`. + +Размер диска нужно скорректировать до развертывания, если текущая библиотека уже приближается к 40-50 GB. + +## 1. Предварительные проверки + +1. Проверить, что CTID `149` свободен: + - в Proxmox API; + - в `pct list` на обоих узлах; + - в PBS среди исторических VMID. +2. Проверить IP `192.168.1.34`: + - отсутствие в inventory; + - отсутствие DHCP reservation; + - отсутствие ответа на ARP/ping; + - отсутствие DNS-записи с другим назначением. +3. Проверить емкость `cloud-pc:data`. +4. Проверить работоспособность PBS datastore и последнего backup job `cloud-pc`. +5. Проверить возможность создать DNS-запись `books.ada-dev.ru`. +6. Зафиксировать ожидаемый объем существующей коллекции и темп роста. +7. Получить digest образов после выбора версий. В первой итерации допустим pinned tag; digest предпочтительнее для полностью воспроизводимого состояния. + +Проверка должна завершиться без создания LXC. При конфликте CTID/IP выбрать следующие свободные значения и использовать их согласованно во всех файлах. + +## 2. Inventory + +Изменить `ansible/inventory/hosts.yml`: + +- добавить `grimmory` в `lxc_infra`; +- указать `ansible_host: 192.168.1.34`; +- указать `expected_lan_ip`; +- указать `ansible_user: root`; +- использовать существующий ProxyJump через `ru-vps`; +- добавить в `monitoring_exporters`, если Node Exporter разворачивается сразу. + +После изменения проверить: + +```bash +ansible-inventory --graph +ansible-inventory --host grimmory +``` + +Edge cases: + +- не использовать IP за пределами маршрутизируемого service range `.5-.40`; +- убедиться, что имя `grimmory` разрешается Ansible именно в новый IP; +- не добавлять host в inventory до выбора окончательных CTID/IP во всех playbooks. + +## 3. Provisioning LXC + +Создать `ansible/playbooks/pve-grimmory.yml`, используя роль `pve_lxc`. + +Параметры: + +```yaml +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_storage: data +pve_lxc_disk: 64 +pve_lxc_cores: 2 +pve_lxc_memory: 4096 +pve_lxc_swap: 1024 +pve_lxc_unprivileged: true +pve_lxc_features: + - nesting=1 + - keyctl=1 +``` + +Дополнительно настроить Docker-in-LXC по существующему паттерну: + +- разрешить `/dev/fuse`; +- добавить bind устройства `/dev/fuse`; +- перезапустить LXC только при изменении его PVE-конфигурации; +- дождаться SSH; +- проверить наличие пользовательского SSH-ключа. + +Текущая роль `pve_lxc` не управляет `/dev/fuse`, поэтому эти задачи остаются в playbook, аналогично существующим Docker LXC. + +Проверки: + +```bash +ansible-playbook --syntax-check playbooks/pve-grimmory.yml +ansible-playbook playbooks/pve-grimmory.yml --check +``` + +`--check` для Proxmox API не гарантирует полноценной симуляции, поэтому его результат использовать только как дополнительную проверку. + +После создания проверить: + +- `pct status 149`; +- unprivileged mode включен; +- rootfs действительно находится на `data`; +- `onboot=1`; +- SSH работает через ProxyJump; +- `/dev/fuse` существует внутри LXC; +- повторный запуск playbook не создает изменений. + +## 4. Docker Runtime + +Внутри LXC установить: + +- `docker.io`; +- Docker Compose v2, если доступен как `docker-compose-plugin`; +- `fuse-overlayfs`; +- `ca-certificates`; +- `curl`; +- `mariadb-client` для backup/restore-проверок; +- `prometheus-node-exporter`. + +Настроить `/etc/docker/daemon.json`: + +```json +{ + "storage-driver": "fuse-overlayfs" +} +``` + +Проверить: + +```bash +docker info +docker compose version +docker run --rm hello-world +``` + +Критерий успеха: Docker сообщает `fuse-overlayfs` как storage driver и переживает перезапуск LXC. + +## 5. Persistent Data + +Создать структуру: + +```text +/opt/grimmory/ +├── compose.yml +├── .env +├── data/ +├── books/ +├── bookdrop/ +├── mariadb/ +└── backup-staging/ +``` + +Назначение: + +- `data` -> `/app/data`; +- `books` -> `/books`; +- `bookdrop` -> `/bookdrop`; +- `mariadb` -> `/config`; +- `backup-staging` -> временные согласованные дампы. + +Права каталогов должны соответствовать `APP_USER_ID`, `APP_GROUP_ID`, MariaDB `PUID/PGID`. + +Edge cases: + +- `bookdrop` не является библиотекой: после успешного импорта исходники удаляются оттуда; +- не размещать важные необработанные оригиналы только в `bookdrop`; +- не вкладывать bind mounts друг в друга; +- не использовать `DISK_TYPE=NETWORK`, поскольку файлы находятся на локальном rootfs; +- оставлять минимум 15-20% диска свободным: импорт и обработка могут временно удваивать размер книги; +- учитывать крупные PDF/CBR и возможные heap dump при OOM. + +## 6. Compose + +Управлять двумя контейнерами через Compose, поскольку Grimmory зависит от MariaDB. Для этого сервиса Compose логичнее отдельных systemd `docker run`. + +Основные параметры: + +- `DISK_TYPE=LOCAL`; +- `TZ` соответствует HomeLab; +- `ALLOWED_ORIGINS=https://books.ada-dev.ru`; +- MariaDB доступна только во внутренней Docker network; +- наружу публикуется только Grimmory `6060`; +- порт привязывается к LAN IP LXC, а не `0.0.0.0`, если Compose это позволяет; +- healthcheck использует `/api/v1/healthcheck`; +- `restart: unless-stopped`. + +Создать systemd unit-обертку для: + +```bash +docker compose -f /opt/grimmory/compose.yml up -d --remove-orphans +``` + +Не использовать `--pull always`: обновление должно быть отдельной контролируемой операцией. + +Секреты: + +- случайные отдельные пароли `DB_PASSWORD` и `MYSQL_ROOT_PASSWORD`; +- `.env` с mode `0600`; +- реальные значения не добавляются в Git; +- `.env.example` содержит только имена переменных и фиктивные значения; +- пароль первого администратора создается вручную через UI. + +Проверки: + +```bash +docker compose config +docker compose pull +systemctl start grimmory +docker compose ps +docker compose logs +curl -fsS http://127.0.0.1:6060/api/v1/healthcheck +``` + +Критерии: + +- оба контейнера healthy; +- MariaDB не публикует порт на LAN; +- после рестарта LXC оба контейнера возвращаются в healthy; +- логи не содержат Flyway errors, authentication failures и циклических рестартов. + +## 7. Первый запуск + +1. Открыть сервис сначала по LAN. +2. Создать локального администратора. +3. Сохранить пароль в password manager. +4. Отключить неиспользуемую публичную регистрацию. +5. Создать отдельного обычного пользователя для чтения, если административная учетная запись не должна использоваться на BOOX. +6. Включить OPDS. +7. Создать отдельные OPDS credentials. +8. Выдать пользователю разрешение `Access OPDS`. +9. Создать тестовую библиотеку в `/books`. +10. Импортировать одну тестовую EPUB через BookDrop. + +Важно: OPDS credentials отделены от обычного логина Grimmory. Пароль OPDS после создания нельзя получить повторно. + +## 8. Reverse Proxy + +Создать `ansible/playbooks/reverse-proxy-grimmory.yml` по паттерну Gitea/Vaultwarden. + +Caddy site: + +```caddy +books.ada-dev.ru { + reverse_proxy 192.168.1.34:6060 +} +``` + +Caddy автоматически поддерживает WebSocket upgrade, но это нужно проверить для `/ws`. + +Не добавлять дополнительный Caddy Basic Auth: он конфликтует с Basic Auth OPDS. + +Проверки до переключения DNS: + +- Caddy может достучаться до `192.168.1.34:6060` через OpenVPN; +- upstream healthcheck возвращает `2xx`; +- Caddyfile проходит `caddy validate`; +- конфигурация действительно смонтирована в контейнер Caddy. + +После DNS: + +```bash +curl -fsS https://books.ada-dev.ru/api/v1/healthcheck +``` + +Дополнительно проверить: + +- валидную цепочку TLS; +- WebSocket `/ws`; +- правильные внешние `Host`, scheme и port; +- отсутствие redirect на внутренний `http://192.168.1.34:6060`; +- загрузку файла размером больше стандартного proxy limit; +- доступ с мобильной сети, а не только из домашнего LAN. + +## 9. OPDS и BOOX Go 7 + +Каталог: + +```text +https://books.ada-dev.ru/api/v1/opds +``` + +Настроить в NeoReader: + +- URL корневого каталога; +- отдельный OPDS username/password; +- HTTPS; +- без второго слоя proxy-аутентификации. + +Тестовый сценарий: + +1. Каталог открывается. +2. Отображаются разделы и обложки. +3. Работает поиск. +4. EPUB скачивается. +5. Книга открывается в NeoReader. +6. Кириллица в названии, авторе и описании отображается корректно. +7. Повторная загрузка не создает поврежденный файл. +8. Проверить EPUB, PDF и FB2 отдельно. +9. Проверить работу из домашнего Wi-Fi и внешней сети. +10. Проверить поведение после смены OPDS-пароля. + +Позиция чтения NeoReader обратно в Grimmory через OPDS не синхронизируется. Это ожидаемое поведение, а не дефект сервера. + +## 10. Firewall + +Минимальная политика: + +- SSH `22` только из LAN и OpenVPN; +- `6060` только из LAN/OpenVPN и от нужного reverse-proxy маршрута; +- MariaDB не публикуется; +- остальные входящие соединения запрещены. + +Edge case: Docker published ports могут обходить UFW. Поэтому проверить фактические nftables/iptables rules и доступ к `6060` с нежелательных сегментов, а не полагаться только на вывод `ufw status`. + +Публично сервис должен быть доступен только через Caddy на `ru-vps`, не через router port-forward. + +## 11. Monitoring + +Добавить: + +- Node Exporter target `192.168.1.34:9100`; +- LAN blackbox HTTP probe `/api/v1/healthcheck`; +- публичный HTTPS probe `https://books.ada-dev.ru/api/v1/healthcheck`; +- TLS expiry check; +- базовые алерты по availability, CPU, RAM, disk и backup freshness. + +Не считать один health endpoint достаточным. Он может отвечать при частично неисправной функции OPDS. + +Добавить синтетическую OPDS-проверку: + +- Basic Auth тестового monitoring-пользователя; +- ответ `200`; +- ожидаемый OPDS content type; +- корректный XML/Atom; +- секреты только из runtime env, не из шаблона в Git. + +Если хранить OPDS credential в monitoring нежелательно, ограничиться публичным healthcheck и ручным OPDS smoke test после обновлений. + +## 12. PBS Backup + +Добавить CTID в: + +- `ansible/playbooks/pve-backup-jobs.yml`, job `homelab-pbs-daily-cloud`; +- `ansible/roles/backup_audit/defaults/main.yml`. + +Сохранить исключение Docker merged layers: + +```text +/var/lib/docker/fuse-overlayfs/*/merged +``` + +Проверки: + +1. Применить backup job. +2. Запустить первый backup вручную. +3. Убедиться, что snapshot завершен без warnings. +4. Проверить наличие VMID `149` в PBS. +5. Проверить прохождение backup audit. +6. Проверить размер backup: слишком маленький может означать ошибочное исключение данных. +7. Выполнить тестовое file-level восстановление нескольких файлов. +8. Запланировать полный restore drill в отдельный временный CTID. + +PBS snapshot MariaDB считается crash-consistent, но не заменяет логический dump. + +## 13. Offsite Restic + +Offsite должен содержать: + +- согласованный `mariadb-dump`; +- `/opt/grimmory/data`; +- `compose.yml`; +- зашифрованную или отдельно защищенную конфигурацию; +- manifest с версиями Grimmory и MariaDB. + +Не включать: + +- `/opt/grimmory/books`; +- `/opt/grimmory/bookdrop`; +- `/opt/grimmory/mariadb` как raw-copy работающей БД; +- Docker layers; +- временные heap dump и cache. + +Для дампа: + +- использовать `mariadb-dump --single-transaction`; +- сначала писать во временный файл; +- проверять exit code; +- атомарно переносить завершенный dump в staging; +- только после этого запускать restic; +- удалять старый staging после успешного snapshot. + +Расширить существующий restic-паттерн MariaDB-aware логикой, а не использовать SQLite backup helper. + +Проверки: + +- `mariadb` может прочитать dump; +- restic snapshot содержит dump и `/app/data`; +- backup audit отслеживает профиль Grimmory; +- выполнить пробное восстановление в отдельную временную MariaDB; +- убедиться, что восстановленная схема проходит Flyway startup на той же версии Grimmory. + +## 14. Обновления + +Обновлять только явно, например `v3.2.4 -> v3.2.5`. + +Процесс: + +1. Прочитать release notes и migration warnings. +2. Проверить свободное место. +3. Сделать MariaDB dump. +4. Сделать offsite restic snapshot. +5. Убедиться в наличии свежего PBS backup. +6. Изменить pinned image tag/digest. +7. Pull и restart. +8. Проверить Flyway logs. +9. Проверить health, UI, BookDrop и OPDS с BOOX. +10. Зафиксировать версию в документации. + +Не выполнять code-only downgrade после применения Flyway migration. Для отката возвращать одновременно старый image и pre-upgrade backup базы. + +## 15. Edge Cases + +Обязательно проверить: + +- заполнение rootfs во время массового импорта; +- дубликаты и одинаковые ISBN; +- книги без ISBN; +- кириллические имена и длинные пути; +- два файла одной книги в разных форматах; +- поврежденный EPUB/CBR; +- файлы больше 1 GB; +- cross-filesystem BookDrop move; +- перезапуск во время импорта; +- падение MariaDB во время обработки; +- недоступность Google Books/Open Library/Amazon; +- истечение TLS; +- смена внешнего домена; +- потеря OPDS-пароля; +- потеря admin-пароля; +- OOM и появление большого heap dump; +- несовместимая Flyway migration; +- неожиданное изменение floating Docker tag; +- частичный backup: БД без `/app/data` либо наоборот; +- восстановление БД с файлами библиотеки другого состояния. + +## 16. Финальная приемка + +Развертывание считается завершенным, когда: + +- Ansible запускается повторно без изменений; +- LXC автоматически стартует; +- контейнеры healthy после reboot; +- MariaDB не доступна из LAN; +- HTTPS и WebSocket работают; +- NeoReader открывает OPDS и скачивает тестовые книги; +- импорт через BookDrop проходит полностью; +- Node Exporter и blackbox targets находятся в состоянии `UP`; +- создан и проверен PBS backup; +- создан и тестово восстановлен restic snapshot; +- секреты отсутствуют в Git diff; +- Obsidian-документация обновлена. + +## 17. Ожидаемые файлы + +Основные изменения: + +- `ansible/inventory/hosts.yml`; +- `ansible/playbooks/pve-grimmory.yml`; +- `ansible/playbooks/reverse-proxy-grimmory.yml`; +- `ansible/playbooks/pve-backup-jobs.yml`; +- `ansible/roles/backup_audit/defaults/main.yml`; +- `ansible/playbooks/offsite-restic-yadisk.yml`; +- `ansible/playbooks/backup-audit.yml`; +- `ansible/roles/monitoring_server/templates/prometheus.yml.j2`; +- возможно `ansible/roles/monitoring_blackbox/templates/push-metrics.sh.j2`; +- `ansible/.env.example`, только если потребуются новые runtime secrets. + +Документация: + +- `Notes/Текущее состояние HomeLab после миграции на Proxmox.md`; +- `Log.md`; +- `Context.md`; +- при необходимости `HomeLab.md`. + +## 18. Открытый параметр + +Перед реализацией подтвердить `64 GB` как начальный размер диска или выбрать его по текущему объему коллекции. + +Решение: текущая коллекция меньше `20 GB`, начальный rootfs оставлен `64 GB`. + +## Журнал выполнения + +### 2026-07-28 — preflight и проектные решения + +- Прочитаны актуальные HomeLab notes и существующие Ansible-паттерны LXC, Caddy, monitoring, PBS и restic. +- CTID `149` отсутствует в `pct list` на `cloud-pc` и `mini-pc`, а также среди исторических snapshot в PBS. +- IP `192.168.1.33` отсутствует в inventory и AdGuard config, не отвечает на ping, ARP-состояние с `cloud-pc` — `INCOMPLETE`; пользователь подтвердил отсутствие DHCP reservation. +- Storage `cloud-pc:data` активен: доступно около `836 GB`, занято `3.67%`. +- PBS storage и datastore `pbs` активны; последние snapshots текущих cloud-pc CT созданы `2026-07-27`. +- `books.ada-dev.ru` уже разрешается в `157.22.231.198` (`ru-vps`); случайное имя в зоне не разрешается, поэтому это не wildcard. До добавления Caddy site TLS handshake не завершается. +- Подтверждены `grimmory/grimmory:v3.2.4` и `lscr.io/linuxserver/mariadb:11.4.8`; выбраны OCI index digests `sha256:dfa7afdfcf25d649fd664497a62385dd00cd9678c37546e182c172e41c8e80cb` и `sha256:91de7f701bc7fc3a424b81beafca7a7c6c4c5b7c8be6afd2ae148698695c0b0c`. +- Rootfs подтвержден как `data:64`, timezone — `Europe/Moscow`. +- Пароли MariaDB будут случайно созданы один раз внутри LXC и сохранены только в `/opt/grimmory/.env` с mode `0600`. +- В первой итерации автоматизируются health/TLS probes; OPDS smoke test остается ручным после создания credentials. +- Offsite audit первой итерации: атомарный MariaDB dump, проверка наличия dump в snapshot и `restic check`; автоматический restore в MariaDB отложен. +- Первый apply остановился до создания из-за quoting условия VMID guard; второй — из-за отсутствия прямого маршрута к Proxmox API. Для API использован временный SSH local-forward через `ru-vps`. +- После создания CT выяснился IP conflict: с `mini-pc` адрес `.33` резолвился как `YandexStationMini_1DD2.loc` и отклонял TCP/22, тогда как локальный bridge `cloud-pc` видел LXC. Выбран следующий адрес `192.168.1.34`; он не отвечает с обоих PVE-узлов, ARP — `INCOMPLETE`, DNS отсутствует. + +### 2026-07-28 — реализация и автоматическая приёмка + +- Создан unprivileged LXC `grimmory` CT `149` на `cloud-pc`: `192.168.1.34/24`, `data:64`, 2 vCPU, 4096 MB RAM, 1024 MB swap, `onboot=1`, `nesting=1`, `keyctl=1`, `/dev/fuse`. +- Из-за ограничений API token роль `pve_lxc` используется в create-only mode для Grimmory; VMID guard запрещает менять чужой CT, а network/keyctl/FUSE идемпотентно управляются через `pct` на PVE node. +- Установлены Docker Compose v2 `2.26.1`, Docker `26.1.5`, `fuse-overlayfs`, MariaDB client, Node Exporter и UFW. Docker подтвердил storage driver `fuse-overlayfs`. +- Случайные DB passwords созданы один раз внутри LXC; `/opt/grimmory/.env` имеет mode `0600`, значения не выводились и не добавлялись в Git. +- Compose запустил pinned Grimmory `v3.2.4` и MariaDB `11.4.8`; первый startup применил 142 Flyway migrations и занял около 6 минут. Health wait увеличен до 10 минут. +- После reboot LXC оба контейнера вернулись в `healthy`; MariaDB не слушает host/LAN port, Grimmory привязан к `192.168.1.34:6060`. +- UFW ограничивает SSH/`6060` LAN и OpenVPN, `9100` разрешён только от monitoring LXC. Отдельная `GRIMMORY-FILTER` chain в `DOCKER-USER` закрывает обход UFW через Docker published port. +- Caddy site применён. Проверены public health `200`, сертификат Let's Encrypt до `2026-10-26` и WebSocket/STOMP Upgrade `101` на `/ws`. +- Prometheus targets: Node Exporter `up=1`, LAN health `probe_success=1`, public Grimmory probe `probe_success=1`; TLS expiry покрыт существующим alert. +- CT `149` добавлен в `homelab-pbs-daily-cloud` и PBS audit. Первый PBS backup `ct/149/2026-07-28T14:47:32Z` успешен, размер `2583749785` bytes; audit success, file-level restore трёх объектов проверен. +- Ограничение PBS: storage `data` не поддерживает native LXC snapshots, поэтому job с mode `snapshot` автоматически перешёл в `suspend`; фактическая пауза гостя составила около 1 секунды. +- Создан restic snapshot `a3b012ab` в Yandex Disk. Включены atomic MariaDB dump, `/opt/grimmory/data`, Compose, manifest и зашифрованная restore-конфигурация; books/BookDrop/raw MariaDB исключены. `restic check` и restore непустого `grimmory.sql` прошли. +- Повторные runs `pve-grimmory.yml`, `reverse-proxy-grimmory.yml`, `pve-backup-jobs.yml`, Grimmory offsite/audit и limited monitoring завершились с `changed=0`. +- Обновлены Obsidian current state, `Log.md` и `Context.md`. +- Reverse DNS для `.34` содержит stale имя телефона, но ARP на обоих PVE nodes показывает MAC LXC `bc:24:11:43:75:31`. Нужно исключить `.34` из DHCP pool или закрепить reservation, чтобы предотвратить будущий конфликт. + +### Требует участия пользователя + +- Создать первого локального администратора и сохранить пароль в password manager. +- Отключить public registration, создать обычного reader-пользователя. +- Создать библиотеку `/books`, выполнить тестовый BookDrop import. +- Включить OPDS, создать отдельные OPDS credentials и выдать `Access OPDS`. +- Проверить EPUB, PDF и FB2 на BOOX Go 7 через Wi-Fi и внешнюю сеть. +- После создания OPDS пользователя решить, добавлять ли credential в synthetic monitoring; сейчас OPDS smoke test ручной. +- Создать DHCP reservation/exclusion для `192.168.1.34`. +- Полный restore drill CT и восстановление dump во временную MariaDB остаются отдельной плановой операцией; file-level restore и dump restore-from-restic уже проверены. + +### 2026-07-28 — Caddy KOReader и OPDS compatibility + +- `reverse-proxy-grimmory.yml` разделяет device endpoints `/api/koreader/**` и `/api/v1/opds/**` в отдельный Caddy handler. +- Для этих endpoints upstream compression отключён и установлен `Accept-Encoding: identity`; Caddy не добавляет proxy Basic Auth и не изменяет `Authorization`, `X-Auth-*`, `Range`, `WWW-Authenticate` или download headers. +- Запросы с `Accept-Encoding: gzip, br` подтверждённо возвращают `401` без `Content-Encoding` для KOReader и OPDS. Caddy validate, Grimmory health и idempotent rerun успешны. +- Дополнительно исправлен Grimmory OPDS `500` из-за `No acceptable representation`: Caddy нормализует `Accept` только для Atom/OpenSearch catalog routes, оставляя original `Accept` для book download/cover routes. diff --git a/tools/grimmory-mcp/README.md b/tools/grimmory-mcp/README.md new file mode 100644 index 0000000..5fa616c --- /dev/null +++ b/tools/grimmory-mcp/README.md @@ -0,0 +1,42 @@ +# Grimmory OpenCode MCP + +Read-only Grimmory `v3.2.4` API integration with manual synchronization to +individual Obsidian book notes. + +## Security + +- Grimmory mutations are not implemented. +- Authentication uses the current user's local login because annotations and + reading progress are user-scoped. +- The password is read from a mode `0600` file and is never passed in argv. +- Access and refresh tokens remain in process memory only. + +## Configure + +```bash +cd tools/grimmory-mcp +npm install +npm run configure +``` + +The default password file is: + +```text +~/.config/opencode/secrets/grimmory_password +``` + +## Obsidian behavior + +- Notes: `90 Library/Books/ — grimmory-<id>.md` +- Covers: `99 System/Export/Grimmory/Covers/<id>.<ext>` +- Existing notes are matched by `grimmory_id`, so title changes rename instead + of duplicate the note. +- Text between `grimmory:user:start` and `grimmory:user:end` is preserved. +- Generated metadata, progress, annotations and sessions are replaced from + Grimmory on every sync. +- `99 System/INDEX.md` is rebuilt after a successful manual sync. + +## OpenCode + +Restart OpenCode after changing its MCP configuration. Run `/grimmory-sync` to +synchronize the complete accessible library manually. diff --git a/tools/grimmory-mcp/package-lock.json b/tools/grimmory-mcp/package-lock.json new file mode 100644 index 0000000..bec9826 --- /dev/null +++ b/tools/grimmory-mcp/package-lock.json @@ -0,0 +1,1182 @@ +{ + "name": "grimmory-opencode-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "grimmory-opencode-mcp", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "1.30.0", + "zod": "3.25.76" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@hono/node-server": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", + "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/tools/grimmory-mcp/package.json b/tools/grimmory-mcp/package.json new file mode 100644 index 0000000..ec4120c --- /dev/null +++ b/tools/grimmory-mcp/package.json @@ -0,0 +1,19 @@ +{ + "name": "grimmory-opencode-mcp", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Read-only Grimmory MCP server with manual Obsidian synchronization", + "engines": { + "node": ">=22" + }, + "scripts": { + "start": "node src/server.js", + "configure": "node src/configure.js", + "test": "node --test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.30.0", + "zod": "3.25.76" + } +} diff --git a/tools/grimmory-mcp/src/client.js b/tools/grimmory-mcp/src/client.js new file mode 100644 index 0000000..db23c91 --- /dev/null +++ b/tools/grimmory-mcp/src/client.js @@ -0,0 +1,311 @@ +import { constants } from "node:fs"; +import { open } from "node:fs/promises"; + +function unwrap(payload) { + if ( + payload && + typeof payload === "object" && + !Array.isArray(payload) && + Object.hasOwn(payload, "data") + ) { + return payload.data; + } + return payload; +} + +export class GrimmoryClient { + constructor({ baseUrl, username, passwordFile, fetchImpl = fetch }) { + this.baseUrl = baseUrl.replace(/\/$/, ""); + this.username = username; + this.passwordFile = passwordFile; + this.fetch = fetchImpl; + this.accessToken = null; + this.refreshToken = null; + this.loginPromise = null; + this.refreshPromise = null; + } + + async credentials() { + let password; + let handle; + try { + handle = await open( + this.passwordFile, + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + const info = await handle.stat(); + if (!info.isFile()) { + throw new Error("Grimmory password path must be a regular file"); + } + if (process.getuid && info.uid !== process.getuid()) { + throw new Error("Grimmory password file must be owned by the current user"); + } + if ((info.mode & 0o077) !== 0) { + throw new Error("Grimmory password file must not be accessible by group or others"); + } + password = await handle.readFile("utf8"); + } catch (error) { + if (error.code === "ENOENT") { + throw new Error( + `Grimmory password file is missing: ${this.passwordFile}. Run npm run configure in the MCP directory.`, + ); + } + throw error; + } finally { + await handle?.close(); + } + if (!password) throw new Error("Grimmory password file is empty"); + return { username: this.username, password }; + } + + async login(attempt = 0) { + const response = await this.fetch(`${this.baseUrl}/api/v1/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(await this.credentials()), + }); + let payload; + try { + payload = await this.parseResponse(response); + } catch (error) { + // v3.2.4 can generate the same refresh JWT for concurrent logins in one second. + if (error.status === 400 && attempt === 0) { + await new Promise((resolve) => setTimeout(resolve, 1100)); + return this.login(1); + } + throw error; + } + const tokens = unwrap(payload); + if (!tokens?.accessToken || !tokens?.refreshToken) { + throw new Error("Grimmory login response does not contain tokens"); + } + this.accessToken = tokens.accessToken; + this.refreshToken = tokens.refreshToken; + } + + async refresh() { + if (!this.refreshToken) return this.login(); + if (!this.refreshPromise) { + this.refreshPromise = (async () => { + const response = await this.fetch(`${this.baseUrl}/api/v1/auth/refresh`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ refreshToken: this.refreshToken }), + }); + if (!response.ok) { + this.accessToken = null; + this.refreshToken = null; + return this.login(); + } + const tokens = unwrap(await this.parseResponse(response)); + this.accessToken = tokens.accessToken; + this.refreshToken = tokens.refreshToken; + })().finally(() => { + this.refreshPromise = null; + }); + } + return this.refreshPromise; + } + + async ensureLogin() { + if (this.accessToken) return; + if (!this.loginPromise) { + this.loginPromise = this.login().finally(() => { + this.loginPromise = null; + }); + } + await this.loginPromise; + } + + async parseResponse(response) { + if (response.status === 204) return null; + const text = await response.text(); + let payload = null; + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = text; + } + } + if (!response.ok) { + const message = + payload?.message || payload?.error || text || response.statusText; + throw Object.assign( + new Error(`Grimmory API ${response.status}: ${message}`), + { status: response.status }, + ); + } + return payload; + } + + async request(path, { optional = false, raw = false, retry = true } = {}) { + await this.ensureLogin(); + const response = await this.fetch(`${this.baseUrl}${path}`, { + headers: { authorization: `Bearer ${this.accessToken}` }, + }); + if (response.status === 401 && retry) { + await this.refresh(); + return this.request(path, { optional, raw, retry: false }); + } + if (optional && [204, 404].includes(response.status)) return null; + if (raw) { + if (!response.ok) await this.parseResponse(response); + return response; + } + return unwrap(await this.parseResponse(response)); + } + + currentUser() { + return this.request("/api/v1/users/me"); + } + + async libraries() { + try { + return await this.request("/api/v1/app/libraries"); + } catch (error) { + if (!error.message.startsWith("Grimmory API 500:")) throw error; + const user = await this.currentUser(); + if (user.assignedLibraries?.length) return user.assignedLibraries; + const books = await this.allBooks(); + const byLibrary = new Map(); + for (const book of books) { + const entry = byLibrary.get(book.libraryId) || { + id: book.libraryId, + name: null, + bookCount: 0, + }; + entry.bookCount += 1; + byLibrary.set(book.libraryId, entry); + } + for (const entry of byLibrary.values()) { + const sample = books.find((book) => book.libraryId === entry.id); + if (sample) { + const detail = await this.request( + `/api/v1/books/${sample.id}?withDescription=false`, + ); + entry.name = detail.libraryName || `Library ${entry.id}`; + } + } + return [...byLibrary.values()]; + } + } + + shelves() { + return this.request("/api/v1/app/shelves"); + } + + authors(params = {}) { + return this.request(`/api/v1/app/authors?${query(params)}`); + } + + filterOptions(params = {}) { + return this.request(`/api/v1/app/filter-options?${query(params)}`); + } + + books(params = {}) { + return this.request(`/api/v1/app/books?${query(params)}`); + } + + searchBooks(search, params = {}) { + return this.request( + `/api/v1/app/books/search?${query({ q: search, ...params })}`, + ); + } + + async allBooks() { + const books = []; + const seenPages = new Set(); + for (let page = 0; page < 10_000; page += 1) { + const result = await this.books({ page, size: 50, sort: "title", dir: "asc" }); + if (!Array.isArray(result?.content)) { + throw new Error("Grimmory books response has no content array"); + } + const signature = result.content.map((book) => book.id).join(","); + if (seenPages.has(signature) && signature) { + throw new Error("Grimmory books pagination repeated a page"); + } + seenPages.add(signature); + books.push(...result.content); + if (!hasNextPage(result, page, 50)) return books; + } + throw new Error("Grimmory books pagination exceeded the safety limit"); + } + + async springPage(path) { + const entries = []; + const seenPages = new Set(); + for (let page = 0; page < 10_000; page += 1) { + const separator = path.includes("?") ? "&" : "?"; + const result = await this.request(`${path}${separator}page=${page}&size=100`); + if (!Array.isArray(result?.content)) { + throw new Error("Grimmory session response has no content array"); + } + const signature = JSON.stringify(result.content.map((entry) => entry.id)); + if (seenPages.has(signature) && result.content.length) { + throw new Error("Grimmory session pagination repeated a page"); + } + seenPages.add(signature); + entries.push(...result.content); + if (!hasNextPage(result, page, 100)) return entries; + } + throw new Error("Grimmory session pagination exceeded the safety limit"); + } + + async bookBundle(bookId) { + const id = Number(bookId); + const [book, progress, notebook, annotations, bookmarks, notes, cfiNotes, reviews, sessions] = + await Promise.all([ + this.request(`/api/v1/books/${id}?withDescription=true`), + this.request(`/api/v1/app/books/${id}/progress`, { optional: true }), + this.request(`/api/v1/notebook/export?bookId=${id}`, { optional: true }), + this.request(`/api/v1/annotations/book/${id}`, { optional: true }), + this.request(`/api/v1/bookmarks/book/${id}`, { optional: true }), + this.request(`/api/v1/book-notes/book/${id}`, { optional: true }), + this.request(`/api/v2/book-notes/book/${id}`, { optional: true }), + this.request(`/api/v1/reviews/book/${id}`, { optional: true }), + this.springPage(`/api/v1/reading-sessions/book/${id}`), + ]); + return { + book, + progress, + notebook: notebook || [], + annotations: annotations || [], + bookmarks: bookmarks || [], + notes: notes || [], + cfiNotes: cfiNotes || [], + reviews: reviews || [], + sessions, + }; + } + + cover(bookId) { + return this.request(`/api/v1/media/book/${Number(bookId)}/cover`, { + optional: true, + raw: true, + }); + } +} + +export function query(values) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + if (value === undefined || value === null || value === "") continue; + for (const item of Array.isArray(value) ? value : [value]) { + params.append(key, String(item)); + } + } + return params.toString(); +} + +export function hasNextPage(result, currentPage, pageSize) { + if (typeof result.hasNext === "boolean") return result.hasNext; + if (typeof result.last === "boolean") return !result.last; + const number = result.page?.number ?? result.number ?? currentPage; + const totalPages = result.page?.totalPages ?? result.totalPages; + if (totalPages !== undefined) return number + 1 < totalPages; + if (result.content.length < pageSize) return false; + throw new Error("Grimmory books response has no pagination metadata"); +} + +export { unwrap }; diff --git a/tools/grimmory-mcp/src/configure.js b/tools/grimmory-mcp/src/configure.js new file mode 100644 index 0000000..dd6d5c4 --- /dev/null +++ b/tools/grimmory-mcp/src/configure.js @@ -0,0 +1,42 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; + +const destination = + process.env.GRIMMORY_PASSWORD_FILE || + path.join(os.homedir(), ".config/opencode/secrets/grimmory_password"); + +if (!process.stdin.isTTY) { + console.error("Run this command in an interactive terminal."); + process.exit(1); +} + +process.stdout.write("Grimmory password: "); +process.stdin.setRawMode(true); +process.stdin.resume(); +process.stdin.setEncoding("utf8"); + +let password = ""; +process.stdin.on("data", async (character) => { + if (character === "\u0003") process.exit(130); + if (character === "\r" || character === "\n") { + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdout.write("\n"); + if (!password) { + console.error("Password cannot be empty."); + process.exit(1); + } + await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 }); + await writeFile(destination, password, { mode: 0o600 }); + await chmod(destination, 0o600); + console.log(`Password saved to ${destination}`); + return; + } + if (character === "\u007f") { + password = password.slice(0, -1); + return; + } + password += character; +}); diff --git a/tools/grimmory-mcp/src/obsidian.js b/tools/grimmory-mcp/src/obsidian.js new file mode 100644 index 0000000..375a1b3 --- /dev/null +++ b/tools/grimmory-mcp/src/obsidian.js @@ -0,0 +1,371 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const USER_START = "<!-- grimmory:user:start -->"; +const USER_END = "<!-- grimmory:user:end -->"; + +export class ObsidianSync { + constructor({ vaultPath, booksDir, coversDir, indexScript }) { + this.vaultPath = path.resolve(vaultPath); + this.booksPath = safeJoin(this.vaultPath, booksDir); + this.coversDir = coversDir; + this.coversPath = safeJoin(this.vaultPath, coversDir); + this.indexScript = safeJoin(this.vaultPath, indexScript); + } + + async ensureDirectories() { + await mkdir(this.booksPath, { recursive: true }); + await mkdir(this.coversPath, { recursive: true }); + const realVault = await realpath(this.vaultPath); + assertWithin(realVault, await realpath(this.booksPath)); + assertWithin(realVault, await realpath(this.coversPath)); + } + + async findExistingNote(bookId) { + let files = []; + try { + files = await readdir(this.booksPath); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + const matches = []; + for (const file of files.filter((name) => name.endsWith(".md"))) { + const filePath = path.join(this.booksPath, file); + if (frontmatterId(await readFile(filePath, "utf8")) === Number(bookId)) { + matches.push(filePath); + } + } + if (matches.length > 1) { + throw new Error(`Duplicate Obsidian notes for grimmory_id ${bookId}`); + } + return matches[0] || null; + } + + async saveCover(client, bookId) { + await this.ensureDirectories(); + const response = await client.cover(bookId); + if (!response) return null; + const declaredSize = Number(response.headers.get("content-length")); + if (declaredSize > 10 * 1024 * 1024) throw new Error("Grimmory cover exceeds 10 MB"); + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length > 10 * 1024 * 1024) throw new Error("Grimmory cover exceeds 10 MB"); + const extension = coverExtension(bytes); + if (!extension) throw new Error("Grimmory cover response is not a supported image"); + const relative = path.join(this.coversDir, `${bookId}.${extension}`); + const destination = safeJoin(this.vaultPath, relative); + await atomicWrite(destination, bytes); + return relative.split(path.sep).join("/"); + } + + async syncBook(bundle, coverPath = null) { + await this.ensureDirectories(); + const title = bookTitle(bundle.book); + const desiredPath = path.join( + this.booksPath, + `${sanitizeFilename(title)} — grimmory-${bundle.book.id}.md`, + ); + const existingPath = await this.findExistingNote(bundle.book.id); + let userSection = defaultUserSection(); + if (existingPath) { + const existingContent = await readFile(existingPath, "utf8"); + if (!hasValidUserSection(existingContent)) { + throw new Error(`Existing Grimmory note has invalid user markers: ${existingPath}`); + } + userSection = extractUserSection(existingContent); + } + if (desiredPath !== existingPath) { + try { + await lstat(desiredPath); + throw new Error(`Refusing to overwrite unrelated Obsidian note: ${desiredPath}`); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + await atomicWrite(desiredPath, renderBookNote(bundle, coverPath, userSection)); + if (existingPath && existingPath !== desiredPath) await rm(existingPath); + return path.relative(this.vaultPath, desiredPath); + } + + async rebuildIndex() { + const realVault = await realpath(this.vaultPath); + const realScript = await realpath(this.indexScript); + assertWithin(realVault, realScript); + if (!(await stat(realScript)).isFile()) throw new Error("Vault index script is not a regular file"); + const { stdout } = await execFileAsync("python3", [realScript, "rebuild"], { + cwd: this.vaultPath, + timeout: 120_000, + }); + return stdout.trim(); + } +} + +export function renderBookNote(bundle, coverPath = null, userSection = defaultUserSection()) { + const { book, progress = {}, notebook = [], annotations = [], bookmarks = [], notes = [], cfiNotes = [], reviews = [], sessions = [] } = bundle; + const metadata = book.metadata || {}; + const title = bookTitle(book); + const authors = metadata.authors || book.authors || []; + const status = studyStatus(book.readStatus || progress.readStatus); + const percent = progressPercent(progress.readProgress ?? book.readProgress); + const updated = new Date().toISOString().slice(0, 10); + const created = dateOnly(book.addedOn) || updated; + const cover = coverPath ? `[[${coverPath}]]` : ""; + const frontmatter = [ + "---", + `title: ${yamlString(title)}`, + "type: study", + "tags: [study, book, grimmory]", + `created: ${created}`, + `updated: ${updated}`, + `aliases: ${yamlArray([title])}`, + "kind: book", + `status: ${status}`, + "area:", + "priority: medium", + `author: ${yamlString(authors.join(", "))}`, + `url: ${yamlString(`https://books.ada-dev.ru/book/${book.id}`)}`, + `cover: ${yamlString(cover)}`, + `pages: ${metadata.pageCount ?? ""}`, + `started: ${firstSessionDate(sessions)}`, + `finished: ${dateOnly(book.dateFinished)}`, + "related: []", + `grimmory_id: ${book.id}`, + `grimmory_library_id: ${book.libraryId}`, + `grimmory_read_status: ${yamlString(book.readStatus || progress.readStatus || "UNSET")}`, + `grimmory_progress: ${percent ?? ""}`, + `grimmory_rating: ${book.personalRating ?? ""}`, + `grimmory_synced_at: ${yamlString(new Date().toISOString())}`, + "---", + ].join("\n"); + + const info = [ + `# ${title}`, + "", + "<!-- grimmory:generated:start -->", + "## Информация", + `- **Авторы:** ${authors.join(", ") || "—"}`, + `- **Серия:** ${metadata.seriesName || metadata.series || "—"}${metadata.seriesNumber ? ` #${metadata.seriesNumber}` : ""}`, + `- **Издатель:** ${metadata.publisher || "—"}`, + `- **Дата публикации:** ${metadata.publishedDate || "—"}`, + `- **ISBN:** ${[metadata.isbn13, metadata.isbn10].filter(Boolean).join(", ") || "—"}`, + `- **Язык:** ${metadata.language || "—"}`, + `- **Страниц:** ${metadata.pageCount || "—"}`, + `- **Категории:** ${joinList(metadata.categories)}`, + `- **Теги:** ${joinList(metadata.tags)}`, + `- **Настроения:** ${joinList(metadata.moods)}`, + `- **Полки:** ${joinList((book.shelves || []).map((shelf) => shelf.name))}`, + `- **Статус:** ${book.readStatus || progress.readStatus || "UNSET"}`, + `- **Прогресс:** ${percent === null ? "—" : `${percent}%`}`, + `- **Личная оценка:** ${book.personalRating || "—"}`, + "", + ...(coverPath ? [`![[${coverPath}]]`, ""] : []), + "## Описание", + metadata.description || book.description || "Описание отсутствует.", + "", + renderProgress(progress), + renderNotebook(notebook, annotations, bookmarks, notes, cfiNotes), + renderSessions(sessions), + renderReviews(reviews), + "<!-- grimmory:generated:end -->", + "", + userSection.trim(), + "", + ]; + return `${frontmatter}\n\n${info.join("\n")}`; +} + +function renderProgress(progress) { + const entries = [ + ["Общий", progress.readProgress], + ["EPUB", progress.epubProgress?.percentage], + ["PDF", progress.pdfProgress?.percentage], + ["CBX", progress.cbxProgress?.percentage], + ["Аудиокнига", progress.audiobookProgress?.percentage], + ["KOReader", progress.koreaderProgress?.percentage], + ].filter(([, value]) => value !== undefined && value !== null); + const lines = ["## Прогресс чтения"]; + if (!entries.length) lines.push("Прогресс отсутствует."); + for (const [label, value] of entries) { + lines.push(`- **${label}:** ${progressPercent(value)}%`); + } + if (progress.koreaderProgress?.device) { + lines.push(`- **Устройство KOReader:** ${progress.koreaderProgress.device}`); + } + if (progress.lastReadTime) lines.push(`- **Последнее чтение:** ${progress.lastReadTime}`); + return lines.join("\n"); +} + +function renderNotebook(notebook, annotations, bookmarks, notes, cfiNotes) { + const lines = ["## Аннотации и заметки"]; + const entries = notebook.length + ? notebook.map((entry) => ({ + type: entry.type, + chapter: entry.chapterTitle, + text: entry.text, + note: entry.note, + createdAt: entry.createdAt, + })) + : [ + ...annotations.map((entry) => ({ type: "annotation", chapter: entry.chapterTitle, text: entry.text, note: entry.note, createdAt: entry.createdAt })), + ...bookmarks.map((entry) => ({ type: "bookmark", text: entry.title, note: entry.notes, createdAt: entry.createdAt })), + ...notes.map((entry) => ({ type: "note", text: entry.title, note: entry.content, createdAt: entry.createdAt })), + ...cfiNotes.map((entry) => ({ type: "cfi-note", chapter: entry.chapterTitle, text: entry.selectedText, note: entry.noteContent, createdAt: entry.createdAt })), + ]; + if (!entries.length) lines.push("Аннотации отсутствуют."); + for (const entry of entries) { + lines.push(`### ${entry.chapter || entry.type || "Запись"}`); + if (entry.text) lines.push(`> ${String(entry.text).replace(/\n/g, "\n> ")}`); + if (entry.note) lines.push("", entry.note); + if (entry.createdAt) lines.push("", `_${entry.createdAt}_`); + } + return lines.join("\n"); +} + +function renderSessions(sessions) { + const lines = ["## Сессии чтения"]; + if (!sessions.length) lines.push("Сессии отсутствуют."); + for (const session of sessions) { + lines.push( + `- ${session.startTime} — ${Math.round((session.durationSeconds || 0) / 60)} мин, ${progressPercent(session.startProgress)}% → ${progressPercent(session.endProgress)}%`, + ); + } + return lines.join("\n"); +} + +function renderReviews(reviews) { + const lines = ["## Внешние отзывы"]; + if (!reviews.length) lines.push("Отзывы отсутствуют."); + for (const review of reviews) { + lines.push(`### ${review.title || review.reviewerName || review.metadataProvider || "Отзыв"}`); + lines.push(`- **Источник:** ${review.metadataProvider || "—"}`); + if (review.rating) lines.push(`- **Оценка:** ${review.rating}`); + if (review.body) lines.push("", review.body); + } + return lines.join("\n"); +} + +export function extractUserSection(content) { + const start = content.indexOf(USER_START); + const end = content.indexOf(USER_END); + if (start === -1 || end === -1 || end < start) return defaultUserSection(); + return content.slice(start, end + USER_END.length); +} + +export function hasValidUserSection(content) { + const start = content.indexOf(USER_START); + const end = content.indexOf(USER_END); + return start !== -1 && end !== -1 && end > start && content.indexOf(USER_START, start + 1) === -1 && content.indexOf(USER_END, end + 1) === -1; +} + +export function defaultUserSection() { + return `${USER_START}\n## Заметки\n\n## Связанные заметки\n\n${USER_END}`; +} + +export function sanitizeFilename(value) { + const result = String(value) + .replace(/[\\/:*?"<>|]/g, "-") + .replace(/\s+/g, " ") + .replace(/[. ]+$/g, "") + .trim(); + return (result || "Без названия").slice(0, 160); +} + +export function progressPercent(value) { + if (value === undefined || value === null || value === "") return null; + const number = Number(value); + if (!Number.isFinite(number)) return null; + return Math.round((number <= 1 ? number * 100 : number) * 10) / 10; +} + +function studyStatus(status) { + return { + READING: "active", + RE_READING: "active", + PAUSED: "paused", + READ: "done", + WONT_READ: "dropped", + ABANDONED: "dropped", + }[status] || "planned"; +} + +function bookTitle(book) { + return book.metadata?.title || book.title || `Книга ${book.id}`; +} + +function yamlString(value) { + return JSON.stringify(value ?? ""); +} + +function yamlArray(values) { + return JSON.stringify(values.filter(Boolean)); +} + +function joinList(values) { + return Array.isArray(values) && values.length ? values.join(", ") : "—"; +} + +function dateOnly(value) { + return value ? String(value).slice(0, 10) : ""; +} + +function firstSessionDate(sessions) { + const dates = sessions.map((session) => session.startTime).filter(Boolean).sort(); + return dateOnly(dates[0]); +} + +function coverExtension(bytes) { + if (bytes[0] === 0xff && bytes[1] === 0xd8) return "jpg"; + if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "png"; + if (bytes.subarray(0, 6).toString("ascii").startsWith("GIF8")) return "gif"; + if (bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") return "webp"; + return null; +} + +function safeJoin(root, relative) { + const result = path.resolve(root, relative); + if (result !== root && !result.startsWith(`${root}${path.sep}`)) { + throw new Error(`Path escapes Obsidian vault: ${relative}`); + } + return result; +} + +function assertWithin(root, candidate) { + if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) { + throw new Error(`Resolved path escapes Obsidian vault: ${candidate}`); + } +} + +function frontmatterId(content) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + if (!match) return null; + const markers = [...match[1].matchAll(/^grimmory_id:\s*(.*?)\s*$/gm)]; + if (!markers.length) return null; + if (markers.length !== 1 || !/^\d+$/.test(markers[0][1])) { + throw new Error("Ambiguous or malformed grimmory_id in Obsidian frontmatter"); + } + return Number(markers[0][1]); +} + +async function atomicWrite(destination, content) { + await mkdir(path.dirname(destination), { recursive: true }); + const temporary = `${destination}.tmp-${randomUUID()}`; + let handle; + try { + handle = await open( + temporary, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, + 0o600, + ); + await handle.writeFile(content); + await handle.close(); + handle = null; + await rename(temporary, destination); + } finally { + await handle?.close(); + await rm(temporary, { force: true }); + } +} diff --git a/tools/grimmory-mcp/src/server.js b/tools/grimmory-mcp/src/server.js new file mode 100644 index 0000000..9b5e0ee --- /dev/null +++ b/tools/grimmory-mcp/src/server.js @@ -0,0 +1,194 @@ +#!/usr/bin/env node +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { GrimmoryClient } from "./client.js"; +import { ObsidianSync } from "./obsidian.js"; + +const config = { + baseUrl: process.env.GRIMMORY_BASE_URL || "https://books.ada-dev.ru", + username: process.env.GRIMMORY_USERNAME || "ada", + passwordFile: + process.env.GRIMMORY_PASSWORD_FILE || + `${process.env.HOME}/.config/opencode/secrets/grimmory_password`, + vaultPath: + process.env.OBSIDIAN_VAULT || + `${process.env.HOME}/Documents/Vaults/SecondBrain`, + booksDir: process.env.OBSIDIAN_BOOKS_DIR || "90 Library/Books", + coversDir: + process.env.OBSIDIAN_COVERS_DIR || + "99 System/Export/Grimmory/Covers", + indexScript: + process.env.OBSIDIAN_INDEX_SCRIPT || + "99 System/Tools/agent-tools/vault_index.py", +}; + +if (!config.baseUrl.startsWith("https://")) { + throw new Error("GRIMMORY_BASE_URL must use HTTPS"); +} + +const client = new GrimmoryClient(config); +const obsidian = new ObsidianSync(config); +const server = new McpServer({ name: "grimmory", version: "0.1.0" }); + +const jsonResult = (data, isError = false) => ({ + content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + structuredContent: { result: data }, + isError, +}); + +server.registerTool( + "current_user", + { description: "Return the current Grimmory user, permissions and assigned libraries." }, + async () => jsonResult(await client.currentUser()), +); + +server.registerTool( + "list_libraries", + { description: "List Grimmory libraries accessible to the current user." }, + async () => jsonResult(await client.libraries()), +); + +server.registerTool( + "list_shelves", + { description: "List Grimmory shelves accessible to the current user." }, + async () => jsonResult(await client.shelves()), +); + +server.registerTool( + "list_books", + { + description: "List a page of Grimmory books with metadata and reading state.", + inputSchema: { + page: z.number().int().min(0).default(0), + size: z.number().int().min(1).max(50).default(20), + libraryId: z.number().int().positive().optional(), + status: z.array(z.string()).optional(), + sort: z.string().default("title"), + dir: z.enum(["asc", "desc"]).default("asc"), + }, + }, + async (input) => jsonResult(await client.books(input)), +); + +server.registerTool( + "search_books", + { + description: "Search Grimmory books by title, author or metadata.", + inputSchema: { + query: z.string().min(1), + page: z.number().int().min(0).default(0), + size: z.number().int().min(1).max(50).default(20), + }, + }, + async ({ query, ...params }) => + jsonResult(await client.searchBooks(query, params)), +); + +server.registerTool( + "get_book", + { + description: + "Return a rich book bundle: metadata, progress, annotations, bookmarks, notes, reviews and reading sessions.", + inputSchema: { bookId: z.number().int().positive() }, + }, + async ({ bookId }) => jsonResult(await client.bookBundle(bookId)), +); + +server.registerTool( + "list_authors", + { + description: "List a page of Grimmory authors.", + inputSchema: { + page: z.number().int().min(0).default(0), + size: z.number().int().min(1).max(50).default(30), + search: z.string().optional(), + }, + }, + async (input) => jsonResult(await client.authors(input)), +); + +server.registerTool( + "get_filter_options", + { + description: + "Return available authors, categories, tags, moods, statuses and other catalog filter values.", + inputSchema: { libraryId: z.number().int().positive().optional() }, + }, + async (input) => jsonResult(await client.filterOptions(input)), +); + +server.registerTool( + "sync_book_to_obsidian", + { + description: + "Fetch one Grimmory book and update its Obsidian note while preserving the user notes/links section.", + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + inputSchema: { + bookId: z.number().int().positive(), + includeCover: z.boolean().default(true), + rebuildIndex: z.boolean().default(true), + }, + }, + async ({ bookId, includeCover, rebuildIndex }) => { + const bundle = await client.bookBundle(bookId); + const cover = includeCover ? await obsidian.saveCover(client, bookId) : null; + const note = await obsidian.syncBook(bundle, cover); + try { + const index = rebuildIndex ? await obsidian.rebuildIndex() : null; + return jsonResult({ status: "complete", bookId, note, cover, indexRebuilt: rebuildIndex, index }); + } catch (error) { + return jsonResult({ status: "index_failed", bookId, note, cover, indexRebuilt: false, error: error.message }, true); + } + }, +); + +server.registerTool( + "sync_all_to_obsidian", + { + description: + "Manually synchronize every accessible Grimmory book to an individual Obsidian note, then rebuild the vault index.", + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + inputSchema: { includeCovers: z.boolean().default(true) }, + }, + async ({ includeCovers }) => { + const books = await client.allBooks(); + const results = []; + const failures = []; + for (const book of books) { + try { + const bundle = await client.bookBundle(book.id); + const cover = includeCovers + ? await obsidian.saveCover(client, book.id) + : null; + const note = await obsidian.syncBook(bundle, cover); + results.push({ bookId: book.id, title: book.title, note, cover }); + } catch (error) { + failures.push({ bookId: book.id, title: book.title, error: error.message }); + } + } + let index = null; + let indexError = null; + if (results.length || failures.length) { + try { + index = await obsidian.rebuildIndex(); + } catch (error) { + indexError = error.message; + } + } + const status = indexError ? "index_failed" : failures.length ? "partial" : "complete"; + return jsonResult({ + status, + total: books.length, + synchronized: results.length, + failed: failures.length, + results, + failures, + indexRebuilt: Boolean(index), + index, + indexError, + }, status !== "complete"); + }, +); + +await server.connect(new StdioServerTransport()); diff --git a/tools/grimmory-mcp/test/client.test.js b/tools/grimmory-mcp/test/client.test.js new file mode 100644 index 0000000..f97e867 --- /dev/null +++ b/tools/grimmory-mcp/test/client.test.js @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { GrimmoryClient, query, unwrap } from "../src/client.js"; + +test("unwrap handles Grimmory API response envelope", () => { + assert.deepEqual(unwrap({ status: 200, data: { id: 7 } }), { id: 7 }); + assert.deepEqual(unwrap([1, 2]), [1, 2]); +}); + +test("query repeats array values and skips empty values", () => { + assert.equal( + query({ page: 0, status: ["READING", "READ"], search: "" }), + "page=0&status=READING&status=READ", + ); +}); + +test("libraries falls back to the current user on the v3.2.4 endpoint bug", async () => { + const responses = [ + new Response(JSON.stringify({ message: "broken" }), { status: 500 }), + new Response(JSON.stringify({ data: { assignedLibraries: [{ id: 3 }] } })), + ]; + const client = new GrimmoryClient({ + baseUrl: "https://example.test", + username: "ada", + passwordFile: "/unused", + fetchImpl: async () => responses.shift(), + }); + client.accessToken = "test"; + assert.deepEqual(await client.libraries(), [{ id: 3 }]); +}); + +test("springPage supports the nested Spring page metadata", async () => { + let request = 0; + const client = new GrimmoryClient({ + baseUrl: "https://example.test", + username: "ada", + passwordFile: "/unused", + fetchImpl: async () => { + const page = request++; + return new Response( + JSON.stringify({ + data: { + content: page === 0 ? Array.from({ length: 100 }, (_, id) => id) : [100], + page: { number: page, totalPages: 2 }, + }, + }), + ); + }, + }); + client.accessToken = "test"; + assert.equal((await client.springPage("/sessions")).length, 101); + assert.equal(request, 2); +}); + +test("allBooks follows nested pagination and rejects missing metadata", async () => { + let request = 0; + const client = new GrimmoryClient({ + baseUrl: "https://example.test", + username: "ada", + passwordFile: "/unused", + fetchImpl: async () => { + const page = request++; + return new Response(JSON.stringify({ data: { + content: page === 0 ? Array.from({ length: 50 }, (_, id) => ({ id })) : [{ id: 50 }], + page: { number: page, totalPages: 2 }, + } })); + }, + }); + client.accessToken = "test"; + assert.equal((await client.allBooks()).length, 51); + assert.equal(request, 2); +}); + +test("concurrent first requests share one login", async () => { + let logins = 0; + const client = new GrimmoryClient({ + baseUrl: "https://example.test", + username: "ada", + passwordFile: "/unused", + fetchImpl: async () => new Response(JSON.stringify({ data: { ok: true } })), + }); + client.login = async () => { + logins += 1; + await new Promise((resolve) => setTimeout(resolve, 10)); + client.accessToken = "test"; + }; + await Promise.all([client.request("/one"), client.request("/two")]); + assert.equal(logins, 1); +}); + +test("springPage rejects missing pagination metadata on a full page", async () => { + const client = new GrimmoryClient({ + baseUrl: "https://example.test", + username: "ada", + passwordFile: "/unused", + fetchImpl: async () => new Response(JSON.stringify({ data: { + content: Array.from({ length: 100 }, (_, id) => ({ id })), + } })), + }); + client.accessToken = "test"; + await assert.rejects(() => client.springPage("/sessions"), /no pagination metadata/); +}); diff --git a/tools/grimmory-mcp/test/obsidian.test.js b/tools/grimmory-mcp/test/obsidian.test.js new file mode 100644 index 0000000..c098ee8 --- /dev/null +++ b/tools/grimmory-mcp/test/obsidian.test.js @@ -0,0 +1,192 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, readFile, readdir, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + ObsidianSync, + extractUserSection, + progressPercent, + renderBookNote, + sanitizeFilename, +} from "../src/obsidian.js"; + +const bundle = { + book: { + id: 42, + libraryId: 1, + title: "Test Book", + readStatus: "READING", + personalRating: 4, + metadata: { + title: "Test Book", + authors: ["Ada Author"], + categories: ["Science"], + tags: ["AI"], + moods: [], + pageCount: 300, + description: "Description", + }, + shelves: [{ name: "Reading" }], + }, + progress: { readProgress: 0.425, koreaderProgress: { percentage: 42.5 } }, + notebook: [{ type: "annotation", text: "Quote", note: "Comment" }], + annotations: [], + bookmarks: [], + notes: [], + cfiNotes: [], + reviews: [], + sessions: [], +}; + +test("render creates a study note with normalized progress", () => { + const note = renderBookNote(bundle, "99 System/Export/Grimmory/Covers/42.jpg"); + assert.match(note, /grimmory_id: 42/); + assert.match(note, /status: active/); + assert.match(note, /grimmory_progress: 42.5/); + assert.match(note, /> Quote/); +}); + +test("user section survives extraction", () => { + const note = renderBookNote(bundle); + const edited = note.replace("## Связанные заметки", "## Связанные заметки\n\n- [[AI]]"); + assert.match(extractUserSection(edited), /\[\[AI\]\]/); +}); + +test("filename and progress normalization are safe", () => { + assert.equal(sanitizeFilename('A/B: C?'), "A-B- C-"); + assert.equal(progressPercent(0.5), 50); + assert.equal(progressPercent(75), 75); +}); + +test("syncBook creates one note in the configured vault directory", async () => { + const { vault, sync } = await tempSync(); + + const relative = await sync.syncBook(bundle); + const files = await readdir(path.join(vault, "Books")); + const content = await readFile(path.join(vault, relative), "utf8"); + + assert.equal(relative, "Books/Test Book — grimmory-42.md"); + assert.deepEqual(files, ["Test Book — grimmory-42.md"]); + assert.match(content, /grimmory_id: 42/); + assert.match(content, /<!-- grimmory:user:start -->/); +}); + +test("syncBook preserves the user section and renames title changes without duplicates", async () => { + const { vault, sync } = await tempSync(); + const first = await sync.syncBook({ + ...bundle, + book: { ...bundle.book, title: "Old Title", metadata: { ...bundle.book.metadata, title: "Old Title" } }, + }); + const edited = (await readFile(path.join(vault, first), "utf8")).replace( + "## Связанные заметки", + "## Связанные заметки\n\n- [[User Link]]", + ); + await writeFile(path.join(vault, first), edited); + + const second = await sync.syncBook({ + ...bundle, + book: { ...bundle.book, title: "New Title", metadata: { ...bundle.book.metadata, title: "New Title" } }, + }); + const files = await readdir(path.join(vault, "Books")); + const content = await readFile(path.join(vault, second), "utf8"); + + assert.equal(second, "Books/New Title — grimmory-42.md"); + assert.deepEqual(files, ["New Title — grimmory-42.md"]); + assert.match(content, /\[\[User Link\]\]/); +}); + +test("saveCover writes only under the configured covers directory", async () => { + const { vault, sync } = await tempSync(); + const client = { + cover: async () => + new Response(Uint8Array.from([0xff, 0xd8, 0xff]), { + headers: { "content-type": "application/json" }, + }), + }; + + const relative = await sync.saveCover(client, 42); + const bytes = await readFile(path.join(vault, relative)); + + assert.equal(relative, "Covers/42.jpg"); + assert.deepEqual([...bytes], [0xff, 0xd8, 0xff]); + assert.throws( + () => new ObsidianSync({ vaultPath: vault, booksDir: "Books", coversDir: "../Covers", indexScript: "index.py" }), + /Path escapes Obsidian vault/, + ); +}); + +test("syncBook ignores body IDs and refuses duplicate frontmatter IDs", async () => { + const { vault, sync } = await tempSync(); + await mkdir(path.join(vault, "Books"), { recursive: true }); + await writeFile(path.join(vault, "Books/unrelated.md"), "Body mentions\ngrimmory_id: 42\n"); + await sync.syncBook(bundle); + assert.equal((await readdir(path.join(vault, "Books"))).length, 2); + + await writeFile( + path.join(vault, "Books/duplicate.md"), + "---\ntitle: duplicate\ngrimmory_id: 42\n---\n", + ); + await assert.rejects(() => sync.syncBook(bundle), /Duplicate Obsidian notes/); +}); + +test("syncBook refuses to overwrite a generated note with broken user markers", async () => { + const { vault, sync } = await tempSync(); + const relative = await sync.syncBook(bundle); + const file = path.join(vault, relative); + const broken = (await readFile(file, "utf8")).replace("<!-- grimmory:user:end -->", ""); + await writeFile(file, broken); + await assert.rejects(() => sync.syncBook(bundle), /invalid user markers/); + assert.equal(await readFile(file, "utf8"), broken); +}); + +test("syncBook rejects malformed or repeated frontmatter IDs", async () => { + for (const marker of ["grimmory_id: 42 # comment", "grimmory_id: 42\ngrimmory_id: 42"]) { + const { vault, sync } = await tempSync(); + await mkdir(path.join(vault, "Books"), { recursive: true }); + await writeFile(path.join(vault, "Books/bad.md"), `---\ntitle: Bad\n${marker}\n---\n`); + await assert.rejects(() => sync.syncBook(bundle), /Ambiguous or malformed/); + } +}); + +test("saveCover rejects a covers-directory symlink outside the vault", async () => { + const { vault, sync } = await tempSync(); + const outside = await mkdtemp(path.join(os.tmpdir(), "grimmory-cover-outside-")); + await symlink(outside, path.join(vault, "Covers")); + let requested = false; + await assert.rejects( + () => sync.saveCover({ cover: async () => { requested = true; } }, 42), + /Resolved path escapes Obsidian vault/, + ); + assert.equal(requested, false); +}); + +test("rebuildIndex invokes the configured script inside the temporary vault", async () => { + const { vault, sync } = await tempSync(); + await writeFile( + path.join(vault, "index.py"), + [ + "import os, pathlib, sys", + "pathlib.Path('index-called.txt').write_text(sys.argv[1] + '\\n' + os.getcwd())", + "print('rebuilt')", + ].join("\n"), + ); + await chmod(path.join(vault, "index.py"), 0o700); + + assert.equal(await sync.rebuildIndex(), "rebuilt"); + const marker = await readFile(path.join(vault, "index-called.txt"), "utf8"); + assert.equal(marker, `rebuild\n${vault}`); +}); + +async function tempSync() { + const vault = await mkdtemp(path.join(os.tmpdir(), "grimmory-obsidian-test-")); + return { + vault, + sync: new ObsidianSync({ + vaultPath: vault, + booksDir: "Books", + coversDir: "Covers", + indexScript: "index.py", + }), + }; +} diff --git a/tools/grimmory-mcp/test/server.test.js b/tools/grimmory-mcp/test/server.test.js new file mode 100644 index 0000000..9fb188f --- /dev/null +++ b/tools/grimmory-mcp/test/server.test.js @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +test("MCP server exposes the read and manual sync tools", async () => { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [path.join(projectRoot, "src/server.js")], + env: { ...process.env }, + }); + const client = new Client({ name: "grimmory-test", version: "0.1.0" }); + try { + await client.connect(transport); + const { tools } = await client.listTools(); + const names = tools.map((tool) => tool.name); + assert.ok(names.includes("get_book")); + assert.ok(names.includes("sync_all_to_obsidian")); + assert.equal(names.length, 10); + } finally { + await client.close(); + } +});