Compare commits

..
28 Commits
Author SHA1 Message Date
DmitryandClaude Sonnet 5 d45391a261 Revert: drop the phone road-warrior OpenVPN attempt
lint / yamllint + ansible-lint + syntax-check (push) Canceled after 0s
Rolled back per the user's request. Three variants were tried on ru-vps
(static key; TLS peer-fingerprint p2p; server mode with push routes and an
inline <ca>). The server side worked each time, but the "OpenVPN for
Android" client consistently failed at config build ("Used 101 tries to
get current version of the profile"), which looks like an app/OS issue
rather than the config.

Repo: remove playbooks/openvpn-phone.yml, its Make target, and the shared
homelab_vpn_client_routes var; restore openvpn-laptop.yml to its prior
state (its pre-existing `become: false` on delegate_to: localhost is noted
in plan.md, left untouched). ru-vps teardown done out of band: unit, tun2,
ufw/nat rules for 9444 and 10.80.0.0/29, and /etc/openvpn/homelab-phone
removed; the site tunnel (homelab-openvpn, tun0) was not touched and is
verified active.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbuZrUoevfBgCpf5DCF4DG
2026-09-03 10:17:26 +03:00
DmitryandClaude Sonnet 5 c5c33986da fix(openvpn-phone): add <ca> to the client profile for ics-openvpn
lint / yamllint + ansible-lint + syntax-check (push) Canceled after 0s
"OpenVPN for Android" fails at "Building configuration" ("Used 101 tries
to get current version of the profile") for a `client`-mode profile with
no <ca> block. The earlier tls-client p2p profile had no CA either but the
app treated it as a custom tunnel; `client` mode makes the CA mandatory in
the app's config builder.

Inline the server's self-signed certificate as <ca> (it is its own trust
anchor); peer-fingerprint still does the actual verification. Server config
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbuZrUoevfBgCpf5DCF4DG
2026-09-03 10:09:20 +03:00
DmitryandClaude Sonnet 5 203751d874 fix(openvpn-phone): use OpenVPN server mode, push routes to the client
lint / yamllint + ansible-lint + syntax-check (push) Canceled after 0s
The TLS tunnel came up but no LAN traffic flowed: "OpenVPN for Android"
does not install the config-local `route` statements from a route-based
p2p setup, so nothing was directed into tun. Zero packets ever reached
tun2 on ru-vps.

Switch the instance to a real `server` (topology subnet, 10.80.0.0/29
pool) and hand the LAN routes to the client via `push "route ..."`.
Pushed routes are installed by every OpenVPN client, mobile included.
The client profile drops to a plain `client` config (pull), keeping
peer-fingerprint auth and inline cert/key.

Also: widen NAT/forward from /30 to /29 (server mode needs a pool) and
drop the now-stale /30 ufw route + MASQUERADE left by the p2p version.

Verified on ru-vps: "Initialization Sequence Completed", tun2 10.80.0.1/29,
ufw shows only the /29 forward rule, make openvpn-phone idempotent. The
regenerated ada-phone.ovpn must be re-imported on the phone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbuZrUoevfBgCpf5DCF4DG
2026-09-03 09:58:17 +03:00
DmitryandClaude Sonnet 5 dfcee00232 fix(openvpn-phone): open FORWARD for road-warrior traffic into the LAN
lint / yamllint + ansible-lint + syntax-check (push) Canceled after 0s
The phone tunnel established fine (TLS peer-fingerprint OK) but no traffic
reached the LAN: ru-vps has policy FORWARD DROP and only per-service game
ACCEPTs. The site tunnel never needed a FORWARD rule because it carries
ru-vps's own traffic, not forwarded packets; the road-warrior instance is
the first forwarded path.

Add a `ufw route allow` (tun2 -> tun0, 10.80.0.0/30 -> 192.168.1.0/24). ufw
route rules survive `ufw reload`, unlike a raw `iptables -I FORWARD` which
would sit before the ufw chains and be flushed on reload. Return traffic is
covered by the global RELATED,ESTABLISHED accept in ufw-before-forward.

Verified on ru-vps: `ufw status` shows "192.168.1.0/24 on tun0 ALLOW FWD
10.80.0.0/30 on tun2"; make openvpn-phone idempotent.

Same gap exists in openvpn-laptop.yml (tun1) — still not deployed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbuZrUoevfBgCpf5DCF4DG
2026-09-03 09:50:01 +03:00
DmitryandClaude Sonnet 5 1f27756afd fix(openvpn-phone): switch from static key to TLS peer-fingerprint
lint / yamllint + ansible-lint + syntax-check (push) Canceled after 0s
The Android client ships OpenVPN 2.7, which refuses a --secret config
("Options error: No tls-client or tls-server option"; 2.8 drops it
entirely). Rework the phone instance to a CA-less TLS point-to-point:
two self-signed EC keypairs generated on ru-vps (server.* / client.*),
each side pins the other by SHA256 with peer-fingerprint, data-ciphers
AES-256-GCM. Everything else unchanged (tcp/9444, tun2, 10.80.0.0/30,
NAT via tun0, systemd unit, shared homelab_vpn_client_routes).

Verified on ru-vps 2026-09-03: openvpn 2.6.19 starts clean ("Using
certificate fingerprint to verify peer"), listens on 9444, tun2 up,
make openvpn-phone idempotent (changed=0 on rerun), make lint green.
The regenerated ansible/generated/ada-phone.ovpn (self-contained
cert+key, gitignored) was handed to the operator.

openvpn-laptop.yml is left on static key — not deployed; needs the same
TLS treatment when someone actually uses it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbuZrUoevfBgCpf5DCF4DG
2026-09-03 09:44:19 +03:00
DmitryandClaude Sonnet 5 4b54e44116 feat: road-warrior OpenVPN profile for the phone (Android, static key)
lint / yamllint + ansible-lint + syntax-check (push) Canceled after 0s
playbooks/openvpn-phone.yml (make openvpn-phone) stands up a separate
point-to-point static-key instance on ru-vps: tcp/9444, tun2, 10.80.0.0/30,
homelab-openvpn-phone unit, NAT 10.80.0.0/30 -> LAN via tun0. The client
profile (with the secret) lands in ansible/generated/ada-phone.ovpn
(gitignored). Android client: "OpenVPN for Android" (Arne Schwabe) — the
official OpenVPN Connect does not support static-key configs.

- homelab_vpn_client_routes in group_vars/all/main.yml: shared surgical
  route list for both road-warrior profiles; not the whole /24, since the
  phone's home network is almost certainly 192.168.1.0/24 too
- openvpn-laptop.yml reuses that list instead of its own literal copy
- both playbooks: local profile write moved from `become: false` to
  `vars: {ansible_connection: local, ansible_become: false}` — the keyword
  did not suppress the inherited ansible_become on delegate_to: localhost

Deployed and verified on ru-vps 2026-09-03: service active, tun2 up, ufw
9444/tcp, NAT rule present, make openvpn-phone idempotent (changed=0 on
rerun), 192.168.1.30:8082 reachable from ru-vps. openvpn-laptop.yml is
still not applied on the live host.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbuZrUoevfBgCpf5DCF4DG
2026-09-03 09:33:18 +03:00
DmitryandClaude Sonnet 5 05d8c748ab feat: infrastructure dashboard (Homepage) generated from the service registry
lint / yamllint + ansible-lint + syntax-check (push) Canceled after 0s
playbooks/dashboard.yml deploys Homepage as a second compose stack on the
monitoring LXC (CT 155) next to Uptime Kuma and renders its config from
homelab_services: one tile per service, link to its UI, grouped by Proxmox
node. Adding a service to the registry is enough — no second service list.

- new registry consumer: playbooks/dashboard.yml + playbooks/templates/homepage-*.j2
- homelab_dashboard_* vars in group_vars/all/services.yml (top-level, like
  homelab_reverse_proxy_*); image pinned by digest, floating tag needs an
  explicit -e dashboard_allow_floating_tag=true
- bootstrap-dashboard-pve-token.yml: read-only homepage@pve!dashboard token
  (PVEAuditor) for the Proxmox widget, secret in the root .env as DASHBOARD_PVE_*
- Makefile: dashboard, dry-dashboard, bootstrap-dashboard-token
- container binds the LAN address only (192.168.1.30:8082), not published via Caddy
- docs: architecture.md Monitoring section, plan.md active task, consumer lists

Deployed to CT 155 on 2026-09-03: container healthy, http://192.168.1.30:8082/
returns 200, `make dashboard` idempotent, `make validate` and `make lint` green.
Pending operator steps: `make bootstrap-dashboard-token` (blocked in the agent
session as credential creation) and an Uptime Kuma status page with slug homelab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbuZrUoevfBgCpf5DCF4DG
2026-09-03 09:17:16 +03:00
DmitryandClaude Sonnet 5 ca48ef2696 fix: gate the remaining migrated creation plays behind provisioner != tofu
pve-emergency-bot.yml, pve-monitoring.yml and pve-docker-test.yml still ran
their creation plays unconditionally. Invoking make deploy-<svc> would pct
start / pct reboot the stopped OLD VMID (148/146/145) on its production IP,
colliding with the live tofu-managed container.

Add the same pre_tasks `meta: end_play` guard the other seven migrated services
already carry: skip while homelab_services['<svc>'].provisioner == 'tofu',
override with -e pve_<svc>_legacy_provisioning_enabled=true for an intentional
legacy rollback. Verified with --check: both plays in each file end immediately,
no pct calls.

Config for these three lives elsewhere (emergency-access.yml, uptime-kuma.yml,
pve-docker-test.yml play 2), so gating the creation plays makes the files
full no-ops under tofu, like pve-gyro.yml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:18:53 +03:00
DmitryandClaude Sonnet 5 a23e944756 docs(plan): record onboot fix on OLD containers and the commit layout
- Note that onboot: 0 was set on all 11 stopped OLD/decommissioned containers
  (132/140-150 range) so a node reboot cannot start them into an IP conflict
  with the live NEW containers. Reversible; superseded by pct destroy in a week.
- Note the working tree was split into 11 topical commits (22394cb..d2e1e68).
- Restate that the section 6 cleanup (drop roles/pve_lxc, strip creation plays,
  extend validate.yml, refresh architecture.md/legacy-warning.md) is a separate
  task, now unblocked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:15:13 +03:00
DmitryandClaude Sonnet 5 d2e1e6876a docs: AI project context (docs/ai) and repository documentation refresh
- docs/ai/: stable, repo-verified context - README, architecture, tech-stack,
  edge-cases, plan (confirmed active work only), migration-tofu (the blue-green
  OpenTofu migration runbook and per-service findings), legacy-warning, links.
- AGENTS.md: slimmed to a working contract that points at docs/ai instead of
  restating it; CLAUDE.md is an adapter that @-includes it.
- README.md, ansible/README.md, ansible/roles/README.md,
  roles/lxc_docker_host/README.md: bring wording in line with the current
  control plane (Makefile entry point, registry, tofu, memoir-bot gone).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:06:10 +03:00
DmitryandClaude Sonnet 5 5e27ba2513 fix: update playbooks read the backup VMID from the registry; drop client-side prune
The *-update.yml playbooks hard-coded the pre-migration VMIDs, so after cutover
the "safety backup" ran against the stopped OLD container. Now the VMID comes
from homelab_services['<svc>'].vmid.

Also removes `--prune-backups keep-all=1` from the vzdump calls: retention is
PBS's job (prune-pbs), and the client-side flag needed Datastore.Modify/Prune
the ansible@pve token does not have, which made vzdump print "Backup ... failed"
and exit non-zero after a successful upload.

gitea-update.yml additionally splits the offsite backup (hosts: gitea) from its
audit (hosts: cloud-pc), matching the profile move into the container.
pve-*.yml imports pass pve_provisioning_enabled: false so the runtime update
path never re-enters container creation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:05:56 +03:00
DmitryandClaude Sonnet 5 a3fe8031fe feat: migrate all managed LXC provisioning to OpenTofu (blue-green)
Blue-green: a new container is created beside the old one, data is copied, the
IP is moved onto it, and the old container is kept stopped as rollback for at
least a week. Keeping the IP means only the VMID changes, and its consumers
(backup jobs, backup audit) already derive it from the registry.

Batch 1 (2026-09-02): emergency-bot 148->151, docker-test 145->152,
gitea 141->153, vaultwarden 140->154, monitoring 146->155, gyro 150->156,
grimmory 149->157.
Batch 2 (2026-09-03): adguard 144->158, mihomo 143->159, ovpn-mini 132->160.
All migratable LXC are now provisioner: tofu. hermes-ai (frozen) and pbs stay.

- tofu/services.tf + tofu/svc-*.tf: one resource per service, reproducing the
  pct-config etalon. /dev/fuse -> features.fuse; /dev/net/tun ->
  device_passthrough (first live use on mihomo and ovpn-mini); gitea bind mount
  -> datastore volume (data finally reaches PBS); console { type = "shell" }
  declared explicitly (provider tracks cmode there).
- services.yml: vmid + provisioner: tofu for every migrated service; features
  strings and device notes updated to the tofu representation; also drops the
  memoir-bot entry and adds homelab_reverse_proxy_image/_unit.
- pve-*.yml: configuration play target is `{{ pve_config_target | default(...) }}`
  so it can run against <name>-new on a temp address (a bare --limit zeroes the
  play instead of retargeting it). Container-creation plays are gated behind
  `provisioner != 'tofu'` / `pve_provisioning_enabled` (meta: end_play), so a
  stray run cannot pct start a stopped OLD VMID on a live IP. Override for
  intentional legacy rollback: -e pve_<svc>_legacy_provisioning_enabled=true.
- ssh_config: drop memoir-bot; ovpn-mini gets ProxyJump none (a jump via ru-vps
  would route through the very tunnel ovpn-mini terminates).
- gyro.yml / uptime-kuma.yml: same pve_config_target override.
- roles/uptime_kuma: only freeze homelab-monitoring when the unit actually
  exists (a fresh blue-green container never had it).
- offsite-restic-yadisk.yml: the gitea restic profile now runs inside the LXC
  (hosts: gitea), since the bind-mount host path is gone after the volume move;
  lost+found excluded (unreadable in an unprivileged LXC, restic exit 3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:05:46 +03:00
DmitryandClaude Sonnet 5 e349f19e68 feat: remove storage-level PBS prune, keep retention in PBS
playbooks/pve-storage-pbs.yml: drop the prune-backups policy from the PVE
storage entry `pbs` so retention authority lives only in the PBS prune job
(prune-pbs). The weekly local PBS-container backup on storage `backup`
(keep-last=2) is an intentional exception and left alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:05:17 +03:00
DmitryandClaude Sonnet 5 79878e36f9 feat: adopt the ru-vps Caddy stack, fix cluster quorum, decommission ZeroTier
One ru-vps housekeeping sweep (2026-09-02/03):

- playbooks/ru-vps-base.yml (new): adopt the Caddy compose stack into Ansible
  (pinned image by digest, homelab-caddy.service), and manage the corosync-qnetd
  UFW rule - allow 5403/tcp from homelab_pve_egress_ip, drop the stale rule for
  the retired ZeroTier 10.122.62.0/24. The qdevice had gone silent because its
  only allowed path was the decommissioned ZeroTier network.
- group_vars/all/main.yml: homelab_pve_egress_ip (the NATed home egress the PVE
  nodes reach corosync-qnetd from - a direct path that does not depend on the
  OpenVPN tunnel). Marked dynamic: a change silently re-breaks the qdevice.
- playbooks/status.yml: CLUSTER QUORUM section (pvecm status per PVE node) so a
  repeat failure is visible. Also drops the memoir-bot unit list and moves the
  gitea offsite-restic unit to the gitea host (see the OpenTofu-migration commit).
- playbooks/ru-vps-zerotier-decommission.yml (new): stop the zerotier container,
  disable ssh-zt22.service, remove the interface/9993/9001/10.122.62.0/24 UFW
  rules. Node identity and data are kept; removal is a separate step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:05:11 +03:00
DmitryandClaude Sonnet 5 e24b75534e feat: decommission memoir-bot (CT 142)
Service was unused. Live steps (revoke SecondBrain deploy key, drop the Uptime
Kuma monitor, pct stop 142) were done 2026-09-02; pct destroy is deliberately
deferred a week.

- delete playbooks/pve-memoir-bot.yml.
- hosts.yml: drop the memoir-bot host and its monitoring_exporters entry.
- roles/monitoring_server/templates/prometheus.yml.j2: drop 192.168.1.26 target.
- roles/uptime_kuma/defaults: drop 192.168.1.26 from no_proxy.
- roles/lxc_docker_host/defaults: drop the memoir-bot extra-packages comment.

The registry entry, the ssh_config Host block and the status.yml unit list are
removed in the commits that also carry OpenTofu-migration / quorum changes to
those files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:04:45 +03:00
DmitryandClaude Sonnet 5 baef2c49b3 feat: derive PBS backup jobs and audit VMIDs from the service registry
- pve-backup-jobs.yml: each job's vmid list is now computed from
  homelab_services by backup.job instead of a hand-maintained CSV. Adding a
  service no longer needs a separate edit here (the forgotten-edit failure
  mode that left CT 148 emergency-bot without a backup).
- roles/backup_audit/defaults: backup_audit_pbs_vmids derived from
  homelab_services by the monitoring.backup_audit_vmid flag; single shared
  freshness threshold backup_audit_pbs_max_age_hours (48).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:04:36 +03:00
DmitryandClaude Sonnet 5 7031d7dbb3 feat: read-only service-registry to Proxmox drift gate
playbooks/validate.yml (make validate): reads pct config for every service in
homelab_services and fails if hostname, IP, cores, memory or swap disagree
with the registry. Read-only; meant as a pre/post gate around any inventory or
provisioning change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:04:29 +03:00
DmitryandClaude Sonnet 5 646f2bbc8f feat(tofu): OpenTofu provisioning scaffold, auth modes and pilot notes
- providers.tf / variables.tf / versions.tf: bpg/proxmox ~> 0.84, endpoint and
  credentials from TF_VAR_* (set by the Makefile tofu-* targets from the
  repo-root .env). Two auth modes: root@pam by password (privileged: features
  beyond nesting, device passthrough, datastore mount points) or the
  ansible@pve token.
- README.md: pilot results on VMID 199 - what the token can and cannot do,
  why a root token still fails the literal `$authuser eq 'root@pam'` check,
  the cmode/console drift finding, and the chosen root@pam-by-password mode.
- pilot.tf.example: reference resource shape (features, device_passthrough,
  mount_point), not loaded (.example).
- .terraform.lock.hcl: pin the provider.

State has no backend yet; tofu/*.tfstate stays local and git-ignored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:04:23 +03:00
DmitryandClaude Sonnet 5 e0c53a1b1b chore: relocate .env to the repository root and refresh Make targets
.env is now consumed by both Ansible and OpenTofu, so keep a single copy at
the repo root instead of ansible/.env:

- rename ansible/.env.example -> .env.example.
- Makefile: ENV_FILE ?= $(REPO_ROOT)/.env (absolute, works from any cwd);
  REQUIRE_ENV/LOAD_ENV updated; help text.
- bootstrap-pve-api-token.yml / bootstrap-monitoring-pve-token.yml write and
  read ../../.env; bootstrap now keeps backup: true (it rewrites the whole
  file, clobbering MONITORING_*/EMERGENCY_*/PROXMOX_ROOT_PASSWORD).
- roles/pve_lxc, roles/monitoring_server: fail_msg points at the repo-root .env.

The Makefile also picks up the new targets added by later commits
(tofu-*, validate, pbs-storage, ru-vps-base, zerotier-decommission); they are
grouped here so all recipes land together.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:04:12 +03:00
DmitryandClaude Sonnet 5 22394cbaba build: add OpenTofu to the dev shell and ignore its state
- flake.nix: add pkgs.opentofu to the devshell (LXC provisioning pilot).
- .gitignore: ignore tofu/.terraform/, *.tfstate*, *.tfplan and tofu/.env.
  The provider lock file (tofu/.terraform.lock.hcl) stays tracked on purpose:
  it pins the provider version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:03:52 +03:00
DmitryandClaude Opus 5 7700ed5a88 Sync documentation with the actual infrastructure
lint / yamllint + ansible-lint + syntax-check (push) Canceled after 0s
The host table listed 9 hosts against 16 in the inventory, the role tree
did not match roles/, and the documented setup path used a venv that no
longer works.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
2026-08-26 21:39:28 +03:00
190 changed files with 17797 additions and 275 deletions
+85
View File
@@ -0,0 +1,85 @@
---
# ansible-lint для HomeLab infras.
#
# Profile: moderate.
# - `production` / `safety` дали бы сотни нарушений на текущем коде (fqcn,
# jinja[spacing], no-handler, key-order, галактические метаданные ролей) —
# линтер стал бы шумом, который все игнорируют.
# - Фактическая проверка: на этом репозитории `basic` и `moderate` дают
# ровно один и тот же набор нарушений (все сработавшие правила помечены
# profile:basic). То есть `moderate` сегодня ничего не стоит, но держит
# планку выше для нового кода. Отсюда выбор.
# - Правила корректности и безопасности (syntax-check, risky-file-permissions,
# risky-shell-pipe, risky-octal, no-changed-when, no-free-form, deprecated-*,
# jinja[invalid], sanity) НЕ отключены — они остаются fatal.
profile: moderate
exclude_paths:
- archive/ # исторические NixOS/docker-compose/ansible конфиги, read-only
- ansible/.venv/ # gitignored, локальное venv
- ansible/collections/ # gitignored, установленные galaxy-коллекции
- ansible/generated/ # gitignored артефакты
- .opencode/ # конфиги агентов + node_modules
- tools/ # tools/grimmory-mcp — JS, не ansible
- node_modules/
- .direnv/
- .git/
# Зашифрованный ansible-vault: линтер не может его расшифровать и шумит
# предупреждениями о Decryption failed.
- ansible/inventory/host_vars/gyro/vault.yml
# Явно указываем, что считать плейбуками/тасками — иначе ansible-lint
# принимает inventory/*.yml и roles/*/files/*.yml за плейбуки.
kinds:
- playbook: 'ansible/playbooks/*.yml'
- tasks: 'ansible/tasks/*.yml'
# ------------------------------------------------------------------
# warn_list — правила, которые СЕЙЧАС массово срабатывают на легаси-коде.
# Они видны в выводе как warning, но не роняют CI. Это осознанный
# «нулевой baseline»: CI зелёный, долг виден.
# По каждому пункту — почему и стоит ли возвращать в fatal.
# ------------------------------------------------------------------
warn_list:
# 40 срабатываний. Роли используют осмысленные кросс-ролевые префиксы
# (openvpn_*, monitoring_*, emergency_*), а не имя роли. Переименование
# затронет inventory, host_vars и все плейбуки разом.
# ВЕРНУТЬ В FATAL: после разового переименования переменных ролей.
- var-naming[no-role-prefix]
# 19 срабатываний. Имена задач в нижнем регистре ("restart gitea lxc").
# Чисто косметика, на поведение не влияет.
# ВЕРНУТЬ В FATAL: после массового причёсывания имён (дешёвый разовый PR).
- name[casing]
# 18 срабатываний. Часть файлов без "---" в начале.
# ВЕРНУТЬ В FATAL: тривиально чинится, но затрагивает 18 файлов.
- yaml[document-start]
# 6 срабатываний. Все — верификационные команды с changed_when: false
# (curl для проверки HTTPS-эндпоинта, systemctl is-active, git config
# внутри чужого чекаута, docker exec caddy validate). Замена на
# uri/systemd/git-модули здесь не улучшает код, а иногда невозможна
# (команда исполняется внутри pct/docker exec).
# ВЕРНУТЬ В FATAL: вряд ли — правило по сути false-positive для этого стиля.
- command-instead-of-module
# 5 срабатываний: безымянные `- import_playbook:` записи в *-update.yml.
# 1 срабатывание: безымянный `- block:` в ru-vps-mihomo-harden.yml.
# Влияет только на читаемость вывода ansible-playbook.
# ВЕРНУТЬ В FATAL: да, после того как проставят name (мелкий PR).
- name[play]
- name[missing]
# 1 срабатывание: emergency_access/tasks/client.yml:46 — become_user без
# become. Это, вероятно, НАСТОЯЩИЙ баг (ключ создаётся не тем пользователем),
# но чинить его — задача не линтера. Держим в warn_list, чтобы CI не был
# красным с первого дня; ВЕРНУТЬ В FATAL сразу после фикса.
- partial-become
# skip_list пуст намеренно: ничего не отключаем полностью, всё либо fatal,
# либо видимый warning.
skip_list: []
use_default_rules: true
+25
View File
@@ -0,0 +1,25 @@
export PROXMOX_HOST=192.168.1.10
export PROXMOX_USER='ansible@pve'
export PROXMOX_TOKEN_ID='homelab'
export PROXMOX_TOKEN_SECRET='replace-me'
export PROXMOX_VALIDATE_CERTS=false
# Optional local SSH-forward port when the control node reaches PVE through ru-vps.
# export PROXMOX_PORT=18006
# Override if the downloaded template name differs.
export PVE_LXC_OSTEMPLATE='local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst'
# Monitoring secrets. Keep actual values in ignored ansible/.env or Ansible Vault.
export MONITORING_TELEGRAM_BOT_TOKEN='replace-me'
export MONITORING_TELEGRAM_CHAT_ID='replace-me'
export MONITORING_GRAFANA_ADMIN_PASSWORD='replace-me'
export MONITORING_PVE_API_USER='monitoring@pve'
export MONITORING_PVE_API_TOKEN_ID='prometheus'
export MONITORING_PVE_API_TOKEN_SECRET='replace-me'
# Emergency reverse SSH Telegram bot. Keep actual values in ignored ansible/.env
# or Ansible Vault. Host keys are pinned public keys in the form "ssh-ed25519 AAAA...".
export EMERGENCY_BOT_TOKEN='replace-me'
export EMERGENCY_ALLOWED_USER_IDS='replace-me'
export EMERGENCY_VPS_HOST_KEY='ssh-ed25519 replace-me'
export EMERGENCY_MINI_PC_HOST_KEY='ssh-ed25519 replace-me'
+3
View File
@@ -0,0 +1,3 @@
# direnv: load the Nix dev shell defined in flake.nix
# Enable once per checkout with: direnv allow
use flake
+134
View File
@@ -0,0 +1,134 @@
---
# Статические проверки Ansible-кода HomeLab infras.
#
# ГДЕ ЭТО ДОЛЖНО ВЫПОЛНЯТЬСЯ
# --------------------------
# Gitea живёт на LXC `gitea` (192.168.1.25). Gitea Actions по умолчанию
# ВЫКЛЮЧЕНЫ и не имеют ни одного раннера — этот workflow не запустится,
# пока раннер не зарегистрирован ОТДЕЛЬНО, вручную:
#
# 1. Включить Actions в Gitea:
# app.ini -> [actions] ENABLED = true
# и в настройках репозитория: Settings -> Actions -> Enable.
#
# 2. Поднять act_runner. Подходящий хост — LXC `docker-test`
# (192.168.1.29): там уже есть Docker, а сборка контейнеров раннера
# не мешает проду. Ставить раннер на сам LXC `gitea` не стоит —
# CI-нагрузка не должна валить git-сервис.
#
# 3. Зарегистрировать раннер (на docker-test):
# act_runner register --no-interactive \
# --instance http://192.168.1.25:3000 \
# --token <RUNNER_TOKEN из Gitea Settings -> Actions -> Runners> \
# --name docker-test-runner \
# --labels ubuntu-latest:docker://catthehacker/ubuntu:act-latest
#
# Метка `ubuntu-latest` обязательна — именно её просит `runs-on` ниже.
#
# 4. Раннеру нужен исходящий интернет (PyPI + Ansible Galaxy).
# На docker-test трафик может идти через mihomo/OpenVPN — проверить,
# что pip и galaxy резолвятся, иначе шаг установки упадёт.
#
# Регистрация раннера НЕ автоматизирована этим репозиторием: она требует
# одноразового токена из веб-интерфейса Gitea.
#
# Локально те же проверки воспроизводятся через nix:
# nix develop -c yamllint .
# nix develop -c ansible-lint
# nix develop -c sh -c 'cd ansible && for f in playbooks/*.yml; do ansible-playbook --syntax-check "$f"; done'
name: lint
on:
push:
pull_request:
jobs:
lint:
name: yamllint + ansible-lint + syntax-check
runs-on: ubuntu-latest
env:
# ansible.cfg лежит в ansible/ и использует ОТНОСИТЕЛЬНЫЕ пути
# (roles_path = roles). Из корня репозитория он не работает, поэтому
# пути задаются абсолютно через окружение. Без этого ansible-lint
# выдаёт 12 ложных syntax-check[specific] «role not found».
ANSIBLE_ROLES_PATH: ${{ github.workspace }}/ansible/roles
ANSIBLE_COLLECTIONS_PATH: ${{ github.workspace }}/ansible/collections
ANSIBLE_INVENTORY: ${{ github.workspace }}/ansible/inventory/hosts.yml
# Ansible шумит депрекейшенами ядра — в CI они не наши.
ANSIBLE_DEPRECATION_WARNINGS: "false"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
steps:
- name: Checkout
uses: actions/checkout@v4
# Образ catthehacker/ubuntu:act-latest уже несёт python3, но не всегда
# python3-venv. Ставим явно, чтобы шаг не был хрупким.
- name: Ensure python3 + venv
run: |
set -eux
if ! command -v python3 >/dev/null 2>&1 || ! python3 -m venv --help >/dev/null 2>&1; then
apt-get update
apt-get install -y --no-install-recommends python3 python3-venv python3-pip
fi
python3 --version
- name: Install ansible-core, ansible-lint, yamllint
run: |
set -eux
python3 -m venv /tmp/lintenv
. /tmp/lintenv/bin/activate
python3 -m pip install --upgrade pip
# ansible-core/proxmoxer/requests берём из репозитория, чтобы CI и
# локальное окружение не разъезжались.
python3 -m pip install -r ansible/requirements.txt
# Линтеры пинуем: обновление ansible-lint регулярно добавляет новые
# правила и красит CI без единого коммита в инфраструктуру.
python3 -m pip install 'ansible-lint==25.8.2' 'yamllint==1.37.1'
echo "/tmp/lintenv/bin" >> "$GITHUB_PATH"
- name: Install Galaxy collections
run: |
set -eux
ansible-galaxy collection install \
-r ansible/requirements.yml \
-p ansible/collections
- name: Versions
run: |
set -eux
ansible --version | head -n1
ansible-lint --version
yamllint --version
# Конфиг в /.yamllint. Падает только на ошибках (табы, дубли ключей,
# битый YAML); стилевые замечания идут как warning и CI не роняют.
- name: yamllint
run: yamllint -f standard .
# Конфиг в /.ansible-lint, profile: moderate.
- name: ansible-lint
run: ansible-lint
# syntax-check запускается ИЗ ansible/, иначе ansible.cfg с
# относительными roles_path не подхватывается и 13 плейбуков падают
# с «role not found». Переменные Proxmox (PROXMOX_*) для syntax-check
# НЕ нужны: `lookup('env', ...)` на этапе парсинга не вычисляется,
# проверено — все 37 плейбуков проходят с пустым окружением.
- name: ansible-playbook --syntax-check (все плейбуки)
working-directory: ansible
run: |
set -u
rc=0
for f in playbooks/*.yml; do
if ansible-playbook --syntax-check "$f" >/tmp/sc.log 2>&1; then
echo "ok $f"
else
rc=1
echo "FAIL $f"
sed 's/^/ /' /tmp/sc.log
fi
done
exit "$rc"
+29
View File
@@ -6,3 +6,32 @@ 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-*
# direnv shell hook lives in the repo (global gitignore hides it)
!.envrc
# Ansible local runtime dir (facts cache, locks)
.ansible/
# OpenTofu: state содержит фактическую конфигурацию гостей и потому не
# коммитится. Лок-файл провайдера (.terraform.lock.hcl) наоборот нужен в Git —
# он закрепляет версию, поэтому под исключение не попадает.
tofu/.terraform/
tofu/*.tfstate
tofu/*.tfstate.*
tofu/*.tfplan
tofu/.env
@@ -0,0 +1,19 @@
---
description: Reviews Ansible playbooks and roles for unsafe, non-idempotent, or secret-exposing changes before infrastructure execution.
mode: all
model: openai/gpt-5.5
permission:
read: allow
glob: allow
grep: allow
edit: deny
bash: deny
task: deny
todowrite: deny
skill: deny
---
You are a read-only Ansible safety reviewer for HomeLab infrastructure.
Review the requested files or git diff for destructive operations, unsafe shell commands, missing idempotency, incorrect `changed_when` or `failed_when`, missing `no_log` around secrets, excessive privilege, broad host targeting, risky handlers, firewall/network exposure, and absent validation. Respect repository instructions and distinguish definite defects from risks.
Return findings first, ordered by severity, with `path:line`, impact, and a minimal remediation. State explicitly when no findings are identified. Do not edit files or run commands.
+24
View File
@@ -0,0 +1,24 @@
---
description: Validates Ansible inventory, YAML, and playbook syntax without changing infrastructure. Use after Ansible configuration changes.
mode: all
model: openai/gpt-5.5
permission:
read: allow
glob: allow
grep: allow
edit: deny
bash:
"*": deny
"ansible-inventory *": allow
"ansible-playbook * --syntax-check": allow
"ansible-lint *": allow
"yamllint *": allow
task: deny
todowrite: deny
skill: deny
---
You validate Ansible changes using the smallest safe local checks.
Never run a playbook against remote hosts, use `-K`, load `.env`, or run commands that can change infrastructure. Inspect the changed files and project guidance first, then select only relevant validation commands.
Report commands run, pass/fail status, exact failures with paths, checks intentionally not run, and the smallest next action. Do not edit files.
+24
View File
@@ -0,0 +1,24 @@
---
description: Analyzes PBS, restic, and systemd backup-audit output to assess backup freshness and identify failures without changing backup configuration.
mode: all
model: openai/gpt-5.5
permission:
read: allow
glob: allow
grep: allow
edit: deny
bash:
"*": deny
"systemctl status *": ask
"journalctl *": ask
"proxmox-backup-client *": ask
"restic *": ask
task: deny
todowrite: deny
skill: deny
---
You diagnose backup freshness and failures from provided output or explicitly approved read-only backup commands.
Understand the active `backup_audit` role before interpreting results. Do not change schedules, repositories, retention, credentials, timers, services, or backup state. Never reveal secrets.
Return backup status by target, the newest verified backup timestamp when available, root cause evidence, uncertainty, and one safe next check. Keep raw-log quotations minimal.
@@ -0,0 +1,19 @@
---
description: Compresses large Ansible, systemd, Docker, and command outputs into structured failures, changed hosts, and safe next steps.
mode: all
model: openai/gpt-5.5
permission:
read: allow
glob: allow
grep: allow
edit: deny
bash: deny
task: deny
todowrite: deny
skill: deny
---
You summarize supplied command output without executing commands or editing files.
Extract successful and failed hosts, changed tasks, warnings, error signatures, probable root cause, relevant output fragments, and the smallest safe next check. Do not repeat routine output. Redact or omit tokens, keys, passwords, URLs with credentials, and `.env` values.
Use this format: Summary, Failures, Changed State, Evidence, Safe Next Step, Unknowns.
+23
View File
@@ -0,0 +1,23 @@
---
description: Reviews the current Git diff for HomeLab infrastructure regressions, missing documentation, and overly broad changes before execution or commit.
mode: all
model: openai/gpt-5.5
permission:
read: allow
glob: allow
grep: allow
edit: deny
bash:
"*": deny
"git status *": allow
"git diff *": allow
"git log *": allow
task: deny
todowrite: deny
skill: deny
---
You perform a read-only review of the working-tree or staged Git diff in this HomeLab repository.
Follow AGENTS.md and compare changes to relevant inventory, roles, playbooks, and Obsidian documentation when needed. Identify behavioral regressions, overly broad targeting, secret exposure, missing documentation, and missing safe checks. Do not modify files, stage changes, or commit.
Return findings first by severity with `path:line`, then residual risks and a short change summary. Say explicitly if no findings are found.
+201
View File
@@ -0,0 +1,201 @@
---
description: >-
Use this agent when you need read-only reconnaissance of a HomeLab repository
before making infrastructure changes. Use it to navigate the codebase, locate
relevant Ansible inventory variables, host_vars/group_vars, playbooks, roles,
tasks, templates, handlers, dependencies, and Obsidian documentation; identify
existing patterns and constraints; determine the minimal set of files that
should be changed by a later implementation agent; or verify configuration
consistency without modifying files or infrastructure.
<example>
Context: The user wants to add a new backup setting for a HomeLab service, but
the relevant Ansible role and variables are unknown.
user: "Нужно добавить настройку retention для backups в HomeLab. Сначала
разберись, где это правильно делать."
assistant: "Сначала запущу homelab-recon agent для read-only анализа
репозитория и поиска существующих паттернов конфигурации."
<commentary>
Since the user needs to find the correct place for a new infrastructure
setting before changes, use the Task/Agent tool to launch the homelab-recon
agent. The agent should inspect inventory, vars, roles, templates, handlers,
dependencies, and docs without editing anything.
</commentary>
</example>
<example>
Context: The assistant has just completed a logical chunk of planning for an
Ansible change and needs to validate that it matches the current repository
structure.
user: "Проверь, не пропустили ли мы какие-то group_vars или шаблоны для
nginx."
assistant: "Запущу homelab-recon agent, чтобы read-only проверить связанные
vars, templates, роли, handlers и документацию по nginx."
<commentary>
Since the user asks for consistency/navigation across existing HomeLab
configuration, use the Task/Agent tool to launch the homelab-recon agent
rather than making assumptions from memory.
</commentary>
</example>
<example>
Context: The user asks where to implement a new monitoring exporter in the
HomeLab repo.
user: "Куда лучше добавить node exporter для нового хоста?"
assistant: "Использую homelab-recon agent для исследования inventory,
host_vars/group_vars, ролей мониторинга и Obsidian-документации перед
предложением места изменения."
<commentary>
Since the task is to locate the appropriate implementation point and
understand existing patterns, use the Task/Agent tool to launch the
homelab-recon agent in read-only mode.
</commentary>
</example>
mode: all
model: openai/gpt-5.5
permission:
read: allow
glob: allow
grep: allow
bash: deny
edit: deny
webfetch: deny
task: deny
todowrite: deny
websearch: deny
lsp: deny
skill: deny
external_directory:
"*": deny
"/home/ada/Documents/Vaults/SecondBrain/02 Projects/HomeLab/**": allow
---
You are a senior HomeLab infrastructure reconnaissance specialist focused on read-only analysis of Ansible-based repositories and adjacent Obsidian documentation. Your mission is to investigate the current implementation before any change is made, identify the existing patterns and constraints, and provide a precise map of the minimal files that a later implementation step would need to modify.
You operate strictly in read-only mode.
Core responsibilities:
1. Locate relevant Ansible configuration:
- inventories and inventory variables
- host_vars and group_vars
- playbooks and included playbooks
- roles, defaults, vars, tasks, templates, files, handlers, meta dependencies
- collections, requirements files, plugins, filters, and lookup usage when relevant
- service-specific configuration files and generated artifacts referenced by Ansible
2. Locate and use supporting documentation:
- Obsidian notes, markdown documentation, runbooks, architecture notes, decision records, operational checklists, and service documentation
- README files and inline comments that explain conventions or constraints
3. Reconstruct the current behavior:
- determine how the relevant service, host, group, or infrastructure component is configured today
- trace variable precedence where possible
- identify conditionals, tags, includes, dependencies, handlers, templates, and restart/reload behavior
- identify deployment ordering and cross-role relationships
4. Identify repository patterns:
- naming conventions for hosts, groups, variables, roles, templates, tasks, and tags
- common ways new services/settings are added
- secrets handling conventions, vault usage, and boundaries around sensitive values
- idempotency and handler patterns
- documentation conventions
5. Define the minimal change surface:
- list the smallest set of files that likely need modification for the requested future change
- list files that are relevant for context but should probably not be modified
- call out unknowns or decisions requiring user confirmation
6. Verify consistency:
- check whether related vars/templates/tasks/docs agree with each other
- identify duplicate, stale, conflicting, or shadowed variables
- identify missing documentation or mismatches between docs and implementation
Strict read-only constraints:
- You must not edit, create, delete, rename, format, or write any file.
- You must not run commands that change repository state, infrastructure state, secrets, generated files, caches, lockfiles, or external systems.
- You must not run Ansible playbooks against infrastructure, apply Terraform/OpenTofu, restart services, install dependencies, or execute scripts that may mutate state.
- You may inspect files, search text, list directories, and run clearly read-only commands such as grep/rg/find/ls/cat/sed for viewing, git status/log/diff/show, ansible-inventory --list when safe and local, and syntax-like inspection only if it is clearly non-mutating.
- If a command may be mutating or ambiguous, do not run it. Explain the risk and suggest a safe alternative.
- If repository instructions from CLAUDE.md or similar files define stricter rules, follow those rules.
Investigation workflow:
1. Clarify scope if needed:
- If the target service, host, group, environment, or desired setting is ambiguous, ask a concise clarification question.
- If enough context exists to begin, proceed and state your assumptions.
2. Read project guidance first:
- Look for CLAUDE.md, README files, docs indexes, inventory layout notes, or repository conventions.
- Incorporate those rules into your analysis.
3. Map the repository structure:
- Identify the inventory root, playbook entry points, role directories, documentation directories, and Obsidian vault locations.
- Note nonstandard layout choices.
4. Search broadly, then narrow:
- Search for the service/component name, hostnames, group names, variable prefixes, role names, template names, ports, domains, package names, container names, systemd units, and documentation aliases.
- Follow references from playbooks to roles, from roles to tasks/templates/handlers, and from variables to template usage.
5. Trace configuration flow:
- Determine where defaults are defined, where they are overridden, and where they are consumed.
- Pay special attention to group_vars/host_vars precedence, role defaults versus role vars, include_vars, set_fact, vars_files, extra vars references, and inventory group hierarchy.
6. Analyze dependencies and side effects:
- Identify role dependencies, handlers triggered by template/task changes, service reload/restart behavior, firewall/DNS/reverse-proxy/monitoring/backup interactions, and documentation requirements.
7. Produce a concise but actionable report.
Output format:
Provide your findings in a structured report with these sections:
1. Scope and assumptions
- State what you investigated and any assumptions made.
2. Relevant files and why they matter
- List paths grouped by category: inventory, host/group vars, playbooks, roles/tasks, templates/files, handlers, dependencies, docs.
- For each path, include a short reason it is relevant.
3. Current implementation summary
- Explain how the current configuration works, including variable flow and execution flow.
4. Existing patterns and constraints
- Summarize naming, structure, variable, secrets, handler, template, documentation, and deployment conventions.
5. Consistency findings
- Note conflicts, stale docs, duplicate variables, unclear precedence, missing references, or mismatches.
- If no issues were found, say so explicitly while noting the limits of the inspection.
6. Minimal files for a future change
- Provide a prioritized list of files likely requiring modification.
- Separate "must change", "may need change", and "context only / probably do not change".
7. Open questions and risks
- List decisions that require user confirmation, unresolved ambiguity, and operational risks.
8. Suggested next step
- Recommend what the implementation agent or user should do next, without making changes yourself.
Quality standards:
- Be evidence-driven. Reference concrete file paths and, when useful, specific variable names, role names, task names, or documentation headings.
- Do not overstate certainty. Distinguish confirmed facts from inferred patterns.
- Prefer the minimal viable change surface over broad rewrites.
- Preserve HomeLab safety: avoid recommendations that could accidentally affect unrelated hosts or services.
- Treat secrets carefully: identify where secret values are referenced, but do not print secret contents. If secrets appear in plain text, mention the exposure without repeating the value.
- If documentation conflicts with code, clearly identify both sources and which appears authoritative.
- If you cannot find relevant files, report the searches performed and propose likely next search terms or clarification questions.
Behavioral boundaries:
- You are not an implementation agent. Do not patch files.
- You are not an operations executor. Do not deploy or validate against live infrastructure.
- You are a repository reconnaissance and consistency-analysis agent. Your deliverable is a map, diagnosis, and minimal-change recommendation for subsequent work.
+22
View File
@@ -0,0 +1,22 @@
---
description: Audits HomeLab inventory against Ansible documentation and Obsidian notes to find stale hosts, groups, services, and topology drift.
mode: all
model: openai/gpt-5.5
permission:
read: allow
glob: allow
grep: allow
edit: deny
bash: deny
task: deny
todowrite: deny
skill: deny
external_directory:
"*": deny
"/home/ada/Documents/Vaults/SecondBrain/02 Projects/HomeLab/**": allow
---
You are a read-only consistency auditor for HomeLab.
Compare the canonical Ansible inventory with active playbooks, repository README files, and the HomeLab Obsidian vault. Check host names, groups, IPs, service assignments, topology, and operational instructions. Treat active Ansible configuration as authoritative unless the repository says otherwise; do not use the archive as an active source of truth.
Return confirmed mismatches with both sources and paths, suspected drift separately, the minimal files to update, and any ambiguity. Do not edit files or infrastructure.
+23
View File
@@ -0,0 +1,23 @@
---
description: Diagnoses bounded Ansible, systemd, Docker, and service logs to identify root causes and safe next checks without changing systems.
mode: all
model: openai/gpt-5.5
permission:
read: allow
glob: allow
grep: allow
edit: deny
bash:
"*": deny
"journalctl *": ask
"systemctl status *": ask
"docker logs *": ask
task: deny
todowrite: deny
skill: deny
---
You are a read-only operations diagnostician. Analyze provided logs or run only explicitly approved, bounded diagnostic commands.
For logs, use time and line limits such as `--since`, `-n`, and `--tail`. Do not restart, install, reconfigure, or contact services in a way that changes state. Never expose secrets.
Return Summary, most likely root cause, evidence with timestamps, alternative hypotheses, and one smallest safe next check. Avoid listing every log line.
+27
View File
@@ -0,0 +1,27 @@
---
description: Diagnoses HomeLab OpenVPN, ProxyJump, DNS, routing, and service reachability from configuration and approved read-only diagnostics.
mode: all
model: openai/gpt-5.5
permission:
read: allow
glob: allow
grep: allow
edit: deny
bash:
"*": deny
"ip route *": ask
"ip addr *": ask
"ss *": ask
"ping *": ask
"nc *": ask
"getent hosts *": ask
"journalctl *": ask
task: deny
todowrite: deny
skill: deny
---
You diagnose HomeLab network issues using active Ansible configuration, documentation, and explicitly approved read-only diagnostics.
Trace the expected path through OpenVPN, the JumpHost, routes, DNS, firewall ports, and target services. Do not modify interfaces, firewall rules, VPN configuration, routes, DNS, or remote hosts. Do not expose credentials.
Return expected path, observed break point, evidence, likely root cause, and the smallest safe next check.
@@ -0,0 +1,22 @@
---
description: Reads relevant HomeLab Obsidian notes before infrastructure work and extracts decisions, constraints, topology, and recent operational context.
mode: all
model: openai/gpt-5.5
permission:
read: allow
glob: allow
grep: allow
edit: deny
bash: deny
task: deny
todowrite: deny
skill: deny
external_directory:
"*": deny
"/home/ada/Documents/Vaults/SecondBrain/02 Projects/HomeLab/**": allow
---
You are a read-only documentation researcher for HomeLab infrastructure.
Find only the notes relevant to the requested service or change. Extract confirmed decisions, current topology, operational constraints, recent changes, and documentation that should be updated afterward. Cross-check any important claim with active Ansible configuration when it is available. Do not edit notes, repository files, or infrastructure.
Return a concise report with sources, confirmed constraints, potential documentation drift, and open questions.
+69
View File
@@ -0,0 +1,69 @@
---
# yamllint для HomeLab infras.
# Цель: ловить реальные поломки YAML (табы, дубли ключей, битые отступы,
# незакрытые кавычки), а не навязывать стиль. Всё, что даёт шум на живом
# Ansible-коде, ослаблено осознанно — см. комментарии.
extends: default
# gitignore-style пути, которые линтить не нужно.
ignore: |
/ansible/.venv/
/ansible/collections/
/ansible/generated/
/archive/
/.opencode/
/tools/
node_modules/
/ansible/inventory/host_vars/*/vault.yml
/.direnv/
/result
/result-*
rules:
# Длинные строки — норма: pct/docker/curl-команды в one-line shell,
# длинные URL, ZeroTier/OpenVPN-конфиги. Перенос сделал бы их менее читаемыми.
line-length: disable
# Ansible исторически допускает yes/no наравне с true/false, и часть репо
# написана так. Не ошибка — не мешаем.
truthy:
allowed-values: ['true', 'false', 'yes', 'no']
check-keys: false
# Комментарии вида "#comment" и inline-комментарии на одном пробеле
# встречаются часто и ни на что не влияют.
comments:
require-starting-space: true
min-spaces-from-content: 1
comments-indentation: disable
# Ansible-стиль: списки под ключом с отступом — вопрос вкуса, обе формы валидны.
indentation:
spaces: 2
indent-sequences: consistent
check-multi-line-strings: false
# Пустая строка в конце файла обязательна (реальные diff-артефакты),
# но лишние пробелы в конце строк — только предупреждение.
trailing-spaces: enable
new-line-at-end-of-file: enable
# Ошибки, которые ломают парсинг или молча меняют смысл — строго.
key-duplicates: enable
octal-values:
forbid-implicit-octal: true
forbid-explicit-octal: true
# "---" в начале файла — полезная конвенция Ansible, но не критично.
document-start:
level: warning
# braces/brackets: Jinja-выражения в inline-словарях часто дают ложные
# срабатывания на пробелах внутри {{ }}.
braces:
min-spaces-inside: 0
max-spaces-inside: 1
brackets:
min-spaces-inside: 0
max-spaces-inside: 1
+155
View File
@@ -0,0 +1,155 @@
# AGENTS.md
## Назначение
HomeLab infras - Ansible-first control plane для домашней инфраструктуры на
Proxmox VE. Желаемое состояние хранится в inventory, vars, roles и playbooks;
прямые изменения на серверах допустимы только для read-only диагностики или
break-glass восстановления и затем должны быть отражены в Ansible.
Стабильный контекст проекта находится в [`docs/ai/`](docs/ai/README.md). Не
загружай весь набор по умолчанию: читай только документы, относящиеся к задаче.
## Что читать
| Задача | Контекст |
|---|---|
| Любое изменение | `README.md`, [`docs/ai/README.md`](docs/ai/README.md), релевантная секция [`architecture.md`](docs/ai/architecture.md) |
| Inventory или новый service | `ansible/inventory/hosts.yml`, `ansible/inventory/group_vars/all/services.yml`, [`architecture.md`](docs/ai/architecture.md), [`edge-cases.md`](docs/ai/edge-cases.md) |
| Provisioning или update | `ansible/README.md`, relevant playbook/role, [`edge-cases.md`](docs/ai/edge-cases.md), [`legacy-warning.md`](docs/ai/legacy-warning.md) |
| Network, SSH, OpenVPN, Caddy | `ansible/ssh_config`, `ansible/inventory/group_vars/all/main.yml`, [`architecture.md`](docs/ai/architecture.md), [`links.md`](docs/ai/links.md) |
| Backups и recovery | `ansible/playbooks/pve-backup-jobs.yml`, `ansible/roles/backup_audit/`, [`architecture.md`](docs/ai/architecture.md), [`edge-cases.md`](docs/ai/edge-cases.md) |
| Grimmory MCP | `tools/grimmory-mcp/README.md`, [`tech-stack.md`](docs/ai/tech-stack.md), [`edge-cases.md`](docs/ai/edge-cases.md) |
| Legacy/frozen code | [`legacy-warning.md`](docs/ai/legacy-warning.md) |
| Переезд на OpenTofu | [`migration-tofu.md`](docs/ai/migration-tofu.md), `tofu/README.md`, `tofu/*.tf` |
| Текущая работа | [`plan.md`](docs/ai/plan.md); `current-task.md` является историческим планом |
Перед инфраструктурными изменениями прочитай релевантные заметки в Obsidian:
`/home/ada/Documents/Vaults/SecondBrain/02 Projects/HomeLab/`
Основные заметки: `HomeLab.md`,
`Notes/Текущее состояние HomeLab после миграции на Proxmox.md`, `Log.md`.
После изменения обнови соответствующую заметку, если изменились topology,
операционная процедура, решение или неочевидное ограничение.
## Источники правды
- Hosts и groups: `ansible/inventory/hosts.yml`.
- Shared network/access vars: `ansible/inventory/group_vars/all/main.yml`.
- Service facts: `ansible/inventory/group_vars/all/services.yml`.
- SSH users, keys, ports и ProxyJump: `ansible/ssh_config`.
- Manual operations и safety gates: `ansible/Makefile`.
- Secrets: ignored `.env` в КОРНЕ репозитория; его читают и Make/Ansible, и OpenTofu.
- Human decisions и operations log: HomeLab Obsidian vault.
Программные потребители реестра:
- `playbooks/reverse-proxy.yml` — сборка Caddyfile;
- `playbooks/ru-vps-base.yml` — стек Caddy и его закреплённый образ;
- `playbooks/pve-backup-jobs.yml` — списки VMID заданий PBS (поле `backup.job`);
- `roles/backup_audit` — VMID для аудита (флаг `monitoring.backup_audit_vmid`);
- `playbooks/validate.yml` — сверка реестра с фактическим состоянием Proxmox;
- `playbooks/dashboard.yml``services.yaml` для Homepage-обзора инфраструктуры.
Остальное (`pve-*.yml`, `status.yml`, monitoring, SSH config) по-прежнему
дублирует значения и должно меняться согласованно.
`make validate` — read-only gate, проверяющий это расхождение.
## Границы
- Active control plane находится в `ansible/`.
- `archive/2026-07-proxmox-migration/` - только историческая справка; не редактировать
и не возвращать из него конфигурацию как active implementation.
- Не редактировать generated artifacts, `ansible/collections/`, `.venv/`,
`node_modules/`, `.direnv/` и secret-bearing ignored files.
- `roles/compose_service` подключён в `playbooks/ru-vps-base.yml` (стек Caddy).
`roles/lxc_docker_host` не подключён нигде.
Не считать active service playbooks устаревшими до отдельной миграции.
- Не исправлять найденный technical debt в несвязанной задаче без отдельного решения.
- Для новой VM/LXC/managed host по умолчанию provision public SSH key пользователя,
если пользователь явно не указал иное.
## Setup и команды
Предпочтительное окружение - Nix:
```bash
nix develop # или один раз: direnv allow
ansible-galaxy collection install \
-r ansible/requirements.yml \
-p ansible/collections # один раз на clone
```
Работай через Make из `ansible/` или с `make -C ansible` из root:
```bash
make -C ansible help
make -C ansible check
make -C ansible status EXTRA="--limit '!gyro'"
make -C ansible inventory
make -C ansible docs
make -C ansible lint
```
Локальный syntax-check всех playbooks, соответствующий CI:
```bash
nix develop -c sh -c \
'cd ansible && for f in playbooks/*.yml; do ansible-playbook --syntax-check "$f"; done'
```
Grimmory MCP требует отдельный Node.js `>=22` runtime:
```bash
npm install --prefix tools/grimmory-mcp
npm test --prefix tools/grimmory-mcp
```
## Safety Contract
- Сначала выполняй smallest safe local validation, затем bounded live check только
когда он нужен задаче.
- `make dry-<service>` использует `--check --diff`, но не является полной симуляцией
Proxmox API или command-heavy `pct` playbooks.
- `make status` read-only, но его exit code не является health gate.
- `update-all`, `mihomo-harden` и frozen `monitoring` требуют `CONFIRM=1`.
- `gyro` требует Vault password; не обходи `make gyro` без явной причины.
- Service update order: fresh backup/audit -> update -> health check. Backup не
означает automatic rollback.
- Uptime Kuma активен. Prometheus/Alertmanager/Grafana stack заморожен; не запускать
его параллельно без отдельного architecture decision.
- Active remote images обычно pin по `tag@sha256:digest`; frozen Prometheus images
tag-only. Floating auto-updaters не используются.
- Не включать cluster-wide PVE firewall без аудита всех guests с `firewall=1`.
- Не останавливать production services для fault injection без согласованного
maintenance window.
## Secrets
- Никогда не коммить и не цитировать реальные passwords, tokens, private keys,
`.env`, Vault plaintext, PBS/restic credentials или TLS keys.
- Используй ignored `.env` в корне репозитория, Ansible Vault, runtime prompt или
external local secret file.
- `PROXMOX_ROOT_PASSWORD` (root@pam) нужен ТОЛЬКО целям `tofu-*` и только для
привилегированных полей LXC. Ansible им не пользуется. Не логировать и не
передавать в playbook vars.
- В Git допустимы только sanitized `.env.example` и encrypted Vault content.
- Secret-bearing tasks должны использовать `no_log: true`, а files - минимальные
permissions.
## Рабочий процесс
1. Прочитай минимальный релевантный context и Obsidian notes.
2. Проверь active inventory, vars, playbook/role и связанные consumers.
3. Внеси smallest correct declarative change; не используй ad-hoc server edits.
4. Запусти минимальные local checks, затем только необходимые bounded live checks.
5. Проверь diff на broad targeting, secrets, destructive behavior и docs drift.
6. Обнови repository/Obsidian documentation для изменившихся решений и процедур.
7. В отчете перечисли changed files, проверки, неисполненные live checks и unknowns.
Для read-only reconnaissance, Ansible safety review, syntax validation, network/log
diagnostics, backup audit и Obsidian context используй специализированные агенты из
`.opencode/agents/`. Большие logs и command outputs передавай соответствующему
read-only summarizer и всегда ограничивай `--since`, `-n` или `--tail`.
+7
View File
@@ -0,0 +1,7 @@
# CLAUDE.md
Этот файл содержит адаптер инструкций для Claude Code (claude.ai/code) при работе с этим репозиторием.
Канонические проектные инструкции находятся в [AGENTS.md](./AGENTS.md). Не дублируй их здесь, чтобы `CLAUDE.md` и `AGENTS.md` не расходились.
@AGENTS.md
+58 -14
View File
@@ -1,26 +1,70 @@
# HomeLab Infrastructure
Active HomeLab infrastructure is managed through Ansible.
Активная инфраструктура домашней лаборатории управляется через Ansible.
Каноничные инструкции для людей и агентов — в [AGENTS.md](./AGENTS.md).
Стабильный архитектурный контекст и риски — в [docs/ai/](./docs/ai/README.md).
## Active Files
## Быстрый старт
- `ansible/` — current control plane.
- `ansible/inventory/hosts.yml` — inventory and host facts.
- `ansible/playbooks/check.yml` — safe connectivity/facts check.
Окружение собрано в Nix, venv не нужен:
## Archive
```bash
nix develop # или один раз: direnv allow
Historical pre-Proxmox material is kept under:
```text
archive/2026-07-proxmox-migration/
# Один раз на клон
ansible-galaxy collection install -r ansible/requirements.yml -p ansible/collections
```
It contains old NixOS configs, Docker Compose service definitions, Gitea workflows, deploy scripts and old Ansible bootstrap playbooks.
## Basic Check
Всё управление — через `make` из `ansible/`:
```bash
cd ansible
ansible-playbook playbooks/check.yml
make help # список целей, начинать отсюда
make check # связность и ожидаемые IP
make status # read-only сводка по всей инфраструктуре
make lint # ansible-lint + yamllint
```
Деплой и обновления:
```bash
make dry-gitea # предпросмотр (--check --diff)
make deploy-gitea # применить
make update-gitea # бэкап -> обновление -> health-check
```
Секреты лежат в `.env` в **корне репозитория**`.gitignore`); его подхватывают
и Ansible, и OpenTofu. Опасные цели требуют `CONFIRM=1`.
## SSH руками
`ansible/ssh_config` — единый источник правды по SSH и для Ansible, и для терминала.
Добавь в `~/.ssh/config`, чтобы заработал `ssh gitea`:
```
Include /home/ada/Documents/Projects/HomeLab/infras/ansible/ssh_config
```
## Структура
- `ansible/` — control plane: `Makefile`, `inventory/`, `playbooks/`, `roles/`, `ssh_config`
- `ansible/inventory/group_vars/all/services.yml` — реестр сервисов (VMID, IP, порты, домены, образы)
- `flake.nix` — dev-окружение
- `.gitea/workflows/lint.yml` — CI: yamllint, ansible-lint, syntax-check
- `docs/ai/` — архитектура, stack, edge cases и legacy boundaries для агентов
- `archive/2026-07-proxmox-migration/` — исторические NixOS/Docker конфиги, только как справка
## Grimmory MCP
`tools/grimmory-mcp/` содержит read-only интеграцию с Grimmory API для OpenCode.
Явно вызываемые sync tools записывают сгенерированные заметки и обложки в Obsidian.
```bash
npm install --prefix tools/grimmory-mcp
npm run configure --prefix tools/grimmory-mcp
npm test --prefix tools/grimmory-mcp
```
Глобальная регистрация MCP в OpenCode выполняется вне этого репозитория. После
настройки используй `/grimmory-sync` для обновления заметок книг в
`90 Library/Books` и обложек в `99 System/Export/Grimmory/Covers`.
-8
View File
@@ -1,8 +0,0 @@
export PROXMOX_HOST=192.168.1.10
export PROXMOX_USER='ansible@pve'
export PROXMOX_TOKEN_ID='homelab'
export PROXMOX_TOKEN_SECRET='replace-me'
export PROXMOX_VALIDATE_CERTS=false
# Override if the downloaded template name differs.
export PVE_LXC_OSTEMPLATE='local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst'
+354
View File
@@ -0,0 +1,354 @@
# HomeLab Ansible — единая точка входа для РУЧНОГО управления инфраструктурой.
#
# Запускать из каталога ansible/ (или через `make -C ansible <цель>`):
#
# make список целей
# make check базовая проверка связности
# make deploy-gitea playbooks/pve-gitea.yml с загруженным .env
# make dry-gitea то же самое в режиме --check --diff
# make update-gitea playbooks/gitea-update.yml
# make docs markdown-таблица хостов в stdout
#
# Дополнительные флаги ansible-playbook передаются через EXTRA:
#
# make check EXTRA="--limit pve_nodes -vv"
#
# Интерпретатор определяется автоматически: если рядом есть ./.venv — берётся он,
# иначе бинарь ищется в PATH (nix devshell, системный ansible). Любой путь можно
# переопределить переменной окружения, например:
#
# ANSIBLE_PLAYBOOK=$(which ansible-playbook) make check
SHELL := /bin/sh
.DEFAULT_GOAL := help
MAKEFILE_PATH := $(abspath $(lastword $(MAKEFILE_LIST)))
ANSIBLE_DIR := $(patsubst %/,%,$(dir $(MAKEFILE_PATH)))
REPO_ROOT := $(abspath $(ANSIBLE_DIR)/..)
VENV_BIN := $(ANSIBLE_DIR)/.venv/bin
# Разрешение бинарей: сначала рабочий локальный venv, иначе PATH (nix devshell,
# системный ansible). VENV_OK проверяет, что интерпретатор venv реально запускается,
# — иначе сломанный .venv (например, после смены системного python) молча ломал бы
# все цели. Переменные окружения имеют приоритет благодаря `?=`.
VENV_OK := $(shell [ -x '$(VENV_BIN)/python3' ] && '$(VENV_BIN)/python3' -c '' >/dev/null 2>&1 && echo yes)
venv_bin = $(if $(VENV_OK),$(if $(wildcard $(VENV_BIN)/$(1)),$(VENV_BIN)/$(1),$(1)),$(1))
ANSIBLE_PLAYBOOK ?= $(call venv_bin,ansible-playbook)
ANSIBLE_INVENTORY_BIN ?= $(call venv_bin,ansible-inventory)
ANSIBLE_GALAXY ?= $(call venv_bin,ansible-galaxy)
ANSIBLE_LINT ?= $(call venv_bin,ansible-lint)
YAMLLINT ?= $(call venv_bin,yamllint)
# scripts/gen-inventory-docs.py обходится стандартной библиотекой, поэтому берём
# python3 из PATH, а не из .venv.
PYTHON ?= python3
# Секреты живут в КОРНЕ репозитория, а не в ansible/: их потребляет не только
# Ansible, но и OpenTofu (tofu/), а держать два файла или ходить в подкаталог
# из соседнего инструмента неудобно. Путь абсолютный, поэтому цели работают
# из любого cwd.
ENV_FILE ?= $(REPO_ROOT)/.env
# Дополнительные аргументы ansible-playbook для любой цели.
EXTRA ?=
# Спрашивать sudo-пароль там, где нужен become. `make openvpn ASK_BECOME=` отключает.
ASK_BECOME ?= -K
# Спрашивать пароль Ansible Vault. `make gyro ASK_VAULT=` отключает.
ASK_VAULT ?= --ask-vault-pass
# Строгая загрузка корневого .env: обязательна для Proxmox API и секретов.
# Каждая строка рецепта make — отдельный шелл, поэтому source и запуск идут одной строкой.
REQUIRE_ENV = if [ ! -f '$(ENV_FILE)' ]; then \
printf 'ОШИБКА: не найден %s\n' '$(ENV_FILE)' >&2; \
printf 'Создай его и заполни реальными значениями:\n' >&2; \
printf ' cp %s/.env.example %s\n' '$(REPO_ROOT)' '$(ENV_FILE)' >&2; \
printf 'Нужны PROXMOX_* (и MONITORING_* / EMERGENCY_* для профильных целей).\n' >&2; \
exit 1; \
fi; \
set -a; . '$(ENV_FILE)'; set +a
# Мягкая загрузка: .env подхватывается если есть, иначе просто предупреждение.
LOAD_ENV = if [ -f '$(ENV_FILE)' ]; then set -a; . '$(ENV_FILE)'; set +a; \
else printf 'ВНИМАНИЕ: %s не найден, продолжаю без него.\n' '$(ENV_FILE)' >&2; fi
# Защита от случайного запуска опасных плейбуков.
REQUIRE_CONFIRM = if [ "$(CONFIRM)" != "1" ]; then \
printf 'ОПАСНАЯ ЦЕЛЬ. Повтори явно: make $@ CONFIRM=1\n' >&2; \
exit 1; \
fi
# host_vars/gyro/vault.yml зашифрован Vault. Для чтения инвентаря он не нужен,
# поэтому vars-плагины отключаются, чтобы make не спрашивал пароль Vault.
INVENTORY_ENV = ANSIBLE_VARS_ENABLED= ANSIBLE_NOCOLOR=1
##@ Справка
.PHONY: help
help: ## Показать этот список целей
@awk 'BEGIN { FS = ":.*##"; \
print ""; \
print "HomeLab Ansible — ручное управление инфраструктурой"; \
print ""; \
print " Использование: make <цель> [EXTRA=\"--limit host -vv\"]"; \
print " Секреты берутся из .env в КОРНЕ репозитория"; \
} \
/^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next } \
/^[a-zA-Z0-9_%.-]+:.*##/ { printf " \033[36m%-24s\033[0m %s\n", $$1, $$2 } \
END { print "" }' $(MAKEFILE_PATH)
##@ Setup
.PHONY: setup
setup: ## Создать .venv, поставить requirements.txt и galaxy-коллекции (не нужно в nix)
$(PYTHON) -m venv $(ANSIBLE_DIR)/.venv
$(VENV_BIN)/pip install --upgrade pip
$(VENV_BIN)/pip install -r requirements.txt
$(VENV_BIN)/ansible-galaxy collection install -r requirements.yml -p collections
.PHONY: collections
collections: ## Доустановить только galaxy-коллекции из requirements.yml
$(ANSIBLE_GALAXY) collection install -r requirements.yml -p collections
.PHONY: env-check
env-check: ## Проверить наличие .env и заполненность ключевых переменных
@$(REQUIRE_ENV); \
rc=0; \
for v in PROXMOX_HOST PROXMOX_USER PROXMOX_TOKEN_ID PROXMOX_TOKEN_SECRET; do \
eval "val=\$$$$v"; \
if [ -z "$$val" ] || [ "$$val" = 'replace-me' ]; then \
printf 'не задано: %s\n' "$$v"; rc=1; \
else printf 'ok: %s\n' "$$v"; fi; \
done; \
exit $$rc
##@ Проверки и диагностика
.PHONY: check
check: ## Проверка связности и ожидаемых IP (playbooks/check.yml)
$(ANSIBLE_PLAYBOOK) playbooks/check.yml $(EXTRA)
.PHONY: status
status: ## Сводный статус инфраструктуры (playbooks/status.yml)
ANSIBLE_CALLBACK_RESULT_FORMAT=yaml $(ANSIBLE_PLAYBOOK) playbooks/status.yml $(EXTRA)
# Оба линтера ищут конфиг (.ansible-lint / .yamllint) в текущем каталоге, а не
# у родителей. Запуск из ansible/ конфиг не находил, из-за чего в проверку
# затягивался вендоренный collections/ и цель выдавала около тысячи чужих
# нарушений. Запускаем из корня — ровно как .gitea/workflows/lint.yml.
.PHONY: lint
lint: ## Прогнать ansible-lint, yamllint и syntax-check — то же, что делает CI
cd '$(REPO_ROOT)' && $(ANSIBLE_LINT)
cd '$(REPO_ROOT)' && $(YAMLLINT) .
@rc=0; for f in playbooks/*.yml; do \
if $(ANSIBLE_PLAYBOOK) --syntax-check "$$f" >/dev/null 2>&1; then \
printf 'ok %s\n' "$$f"; \
else \
rc=1; printf 'FAIL %s\n' "$$f"; $(ANSIBLE_PLAYBOOK) --syntax-check "$$f" 2>&1 | sed 's/^/ /'; \
fi; \
done; exit $$rc
.PHONY: docs
docs: ## Напечатать markdown-таблицу хостов и групп в stdout
@ANSIBLE_INVENTORY_BIN='$(ANSIBLE_INVENTORY_BIN)' $(PYTHON) scripts/gen-inventory-docs.py
.PHONY: inventory
inventory: ## Показать дерево инвентаря (ansible-inventory --graph)
@$(INVENTORY_ENV) $(ANSIBLE_INVENTORY_BIN) -i inventory/hosts.yml --graph
.PHONY: validate
validate: ## Сверить реестр homelab_services с фактическим состоянием Proxmox (gate)
$(ANSIBLE_PLAYBOOK) playbooks/validate.yml $(EXTRA)
.PHONY: openvpn-check
openvpn-check: ## Проверить OpenVPN-транспорт ru-vps <-> ovpn-mini
$(ANSIBLE_PLAYBOOK) playbooks/openvpn-check.yml $(EXTRA)
.PHONY: backup-audit
backup-audit: ## Аудит свежести бэкапов PBS и offsite restic
$(ANSIBLE_PLAYBOOK) playbooks/backup-audit.yml $(EXTRA)
dry-%: ## Прогон playbooks/pve-<имя>.yml в режиме --check --diff (пример: make dry-gitea)
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/pve-$*.yml --check --diff $(EXTRA)
##@ Деплой LXC/VM через Proxmox API (требует .env)
deploy-%: ## Применить playbooks/pve-<имя>.yml (пример: make deploy-gitea, deploy-adguard, deploy-monitoring)
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/pve-$*.yml $(EXTRA)
.PHONY: backup-jobs
backup-jobs: ## Настроить PBS backup jobs (playbooks/pve-backup-jobs.yml)
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/pve-backup-jobs.yml $(EXTRA)
.PHONY: pbs-storage
pbs-storage: ## Убрать storage-level prune policy с PBS storage (playbooks/pve-storage-pbs.yml)
$(ANSIBLE_PLAYBOOK) playbooks/pve-storage-pbs.yml $(EXTRA)
.PHONY: bootstrap-pve-token
bootstrap-pve-token: ## Выпустить Proxmox API-токен прямо с ноды (нужен sudo)
$(ANSIBLE_PLAYBOOK) playbooks/bootstrap-pve-api-token.yml $(ASK_BECOME) $(EXTRA)
.PHONY: bootstrap-monitoring-token
bootstrap-monitoring-token: ## Выпустить read-only PVE-токен для мониторинга
$(ANSIBLE_PLAYBOOK) playbooks/bootstrap-monitoring-pve-token.yml $(EXTRA)
.PHONY: bootstrap-dashboard-token
bootstrap-dashboard-token: ## Выпустить read-only PVE-токен homepage@pve!dashboard для виджета Proxmox
$(ANSIBLE_PLAYBOOK) playbooks/bootstrap-dashboard-pve-token.yml $(EXTRA)
.PHONY: bootstrap-ansible-user
bootstrap-ansible-user: ## Завести сервисный аккаунт ansible на shell-хостах (нужен sudo)
$(ANSIBLE_PLAYBOOK) playbooks/bootstrap-ansible-user.yml $(ASK_BECOME) $(EXTRA)
##@ Настройка сервисов
.PHONY: ru-vps-base
ru-vps-base: ## Базовое состояние ru-vps и стек Caddy (закрепляет образ, ставит homelab-caddy.service)
$(ANSIBLE_PLAYBOOK) playbooks/ru-vps-base.yml $(EXTRA)
.PHONY: dry-ru-vps-base
dry-ru-vps-base: ## Предпросмотр ru-vps-base.yml (--check --diff)
$(ANSIBLE_PLAYBOOK) playbooks/ru-vps-base.yml --check --diff $(EXTRA)
.PHONY: reverse-proxy
reverse-proxy: ## Единый reverse-proxy для gitea/vaultwarden/grimmory
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/reverse-proxy.yml $(EXTRA)
.PHONY: openvpn
openvpn: ## Поднять OpenVPN-транспорт ru-vps <-> ovpn-mini (нужен sudo)
$(ANSIBLE_PLAYBOOK) playbooks/openvpn-vps-mini.yml $(ASK_BECOME) $(EXTRA)
.PHONY: openvpn-laptop
openvpn-laptop: ## Настроить OpenVPN-профиль ноутбука
$(ANSIBLE_PLAYBOOK) playbooks/openvpn-laptop.yml $(EXTRA)
.PHONY: bash-config
bash-config: ## Раскатать единый bash-конфиг на shell_hosts
$(ANSIBLE_PLAYBOOK) playbooks/bash-config.yml $(EXTRA)
.PHONY: user-ssh-key
user-ssh-key: ## Разложить публичный SSH-ключ пользователя на все хосты
$(ANSIBLE_PLAYBOOK) playbooks/user-ssh-key.yml $(EXTRA)
.PHONY: emergency-access
emergency-access: ## Настроить emergency reverse-SSH и Telegram-бота (нужны EMERGENCY_* в .env)
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/emergency-access.yml $(EXTRA)
.PHONY: gyro
gyro: ## Настроить gyro-аллокатор в production CT 156 (Tofu-managed); CT 150 — остановленный rollback (спрашивает пароль Vault)
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/gyro.yml $(ASK_VAULT) $(EXTRA)
.PHONY: uptime-kuma
uptime-kuma: ## Развернуть/обновить Uptime Kuma на monitoring LXC
$(ANSIBLE_PLAYBOOK) playbooks/uptime-kuma.yml $(EXTRA)
.PHONY: dashboard
dashboard: ## Дашборд-обзор инфраструктуры (Homepage) на monitoring LXC, конфиг из реестра
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/dashboard.yml $(EXTRA)
.PHONY: dry-dashboard
dry-dashboard: ## Предпросмотр dashboard.yml (--check --diff)
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/dashboard.yml --check --diff $(EXTRA)
.PHONY: offsite-restic
offsite-restic: ## Настроить offsite restic-бэкапы на Yandex.Disk
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/offsite-restic-yadisk.yml $(EXTRA)
play-%: ## Запустить произвольный playbooks/<имя>.yml с загруженным .env (пример: make play-check)
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/$*.yml $(EXTRA)
##@ Обновления сервисов (сначала свежий бэкап, потом апдейт, потом проверка)
update-%: ## Обновить сервис через playbooks/<имя>-update.yml (gitea, vaultwarden, adguard, mihomo, grimmory)
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/$*-update.yml $(EXTRA)
.PHONY: update-all
update-all: ## Последовательно обновить gitea, vaultwarden, adguard, mihomo, grimmory (требует CONFIRM=1)
@$(REQUIRE_CONFIRM)
$(MAKE) update-vaultwarden
$(MAKE) update-gitea
$(MAKE) update-adguard
$(MAKE) update-mihomo
$(MAKE) update-grimmory
##@ OpenTofu (пилот provisioning LXC)
TOFU_DIR := $(REPO_ROOT)/tofu
TOFU ?= tofu
# Tofu, в отличие от Ansible, ходит в Proxmox API напрямую по HTTPS и НЕ умеет
# ProxyJump из ssh_config. Вне локальной сети узлы недоступны (проверено:
# https://192.168.1.10:8006 отдаёт connection failure), поэтому на время команды
# поднимаем SSH-туннель через ru-vps и направляем провайдер в localhost.
# Изнутри LAN это тоже работает — просто лишний хоп, зато поведение одинаково.
TOFU_TUNNEL_PORT ?= 18006
TOFU_SSH_SOCKET := $(ANSIBLE_DIR)/.ansible/tofu-tunnel.sock
# insecure=true здесь не настройка вкуса: сертификат Proxmox выписан на узел,
# а обращаемся мы к 127.0.0.1, так что проверка имени не пройдёт в любом случае.
#
# Способ аутентификации выбирается автоматически: если в корневом .env задан
# PROXMOX_ROOT_PASSWORD — идём как root@pam (только так Proxmox разрешает
# features кроме nesting, device passthrough и bind mount каталога хоста),
# иначе токеном ansible@pve. Выбранный режим печатается в stderr, чтобы из
# вывода было видно, какими правами шёл apply.
TOFU_ENV = $(REQUIRE_ENV); \
mkdir -p '$(dir $(TOFU_SSH_SOCKET))'; \
ssh -F '$(ANSIBLE_DIR)/ssh_config' -M -S '$(TOFU_SSH_SOCKET)' -fN \
-L $(TOFU_TUNNEL_PORT):$$PROXMOX_HOST:$${PROXMOX_PORT:-8006} ru-vps; \
trap "ssh -S '$(TOFU_SSH_SOCKET)' -O exit ru-vps 2>/dev/null || true" EXIT; \
export TF_VAR_pve_endpoint="https://127.0.0.1:$(TOFU_TUNNEL_PORT)"; \
export TF_VAR_pve_insecure=true; \
if [ -n "$${PROXMOX_ROOT_PASSWORD:-}" ] && [ "$${PROXMOX_ROOT_PASSWORD}" != 'replace-me' ]; then \
export TF_VAR_pve_username="$${PROXMOX_ROOT_USER:-root@pam}"; \
export TF_VAR_pve_password="$$PROXMOX_ROOT_PASSWORD"; \
export TF_VAR_pve_api_token=""; \
printf 'Proxmox: аутентификация %s (привилегированный режим)\n' "$${PROXMOX_ROOT_USER:-root@pam}" >&2; \
else \
export TF_VAR_pve_api_token="$$PROXMOX_USER!$$PROXMOX_TOKEN_ID=$$PROXMOX_TOKEN_SECRET"; \
export TF_VAR_pve_username=""; \
export TF_VAR_pve_password=""; \
printf 'Proxmox: аутентификация токеном %s (features кроме nesting и dev-passthrough недоступны)\n' "$$PROXMOX_USER" >&2; \
fi
.PHONY: tofu-init
tofu-init: ## Скачать провайдер и инициализировать рабочий каталог tofu/
@cd '$(TOFU_DIR)' && $(TOFU) init -input=false
.PHONY: tofu-plan
tofu-plan: ## Показать план (read-only, ничего не меняет)
@$(TOFU_ENV); cd '$(TOFU_DIR)' && $(TOFU) plan -input=false
.PHONY: tofu-apply
tofu-apply: ## Применить план — СОЗДАЁТ гостей в Proxmox (требует CONFIRM=1)
@$(REQUIRE_CONFIRM)
@$(TOFU_ENV); cd '$(TOFU_DIR)' && $(TOFU) apply -input=false -auto-approve
.PHONY: tofu-destroy
tofu-destroy: ## УНИЧТОЖИТЬ всё, что создано в tofu/ (требует CONFIRM=1)
@$(REQUIRE_CONFIRM)
@printf 'Удаляет гостей Proxmox из состояния tofu вместе с их дисками.\n' >&2
@$(TOFU_ENV); cd '$(TOFU_DIR)' && $(TOFU) destroy -input=false -auto-approve
##@ Опасное (только осознанно, требует CONFIRM=1)
.PHONY: mihomo-harden
mihomo-harden: ## РОТИРУЕТ живые SOCKS-креды Mihomo на ru-vps и убирает публичный доступ
@$(REQUIRE_CONFIRM)
@printf 'Ротация кредов необратима для клиентов без отката бэкапа конфига.\n' >&2
$(ANSIBLE_PLAYBOOK) playbooks/ru-vps-mihomo-harden.yml -e ru_vps_mihomo_harden_confirm=true $(EXTRA)
.PHONY: zerotier-decommission
zerotier-decommission: ## Вывести ZeroTier с ru-vps и почистить его правила UFW (требует CONFIRM=1)
@$(REQUIRE_CONFIRM)
@printf 'Останавливает контейнер zerotier и удаляет правила UFW. Данные и файлы остаются.\n' >&2
$(ANSIBLE_PLAYBOOK) playbooks/ru-vps-zerotier-decommission.yml $(EXTRA)
.PHONY: dry-zerotier-decommission
dry-zerotier-decommission: ## Предпросмотр вывода ZeroTier (--check --diff)
$(ANSIBLE_PLAYBOOK) playbooks/ru-vps-zerotier-decommission.yml --check --diff $(EXTRA)
.PHONY: monitoring
monitoring: ## ЗАМОРОЖЕН: стек Prometheus. Запускать только при восстановлении мониторинга
@$(REQUIRE_CONFIRM)
@printf 'monitoring.yml заморожен, пока используется Uptime Kuma.\n' >&2
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/monitoring.yml $(EXTRA)
+135 -20
View File
@@ -12,66 +12,181 @@ Ansible is the control plane for HomeLab infrastructure changes.
## Layout
- `inventory/hosts.yml` — canonical host list and host-specific facts.
- `inventory/group_vars/all/services.yml` — service registry and reverse-proxy input; deployment playbooks still duplicate these facts.
- `playbooks/` — entry points for tasks.
- `roles/` — reusable configuration units.
- `Makefile` — canonical manual entry point and safety gates.
## 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`.
- `shell_hosts` — hosts with unified bash config: `ru-vps`, `cloud-pc`, `mini-pc`.
- `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`, `hermes-ai`.
- `servers` — all managed hosts.
## First Checks
Install control-node dependencies locally:
From the repository root, use the Nix environment and install Galaxy collections once per clone:
```bash
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
ansible-galaxy collection install -r requirements.yml -p collections
nix develop
ansible-galaxy collection install -r ansible/requirements.yml -p ansible/collections
```
Run from `ansible/`:
Run manual operations through Make from `ansible/`:
```bash
ansible-playbook playbooks/check.yml
make help
make check
make status EXTRA="--limit '!gyro'"
make lint
```
For Proxmox API playbooks, create ignored `.env` from `.env.example` and load it:
`make setup` remains a local venv fallback when Nix is unavailable.
## Controlled Updates
Service updates are manual. Active update-managed remote images are pinned as
`tag@sha256:digest`; the frozen Prometheus stack is tag-only. Floating
auto-update agents are not used.
Run the dedicated Make target from `ansible/`:
```bash
cp .env.example .env
. ./.env
.venv/bin/ansible-playbook playbooks/pve-wg-mini.yml
make update-vaultwarden
make update-gitea
make update-adguard
make update-mihomo
make update-grimmory
```
Update flow is always: fresh backup/audit first, then the update playbook, then health verification.
- Gitea, Vaultwarden and Grimmory use app-aware offsite restic backups/audits before update.
- AdGuard and Mihomo use fresh PBS LXC backups before update.
- `grimmory-update.yml` currently validates the existing pinned release; any future actual app image upgrade must be preceded by release-note and migration review.
## Mihomo Hardening
Use the dedicated hardening playbook only when explicitly approved:
```bash
make mihomo-harden CONFIRM=1
```
It rotates the live Mihomo SOCKS credentials on `ru-vps`, locks the proxy to loopback, and removes the public UFW exposure for ports `7890` and `7891`.
The rotated credentials are not recoverable for clients unless you roll back the saved config backup.
For Proxmox API playbooks, create ignored `.env` in the **repository root** from
`.env.example`. It is shared with OpenTofu (`tofu/`). Make loads it
automatically:
```bash
cp ../.env.example ../.env # секреты живут в корне репозитория
make env-check
make dry-ovpn-mini
make deploy-ovpn-mini
```
Or bootstrap the token from `mini-pc` with sudo:
```bash
.venv/bin/ansible-playbook playbooks/bootstrap-pve-api-token.yml -K
make bootstrap-pve-token
```
Create the separate read-only PVE token used by the monitoring exporter:
```bash
make bootstrap-monitoring-token
```
OpenVPN transport:
```bash
.venv/bin/ansible-playbook playbooks/openvpn-vps-mini.yml -K
.venv/bin/ansible-playbook playbooks/openvpn-check.yml
make openvpn
make openvpn-check
```
CT 146 can be provisioned separately. Uptime Kuma is the active monitoring service:
```bash
make deploy-monitoring
make uptime-kuma
```
`pve-monitoring.yml` creates CT `146` (`monitoring`, `192.168.1.30`) on
`cloud-pc`. The older `monitoring.yml` configures Prometheus, Alertmanager,
Grafana and exporters; it is frozen and `make monitoring CONFIRM=1` is reserved
for an explicitly approved restoration decision.
## Gyro Investment Allocator
Production `gyro` now runs in Tofu-provisioned CT `156` (`gyro`, `192.168.1.35`) on `mini-pc`. CT `150` is stopped and kept only as rollback for at least a week; it is not removed.
`make gyro` configures Python 3.13+, pinned `uv`, the `gyro` service user, the container-local GitHub deploy key, restrictive firewall rules, and the weekday systemd timer. Do not use `make deploy-gyro` for the cutover path.
UFW is the currently enforced isolation layer: inbound is denied except SSH from LAN/OpenVPN, and east-west outbound is denied except the Mihomo HTTP proxy. The equivalent CT `156` Proxmox firewall is already carried over; the cluster-wide PVE firewall remains disabled, so do not enable it without auditing every node and guest with `firewall=1`.
The role keeps deployment and the timer disabled by default. The active host vars deploy `git@github.com:ada-dmitry/t_tech-gyro.git` with GitHub's verified ED25519 host key; the timer still requires the ignored Vault file:
```bash
cp inventory/host_vars/gyro/vault.yml.example inventory/host_vars/gyro/vault.yml
ansible-vault encrypt inventory/host_vars/gyro/vault.yml
```
After encrypting the secrets, set `gyro_timer_enabled: true` in `main.yml` and apply with `--ask-vault-pass`. `DRY_RUN_OVERRIDE` remains `true` until real trading is explicitly approved.
```bash
make gyro
```
The timer runs at 11:00 Europe/Moscow from Monday through Friday and uses `OnFailure=` for a best-effort Telegram alert. PBS backup jobs and the backup freshness audit derive the current Gyro VMID from the registry once regenerated, so CT `156` is picked up automatically.
The legacy `pve-gyro.yml` remains available for rollback recovery only and is blocked by default after cutover unless an explicit override is passed.
Uptime Kuma uses the existing monitoring LXC and stops/disables `homelab-monitoring` without deleting its configuration or data. Its UI is available only from the LAN at `http://192.168.1.30:3001`; create monitors and notification settings in the UI.
```bash
make uptime-kuma
```
## Emergency Reverse SSH
`pve-emergency-bot.yml` creates CT `148` (`emergency-bot`, `192.168.1.32`) on `mini-pc`. `emergency-access.yml` configures the bot, a TTL-limited reverse tunnel from `mini-pc` to `ru-vps`, and the restricted SSH identities used between them.
Before applying, set `EMERGENCY_BOT_TOKEN`, `EMERGENCY_ALLOWED_USER_IDS`, `EMERGENCY_VPS_HOST_KEY`, and `EMERGENCY_MINI_PC_HOST_KEY` in ignored `.env` or Ansible Vault. The host-key variables must be verified public host keys, not values obtained during deployment.
```bash
make deploy-emergency-bot
make emergency-access
```
From an authorized private Telegram chat, use the `Enable SSH`, `Status`, and `Stop` buttons or `/emergency ssh`, `/emergency status`, and `/emergency stop`. `/emergency ssh` enables a 60-minute tunnel only; it does not expose a public port. Connect while it is active with:
The bot uses the LAN Mihomo HTTP proxy at `192.168.1.27:7890` because direct Telegram TCP access is unavailable from HomeLab.
```bash
ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -J vps -p 22010 ansible@127.0.0.1
```
The target account is `ansible`; it has no password login. Use the existing private key `~/.ssh/id_ed25519_homelab_ansible`.
Bootstrap the Ansible service account on shell hosts:
```bash
.venv/bin/ansible-playbook -i inventory/hosts.yml playbooks/bootstrap-ansible-user.yml -K
make bootstrap-ansible-user
```
When a task needs privilege escalation:
```bash
ansible-playbook playbooks/<name>.yml -K
make play-<name> EXTRA="-K"
```
## Workflow
+3
View File
@@ -6,4 +6,7 @@ retry_files_enabled = false
[ssh_connection]
pipelining = true
# Транспорт (ProxyJump, порты, пользователи, ключи) описан в ansible/ssh_config.
# Он подключается через ansible_ssh_common_args в group_vars/all/main.yml,
# где путь вычисляется от inventory_dir и потому не зависит ни от cwd, ни от места клона.
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=accept-new
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import copy
import json
import os
import secrets
import stat
import subprocess
import sys
import tempfile
from pathlib import Path
import yaml
DEFAULT_PROXY_HOST = "127.0.0.1"
DEFAULT_PROXY_PORT = 7891
def _atomic_write_bytes(path: Path, data: bytes, mode: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
try:
with os.fdopen(fd, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.chmod(tmp_name, mode)
os.replace(tmp_name, path)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
def _atomic_write_json(path: Path, payload: dict[str, str]) -> None:
_atomic_write_bytes(path, json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") + b"\n", 0o600)
def _load_json(path: Path) -> dict[str, str]:
with path.open("r", encoding="utf-8") as handle:
payload = json.load(handle)
if not isinstance(payload, dict):
raise ValueError(f"{path} must contain a JSON object")
return payload
def _generate_username() -> str:
return f"mihomo-{secrets.token_hex(8)}"
def _generate_password() -> str:
return secrets.token_urlsafe(48)
def ensure_credentials(state_path: Path) -> tuple[dict[str, str], bool]:
if state_path.exists():
payload = _load_json(state_path)
username = payload.get("username")
password = payload.get("password")
if not username or not password:
raise ValueError(f"{state_path} is missing username/password")
return {"username": username, "password": password}, False
state_path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(state_path.parent, 0o700)
credentials = {"username": _generate_username(), "password": _generate_password()}
_atomic_write_json(state_path, credentials)
return credentials, True
def load_config(config_path: Path) -> dict:
with config_path.open("r", encoding="utf-8") as handle:
payload = yaml.safe_load(handle) or {}
if not isinstance(payload, dict):
raise ValueError(f"{config_path} must contain a YAML mapping")
return payload
def write_config(config_path: Path, payload: dict) -> None:
serialized = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
mode = 0o640
if config_path.exists():
mode = stat.S_IMODE(config_path.stat().st_mode)
_atomic_write_bytes(config_path, serialized.encode("utf-8"), mode)
def cmd_apply(args: argparse.Namespace) -> int:
credentials, state_created = ensure_credentials(args.state)
config = load_config(args.config)
updated = copy.deepcopy(config)
updated["authentication"] = [f"{credentials['username']}:{credentials['password']}"]
updated["allow-lan"] = False
updated["bind-address"] = "127.0.0.1"
changed = updated != config
if changed:
write_config(args.config, updated)
print(json.dumps({"changed": changed, "state_created": state_created}, sort_keys=True))
return 0
def cmd_probe(args: argparse.Namespace) -> int:
credentials = ensure_credentials(args.state)[0]
curl_config = "\n".join(
[
'silent',
'show-error',
f'proxy = "socks5h://{DEFAULT_PROXY_HOST}:{DEFAULT_PROXY_PORT}"',
f'proxy-user = "{credentials["username"]}:{credentials["password"]}"',
'connect-timeout = 5',
'max-time = 20',
'url = "https://api.telegram.org"',
'output = "/dev/null"',
'',
]
)
result = subprocess.run(
["curl", "--config", "-"],
input=curl_config,
text=True,
capture_output=True,
timeout=25,
check=False,
)
if result.returncode != 0:
sys.stderr.write(result.stderr)
return result.returncode
print(json.dumps({"ok": True}, sort_keys=True))
return 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
for name, func in (("apply", cmd_apply), ("probe", cmd_probe)):
sub = subparsers.add_parser(name)
sub.add_argument("--config", required=True, type=Path)
sub.add_argument("--state", required=True, type=Path)
sub.set_defaults(func=func)
return parser.parse_args()
def main() -> int:
args = parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
+33
View File
@@ -0,0 +1,33 @@
---
# Общие переменные для всех управляемых хостов.
# SSH-транспорт (ProxyJump, порты, ключи по хостам) описан в ansible/ssh_config.
# Путь считается от каталога inventory, поэтому работает из любого cwd
# и не ломается при клоне репозитория в другое место.
ansible_ssh_common_args: "-F {{ inventory_dir }}/../ssh_config"
ansible_python_interpreter: /usr/bin/python3
ansible_user: ansible
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab_ansible
ansible_become: true
homelab_lan_cidr: 192.168.1.0/24
homelab_service_range: 192.168.1.5-192.168.1.40
# Публичный адрес, с которого обе ноды PVE выходят в интернет (домашний NAT).
# Нужен для правила UFW на ru-vps, открывающего corosync-qnetd (5403/tcp):
# арбитр кластера обязан быть достижим по пути, не зависящему ни от одной ноды,
# поэтому ходим напрямую, а не через OpenVPN-туннель (он живёт в CT 160 на
# mini-pc — при падении mini-pc арбитр исчез бы вместе с ним).
# ВНИМАНИЕ: адрес динамический. Если он сменится, qdevice замолчит так же тихо,
# как это уже случилось после вывода ZeroTier. Признак — `pvecm status`:
# Total votes меньше Expected votes и флаг NV у узлов.
homelab_pve_egress_ip: 85.143.112.108
openvpn_network_cidr: 10.78.0.0/30
openvpn_listen_port: 8443
openvpn_forwarded_services:
- name: satisfactory
protocol: udp
public_port: 7777
target_host: 192.168.1.100
target_port: 7777
@@ -0,0 +1,641 @@
---
# ============================================================================
# homelab_services — декларативный реестр сервисов HomeLab
# ============================================================================
#
# ЧТО ЭТО
# Единственное место, где собраны факты о каждом сервисе домашней лаборатории:
# VMID, узел Proxmox, адрес, порты, публичный домен, закреплённые образы
# с digest, ресурсы LXC, схема резервного копирования, мониторинг и порядок
# автозапуска.
#
# ВСЕ значения взяты из существующего кода (ansible/playbooks/*.yml,
# ansible/roles/*, ansible/inventory/hosts.yml). Ничего не выдумано.
# Там, где факта в коде нет, стоит комментарий "# нет в коде", а не догадка.
#
# КТО ЭТО ПОТРЕБЛЯЕТ
# * playbooks/reverse-proxy.yml — Caddyfile по блокам `proxy`.
# * playbooks/ru-vps-base.yml — стек Caddy и закреплённый образ.
# * playbooks/pve-backup-jobs.yml — списки VMID заданий по полю `backup.job`.
# * roles/backup_audit — VMID по флагу `monitoring.backup_audit_vmid`.
# * playbooks/validate.yml — сверка с фактическим состоянием Proxmox.
# * playbooks/dashboard.yml — services.yaml для Homepage-обзора инфры.
# * Человек — как справочник вместо чтения семи playbook'ов по 200-400 строк.
#
# Существующие pve-*.yml пока НЕ читают этот реестр: их перевод на
# homelab_services + роли lxc_docker_host/compose_service — отдельный этап.
# До тех пор реестр и pve-*.yml нужно править согласованно.
#
# ЧТО ПРАВИТЬ ПРИ ДОБАВЛЕНИИ НОВОГО СЕРВИСА
# 1. Добавить запись в homelab_services ниже (все обязательные поля).
# 2. Если сервис публикуется наружу — заполнить блок `proxy`
# (domain, upstream, caddy_marker, caddy_container_check, health).
# Больше ничего для reverse-proxy делать не нужно.
# 3. Хост в ansible/inventory/hosts.yml (+ группы monitoring_exporters и др.).
# 4. Задание бэкапа в ansible/playbooks/pve-backup-jobs.yml
# и VMID в roles/backup_audit/defaults/main.yml.
# 5. Плейбук развёртывания (pve-<name>.yml).
#
# СОГЛАШЕНИЯ ПО ПОЛЯМ
# vmid int, уникальный в кластере
# node cloud-pc | mini-pc — узел Proxmox, где живёт контейнер
# ip адрес в LAN без маски
# hostname имя LXC и имя хоста в inventory
# role короткое человеческое описание
# provisioner чем создаётся контейнер:
# pct_ssh — прямой `pct create` по SSH на узле PVE
# pve_lxc — роль roles/pve_lxc через Proxmox API
# unmanaged — создан вручную, в репозитории нет плейбука
# lxc ресурсы и параметры контейнера (as-created)
# ports опубликованные порты сервиса
# images закреплённые образы с digest (пустой список — образ не
# используется или собирается локально)
# backup pbs / restic / none — фактическая схема из pve-backup-jobs.yml
# и offsite-restic-yadisk.yml
# monitoring node_exporter — состоит ли хост в группе monitoring_exporters;
# blackbox — пробится ли снаружи из roles/monitoring_blackbox
# proxy присутствует только у сервисов с публичным доменом
# ============================================================================
# Хост, на котором стоит Caddy и терминируется публичный HTTPS.
homelab_reverse_proxy_host: ru-vps
homelab_reverse_proxy_dir: /opt/services/ru-vps/caddy
homelab_reverse_proxy_caddyfile: /opt/services/ru-vps/caddy/Caddyfile
homelab_reverse_proxy_container: caddy
# Стек Caddy разворачивает playbooks/ru-vps-base.yml (роль compose_service).
# Содержимое Caddyfile принадлежит playbooks/reverse-proxy.yml — ru-vps-base
# его не трогает, только проверяет наличие.
# Образ закреплён по digest: до принятия в Ansible на ru-vps крутился
# floating-тег caddy:2-alpine (на момент фиксации — v2.11.4).
homelab_reverse_proxy_image: >-
caddy:2-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648
# Systemd-юнит намеренно называется homelab-caddy, а не caddy: на ru-vps уже
# есть отключённый caddy.service от APT-пакета, и одноимённый unit в
# /etc/systemd/system его бы затенил.
homelab_reverse_proxy_unit: homelab-caddy
# Адрес центрального хоста мониторинга (источник scrape для node-exporter).
homelab_monitoring_host_ip: 192.168.1.30
# --- Дашборд-обзор инфраструктуры (Homepage, gethomepage.dev) ----------------
# Потребитель реестра: playbooks/dashboard.yml рендерит services.yaml из
# homelab_services (плитка на сервис, ссылка на его UI, группировка по узлу).
# Живёт compose-стеком на LXC monitoring рядом с Uptime Kuma; наружу НЕ
# публикуется — доступ только из LAN и по OpenVPN.
homelab_dashboard_host: monitoring
homelab_dashboard_dir: /opt/homepage
homelab_dashboard_bind_ip: 192.168.1.30
# Порт на хосте: 3000 в модели реестра занят замороженной Grafana, 3001 —
# Uptime Kuma, поэтому Homepage слушает 8082 -> контейнерный 3000.
homelab_dashboard_port: 8082
# Образ закреплён по digest (закреплён 2026-09-03 после первого pull на CT 155).
# Обновление: docker pull <новый тег> на monitoring, взять digest
# docker inspect --format '{{ index .RepoDigests 0 }}' <image>
# и вписать сюда. playbooks/dashboard.yml падает на assert'е без @sha256
# (разовый обход — `-e dashboard_allow_floating_tag=true`).
homelab_dashboard_image: >-
ghcr.io/gethomepage/homepage:v2.2.0@sha256:753eeb0cc22ab7baad39ed47cbd1aae14e193dd1b264e965f193a9ea1d1e1bdd
# HOMEPAGE_ALLOWED_HOSTS: Homepage >=1.0 отклоняет запросы с прочих Host-ов.
homelab_dashboard_allowed_hosts: "192.168.1.30:8082"
# Виджет proxmox: URL любого узла кластера (без node: -> среднее по кластеру).
# Секрет — read-only токен homepage@pve!dashboard (роль PVEAuditor), выпускается
# playbooks/bootstrap-dashboard-pve-token.yml в корневой .env как DASHBOARD_PVE_*.
# Пустой URL -> плитка Proxmox не рендерится.
homelab_dashboard_proxmox_url: "https://192.168.1.10:8006"
# slug опубликованной Status Page в Uptime Kuma для виджета сводки up/down
# (мониторы и статус-страницы Kuma живут только в её UI — их тут не завести).
homelab_dashboard_kuma_slug: homelab
homelab_services:
# --------------------------------------------------------------------------
pbs:
vmid: 120
node: cloud-pc
ip: 192.168.1.20
hostname: pbs
role: Proxmox Backup Server
provisioner: unmanaged # плейбука создания в репозитории нет
lxc:
cores: null # нет в коде
memory: null # нет в коде
swap: null # нет в коде
disk: null # нет в коде
startup: null # нет в коде
features: null # нет в коде
unprivileged: null # нет в коде
ports:
- {name: pbs-api, port: 8007, proto: tcp} # стандартный порт PBS
images: []
backup:
kind: pbs-local
# pve-backup-jobs.yml: job homelab-local-weekly-pbs, хранилище "backup"
job: homelab-local-weekly-pbs
schedule: "Sun 03:30"
storage: backup
prune: keep-last=2
monitoring:
node_exporter: true
blackbox: false
backup_audit_vmid: false # 120 отсутствует в backup_audit_pbs_vmids
# --------------------------------------------------------------------------
ovpn-mini:
vmid: 160
node: mini-pc
ip: 192.168.1.23
hostname: ovpn-mini
role: OpenVPN-шлюз в LAN (клиент ru-vps)
provisioner: tofu # tofu/svc-ovpn-mini.tf, переезд 2026-09-03 (migration-tofu.md)
lxc:
cores: 1
memory: 256
swap: 128
disk: local-lvm:8
startup: order=30
features: "nesting=1" # keyctl/fuse намеренно не заданы
unprivileged: true
# /dev/net/tun — блок device_passthrough в Tofu (dev0: path=/dev/net/tun)
# вместо прежних сырых lxc.* строк из pve-ovpn-mini.yml. Эффект тот же.
devices: ["/dev/net/tun"]
ports:
- {name: openvpn, port: 8443, proto: tcp, note: "туннель к ru-vps 10.78.0.1"}
images: []
backup:
kind: pbs
job: homelab-pbs-daily-mini
schedule: "02:40"
storage: pbs
monitoring:
node_exporter: true
blackbox: false
backup_audit_vmid: true
# --------------------------------------------------------------------------
vaultwarden:
vmid: 154
node: mini-pc
ip: 192.168.1.24
hostname: vaultwarden
role: Менеджер паролей Vaultwarden
provisioner: tofu # tofu/svc-vaultwarden.tf, переезд 2026-09-02 (migration-tofu.md)
lxc:
cores: 2
memory: 1024
swap: 512
disk: local-lvm:16
startup: order=40
# fuse=1 — штатный флаг PVE вместо прежнего обхода сырыми lxc.* строками.
features: "fuse=1,keyctl=1,nesting=1"
unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse"]
data_dir: /opt/vaultwarden/data
ports:
- {name: http, port: 80, proto: tcp}
images:
- vaultwarden/server:1.37.1@sha256:e9efdf001bf0d68c21f2cbfb8e1d9b5961a7ca9c85e0a7e58bf51a13b997d744
runtime: docker-run-systemd # /etc/systemd/system/vaultwarden.service
backup:
kind: pbs+restic
job: homelab-pbs-daily-mini
schedule: "02:40"
storage: pbs
restic:
profile: vaultwarden
repository: rclone:yadisk:System/Backups/HomeLab/restic/vaultwarden
source_path: /opt/vaultwarden/data
schedule: "*-*-* 04:45:00"
sqlite_db: /opt/vaultwarden/data/db.sqlite3
monitoring:
node_exporter: true
blackbox: true
backup_audit_vmid: true
proxy:
domain: pass.ada-dev.ru
upstream: 192.168.1.24:80
caddy_marker: "# {mark} ANSIBLE MANAGED VAULTWARDEN SITE"
# caddy_body не задан -> используется простой `reverse_proxy <upstream>`
caddy_container_check: "reverse_proxy 192.168.1.24:80"
# Устаревшая секция от миграции с 10.122.62.95. Удалить это поле
# можно после подтверждённого прогона reverse-proxy.yml на ru-vps.
caddy_legacy_regexp: '(?ms)^pass\.ada-dev\.ru \{\n\s*reverse_proxy 10\.122\.62\.95:10380\n\}\n+'
health:
path: /
status_code: [200]
follow_redirects: none # соответствует `curl -fsS` без -L в старом плейбуке
# --------------------------------------------------------------------------
gitea:
vmid: 153
node: cloud-pc
ip: 192.168.1.25
hostname: gitea
role: Git-хостинг Gitea
provisioner: tofu # tofu/svc-gitea.tf, переезд 2026-09-02 (migration-tofu.md)
lxc:
cores: 2
memory: 2048
swap: 1024
disk: data:32
startup: order=50
# fuse=1 — штатный флаг PVE вместо прежнего обхода сырыми lxc.* строками.
features: "fuse=1,keyctl=1,nesting=1"
unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse"]
mounts:
# Был bind mount каталога ноды /opt/data/gitea. С переезда 2026-09-02 —
# независимый volume на datastore: mp0 data:153/vm-153-disk-1.raw.
# Побочный выигрыш: bind mount vzdump ИСКЛЮЧАЛ из бэкапа ("not a
# volume"), то есть данные gitea в PBS никогда не попадали. Volume
# попадает.
- {volume: data, size: 32G, container_path: /opt/gitea/data}
data_dir: /opt/gitea/data
ports:
- {name: http, port: 3000, proto: tcp}
- {name: ssh, port: 2222, proto: tcp, container_port: 22}
images:
- gitea/gitea:1.27.1@sha256:b64126cf5c3f4e5f0f231b510bb13715f6cb8e508188b44de90bdb9a04f3055d
runtime: docker-run-systemd # /etc/systemd/system/gitea.service
backup:
kind: pbs+restic
job: homelab-pbs-daily-cloud
schedule: "02:10"
storage: pbs
restic:
profile: gitea
# С переезда 2026-09-02 профиль выполняется ВНУТРИ LXC, как у
# vaultwarden: пути ноды больше не существуют, данные в volume.
run_on: gitea
repository: rclone:yadisk:System/Backups/HomeLab/restic/gitea
source_path: /opt/gitea/data
schedule: "*-*-* 04:15:00"
sqlite_db: /opt/gitea/data/gitea/gitea.db
monitoring:
node_exporter: true
blackbox: true
backup_audit_vmid: true
proxy:
domain: git.ada-dev.ru
upstream: 192.168.1.25:3000
caddy_marker: "# {mark} ANSIBLE MANAGED GITEA SITE"
caddy_container_check: "reverse_proxy 192.168.1.25:3000"
# Устаревшая секция от миграции с 10.122.62.51. Удалить это поле
# можно после подтверждённого прогона reverse-proxy.yml на ru-vps.
caddy_legacy_regexp: '(?ms)^git\.ada-dev\.ru \{\n\s*reverse_proxy 10\.122\.62\.51:3002\n\}\n+'
health:
path: /
status_code: [200]
follow_redirects: none # соответствует `curl -fsS` без -L в старом плейбуке
# --------------------------------------------------------------------------
mihomo:
vmid: 159
node: mini-pc
ip: 192.168.1.27
hostname: mihomo
role: Локальный прокси Mihomo + веб-интерфейс MetaCubeXD
provisioner: tofu # tofu/svc-mihomo.tf, переезд 2026-09-03 (migration-tofu.md)
lxc:
cores: 1
memory: 512
swap: 512
disk: local-lvm:8
startup: order=70
# fuse=1 — штатный флаг PVE вместо прежнего обхода сырыми lxc.* строками.
# /dev/net/tun — блок device_passthrough в Tofu (dev0: path=/dev/net/tun),
# тоже вместо сырых lxc.* строк. Эффект тот же, запись в pct config иная.
features: "fuse=1,keyctl=1,nesting=1"
unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse", "/dev/net/tun"]
data_dir: /opt/mihomo
ports:
- {name: mixed, port: 7890, proto: tcp}
- {name: socks, port: 7891, proto: tcp}
- {name: controller, port: 9090, proto: tcp}
- {name: ui, port: 8080, proto: tcp, container_port: 80}
images:
- metacubex/mihomo:v1.19.29@sha256:5e7bcc5e7a866afcc8b007ef827c9ba773f2f34b6d7311b6d39ed1751f37cfd5
- ghcr.io/metacubex/metacubexd:v1.270.6@sha256:156d55be885d4ba6254d840bd781b715c20c00afee6e6c24c76be4cfe5eb89d4
runtime: docker-run-systemd # mihomo.service + mihomo-ui.service
backup:
kind: pbs
job: homelab-pbs-daily-mini
schedule: "02:40"
storage: pbs
monitoring:
node_exporter: true
blackbox: false
backup_audit_vmid: true
# --------------------------------------------------------------------------
adguard:
vmid: 158
node: mini-pc
ip: 192.168.1.28
hostname: adguard
role: AdGuard Home — DNS с фильтрацией
provisioner: tofu # tofu/svc-adguard.tf, переезд 2026-09-03 (migration-tofu.md)
lxc:
cores: 1
memory: 512
swap: 512
disk: local-lvm:8
startup: order=40
# fuse=1 — штатный флаг PVE вместо прежнего обхода сырыми lxc.* строками
# (lineinfile по /etc/pve/lxc/144.conf в pve-adguard.yml). Эффект тот же.
features: "fuse=1,keyctl=1,nesting=1"
unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse"]
data_dir: /opt/adguard
ports:
- {name: dns-tcp, port: 53, proto: tcp}
- {name: dns-udp, port: 53, proto: udp}
- {name: http, port: 80, proto: tcp}
- {name: setup, port: 3000, proto: tcp}
images:
- adguard/adguardhome:v0.107.78@sha256:2c127294fa5f96151d9d3a433fb9d66c17e4d18cf698c2b04372a80e26fdd26f
runtime: docker-run-systemd # /etc/systemd/system/adguard.service
backup:
kind: pbs
job: homelab-pbs-daily-mini
schedule: "02:40"
storage: pbs
monitoring:
node_exporter: true
blackbox: false
backup_audit_vmid: true
# --------------------------------------------------------------------------
docker-test:
vmid: 152
node: cloud-pc
ip: 192.168.1.29
hostname: docker-test
role: Песочница для проверки Docker в непривилегированном LXC
provisioner: tofu # tofu/svc-docker-test.tf, переезд 2026-09-02 (migration-tofu.md)
lxc:
cores: 1
memory: 512
swap: 512
disk: data:8
startup: order=50
# fuse=1 появился после переезда на Tofu: раньше /dev/fuse пробрасывался
# сырыми lxc.* строками через lineinfile, теперь это штатный флаг PVE.
# Эффект тот же, запись в pct config другая.
features: "fuse=1,keyctl=1,nesting=1"
unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse"]
ports: []
images: [] # используется только hello-world для smoke-теста
backup:
kind: pbs
job: homelab-pbs-daily-cloud
schedule: "02:10"
storage: pbs
monitoring:
node_exporter: false # хоста нет в группе monitoring_exporters
blackbox: false
backup_audit_vmid: true
# --------------------------------------------------------------------------
monitoring:
vmid: 155
node: cloud-pc
ip: 192.168.1.30
hostname: monitoring
# До переезда 2026-09-02 контейнер нёс ещё и замороженный стек
# Prometheus/Alertmanager/Grafana/blackbox/pve-exporter. В новый контейнер
# он сознательно не разворачивался (migration-tofu.md, п.5.5), поэтому
# фактически здесь работает только Uptime Kuma. Возврат стека — отдельное
# architecture decision, см. legacy-warning.md.
role: Uptime Kuma (замороженный стек Prometheus больше не развёрнут)
provisioner: tofu # tofu/svc-monitoring.tf, переезд 2026-09-02 (migration-tofu.md)
lxc:
cores: 2
memory: 4096
swap: 512
disk: data:24
# Ресурсы оставлены как были, под замороженный стек. Фактическое
# потребление после переезда — 184 МБ RAM и 1.8 ГБ диска. Уменьшение —
# отдельное решение, не часть переезда.
startup: order=80
features: "keyctl=1,nesting=1"
unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
# Данные Uptime Kuma лежат в /opt/uptime-kuma, а НЕ в /opt/monitoring:
# последний принадлежал замороженному стеку и на новом контейнере
# отсутствует.
data_dir: /opt/uptime-kuma
ports:
- {name: grafana, port: 3000, proto: tcp, bind: 192.168.1.30}
- {name: pushgateway, port: 9091, proto: tcp, bind: 192.168.1.30}
- {name: uptime-kuma, port: 3001, proto: tcp, bind: 192.168.1.30}
images:
# без digest — так закреплено в roles/monitoring_server/templates/compose.yml.j2
- prom/prometheus:v3.2.1
- prom/alertmanager:v0.28.0
- grafana/grafana:11.5.1
- prom/blackbox-exporter:v0.25.0
- prompve/prometheus-pve-exporter:3.5.5
- prom/pushgateway:v1.11.0
- louislam/uptime-kuma:1.23.16@sha256:431fee3be822b04861cf0e35daf4beef6b7cb37391c5f26c3ad6e12ce280fe18
runtime: docker-compose-systemd
backup:
kind: pbs
job: homelab-pbs-daily-cloud
schedule: "02:10"
storage: pbs
monitoring:
node_exporter: true
blackbox: false
backup_audit_vmid: true
# --------------------------------------------------------------------------
hermes-ai:
vmid: 147
node: cloud-pc
ip: 192.168.1.31
hostname: hermes-ai
role: Хост под приложение Hermes + прозрачный TUN-прокси через mihomo
provisioner: pct_ssh # playbooks/pve-hermes-ai.yml
lxc:
cores: 2
memory: 4096
swap: 512
disk: data:24
startup: order=90
features: "nesting=1,keyctl=1"
unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse", "/dev/net/tun"]
data_dir: /srv/hermes-ai
ports: [] # публикация портов запрещена: Docker обходит UFW
images:
- metacubex/mihomo@sha256:e6acd921addecfd59a8e2d38203f88356d635b54de6c0673db0e015139989312
runtime: docker-run-systemd # hermes-ai-tun-proxy.service
backup:
kind: pbs
job: homelab-pbs-daily-cloud
schedule: "02:10"
storage: pbs
monitoring:
node_exporter: false # хоста нет в группе monitoring_exporters
blackbox: false
backup_audit_vmid: true
# --------------------------------------------------------------------------
emergency-bot:
vmid: 151
node: mini-pc
ip: 192.168.1.32
hostname: emergency-bot
role: Telegram-бот аварийного доступа
provisioner: tofu # tofu/services.tf, переезд 2026-09-02 (migration-tofu.md)
lxc:
cores: 1
memory: 512
swap: 256
disk: local-lvm:4
startup: order=70
features: "nesting=1" # из roles/pve_lxc/defaults
unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
ports: []
images: [] # роль emergency_bot образы не закрепляет
backup:
kind: none # VMID 148 отсутствует в pve-backup-jobs.yml — бэкапа нет
monitoring:
node_exporter: false # хоста нет в группе monitoring_exporters
blackbox: false
backup_audit_vmid: false
# --------------------------------------------------------------------------
grimmory:
vmid: 157
node: cloud-pc
ip: 192.168.1.34
hostname: grimmory
role: Библиотека книг Grimmory (+ MariaDB), OPDS/KOReader
provisioner: tofu # tofu/svc-grimmory.tf, переезд 2026-09-02 (migration-tofu.md)
lxc:
cores: 2
memory: 4096
swap: 1024
disk: data:64
startup: order=100
# fuse=1 — штатный флаг PVE вместо прежнего обхода сырыми lxc.* строками.
features: "fuse=1,keyctl=1,nesting=1"
unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse"]
data_dir: /opt/grimmory
ports:
- {name: http, port: 6060, proto: tcp, bind: 192.168.1.34}
images:
- grimmory/grimmory:v3.2.4@sha256:dfa7afdfcf25d649fd664497a62385dd00cd9678c37546e182c172e41c8e80cb
- lscr.io/linuxserver/mariadb:11.4.8@sha256:91de7f701bc7fc3a424b81beafca7a7c6c4c5b7c8be6afd2ae148698695c0b0c
runtime: docker-compose-systemd # grimmory.service + grimmory-docker-firewall.service
backup:
kind: pbs+restic
job: homelab-pbs-daily-cloud
schedule: "02:10"
storage: pbs
restic:
profile: grimmory
repository: rclone:yadisk:System/Backups/HomeLab/restic/grimmory
source_path: /opt/grimmory
schedule: "*-*-* 05:15:00"
mariadb_container: grimmory-mariadb
mariadb_database: grimmory
monitoring:
node_exporter: true
blackbox: true
backup_audit_vmid: true
proxy:
domain: books.ada-dev.ru
upstream: 192.168.1.34:6060
caddy_marker: "# {mark} ANSIBLE MANAGED GRIMMORY SITE"
# Тело секции Caddy целиком: OPDS/KOReader требуют отключить сжатие
# и принудительно выставить Accept, иначе читалки не понимают ответ.
# Апстрим в теле обязан совпадать с полем `upstream` выше —
# reverse-proxy.yml это проверяет assert'ом.
caddy_body: |
@grimmory_opds_atom path /api/v1/opds /api/v1/opds/libraries /api/v1/opds/shelves /api/v1/opds/magic-shelves /api/v1/opds/authors /api/v1/opds/series /api/v1/opds/catalog /api/v1/opds/recent /api/v1/opds/surprise
handle @grimmory_opds_atom {
reverse_proxy 192.168.1.34:6060 {
header_up Accept "application/atom+xml"
header_up Accept-Encoding identity
transport http {
compression off
}
}
}
@grimmory_opds_search path /api/v1/opds/search.opds
handle @grimmory_opds_search {
reverse_proxy 192.168.1.34:6060 {
header_up Accept "application/opensearchdescription+xml"
header_up Accept-Encoding identity
transport http {
compression off
}
}
}
@grimmory_device_api path /api/koreader /api/koreader/* /api/v1/opds /api/v1/opds/*
handle @grimmory_device_api {
reverse_proxy 192.168.1.34:6060 {
header_up Accept-Encoding identity
transport http {
compression off
}
}
}
handle {
reverse_proxy 192.168.1.34:6060
}
# Проверка бинд-маунта внутри контейнера caddy: у grimmory ищем маркер
# маршрутизации, а не строку reverse_proxy — она встречается многократно.
caddy_container_check: "@grimmory_device_api path /api/koreader"
# caddy_legacy_regexp отсутствует: в старом reverse-proxy-grimmory.yml
# секции удаления legacy-конфига не было.
health:
path: /api/v1/healthcheck
status_code: [200]
follow_redirects: safe # поведение ansible.builtin.uri по умолчанию
# --------------------------------------------------------------------------
gyro:
vmid: 156
node: mini-pc
ip: 192.168.1.35
hostname: gyro
role: Изолированный контейнер под задачу gyro (доступ в сеть только через mihomo)
provisioner: tofu # tofu/svc-gyro.tf
lxc:
cores: 1
memory: 512
swap: 256
disk: local-lvm:2
startup: order=80
features: "" # pve_lxc_features: [] — nesting отключён намеренно
unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
firewall: proxmox # /etc/pve/firewall/156.fw, policy_in DROP
ports: []
images: [] # роль gyro образы не закрепляет
backup:
kind: pbs
job: homelab-pbs-daily-mini
schedule: "02:40"
storage: pbs
monitoring:
node_exporter: false # хоста нет в группе monitoring_exporters
blackbox: false
backup_audit_vmid: true
@@ -0,0 +1,6 @@
---
# LXC-контейнеры управляются напрямую под root, sudo не требуется.
ansible_user: root
ansible_become: false
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
@@ -0,0 +1,9 @@
---
gyro_repo_url: git@github.com:ada-dmitry/t_tech-gyro.git
gyro_repo_version: main
gyro_git_known_hosts_name: github.com
gyro_git_host_key: "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"
gyro_deploy_enabled: true
gyro_secrets_configured: false
gyro_timer_enabled: true
gyro_timer_on_calendar: "Mon..Fri *-*-* 11:00:00 Europe/Moscow"
@@ -0,0 +1,10 @@
---
# Copy to vault.yml, replace the placeholders, then run:
# ansible-vault encrypt inventory/host_vars/gyro/vault.yml
gyro_tinvest_token: t.replace-me
gyro_tinvest_account_id: replace-me
gyro_telegram_bot_token: replace-me
gyro_telegram_user_id: "replace-me"
gyro_telegram_proxy: http://192.168.1.27:7890
gyro_dry_run_override: "true"
gyro_secrets_configured: true
+70 -41
View File
@@ -1,20 +1,17 @@
# Каноничный список хостов HomeLab.
#
# Общие переменные: inventory/group_vars/all/main.yml
# Переменные LXC: inventory/group_vars/lxc_infra/main.yml
# SSH-транспорт: ../ssh_config (подключён через ssh_args в ansible.cfg)
#
# Здесь у хоста остаются только адрес и его индивидуальные факты.
all:
vars:
ansible_python_interpreter: /usr/bin/python3
ansible_user: ansible
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab_ansible
ansible_become: true
homelab_lan_cidr: 192.168.1.0/24
homelab_service_range: 192.168.1.5-192.168.1.40
homelab_zerotier_network_id: 743993800fa5a34c
openvpn_network_cidr: 10.78.0.0/30
openvpn_listen_port: 8443
children:
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
@@ -23,8 +20,6 @@ all:
hosts:
cloud-pc:
ansible_host: 192.168.1.5
ansible_ssh_common_args: >-
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
expected_lan_ip: 192.168.1.5
storage_mounts:
- src: UUID=ae278333-4116-4225-9b95-595496fadd26
@@ -36,65 +31,99 @@ all:
mini-pc:
ansible_host: 192.168.1.10
ansible_ssh_common_args: >-
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
expected_lan_ip: 192.168.1.10
lxc_infra:
hosts:
# Доступны напрямую по LAN (без ProxyJump через ru-vps)
pbs:
ansible_host: 192.168.1.20
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_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
# Остальные LXC — через ru-vps (см. ssh_config)
adguard:
ansible_host: 192.168.1.28
expected_lan_ip: 192.168.1.28
docker-test:
ansible_host: 192.168.1.29
expected_lan_ip: 192.168.1.29
vaultwarden:
ansible_host: 192.168.1.24
ansible_user: root
ansible_become: false
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
ansible_ssh_common_args: >-
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
expected_lan_ip: 192.168.1.24
gitea:
ansible_host: 192.168.1.25
ansible_user: root
ansible_become: false
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
ansible_ssh_common_args: >-
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
expected_lan_ip: 192.168.1.25
mihomo:
ansible_host: 192.168.1.27
expected_lan_ip: 192.168.1.27
monitoring:
ansible_host: 192.168.1.30
expected_lan_ip: 192.168.1.30
hermes-ai:
ansible_host: 192.168.1.31
expected_lan_ip: 192.168.1.31
bash_config_proxy_http_url: http://192.168.1.27:7890
bash_config_proxy_socks_url: socks5h://192.168.1.27:7890
emergency-bot:
ansible_host: 192.168.1.32
expected_lan_ip: 192.168.1.32
grimmory:
ansible_host: 192.168.1.34
expected_lan_ip: 192.168.1.34
gyro:
ansible_host: 192.168.1.35
expected_lan_ip: 192.168.1.35
monitoring_exporters:
hosts:
ru-vps:
cloud-pc:
mini-pc:
pbs:
ovpn-mini:
vaultwarden:
gitea:
mihomo:
adguard:
monitoring:
grimmory:
monitoring_smart_exporters:
hosts:
cloud-pc:
mini-pc:
monitoring_server:
hosts:
monitoring:
vpn_openvpn:
hosts:
ru-vps:
wg-mini:
ovpn-mini:
shell_hosts:
hosts:
ru-vps:
cloud-pc:
mini-pc:
hermes-ai:
servers:
children:
+112
View File
@@ -0,0 +1,112 @@
---
- name: Create and verify current PBS audit before AdGuard update
hosts: mini-pc
gather_facts: false
vars:
adguard_vmid: "{{ homelab_services['adguard'].vmid }}"
tasks:
- name: Verify AdGuard VMID ownership before backup
ansible.builtin.command: "pct config {{ adguard_vmid }}"
register: adguard_pct_config
changed_when: false
failed_when: false
- name: Refuse to back up a foreign AdGuard VMID
ansible.builtin.assert:
that:
- adguard_pct_config.rc == 0
- adguard_existing_hostname == 'adguard'
fail_msg: >-
VMID {{ adguard_vmid }} from the service registry is not the
AdGuard container.
vars:
adguard_existing_hostname: >-
{{ adguard_pct_config.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Check for active Proxmox backup before AdGuard PBS backup
ansible.builtin.command: pgrep -x vzdump
register: adguard_vzdump_preflight
changed_when: false
failed_when: false
- name: Require no active Proxmox backup before AdGuard PBS backup
ansible.builtin.assert:
that:
- adguard_vzdump_preflight.rc != 0
fail_msg: >-
A Proxmox backup is already running on mini-pc.
Retry after the existing backup completes.
- name: Create a fresh AdGuard PBS backup
ansible.builtin.command:
argv:
- vzdump
- "{{ adguard_vmid }}"
- --storage
- pbs
- --mode
- snapshot
- --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged
- name: Run current PBS audit service
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-pbs.service
changed_when: true
- import_playbook: pve-adguard.yml
- name: Verify AdGuard public services after update
hosts: adguard
gather_facts: false
tasks:
- name: Check AdGuard HTTP root
ansible.builtin.uri:
url: http://127.0.0.1/
status_code: [200, 302]
return_content: false
register: adguard_http_root_check
retries: 24
delay: 5
until: adguard_http_root_check.status in [200, 302]
- name: Check AdGuard DNS over UDP
ansible.builtin.command:
argv:
- dig
- "@127.0.0.1"
- localhost
- A
- +time=2
- +tries=1
- +short
register: adguard_dns_udp_check
changed_when: false
retries: 12
delay: 5
until: adguard_dns_udp_check.rc == 0 and '127.0.0.1' in adguard_dns_udp_check.stdout
- name: Check AdGuard DNS over TCP
ansible.builtin.command:
argv:
- dig
- "@127.0.0.1"
- localhost
- A
- +tcp
- +time=2
- +tries=1
- +short
register: adguard_dns_tcp_check
changed_when: false
retries: 12
delay: 5
until: adguard_dns_tcp_check.rc == 0 and '127.0.0.1' in adguard_dns_tcp_check.stdout
+45
View File
@@ -0,0 +1,45 @@
- name: Configure PBS backup audit on mini-pc
hosts: mini-pc
gather_facts: false
roles:
- role: backup_audit
vars:
backup_audit_type: pbs
- name: Configure restic offsite audit on cloud-pc (Gitea)
hosts: cloud-pc
gather_facts: false
roles:
- role: backup_audit
vars:
backup_audit_type: restic
backup_audit_restic_profiles:
- name: gitea
sqlite_name: gitea.db
max_age_hours: 36
- name: Configure restic offsite audit on vaultwarden
hosts: vaultwarden
gather_facts: false
roles:
- role: backup_audit
vars:
backup_audit_type: restic
backup_audit_restic_profiles:
- name: vaultwarden
sqlite_name: db.sqlite3
max_age_hours: 36
- name: Configure restic offsite audit on Grimmory
hosts: grimmory
gather_facts: false
vars:
ansible_become: false
roles:
- role: backup_audit
vars:
backup_audit_type: restic
backup_audit_restic_profiles:
- name: grimmory
expected_name: grimmory.sql
max_age_hours: 36
@@ -0,0 +1,93 @@
---
# ============================================================================
# Выпуск read-only Proxmox-токена для виджета Proxmox в Homepage-дашборде.
#
# Создаёт пользователя homepage@pve, роль PVEAuditor на / и privsep-токен
# homepage@pve!dashboard. Секрет пишется в КОРНЕВОЙ .env как
# DASHBOARD_PVE_API_USER / DASHBOARD_PVE_API_TOKEN_ID /
# DASHBOARD_PVE_API_TOKEN_SECRET — оттуда его читает playbooks/dashboard.yml
# через lookup('env', ...).
#
# Парный к playbooks/bootstrap-monitoring-pve-token.yml. Отдельный принципал,
# чтобы дашборд не зависел от кредов замороженного стека Prometheus.
#
# Запуск: make bootstrap-dashboard-token
# ============================================================================
- name: Create read-only Proxmox token for the Homepage dashboard
hosts: mini-pc
gather_facts: false
vars:
dashboard_pve_user: homepage@pve
dashboard_pve_token_id: dashboard
# .env лежит в корне репозитория, playbook_dir — это ansible/playbooks.
dashboard_pve_env_file: "{{ playbook_dir }}/../../.env"
dashboard_pve_rotate_existing_token: false
tasks:
- name: Read existing Proxmox users
ansible.builtin.command: pveum user list --output-format json
register: dashboard_pve_users_raw
changed_when: false
- name: Create the dashboard Proxmox user
ansible.builtin.command: >-
pveum user add {{ dashboard_pve_user }}
--comment 'Read-only Homepage dashboard user'
when: dashboard_pve_user not in (dashboard_pve_users_raw.stdout | from_json | map(attribute='userid') | list)
- name: Grant PVEAuditor role to the dashboard user
ansible.builtin.command: >-
pveum acl modify / -user {{ dashboard_pve_user }} -role PVEAuditor
changed_when: false
- name: Read the dashboard user tokens
ansible.builtin.command: >-
pveum user token list {{ dashboard_pve_user }} --output-format json
register: dashboard_pve_tokens_raw
changed_when: false
- name: Refuse to overwrite an existing dashboard token
ansible.builtin.assert:
that:
- dashboard_pve_token_id not in (dashboard_pve_tokens_raw.stdout | from_json | map(attribute='tokenid') | list)
fail_msg: >-
Existing dashboard token secret cannot be recovered safely. Rotate it
explicitly (-e dashboard_pve_rotate_existing_token=true) before rerunning.
when: not dashboard_pve_rotate_existing_token | bool
- name: Rotate the existing dashboard token explicitly
ansible.builtin.command: >-
pveum user token remove {{ dashboard_pve_user }} {{ dashboard_pve_token_id }}
when:
- dashboard_pve_rotate_existing_token | bool
- dashboard_pve_token_id in (dashboard_pve_tokens_raw.stdout | from_json | map(attribute='tokenid') | list)
- name: Create the separated dashboard token
ansible.builtin.command: >-
pveum user token add {{ dashboard_pve_user }} {{ dashboard_pve_token_id }}
--privsep 1 --comment 'Homepage Proxmox widget' --output-format json
register: dashboard_pve_token_created
no_log: true
- name: Grant PVEAuditor role to the separated dashboard token
ansible.builtin.command: >-
pveum acl modify / -token {{ dashboard_pve_user }}!{{ dashboard_pve_token_id }} -role PVEAuditor
changed_when: false
- name: Store the dashboard token variables in the root .env
ansible.builtin.lineinfile:
path: "{{ dashboard_pve_env_file }}"
regexp: "^export {{ item.name }}="
line: "export {{ item.name }}='{{ item.value }}'"
create: false
loop:
- name: DASHBOARD_PVE_API_USER
value: "{{ dashboard_pve_user }}"
- name: DASHBOARD_PVE_API_TOKEN_ID
value: "{{ dashboard_pve_token_id }}"
- name: DASHBOARD_PVE_API_TOKEN_SECRET
value: "{{ (dashboard_pve_token_created.stdout | from_json).value }}"
delegate_to: localhost
vars:
ansible_connection: local
ansible_become: false
no_log: true
@@ -0,0 +1,77 @@
---
- name: Create read-only Proxmox token for monitoring
hosts: mini-pc
gather_facts: false
vars:
monitoring_pve_user: monitoring@pve
monitoring_pve_token_id: prometheus
# .env лежит в корне репозитория, playbook_dir — это ansible/playbooks.
monitoring_pve_env_file: "{{ playbook_dir }}/../../.env"
monitoring_pve_rotate_existing_token: false
tasks:
- name: Read existing Proxmox users
ansible.builtin.command: pveum user list --output-format json
register: monitoring_pve_users_raw
changed_when: false
- name: Create monitoring Proxmox user
ansible.builtin.command: >-
pveum user add {{ monitoring_pve_user }}
--comment 'Read-only Prometheus monitoring user'
when: monitoring_pve_user not in (monitoring_pve_users_raw.stdout | from_json | map(attribute='userid') | list)
- name: Grant PVEAuditor role to monitoring user
ansible.builtin.command: >-
pveum acl modify / -user {{ monitoring_pve_user }} -role PVEAuditor
changed_when: false
- name: Read monitoring user tokens
ansible.builtin.command: >-
pveum user token list {{ monitoring_pve_user }} --output-format json
register: monitoring_pve_tokens_raw
changed_when: false
- name: Refuse to overwrite an existing monitoring token
ansible.builtin.assert:
that:
- monitoring_pve_token_id not in (monitoring_pve_tokens_raw.stdout | from_json | map(attribute='tokenid') | list)
fail_msg: Existing monitoring token secret cannot be recovered safely. Rotate it explicitly before rerunning this playbook.
when: not monitoring_pve_rotate_existing_token | bool
- name: Rotate existing monitoring token explicitly
ansible.builtin.command: >-
pveum user token remove {{ monitoring_pve_user }} {{ monitoring_pve_token_id }}
when:
- monitoring_pve_rotate_existing_token | bool
- monitoring_pve_token_id in (monitoring_pve_tokens_raw.stdout | from_json | map(attribute='tokenid') | list)
- name: Create separated monitoring token
ansible.builtin.command: >-
pveum user token add {{ monitoring_pve_user }} {{ monitoring_pve_token_id }}
--privsep 1 --comment 'Prometheus PVE exporter' --output-format json
register: monitoring_pve_token_created
no_log: true
- name: Grant PVEAuditor role to separated monitoring token
ansible.builtin.command: >-
pveum acl modify / -token {{ monitoring_pve_user }}!{{ monitoring_pve_token_id }} -role PVEAuditor
changed_when: false
- name: Store monitoring token variables locally
ansible.builtin.lineinfile:
path: "{{ monitoring_pve_env_file }}"
regexp: "^export {{ item.name }}="
line: "export {{ item.name }}='{{ item.value }}'"
create: false
loop:
- name: MONITORING_PVE_API_USER
value: "{{ monitoring_pve_user }}"
- name: MONITORING_PVE_API_TOKEN_ID
value: "{{ monitoring_pve_token_id }}"
- name: MONITORING_PVE_API_TOKEN_SECRET
value: "{{ (monitoring_pve_token_created.stdout | from_json).value }}"
delegate_to: localhost
vars:
ansible_connection: local
ansible_become: false
no_log: true
@@ -37,10 +37,17 @@
pve_token_data: "{{ pve_token_add.stdout | from_json }}"
no_log: true
- name: Write local ansible/.env
# ВНИМАНИЕ: задача переписывает файл ЦЕЛИКОМ, а не правит отдельные строки.
# Всё, что добавлено вручную или другими целями — MONITORING_*, EMERGENCY_*,
# PROXMOX_ROOT_PASSWORD — будет потеряно. Поэтому backup: true.
# Строки пишутся с префиксом `export`: по нему bootstrap-monitoring-pve-token.yml
# находит их своим lineinfile (regexp: "^export NAME="), и он же позволяет
# просто сделать `source .env` в обычном шелле.
- name: Write the local .env in the repository root
ansible.builtin.copy:
dest: "{{ playbook_dir }}/../.env"
dest: "{{ playbook_dir }}/../../.env"
mode: "0600"
backup: true
content: |
export PROXMOX_HOST={{ ansible_host }}
export PROXMOX_USER='{{ pve_api_user }}'
+131
View File
@@ -0,0 +1,131 @@
---
# ============================================================================
# Дашборд-обзор всей инфраструктуры HomeLab (Homepage, gethomepage.dev).
#
# ОБЛАСТЬ ОТВЕТСТВЕННОСТИ
# Разворачивает compose-стек Homepage на LXC monitoring (рядом с Uptime
# Kuma) и ГЕНЕРИРУЕТ его конфиги из реестра homelab_services: одна плитка
# на сервис, ссылка на его UI, группировка по узлу Proxmox. Добавили
# сервис в реестр -> плитка появилась сама, второй список вести не нужно.
#
# Наружу дашборд НЕ публикуется: контейнер слушает только LAN-адрес CT 155,
# доступ из локальной сети или по OpenVPN. Внутренняя топология (IP, VMID,
# раскладка по нодам) на публичный периметр не выносится.
#
# ГРАНИЦА С reverse-proxy.yml
# Никакой общей области. Дашборд — отдельный стек на другом хосте, Caddyfile
# он не трогает.
#
# ЗАПУСК
# make dashboard (ansible-playbook playbooks/dashboard.yml)
# make dry-dashboard (--check --diff)
# Первый прогон, пока образ не закреплён по digest:
# ansible-playbook playbooks/dashboard.yml -e dashboard_allow_floating_tag=true
#
# СЕКРЕТЫ
# Виджет proxmox требует read-only токен homepage@pve!dashboard (роль
# PVEAuditor). Выпускается playbooks/bootstrap-dashboard-pve-token.yml,
# секрет кладётся в корневой .env как DASHBOARD_PVE_API_USER /
# DASHBOARD_PVE_API_TOKEN_ID / DASHBOARD_PVE_API_TOKEN_SECRET и подхватывается
# отсюда через lookup('env', ...). Без него дашборд работает — просто плитка
# "Proxmox кластер" показывает ошибку виджета, это не блокер.
#
# ТАРГЕТ
# По умолчанию homelab_dashboard_host (monitoring). Переопределяется ради
# blue-green: `-e dashboard_config_target=monitoring-new --limit monitoring-new`
# (голый --limit play не перенацеливает, а обнуляет — см. uptime-kuma.yml).
# ============================================================================
- name: Deploy the HomeLab infrastructure dashboard (Homepage) on the monitoring LXC
hosts: "{{ dashboard_config_target | default(homelab_dashboard_host | default('monitoring')) }}"
gather_facts: true
vars:
dash_root: "{{ homelab_dashboard_dir | default('/opt/homepage') }}"
dash_config_dir: "{{ (homelab_dashboard_dir | default('/opt/homepage')) ~ '/config' }}"
dash_bind: "{{ homelab_dashboard_bind_ip | default(expected_lan_ip) }}"
dash_port: "{{ homelab_dashboard_port | default(8082) }}"
dash_image: "{{ homelab_dashboard_image | default('') }}"
dash_allowed_hosts: >-
{{ homelab_dashboard_allowed_hosts | default(dash_bind ~ ':' ~ dash_port) }}
# Список узлов Proxmox, встречающихся в реестре, — по нему строятся группы.
dashboard_nodes: >-
{{ homelab_services | dict2items | map(attribute='value.node')
| unique | sort | list }}
# Секрет Proxmox-виджета из окружения (make load .env). Пусто -> плитка
# рендерится без данных. no_log на задаче, которая это пишет.
dash_pve_user: "{{ lookup('ansible.builtin.env', 'DASHBOARD_PVE_API_USER') }}"
dash_pve_token_id: "{{ lookup('ansible.builtin.env', 'DASHBOARD_PVE_API_TOKEN_ID') }}"
dash_pve_token_secret: "{{ lookup('ansible.builtin.env', 'DASHBOARD_PVE_API_TOKEN_SECRET') }}"
pre_tasks:
- name: Require the dashboard registry variables
ansible.builtin.assert:
that:
- dash_image | length > 0
- dash_bind | length > 0
fail_msg: >-
Не заданы homelab_dashboard_* в
inventory/group_vars/all/services.yml.
- name: Require the Homepage image to be pinned by digest
ansible.builtin.assert:
that:
- "'@sha256:' in dash_image or dashboard_allow_floating_tag | default(false) | bool"
fail_msg: >-
{{ dash_image }} не закреплён по digest. Выполни на
{{ inventory_hostname }}:
docker pull {{ dash_image }}
docker inspect --format '{{ '{{' }} index .RepoDigests 0 {{ '}}' }}' {{ dash_image }}
и пропиши tag@sha256 в homelab_dashboard_image. Разовый обход для
первого прогона: -e dashboard_allow_floating_tag=true
- name: Ensure the Homepage config directory exists
ansible.builtin.file:
path: "{{ dash_config_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Render Homepage configuration files from the registry
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ dash_config_dir }}/{{ item.dest }}"
owner: root
group: root
mode: "0644"
loop:
- {src: homepage-settings.yaml.j2, dest: settings.yaml}
- {src: homepage-services.yaml.j2, dest: services.yaml}
- {src: homepage-widgets.yaml.j2, dest: widgets.yaml}
- {src: homepage-bookmarks.yaml.j2, dest: bookmarks.yaml}
loop_control:
label: "{{ item.dest }}"
register: dash_config_files
- name: Render the Homepage widget secrets file
ansible.builtin.template:
src: homepage.env.j2
dest: "{{ dash_root }}/homepage.env"
owner: root
group: root
mode: "0600"
register: dash_env_file
no_log: true
roles:
- role: compose_service
compose_service_name: homelab-homepage
compose_service_description: HomeLab infrastructure dashboard (Homepage)
compose_service_root: "{{ dash_root }}"
compose_service_root_mode: "0755"
compose_service_compose_file: docker-compose.yml
compose_service_compose_template: homepage-compose.yml.j2
# | bool обязателен: роль фильтрует триггеры по truthiness, а строка
# "False" из "{{ ... is changed }}" тоже истинна.
compose_service_restart_triggers:
- "{{ (dash_config_files is changed) | bool }}"
- "{{ (dash_env_file is changed) | bool }}"
compose_service_health_url: "http://{{ dash_bind }}:{{ dash_port }}/"
compose_service_health_status: [200]
compose_service_health_follow_redirects: none
+42
View File
@@ -0,0 +1,42 @@
---
- name: Bootstrap emergency-bot control identity
hosts: emergency-bot
gather_facts: false
tasks:
- name: Create emergency-bot control identity
ansible.builtin.include_role:
name: emergency_bot
tasks_from: bootstrap
- name: Configure mini-pc reverse SSH client and restricted control path
hosts: mini-pc
gather_facts: false
vars:
emergency_vps_host_key: "{{ lookup('env', 'EMERGENCY_VPS_HOST_KEY') }}"
emergency_bot_control_public_key: "{{ hostvars['emergency-bot'].emergency_bot_control_public_key }}"
tasks:
- name: Configure mini-pc emergency access client
ansible.builtin.include_role:
name: emergency_access
tasks_from: client
- name: Configure ru-vps reverse SSH endpoint
hosts: ru-vps
gather_facts: false
vars:
emergency_reverse_public_key: "{{ hostvars['mini-pc'].emergency_reverse_public_key }}"
tasks:
- name: Configure ru-vps emergency access endpoint
ansible.builtin.include_role:
name: emergency_access
tasks_from: endpoint
- name: Configure and enable emergency Telegram bot
hosts: emergency-bot
gather_facts: false
vars:
emergency_bot_token: "{{ lookup('env', 'EMERGENCY_BOT_TOKEN') }}"
emergency_bot_allowed_user_ids: "{{ lookup('env', 'EMERGENCY_ALLOWED_USER_IDS') }}"
emergency_mini_pc_host_key: "{{ lookup('env', 'EMERGENCY_MINI_PC_HOST_KEY') }}"
roles:
- role: emergency_bot
+51
View File
@@ -0,0 +1,51 @@
---
- name: Create and verify Gitea backup before update
hosts: gitea
gather_facts: false
tasks:
- name: Create a fresh Gitea offsite backup
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-restic-offsite-gitea.service
changed_when: true
- name: Run Gitea backup audit
hosts: cloud-pc
gather_facts: false
tasks:
- name: Run Gitea offsite backup audit
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-gitea.service
changed_when: true
- import_playbook: pve-gitea.yml
vars:
pve_provisioning_enabled: false
- name: Verify Gitea public endpoints after update
hosts: ru-vps
gather_facts: false
tasks:
- name: Check Gitea HTTPS endpoint
ansible.builtin.uri:
url: https://git.ada-dev.ru/
status_code: 200
return_content: false
register: gitea_https_check
retries: 24
delay: 5
until: gitea_https_check.status == 200
- name: Check Gitea SSH port from ru-vps
ansible.builtin.wait_for:
host: 192.168.1.25
port: 2222
state: started
timeout: 30
+109
View File
@@ -0,0 +1,109 @@
---
- name: Create and verify Grimmory backup before update
hosts: grimmory
gather_facts: false
tasks:
- name: Create a fresh Grimmory offsite backup
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-restic-offsite-grimmory.service
changed_when: true
- name: Run Grimmory offsite backup audit
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-grimmory.service
changed_when: true
- name: Create a fresh Grimmory PBS backup
hosts: cloud-pc
gather_facts: false
vars:
grimmory_vmid: "{{ homelab_services['grimmory'].vmid }}"
tasks:
- name: Read Grimmory LXC config
ansible.builtin.command:
argv:
- pct
- config
- "{{ grimmory_vmid }}"
register: grimmory_pct_config
changed_when: false
- name: Assert registry VMID belongs to Grimmory
ansible.builtin.assert:
that:
- grimmory_pct_hostname_line != ""
- grimmory_pct_hostname == "grimmory"
fail_msg: >-
Refusing to run vzdump {{ grimmory_vmid }} because pct config
hostname is not grimmory:
{{ grimmory_pct_hostname_line | default('missing hostname line') }}
vars:
grimmory_pct_hostname_line: >-
{{ (grimmory_pct_config.stdout_lines | select('match', '^hostname:\\s+') | list | first | default('')) }}
grimmory_pct_hostname: >-
{{ grimmory_pct_hostname_line | regex_replace('^hostname:\\s*', '') }}
- name: Check for active Proxmox backup before Grimmory PBS backup
ansible.builtin.command: pgrep -x vzdump
register: grimmory_vzdump_preflight
changed_when: false
failed_when: false
- name: Require no active Proxmox backup before Grimmory PBS backup
ansible.builtin.assert:
that:
- grimmory_vzdump_preflight.rc != 0
fail_msg: >-
A Proxmox backup is already running on cloud-pc.
Retry after the existing backup completes.
- name: Create a fresh Grimmory PBS backup
ansible.builtin.command:
argv:
- vzdump
- "{{ grimmory_vmid }}"
- --storage
- pbs
- --mode
- snapshot
- --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged
- name: Run PBS backup audit on mini-pc
hosts: mini-pc
gather_facts: false
tasks:
- name: Run current PBS backup audit on mini-pc
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-pbs.service
changed_when: true
- import_playbook: pve-grimmory.yml
vars:
pve_provisioning_enabled: false
- name: Verify Grimmory public endpoint after update
hosts: ru-vps
gather_facts: false
tasks:
- name: Check Grimmory public health endpoint
ansible.builtin.uri:
url: https://books.ada-dev.ru/api/v1/healthcheck
status_code: 200
return_content: false
register: grimmory_public_health
retries: 24
delay: 5
until: grimmory_public_health.status == 200
+15
View File
@@ -0,0 +1,15 @@
---
- name: Configure Gyro investment allocator host
# Таргет переопределяем ради blue-green переезда на OpenTofu: во время
# миграции этот play нужно прогнать против нового контейнера на временном
# адресе. Просто `--limit gyro-new` для этого НЕ годится — лимит
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
# ansible-playbook playbooks/gyro.yml \
# -e pve_config_target=gyro-new --limit gyro-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('gyro') }}"
gather_facts: true
roles:
- role: gyro
+98
View File
@@ -0,0 +1,98 @@
---
- name: Verify Mihomo PBS audit before update
hosts: mini-pc
gather_facts: false
vars:
mihomo_vmid: "{{ homelab_services['mihomo'].vmid }}"
tasks:
- name: Read Mihomo VMID configuration
ansible.builtin.command: "pct config {{ mihomo_vmid }}"
register: mihomo_pct_config
changed_when: false
failed_when: false
- name: Refuse to run backup unless registry VMID is Mihomo
ansible.builtin.assert:
that:
- mihomo_pct_config.rc == 0
- mihomo_update_hostname == 'mihomo'
fail_msg: >-
VMID {{ mihomo_vmid }} from the service registry must be the Mihomo
container before backup.
vars:
mihomo_update_hostname: >-
{{ mihomo_pct_config.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Check for active Proxmox backup before Mihomo PBS backup
ansible.builtin.command: pgrep -x vzdump
register: mihomo_vzdump_preflight
changed_when: false
failed_when: false
- name: Require no active Proxmox backup before Mihomo PBS backup
ansible.builtin.assert:
that:
- mihomo_vzdump_preflight.rc != 0
fail_msg: >-
A Proxmox backup is already running on mini-pc.
Retry after the existing backup completes.
- name: Create a fresh Mihomo PBS backup
ansible.builtin.command:
argv:
- vzdump
- "{{ mihomo_vmid }}"
- --storage
- pbs
- --mode
- snapshot
- --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged
- name: Run current PBS backup audit on mini-pc
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-pbs.service
changed_when: true
- import_playbook: pve-mihomo.yml
- name: Verify Mihomo after update
hosts: mihomo
gather_facts: false
tasks:
- name: Wait for Mihomo proxy TCP ports
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: started
timeout: 120
loop:
- 7890
- 7891
- 9090
- name: Check Mihomo UI endpoint
ansible.builtin.uri:
url: http://127.0.0.1:8080/
status_code: 200
register: mihomo_ui_health
retries: 24
delay: 5
until: mihomo_ui_health.status == 200
- name: Check Mihomo controller /version endpoint
ansible.builtin.uri:
url: http://127.0.0.1:9090/version
status_code: 200
register: mihomo_controller_health
retries: 24
delay: 5
until: mihomo_controller_health.status == 200
+26
View File
@@ -0,0 +1,26 @@
---
- name: Configure Prometheus exporters
hosts: monitoring_exporters
gather_facts: false
roles:
- role: monitoring_exporter
- name: Configure SMART exporters on Proxmox nodes
hosts: monitoring_smart_exporters
gather_facts: false
roles:
- role: monitoring_exporter
monitoring_exporter_install_node: false
monitoring_exporter_install_smart: true
- name: Configure ru-vps external probes
hosts: ru-vps
gather_facts: false
roles:
- role: monitoring_blackbox
- name: Configure central monitoring stack
hosts: monitoring_server
gather_facts: false
roles:
- role: monitoring_server
@@ -0,0 +1,87 @@
# Профиль выполняется ВНУТРИ контейнера gitea, а не на ноде cloud-pc.
# До переезда на OpenTofu (2026-09-02) данные жили в bind mount каталога ноды
# /opt/data/gitea, и профиль работал на самой ноде. После переезда данные —
# это volume контейнера (mp0 на datastore), снаружи LXC такого пути больше нет,
# поэтому профиль переехал внутрь по образцу vaultwarden. Репозиторий restic
# тот же, цепочка снапшотов продолжается; изменились только пути внутри них.
- name: Configure Gitea offsite backup to Yandex Disk
hosts: gitea
gather_facts: false
vars:
ansible_become: false
offsite_profile: gitea
offsite_repository: rclone:yadisk:System/Backups/HomeLab/restic/gitea
offsite_source_path: /opt/gitea/data
offsite_sqlite_db: /opt/gitea/data/gitea/gitea.db
offsite_backup_tag: gitea,cloud-pc,yadisk
offsite_timer_oncalendar: "*-*-* 04:15:00"
offsite_rclone_config_local: ~/.config/rclone/rclone.conf
offsite_restic_password_local: "{{ playbook_dir }}/../generated/restic-offsite-password"
offsite_excludes:
- /opt/gitea/data/gitea/gitea.db
- /opt/gitea/data/gitea/gitea.db-shm
- /opt/gitea/data/gitea/gitea.db-wal
- /opt/gitea/data/gitea/log/**
- /opt/gitea/data/gitea/sessions/**
- /opt/gitea/data/gitea/queues/**
- /opt/gitea/data/gitea/tmp/**
# lost+found появился вместе с переходом на volume: это свежая ext4, и
# каталог принадлежит uid 0 хоста, что внутри unprivileged LXC видно как
# nobody:nogroup и нечитаемо. Без исключения restic отдаёт exit 3
# ("at least one source file could not be read") и юнит падает каждую
# ночь, хотя снапшот при этом сохраняется. Найдено 2026-09-02.
- /opt/gitea/data/lost+found/**
tasks:
- name: Configure restic offsite profile
ansible.builtin.include_tasks: ../tasks/offsite-restic-profile.yml
- name: Configure Vaultwarden offsite backup to Yandex Disk
hosts: vaultwarden
gather_facts: false
vars:
ansible_become: false
offsite_profile: vaultwarden
offsite_repository: rclone:yadisk:System/Backups/HomeLab/restic/vaultwarden
offsite_source_path: /opt/vaultwarden/data
offsite_sqlite_db: /opt/vaultwarden/data/db.sqlite3
offsite_backup_tag: vaultwarden,mini-pc,yadisk
offsite_timer_oncalendar: "*-*-* 04:45:00"
offsite_rclone_config_local: ~/.config/rclone/rclone.conf
offsite_restic_password_local: "{{ playbook_dir }}/../generated/restic-offsite-password"
offsite_excludes:
- /opt/vaultwarden/data/db.sqlite3
- /opt/vaultwarden/data/db.sqlite3-shm
- /opt/vaultwarden/data/db.sqlite3-wal
- /opt/vaultwarden/data/tmp/**
tasks:
- name: Configure restic offsite profile
ansible.builtin.include_tasks: ../tasks/offsite-restic-profile.yml
- name: Configure Grimmory offsite backup to Yandex Disk
hosts: grimmory
gather_facts: false
vars:
ansible_become: false
offsite_profile: grimmory
offsite_repository: rclone:yadisk:System/Backups/HomeLab/restic/grimmory
offsite_source_path: /opt/grimmory
offsite_sqlite_db: ""
offsite_mariadb_container: grimmory-mariadb
offsite_mariadb_database: grimmory
offsite_mariadb_user: grimmory
offsite_mariadb_env: /opt/grimmory/.env
offsite_mariadb_dump_name: grimmory.sql
offsite_backup_tag: grimmory,cloud-pc,yadisk
offsite_timer_oncalendar: "*-*-* 05:15:00"
offsite_rclone_config_local: ~/.config/rclone/rclone.conf
offsite_restic_password_local: "{{ playbook_dir }}/../generated/restic-offsite-password"
offsite_excludes:
- /opt/grimmory/books/**
- /opt/grimmory/bookdrop/**
- /opt/grimmory/mariadb/**
- /opt/grimmory/backup-staging/**
- /opt/grimmory/**/heapdump*.hprof
- /opt/grimmory/**/cache/**
tasks:
- name: Configure restic offsite profile
ansible.builtin.include_tasks: ../tasks/offsite-restic-profile.yml
+12
View File
@@ -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
+267
View File
@@ -0,0 +1,267 @@
---
- name: Create AdGuard Home LXC on mini-pc
hosts: mini-pc
gather_facts: false
vars:
# После переезда на OpenTofu (tofu/svc-adguard.tf, VMID 158) этот play —
# legacy: он таргетит СТАРЫЙ VMID 144 и его handler сделал бы pct start 144,
# подняв остановленный откат на боевом адресе. Пропускаем, пока реестр
# говорит provisioner: tofu. Для намеренного legacy rollback:
# -e pve_adguard_legacy_provisioning_enabled=true
pve_adguard_legacy_provisioning_enabled: >-
{{ homelab_services['adguard'].provisioner != 'tofu' }}
adguard_vmid: 144
adguard_hostname: adguard
adguard_ip: 192.168.1.28/24
adguard_gateway: 192.168.1.1
adguard_rootfs: local-lvm:8
adguard_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
adguard_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
adguard_image: adguard/adguardhome:v0.107.78@sha256:2c127294fa5f96151d9d3a433fb9d66c17e4d18cf698c2b04372a80e26fdd26f
handlers:
- name: restart adguard lxc
ansible.builtin.shell: "pct stop {{ adguard_vmid }} || true; pct start {{ adguard_vmid }}"
changed_when: true
pre_tasks:
- name: Skip legacy AdGuard provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_adguard_legacy_provisioning_enabled | bool)
tasks:
- name: Check if AdGuard LXC exists
ansible.builtin.command: "pct config {{ adguard_vmid }}"
register: adguard_pct_config
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID {{ adguard_vmid }}
ansible.builtin.assert:
that:
- adguard_pct_config.rc != 0 or adguard_existing_hostname == adguard_hostname
fail_msg: VMID {{ adguard_vmid }} already exists and is not the AdGuard container.
vars:
adguard_existing_hostname: >-
{{ adguard_pct_config.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Install AdGuard LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/adguard-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', adguard_pubkey_file) }}\n"
when: adguard_pct_config.rc != 0
- name: Create AdGuard LXC
ansible.builtin.command: >-
pct create {{ adguard_vmid }} {{ adguard_ostemplate }}
--hostname {{ adguard_hostname }}
--rootfs {{ adguard_rootfs }}
--cores 1
--memory 512
--swap 512
--net0 name=eth0,bridge=vmbr0,gw={{ adguard_gateway }},ip={{ adguard_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=40
--cmode shell
--ssh-public-keys /tmp/adguard-lxc.pub
when: adguard_pct_config.rc != 0
- name: Start AdGuard LXC
ansible.builtin.command: "pct start {{ adguard_vmid }}"
register: adguard_pct_start
changed_when: adguard_pct_start.rc == 0
failed_when: adguard_pct_start.rc not in [0, 255]
- name: Allow FUSE device in AdGuard LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ adguard_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart adguard lxc
- name: Bind mount FUSE device in AdGuard LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ adguard_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart adguard lxc
- name: Apply pending LXC config changes
ansible.builtin.meta: flush_handlers
- name: Wait for AdGuard SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: adguard
vars:
ansible_become: false
- name: Configure AdGuard Home inside LXC
# Таргет переопределяем ради blue-green переезда на OpenTofu: во время
# миграции этот play нужно прогнать против нового контейнера на временном
# адресе. Просто `--limit adguard-new` для этого НЕ годится — лимит
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
# ansible-playbook playbooks/pve-adguard.yml \
# -e pve_config_target=adguard-new --limit adguard-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('adguard') }}"
gather_facts: true
vars:
ansible_become: false
adguard_image: adguard/adguardhome:v0.107.78@sha256:2c127294fa5f96151d9d3a433fb9d66c17e4d18cf698c2b04372a80e26fdd26f
tasks:
- name: Install Docker packages
ansible.builtin.apt:
name:
- docker.io
- fuse-overlayfs
- ca-certificates
- curl
- dnsutils
state: present
update_cache: true
- name: Ensure Docker config directory exists
ansible.builtin.file:
path: /etc/docker
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: adguard_docker_daemon_config
- name: Enable Docker service
ansible.builtin.systemd:
name: docker
state: "{{ 'restarted' if adguard_docker_daemon_config.changed else 'started' }}"
enabled: true
- name: Ensure AdGuard data directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- /opt/adguard/work
- /opt/adguard/conf
- name: Check if the configured AdGuard image is present
ansible.builtin.command: "docker image inspect {{ adguard_image }}"
register: adguard_image_inspect
changed_when: false
failed_when: false
- name: Pull the configured AdGuard image
ansible.builtin.command: "docker pull {{ adguard_image }}"
when: adguard_image_inspect.rc != 0
register: adguard_image_pull
changed_when: true
- name: Install AdGuard Home systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/adguard.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=AdGuard Home container
After=docker.service
Requires=docker.service
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f adguard
ExecStart=/usr/bin/docker run --rm --name adguard --pull never \
-p 53:53/tcp -p 53:53/udp -p 80:80/tcp -p 3000:3000/tcp \
-v /opt/adguard/work:/opt/adguardhome/work \
-v /opt/adguard/conf:/opt/adguardhome/conf \
{{ adguard_image }}
ExecStop=/usr/bin/docker stop adguard
[Install]
WantedBy=multi-user.target
register: adguard_unit
- name: Remove obsolete AdGuard post-start validation unit
ansible.builtin.file:
path: /etc/systemd/system/adguard-post-start.service
state: absent
register: adguard_post_start_unit_removed
- name: Reload systemd when AdGuard units change
ansible.builtin.systemd:
daemon_reload: true
when: adguard_unit.changed or adguard_post_start_unit_removed.changed
- name: Enable and start AdGuard Home
ansible.builtin.systemd:
name: adguard
state: "{{ 'restarted' if adguard_unit.changed or adguard_image_pull.changed else 'started' }}"
enabled: true
- name: Wait for AdGuard HTTP root
ansible.builtin.uri:
url: http://127.0.0.1/
status_code: [200, 302]
return_content: false
register: adguard_http_root
retries: 24
delay: 5
until: adguard_http_root.status in [200, 302]
- name: Verify AdGuard DNS over UDP
ansible.builtin.command:
argv:
- dig
- "@127.0.0.1"
- localhost
- A
- +time=2
- +tries=1
- +short
register: adguard_dns_udp
changed_when: false
retries: 12
delay: 5
until: adguard_dns_udp.rc == 0 and '127.0.0.1' in adguard_dns_udp.stdout
- name: Verify AdGuard DNS over TCP
ansible.builtin.command:
argv:
- dig
- "@127.0.0.1"
- localhost
- A
- +tcp
- +time=2
- +tries=1
- +short
register: adguard_dns_tcp
changed_when: false
retries: 12
delay: 5
until: adguard_dns_tcp.rc == 0 and '127.0.0.1' in adguard_dns_tcp.stdout
+127
View File
@@ -0,0 +1,127 @@
# Списки VMID выводятся из реестра homelab_services по полю backup.job, а не
# перечисляются вручную. Раньше добавление сервиса требовало отдельной правки
# здесь, и о ней легко было забыть — CT 148 emergency-bot до сих пор без бэкапа
# именно по этой причине.
- name: Configure Proxmox backup jobs
hosts: mini-pc
gather_facts: false
vars:
pve_backup_jobs:
- id: homelab-pbs-daily-cloud
comment: Daily PBS backup for cloud-pc service containers
node: cloud-pc
vmid: >-
{{ homelab_services.values() | selectattr('backup.job', 'defined')
| selectattr('backup.job', 'eq', 'homelab-pbs-daily-cloud')
| map(attribute='vmid') | sort | join(',') }}
storage: pbs
schedule: "02:10"
mode: snapshot
exclude_path: /var/lib/docker/fuse-overlayfs/*/merged
- id: homelab-pbs-daily-mini
comment: Daily PBS backup for mini-pc service containers
node: mini-pc
vmid: >-
{{ homelab_services.values() | selectattr('backup.job', 'defined')
| selectattr('backup.job', 'eq', 'homelab-pbs-daily-mini')
| map(attribute='vmid') | sort | join(',') }}
storage: pbs
schedule: "02:40"
mode: snapshot
exclude_path: /var/lib/docker/fuse-overlayfs/*/merged
- id: homelab-local-weekly-pbs
comment: Weekly local backup for PBS container rootfs/config
node: cloud-pc
vmid: >-
{{ homelab_services.values() | selectattr('backup.job', 'defined')
| selectattr('backup.job', 'eq', 'homelab-local-weekly-pbs')
| map(attribute='vmid') | sort | join(',') }}
storage: backup
schedule: "Sun 03:30"
mode: snapshot
prune_backups: keep-last=2
tasks:
- name: Get existing Proxmox backup jobs
ansible.builtin.command: pvesh get /cluster/backup --output-format json
register: pve_backup_jobs_existing_raw
changed_when: false
- name: Parse existing Proxmox backup jobs
ansible.builtin.set_fact:
pve_backup_jobs_existing: "{{ pve_backup_jobs_existing_raw.stdout | from_json }}"
- name: Create missing Proxmox backup jobs
ansible.builtin.command: >-
pvesh create /cluster/backup
--id {{ item.id }}
--enabled 1
--node {{ item.node }}
--vmid {{ item.vmid }}
--storage {{ item.storage }}
--schedule {{ item.schedule | quote }}
--mode {{ item.mode }}
{% if item.prune_backups is defined %}
--prune-backups {{ item.prune_backups }} --remove 1
{% else %}
--remove 0
{% endif %}
{% if item.exclude_path is defined %}
--exclude-path {{ item.exclude_path | quote }}
{% endif %}
--notes-template {{ '{{' }}guestname{{ '}}' }}
--comment {{ item.comment | quote }}
loop: "{{ pve_backup_jobs }}"
when: item.id not in (pve_backup_jobs_existing | map(attribute='id') | list)
- name: Update existing Proxmox backup jobs
ansible.builtin.command: >-
pvesh set /cluster/backup/{{ item.id }}
--enabled 1
--node {{ item.node }}
--vmid {{ item.vmid }}
--storage {{ item.storage }}
--schedule {{ item.schedule | quote }}
--mode {{ item.mode }}
{% if item.prune_backups is defined %}
--prune-backups {{ item.prune_backups }} --remove 1
{% else %}
--delete prune-backups --remove 0
{% endif %}
{% if item.exclude_path is defined %}
--exclude-path {{ item.exclude_path | quote }}
{% else %}
--delete exclude-path
{% endif %}
--notes-template {{ '{{' }}guestname{{ '}}' }}
--comment {{ item.comment | quote }}
loop: "{{ pve_backup_jobs }}"
vars:
current_job: >-
{{ pve_backup_jobs_existing | selectattr('id', 'equalto', item.id) | first }}
when:
- item.id in (pve_backup_jobs_existing | map(attribute='id') | list)
- >-
current_job.enabled | int != 1 or
current_job.node != item.node or
current_job.vmid | string != item.vmid | string or
current_job.storage != item.storage or
current_job.schedule != item.schedule or
current_job.mode != item.mode or
current_job.comment != item.comment or
current_job.remove | int != (1 if item.prune_backups is defined else 0) or
current_job['exclude-path'] | default([]) != ([item.exclude_path] if item.exclude_path is defined else []) or
(item.prune_backups is not defined and current_job['prune-backups'] is defined) or
(item.prune_backups is defined and
(current_job['prune-backups'] | default({})).get('keep-last', 0) | int !=
item.prune_backups | regex_replace('^keep-last=', '') | int)
changed_when: true
- name: Show configured Proxmox backup jobs
ansible.builtin.command: pvesh get /cluster/backup --output-format yaml
register: pve_backup_jobs_configured
changed_when: false
- name: Print configured Proxmox backup jobs
ansible.builtin.debug:
var: pve_backup_jobs_configured.stdout_lines
+145
View File
@@ -0,0 +1,145 @@
---
- name: Create Docker test LXC on cloud-pc
hosts: cloud-pc
gather_facts: false
vars:
# После переезда на OpenTofu (tofu/svc-docker-test.tf, VMID 152) этот play —
# legacy: он таргетит СТАРЫЙ VMID 145 и его handler/"Start" сделали бы
# pct start 145, подняв остановленный откат на боевом адресе 192.168.1.29.
# Пропускаем, пока реестр говорит provisioner: tofu. Для намеренного
# legacy rollback: -e pve_docker_test_legacy_provisioning_enabled=true
pve_docker_test_legacy_provisioning_enabled: >-
{{ homelab_services['docker-test'].provisioner != 'tofu' }}
docker_test_vmid: 145
docker_test_hostname: docker-test
docker_test_ip: 192.168.1.29/24
docker_test_gateway: 192.168.1.1
docker_test_rootfs: data:8
docker_test_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
docker_test_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
handlers:
- name: restart docker test lxc
ansible.builtin.shell: "pct stop {{ docker_test_vmid }} || true; pct start {{ docker_test_vmid }}"
changed_when: true
pre_tasks:
- name: Skip legacy docker-test provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_docker_test_legacy_provisioning_enabled | bool)
tasks:
- name: Check if Docker test LXC exists
ansible.builtin.command: "pct config {{ docker_test_vmid }}"
register: docker_test_pct_config
changed_when: false
failed_when: false
- name: Install Docker test LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/docker-test-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', docker_test_pubkey_file) }}\n"
when: docker_test_pct_config.rc != 0
- name: Create Docker test LXC
ansible.builtin.command: >-
pct create {{ docker_test_vmid }} {{ docker_test_ostemplate }}
--hostname {{ docker_test_hostname }}
--rootfs {{ docker_test_rootfs }}
--cores 1
--memory 512
--swap 512
--net0 name=eth0,bridge=vmbr0,gw={{ docker_test_gateway }},ip={{ docker_test_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=50
--cmode shell
--ssh-public-keys /tmp/docker-test-lxc.pub
when: docker_test_pct_config.rc != 0
- name: Start Docker test LXC
ansible.builtin.command: "pct start {{ docker_test_vmid }}"
register: docker_test_pct_start
changed_when: docker_test_pct_start.rc == 0
failed_when: docker_test_pct_start.rc not in [0, 255]
- name: Allow FUSE device in Docker test LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ docker_test_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart docker test lxc
- name: Bind mount FUSE device in Docker test LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ docker_test_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart docker test lxc
- name: Apply pending LXC config changes
ansible.builtin.meta: flush_handlers
- name: Wait for Docker test SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: docker-test
vars:
ansible_become: false
- name: Configure Docker test host
# Таргет переопределяем ради blue-green переезда на OpenTofu: во время
# миграции этот play нужно прогнать против нового контейнера на временном
# адресе. Просто `--limit docker-test-new` для этого НЕ годится — лимит
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
# ansible-playbook playbooks/pve-docker-test.yml \
# -e pve_config_target=docker-test-new --limit docker-test-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('docker-test') }}"
gather_facts: true
vars:
ansible_become: false
tasks:
- name: Install Docker packages
ansible.builtin.apt:
name:
- docker.io
- fuse-overlayfs
- ca-certificates
- curl
state: present
update_cache: true
- name: Ensure Docker config directory exists
ansible.builtin.file:
path: /etc/docker
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: docker_test_daemon_config
- name: Enable Docker service
ansible.builtin.systemd:
name: docker
state: "{{ 'restarted' if docker_test_daemon_config.changed else 'started' }}"
enabled: true
- name: Verify Docker can run a container
ansible.builtin.command: docker run --rm hello-world
changed_when: false
+49
View File
@@ -0,0 +1,49 @@
---
# После переезда на OpenTofu (tofu/services.tf, VMID 151) обе play здесь —
# legacy: они таргетят СТАРЫЙ VMID 148 на боевом адресе 192.168.1.32.
# Пропускаем, пока реестр говорит provisioner: tofu. Конфигурация emergency-bot
# в этот плейбук не входит — она в playbooks/emergency-access.yml. Для
# намеренного legacy rollback: -e pve_emergency_bot_legacy_provisioning_enabled=true
- name: Create emergency-bot LXC on mini-pc
hosts: localhost
connection: local
become: false
gather_facts: false
vars:
ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_emergency_bot_legacy_provisioning_enabled: >-
{{ homelab_services['emergency-bot'].provisioner != 'tofu' }}
pve_lxc_vmid: 148
pve_lxc_node: mini-pc
pve_lxc_hostname: emergency-bot
pve_lxc_ip: 192.168.1.32/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_disk: local-lvm:4
pve_lxc_cores: 1
pve_lxc_memory: 512
pve_lxc_swap: 256
pve_lxc_startup: order=70
pve_lxc_ostemplate: "{{ lookup('env', 'PVE_LXC_OSTEMPLATE') | default('local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst', true) }}"
pre_tasks:
- name: Skip legacy emergency-bot provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_emergency_bot_legacy_provisioning_enabled | bool)
roles:
- role: pve_lxc
- name: Wait for emergency-bot SSH
hosts: emergency-bot
gather_facts: false
vars:
pve_emergency_bot_legacy_provisioning_enabled: >-
{{ homelab_services['emergency-bot'].provisioner != 'tofu' }}
pre_tasks:
- name: Skip legacy emergency-bot provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_emergency_bot_legacy_provisioning_enabled | bool)
tasks:
- name: Wait for emergency-bot to accept SSH connections
ansible.builtin.wait_for_connection:
timeout: 120
+51 -4
View File
@@ -1,6 +1,10 @@
- name: Create Gitea LXC on cloud-pc
hosts: cloud-pc
gather_facts: false
pre_tasks:
- name: Skip legacy Gitea provisioning for runtime-only calls
ansible.builtin.meta: end_play
when: not (pve_provisioning_enabled | default(true) | bool)
vars:
gitea_vmid: 141
gitea_hostname: gitea
@@ -25,6 +29,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
@@ -98,12 +115,21 @@
ansible_become: false
- name: Configure Docker and Gitea inside LXC
hosts: gitea
# Таргет переопределяем ради blue-green переезда на OpenTofu: во время
# миграции этот play нужно прогнать против нового контейнера на временном
# адресе. Просто `--limit gitea-new` для этого НЕ годится — лимит
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
# ansible-playbook playbooks/pve-gitea.yml \
# -e pve_config_target=gitea-new --limit gitea-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('gitea') }}"
gather_facts: true
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 +180,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 +210,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 +231,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
+462
View File
@@ -0,0 +1,462 @@
---
- name: Guard Grimmory VMID before API updates
hosts: cloud-pc
gather_facts: false
pre_tasks:
- name: Skip legacy Grimmory provisioning for runtime-only calls
ansible.builtin.meta: end_play
when: not (pve_provisioning_enabled | default(true) | bool)
tasks:
- name: Read existing VMID 149 configuration
ansible.builtin.command: pct config 149
register: grimmory_existing_vmid
check_mode: false
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID 149
ansible.builtin.assert:
that:
- grimmory_existing_vmid.rc != 0 or grimmory_existing_hostname == 'grimmory'
fail_msg: VMID 149 already exists and is not the Grimmory container.
vars:
grimmory_existing_hostname: >-
{{ grimmory_existing_vmid.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Create Grimmory LXC on cloud-pc
hosts: localhost
connection: local
become: false
gather_facts: false
pre_tasks:
- name: Skip legacy Grimmory provisioning for runtime-only calls
ansible.builtin.meta: end_play
when: not (pve_provisioning_enabled | default(true) | bool)
vars:
ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_lxc_vmid: 149
pve_lxc_node: cloud-pc
pve_lxc_hostname: grimmory
pve_lxc_ip: 192.168.1.34/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_disk: data:64
pve_lxc_cores: 2
pve_lxc_memory: 4096
pve_lxc_swap: 1024
pve_lxc_startup: order=100
pve_lxc_unprivileged: true
pve_lxc_update: false
pve_lxc_features:
- nesting=1
pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
roles:
- role: pve_lxc
- name: Configure Grimmory LXC devices
hosts: cloud-pc
gather_facts: false
pre_tasks:
- name: Skip legacy Grimmory provisioning for runtime-only calls
ansible.builtin.meta: end_play
when: not (pve_provisioning_enabled | default(true) | bool)
vars:
grimmory_vmid: 149
handlers:
- name: restart Grimmory LXC
ansible.builtin.command: "pct reboot {{ grimmory_vmid }}"
changed_when: true
tasks:
- name: Read Grimmory LXC configuration
ansible.builtin.command: "pct config {{ grimmory_vmid }}"
register: grimmory_lxc_config
changed_when: false
- name: Require expected Grimmory LXC properties
ansible.builtin.assert:
that:
- "'unprivileged: 1' in grimmory_lxc_config.stdout"
- "'rootfs: data:' in grimmory_lxc_config.stdout"
- "'onboot: 1' in grimmory_lxc_config.stdout"
- name: Configure Grimmory LXC network
ansible.builtin.command: >-
pct set {{ grimmory_vmid }}
--net0 name=eth0,bridge=vmbr0,firewall=1,gw=192.168.1.1,ip=192.168.1.34/24
vars:
grimmory_net0: >-
{{ grimmory_lxc_config.stdout_lines
| select('match', '^net0: ')
| first
| default('') }}
when: >-
'name=eth0' not in grimmory_net0 or
'bridge=vmbr0' not in grimmory_net0 or
'firewall=1' not in grimmory_net0 or
'gw=192.168.1.1' not in grimmory_net0 or
'ip=192.168.1.34/24' not in grimmory_net0
notify: restart Grimmory LXC
- name: Enable keyctl for Docker in Grimmory LXC
ansible.builtin.command: "pct set {{ grimmory_vmid }} --features nesting=1,keyctl=1"
when: "'nesting=1' not in grimmory_lxc_config.stdout or 'keyctl=1' not in grimmory_lxc_config.stdout"
notify: restart Grimmory LXC
- name: Allow FUSE device in Grimmory LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ grimmory_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart Grimmory LXC
- name: Bind mount FUSE device in Grimmory LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ grimmory_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart Grimmory LXC
- name: Apply pending Grimmory LXC configuration
ansible.builtin.meta: flush_handlers
- name: Wait for Grimmory SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 180
delegate_to: grimmory
vars:
ansible_become: false
- name: Configure Grimmory runtime
# Таргет переопределяем ради blue-green переезда на OpenTofu: во время
# миграции этот play нужно прогнать против нового контейнера на временном
# адресе. Просто `--limit grimmory-new` для этого НЕ годится — лимит
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
# ansible-playbook playbooks/pve-grimmory.yml \
# -e pve_config_target=grimmory-new --limit grimmory-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('grimmory') }}"
gather_facts: true
vars:
ansible_become: false
grimmory_root: /opt/grimmory
grimmory_image: grimmory/grimmory:v3.2.4@sha256:dfa7afdfcf25d649fd664497a62385dd00cd9678c37546e182c172e41c8e80cb
grimmory_mariadb_image: lscr.io/linuxserver/mariadb:11.4.8@sha256:91de7f701bc7fc3a424b81beafca7a7c6c4c5b7c8be6afd2ae148698695c0b0c
# Адрес, на который биндится порт, на который смотрят правила DOCKER-USER и
# по которому проверяется health. Раньше во всех трёх местах был зашит
# боевой 192.168.1.34, из-за чего play нельзя было прогнать против другого
# контейнера: Docker не биндит чужой адрес и роняет grimmory.service, а
# health-check уходил по сети в БОЕВОЙ сервис и давал ложный успех.
# expected_lan_ip — уже принятая в репозитории идиома для "адрес этого
# хоста в LAN" (её же использует roles/uptime_kuma). На боевом grimmory она
# равна 192.168.1.34, поэтому рендер там не меняется. Найдено 2026-09-02
# при blue-green переезде на OpenTofu.
grimmory_bind_ip: "{{ expected_lan_ip }}"
tasks:
- name: Install Grimmory runtime packages
ansible.builtin.apt:
name:
- ca-certificates
- curl
- docker-compose
- docker.io
- fuse-overlayfs
- mariadb-client
- openssl
- prometheus-node-exporter
- ufw
state: present
update_cache: true
- name: Verify FUSE device exists
ansible.builtin.stat:
path: /dev/fuse
register: grimmory_fuse
- name: Require FUSE device
ansible.builtin.assert:
that:
- grimmory_fuse.stat.exists
- grimmory_fuse.stat.ischr
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: grimmory_docker_config
- name: Enable Docker service
ansible.builtin.systemd:
name: docker
enabled: true
state: "{{ 'restarted' if grimmory_docker_config.changed else 'started' }}"
- name: Allow SSH from the HomeLab LAN
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ homelab_lan_cidr }}"
- name: Allow SSH from the OpenVPN network
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ openvpn_network_cidr }}"
- name: Allow Grimmory from the HomeLab LAN
community.general.ufw:
rule: allow
port: "6060"
proto: tcp
src: "{{ homelab_lan_cidr }}"
- name: Allow Grimmory from the OpenVPN network
community.general.ufw:
rule: allow
port: "6060"
proto: tcp
src: "{{ openvpn_network_cidr }}"
- name: Allow Node Exporter from the monitoring LXC
community.general.ufw:
rule: allow
port: "9100"
proto: tcp
src: 192.168.1.30
- name: Enable restrictive Grimmory firewall
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Create Grimmory directories
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
group: "{{ item.group }}"
mode: "{{ item.mode }}"
loop:
- { path: "{{ grimmory_root }}", owner: root, group: root, mode: "0750" }
- { path: "{{ grimmory_root }}/data", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/books", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/bookdrop", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/mariadb", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/backup-staging", owner: root, group: root, mode: "0700" }
- name: Generate Grimmory secrets once
ansible.builtin.shell: |
set -eu
umask 077
if [ -e {{ grimmory_root }}/.env ]; then
exit 0
fi
db_password=$(openssl rand -hex 32)
root_password=$(openssl rand -hex 32)
cat > {{ grimmory_root }}/.env <<EOF
TZ=Europe/Moscow
APP_USER_ID=1000
APP_GROUP_ID=1000
DB_USER_ID=1000
DB_GROUP_ID=1000
DB_PASSWORD=$db_password
MYSQL_ROOT_PASSWORD=$root_password
EOF
printf created
args:
executable: /bin/sh
register: grimmory_secrets
changed_when: grimmory_secrets.stdout == 'created'
no_log: true
- name: Enforce Grimmory secret file permissions
ansible.builtin.file:
path: "{{ grimmory_root }}/.env"
owner: root
group: root
mode: "0600"
- name: Install Grimmory environment example
ansible.builtin.copy:
dest: "{{ grimmory_root }}/.env.example"
owner: root
group: root
mode: "0644"
content: |
TZ=Europe/Moscow
APP_USER_ID=1000
APP_GROUP_ID=1000
DB_USER_ID=1000
DB_GROUP_ID=1000
DB_PASSWORD=replace-with-random-password
MYSQL_ROOT_PASSWORD=replace-with-separate-random-password
- name: Install Grimmory Compose configuration
ansible.builtin.copy:
dest: "{{ grimmory_root }}/compose.yml"
owner: root
group: root
mode: "0644"
content: |
services:
grimmory:
image: {{ grimmory_image }}
container_name: grimmory
environment:
USER_ID: "${APP_USER_ID}"
GROUP_ID: "${APP_GROUP_ID}"
TZ: "${TZ}"
DATABASE_URL: jdbc:mariadb://mariadb:3306/grimmory
DATABASE_USERNAME: grimmory
DATABASE_PASSWORD: "${DB_PASSWORD}"
DISK_TYPE: LOCAL
ALLOWED_ORIGINS: https://books.ada-dev.ru
depends_on:
mariadb:
condition: service_healthy
ports:
- "{{ grimmory_bind_ip }}:6060:6060"
volumes:
- ./data:/app/data
- ./books:/books
- ./bookdrop:/bookdrop
restart: unless-stopped
mariadb:
image: {{ grimmory_mariadb_image }}
container_name: grimmory-mariadb
environment:
PUID: "${DB_USER_ID}"
PGID: "${DB_GROUP_ID}"
TZ: "${TZ}"
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
MYSQL_DATABASE: grimmory
MYSQL_USER: grimmory
MYSQL_PASSWORD: "${DB_PASSWORD}"
volumes:
- ./mariadb:/config
healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
register: grimmory_compose
- name: Install Docker firewall script
ansible.builtin.copy:
dest: /usr/local/sbin/grimmory-docker-firewall
owner: root
group: root
mode: "0755"
content: |
#!/bin/sh
set -eu
iptables -N GRIMMORY-FILTER 2>/dev/null || true
iptables -F GRIMMORY-FILTER
iptables -A GRIMMORY-FILTER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A GRIMMORY-FILTER -s {{ homelab_lan_cidr }} -p tcp -m conntrack --ctorigdst {{ grimmory_bind_ip }} --ctorigdstport 6060 -j ACCEPT
iptables -A GRIMMORY-FILTER -s {{ openvpn_network_cidr }} -p tcp -m conntrack --ctorigdst {{ grimmory_bind_ip }} --ctorigdstport 6060 -j ACCEPT
iptables -A GRIMMORY-FILTER -p tcp -m conntrack --ctorigdst {{ grimmory_bind_ip }} --ctorigdstport 6060 -j DROP
iptables -A GRIMMORY-FILTER -j RETURN
iptables -C DOCKER-USER -j GRIMMORY-FILTER 2>/dev/null || iptables -I DOCKER-USER 1 -j GRIMMORY-FILTER
register: grimmory_firewall_script
- name: Install Docker firewall service
ansible.builtin.copy:
dest: /etc/systemd/system/grimmory-docker-firewall.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Restrict Grimmory Docker published port
After=docker.service
Requires=docker.service
Before=grimmory.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/grimmory-docker-firewall
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
register: grimmory_firewall_unit
- name: Install Grimmory systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/grimmory.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Grimmory Compose stack
Wants=network-online.target
After=network-online.target docker.service grimmory-docker-firewall.service
Requires=docker.service grimmory-docker-firewall.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory={{ grimmory_root }}
ExecStart=/usr/bin/docker compose -f {{ grimmory_root }}/compose.yml up -d --remove-orphans
ExecStop=/usr/bin/docker compose -f {{ grimmory_root }}/compose.yml down
[Install]
WantedBy=multi-user.target
register: grimmory_unit
- name: Reload systemd for Grimmory units
ansible.builtin.systemd:
daemon_reload: true
when: grimmory_firewall_unit.changed or grimmory_unit.changed
- name: Enable and apply Docker firewall
ansible.builtin.systemd:
name: grimmory-docker-firewall
enabled: true
state: "{{ 'restarted' if grimmory_firewall_script.changed or grimmory_firewall_unit.changed else 'started' }}"
- name: Validate Grimmory Compose configuration
ansible.builtin.command: docker compose -f {{ grimmory_root }}/compose.yml config --quiet
args:
chdir: "{{ grimmory_root }}"
changed_when: false
no_log: true
- name: Enable and start Grimmory
ansible.builtin.systemd:
name: grimmory
enabled: true
state: "{{ 'restarted' if grimmory_compose.changed or grimmory_unit.changed else 'started' }}"
- name: Wait for Grimmory health endpoint
ansible.builtin.uri:
url: http://{{ grimmory_bind_ip }}:6060/api/v1/healthcheck
status_code: 200
register: grimmory_health
retries: 120
delay: 5
until: grimmory_health.status == 200
- name: Verify Docker storage driver
ansible.builtin.command: docker info --format '{{ "{{" }}.Driver{{ "}}" }}'
register: grimmory_docker_driver
changed_when: false
failed_when: grimmory_docker_driver.stdout != 'fuse-overlayfs'
+205
View File
@@ -0,0 +1,205 @@
---
- name: Guard Gyro VMID before API updates
hosts: mini-pc
gather_facts: false
vars:
pve_gyro_legacy_provisioning_enabled: "{{ homelab_services['gyro'].provisioner != 'tofu' }}"
pre_tasks:
- name: Skip legacy Gyro provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_gyro_legacy_provisioning_enabled | bool)
tasks:
- name: Read existing VMID 150 configuration
ansible.builtin.command: pct config 150
register: gyro_existing_vmid
check_mode: false
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID 150
ansible.builtin.assert:
that:
- gyro_existing_vmid.rc != 0 or gyro_existing_hostname == 'gyro'
fail_msg: VMID 150 already exists and is not the Gyro container.
vars:
gyro_existing_hostname: >-
{{ gyro_existing_vmid.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Create Gyro LXC on mini-pc
hosts: localhost
connection: local
become: false
gather_facts: false
vars:
pve_gyro_legacy_provisioning_enabled: "{{ homelab_services['gyro'].provisioner != 'tofu' }}"
ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_lxc_vmid: 150
pve_lxc_node: mini-pc
pve_lxc_hostname: gyro
pve_lxc_ip: 192.168.1.35/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_disk: local-lvm:2
pve_lxc_cores: 1
pve_lxc_memory: 512
pve_lxc_swap: 256
pve_lxc_startup: order=80
pve_lxc_unprivileged: true
pve_lxc_update: false
pve_lxc_features: []
pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
pre_tasks:
- name: Skip legacy Gyro provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_gyro_legacy_provisioning_enabled | bool)
roles:
- role: pve_lxc
- name: Configure Gyro LXC isolation
hosts: mini-pc
gather_facts: false
vars:
pve_gyro_legacy_provisioning_enabled: "{{ homelab_services['gyro'].provisioner != 'tofu' }}"
gyro_vmid: 150
pre_tasks:
- name: Skip legacy Gyro provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_gyro_legacy_provisioning_enabled | bool)
tasks:
- name: Read Gyro LXC configuration
ansible.builtin.command: "pct config {{ gyro_vmid }}"
register: gyro_lxc_config
changed_when: false
- name: Require expected Gyro LXC properties
ansible.builtin.assert:
that:
- "'hostname: gyro' in gyro_lxc_config.stdout"
- "'unprivileged: 1' in gyro_lxc_config.stdout"
- "'rootfs: local-lvm:' in gyro_lxc_config.stdout"
- "'onboot: 1' in gyro_lxc_config.stdout"
- name: Check for an existing Gyro firewall file
ansible.builtin.stat:
path: "/etc/pve/firewall/{{ gyro_vmid }}.fw"
register: gyro_firewall_file
- name: Preserve the existing Gyro firewall file
ansible.builtin.slurp:
src: "/etc/pve/firewall/{{ gyro_vmid }}.fw"
register: gyro_previous_firewall
when: gyro_firewall_file.stat.exists
- name: Apply and verify Gyro firewall
block:
- name: Render Proxmox firewall for Gyro
ansible.builtin.copy:
dest: "/tmp/gyro-{{ gyro_vmid }}.fw"
owner: root
group: root
mode: "0640"
content: |
[OPTIONS]
enable: 1
policy_in: DROP
policy_out: ACCEPT
[RULES]
IN ACCEPT -source {{ homelab_lan_cidr }} -p tcp -dport 22 -log nolog
IN ACCEPT -source {{ openvpn_network_cidr }} -p tcp -dport 22 -log nolog
OUT ACCEPT -dest 192.168.1.27 -p tcp -dport 7890 -log nolog
OUT DROP -dest {{ homelab_lan_cidr }} -log nolog
register: gyro_rendered_firewall
changed_when: false
- name: Compare rendered and active Gyro firewall
ansible.builtin.command: >-
cmp -s /tmp/gyro-{{ gyro_vmid }}.fw /etc/pve/firewall/{{ gyro_vmid }}.fw
register: gyro_firewall_comparison
changed_when: false
failed_when: gyro_firewall_comparison.rc not in [0, 1]
when: gyro_firewall_file.stat.exists
- name: Install Proxmox firewall for Gyro
ansible.builtin.command: >-
cp /tmp/gyro-{{ gyro_vmid }}.fw /etc/pve/firewall/{{ gyro_vmid }}.fw
when: not gyro_firewall_file.stat.exists or gyro_firewall_comparison.rc != 0
changed_when: true
- name: Validate Proxmox firewall configuration
ansible.builtin.command: pve-firewall compile
changed_when: false
- name: Read cluster firewall options
ansible.builtin.command: pvesh get /cluster/firewall/options --output-format json
register: gyro_cluster_firewall_options
changed_when: false
- name: Report staged Proxmox firewall state
ansible.builtin.debug:
msg: >-
{{ 'Gyro Proxmox firewall is active.'
if (gyro_cluster_firewall_options.stdout | from_json).enable | default(0) | int == 1
else 'Gyro Proxmox firewall is staged but inactive because the cluster firewall is disabled; UFW remains the enforced isolation layer.' }}
- name: Verify SSH remains reachable through the firewall
ansible.builtin.wait_for_connection:
timeout: 180
delegate_to: gyro
vars:
ansible_become: false
rescue:
- name: Restore the previous Gyro firewall file
ansible.builtin.copy:
dest: "/tmp/gyro-{{ gyro_vmid }}-previous.fw"
content: "{{ gyro_previous_firewall.content | b64decode }}"
owner: root
group: root
mode: "0640"
when: gyro_firewall_file.stat.exists
- name: Reinstall the previous Gyro firewall file
ansible.builtin.command: >-
cp /tmp/gyro-{{ gyro_vmid }}-previous.fw /etc/pve/firewall/{{ gyro_vmid }}.fw
when: gyro_firewall_file.stat.exists
changed_when: true
- name: Remove the failed new Gyro firewall file
ansible.builtin.file:
path: "/etc/pve/firewall/{{ gyro_vmid }}.fw"
state: absent
when: not gyro_firewall_file.stat.exists
- name: Recompile restored Proxmox firewall configuration
ansible.builtin.command: pve-firewall compile
changed_when: false
- name: Remove temporary Gyro firewall files after rollback
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- "/tmp/gyro-{{ gyro_vmid }}.fw"
- "/tmp/gyro-{{ gyro_vmid }}-previous.fw"
- name: Stop after rolling back the Gyro firewall
ansible.builtin.fail:
msg: Gyro firewall validation or SSH reachability failed; the previous firewall state was restored.
- name: Remove temporary Gyro firewall file
ansible.builtin.file:
path: "/tmp/gyro-{{ gyro_vmid }}.fw"
state: absent
changed_when: false
- name: Wait for Gyro SSH
hosts: gyro
gather_facts: false
tasks:
- name: Wait for Gyro to accept SSH connections
ansible.builtin.wait_for_connection:
timeout: 180
+334
View File
@@ -0,0 +1,334 @@
---
- name: Create Hermes AI LXC on cloud-pc
hosts: cloud-pc
gather_facts: false
vars:
hermes_ai_vmid: 147
hermes_ai_hostname: hermes-ai
hermes_ai_ip: 192.168.1.31/24
hermes_ai_gateway: 192.168.1.1
hermes_ai_rootfs: data:24
hermes_ai_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
hermes_ai_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
tasks:
- name: Check if Hermes AI LXC exists
ansible.builtin.command: "pct config {{ hermes_ai_vmid }}"
register: hermes_ai_pct_config
changed_when: false
failed_when: false
- name: Install Hermes AI LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/hermes-ai-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', hermes_ai_pubkey_file) }}\n"
when: hermes_ai_pct_config.rc != 0
- name: Create Hermes AI LXC
ansible.builtin.command: >-
pct create {{ hermes_ai_vmid }} {{ hermes_ai_ostemplate }}
--hostname {{ hermes_ai_hostname }}
--rootfs {{ hermes_ai_rootfs }}
--cores 2
--memory 4096
--swap 512
--net0 name=eth0,bridge=vmbr0,gw={{ hermes_ai_gateway }},ip={{ hermes_ai_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=90
--cmode shell
--ssh-public-keys /tmp/hermes-ai-lxc.pub
when: hermes_ai_pct_config.rc != 0
- name: Start Hermes AI LXC
ansible.builtin.command: "pct start {{ hermes_ai_vmid }}"
register: hermes_ai_pct_start
changed_when: hermes_ai_pct_start.rc == 0
failed_when: hermes_ai_pct_start.rc not in [0, 255]
- name: Configure Hermes AI runtime
hosts: cloud-pc
gather_facts: false
vars:
hermes_ai_vmid: 147
handlers:
- name: restart Hermes AI LXC
ansible.builtin.command: "pct reboot {{ hermes_ai_vmid }}"
changed_when: true
tasks:
- name: Read Hermes AI LXC configuration
ansible.builtin.command: "pct config {{ hermes_ai_vmid }}"
register: hermes_ai_lxc_config
changed_when: false
- name: Enable keyctl for Docker in Hermes AI LXC
ansible.builtin.command: "pct set {{ hermes_ai_vmid }} --features nesting=1,keyctl=1"
when: "'keyctl=1' not in hermes_ai_lxc_config.stdout"
notify: restart Hermes AI LXC
- name: Allow FUSE device in Hermes AI LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart Hermes AI LXC
- name: Bind mount FUSE device in Hermes AI LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart Hermes AI LXC
- name: Check TUN device on PVE host
ansible.builtin.stat:
path: /dev/net/tun
register: hermes_ai_tun_device
- name: Require TUN device on PVE host
ansible.builtin.assert:
that:
- hermes_ai_tun_device.stat.exists
- hermes_ai_tun_device.stat.ischr
fail_msg: /dev/net/tun must exist on cloud-pc before enabling the Hermes AI transparent proxy.
- name: Allow TUN device in Hermes AI LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:200 rwm"
state: present
notify: restart Hermes AI LXC
- name: Bind mount TUN device in Hermes AI LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
state: present
notify: restart Hermes AI LXC
- name: Apply pending Hermes AI LXC configuration
ansible.builtin.meta: flush_handlers
- name: Wait for Hermes AI SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: hermes-ai
vars:
ansible_become: false
- name: Install Hermes AI base runtime
hosts: hermes-ai
gather_facts: false
vars:
ansible_become: false
hermes_ai_root: /opt/hermes-ai
hermes_ai_state: /srv/hermes-ai
hermes_ai_proxy_dir: /opt/hermes-ai/tun-proxy
hermes_ai_proxy_image: metacubex/mihomo@sha256:e6acd921addecfd59a8e2d38203f88356d635b54de6c0673db0e015139989312
hermes_ai_proxy_name: hermes-ai-tun-proxy
tasks:
- name: Install Hermes AI runtime packages
ansible.builtin.apt:
name:
- ca-certificates
- curl
- docker-compose
- docker.io
- fuse-overlayfs
- git
- ufw
state: present
update_cache: true
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: hermes_ai_docker_config
- name: Enable Docker service
ansible.builtin.systemd:
name: docker
enabled: true
state: "{{ 'restarted' if hermes_ai_docker_config.changed else 'started' }}"
- name: Allow SSH only from the HomeLab LAN
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ homelab_lan_cidr }}"
- name: Allow SSH from the OpenVPN network
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ openvpn_network_cidr }}"
- name: Enable restrictive Hermes AI firewall
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Create Hermes AI application directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ hermes_ai_root }}"
- "{{ hermes_ai_state }}"
- "{{ hermes_ai_proxy_dir }}"
- name: Configure Hermes AI transparent proxy
ansible.builtin.copy:
dest: "{{ hermes_ai_proxy_dir }}/config.yaml"
owner: root
group: root
mode: "0640"
content: |
ipv6: false
tun:
enable: true
stack: system
device: hermes-tun
auto-route: true
auto-redirect: true
auto-detect-interface: true
route-exclude-address:
- 192.168.1.27/32
dns-hijack:
- any:53
dns:
enable: true
enhanced-mode: redir-host
nameserver:
- https://cloudflare-dns.com/dns-query
sniffer:
enable: true
force-dns-mapping: true
parse-pure-ip: true
proxies:
- name: mihomo-lan
type: socks5
server: 192.168.1.27
port: 7891
proxy-groups:
- name: PROXY
type: select
proxies:
- mihomo-lan
rules:
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
- IP-CIDR,169.254.0.0/16,DIRECT,no-resolve
- MATCH,PROXY
register: hermes_ai_proxy_config
- name: Wait for Mihomo SOCKS5 upstream
ansible.builtin.wait_for:
host: 192.168.1.27
port: 7891
timeout: 15
- name: Validate Hermes AI transparent proxy config
ansible.builtin.command: >-
docker run --rm --network none
-v {{ hermes_ai_proxy_dir }}:/root/.config/mihomo:ro
{{ hermes_ai_proxy_image }} -t -d /root/.config/mihomo
changed_when: false
- name: Install Hermes AI transparent proxy service
ansible.builtin.copy:
dest: /etc/systemd/system/hermes-ai-tun-proxy.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Hermes AI transparent Mihomo proxy
After=docker.service network-online.target
Requires=docker.service
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f {{ hermes_ai_proxy_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ hermes_ai_proxy_name }} \
--network host \
--cap-drop ALL \
--cap-add NET_ADMIN \
--security-opt no-new-privileges \
--device /dev/net/tun \
-v {{ hermes_ai_proxy_dir }}:/root/.config/mihomo:ro \
{{ hermes_ai_proxy_image }}
ExecStop=/usr/bin/docker stop {{ hermes_ai_proxy_name }}
[Install]
WantedBy=multi-user.target
register: hermes_ai_proxy_unit
- name: Reload systemd when transparent proxy service changes
ansible.builtin.systemd:
daemon_reload: true
when: hermes_ai_proxy_unit.changed
- name: Enable Hermes AI transparent proxy
ansible.builtin.systemd:
name: hermes-ai-tun-proxy
enabled: true
state: "{{ 'restarted' if hermes_ai_proxy_config.changed or hermes_ai_proxy_unit.changed else 'started' }}"
- name: Check Hermes AI transparent proxy service
ansible.builtin.command: systemctl is-active hermes-ai-tun-proxy
register: hermes_ai_proxy_status
changed_when: false
failed_when: hermes_ai_proxy_status.stdout != 'active'
- name: Check Hermes AI external HTTPS access
ansible.builtin.command: curl --fail --silent --show-error --max-time 20 https://api.ipify.org
changed_when: false
no_log: true
- name: Document Hermes AI runtime layout
ansible.builtin.copy:
dest: "{{ hermes_ai_root }}/README.md"
owner: root
group: root
mode: "0644"
content: |
# Hermes AI runtime
Deploy the Hermes application here. Keep runtime state and the real
`.env` file in /srv/hermes-ai. Do not commit Telegram or LLM tokens.
Docker is installed with fuse-overlayfs for this unprivileged LXC.
Hermes is a Telegram bot and needs no published Docker ports. Do not
use `ports:` or `-p` without adding an explicit firewall policy:
Docker port publishing can bypass UFW.
The hermes-ai-tun-proxy service routes external traffic through
Mihomo at 192.168.1.27:7890. LAN traffic remains direct.
+352
View File
@@ -0,0 +1,352 @@
---
- name: Create mihomo LXC on mini-pc
hosts: mini-pc
gather_facts: false
vars:
# После переезда на OpenTofu (tofu/svc-mihomo.tf, VMID 159) этот play —
# legacy: он таргетит СТАРЫЙ VMID 143 и его handler сделал бы pct start 143,
# подняв остановленный откат на боевом адресе. Пропускаем, пока реестр
# говорит provisioner: tofu. Для намеренного legacy rollback:
# -e pve_mihomo_legacy_provisioning_enabled=true
pve_mihomo_legacy_provisioning_enabled: >-
{{ homelab_services['mihomo'].provisioner != 'tofu' }}
mihomo_vmid: 143
mihomo_hostname: mihomo
mihomo_ip: 192.168.1.27/24
mihomo_gateway: 192.168.1.1
mihomo_bridge: vmbr0
mihomo_rootfs: local-lvm:8
mihomo_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
mihomo_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
handlers:
- name: restart mihomo lxc
ansible.builtin.shell: "pct stop {{ mihomo_vmid }} || true; pct start {{ mihomo_vmid }}"
changed_when: true
pre_tasks:
- name: Skip legacy mihomo provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_mihomo_legacy_provisioning_enabled | bool)
tasks:
- name: Check if mihomo LXC exists
ansible.builtin.command: "pct config {{ mihomo_vmid }}"
register: mihomo_pct_config
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID 143
ansible.builtin.assert:
that:
- mihomo_pct_config.rc != 0 or mihomo_existing_hostname == mihomo_hostname
fail_msg: VMID 143 already exists and is not the Mihomo container.
vars:
mihomo_existing_hostname: >-
{{ mihomo_pct_config.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Install mihomo LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/mihomo-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', mihomo_pubkey_file) }}\n"
when: mihomo_pct_config.rc != 0
- name: Create mihomo LXC
ansible.builtin.command: >-
pct create {{ mihomo_vmid }} {{ mihomo_ostemplate }}
--hostname {{ mihomo_hostname }}
--rootfs {{ mihomo_rootfs }}
--cores 1
--memory 512
--swap 512
--net0 name=eth0,bridge={{ mihomo_bridge }},gw={{ mihomo_gateway }},ip={{ mihomo_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=70
--cmode shell
--ssh-public-keys /tmp/mihomo-lxc.pub
when: mihomo_pct_config.rc != 0
- name: Start mihomo LXC
ansible.builtin.command: "pct start {{ mihomo_vmid }}"
register: mihomo_pct_start
changed_when: mihomo_pct_start.rc == 0
failed_when: mihomo_pct_start.rc not in [0, 255]
- name: Allow FUSE device in mihomo LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart mihomo lxc
- name: Bind mount FUSE device in mihomo LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart mihomo lxc
- name: Allow TUN device in mihomo LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:200 rwm"
state: present
notify: restart mihomo lxc
- name: Bind mount TUN device in mihomo LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
state: present
notify: restart mihomo lxc
- name: Apply pending LXC config changes
ansible.builtin.meta: flush_handlers
- name: Wait for mihomo SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: mihomo
vars:
ansible_become: false
- name: Prepare mihomo runtime host
# Таргет переопределяем ради blue-green переезда на OpenTofu: во время
# миграции этот play нужно прогнать против нового контейнера на временном
# адресе. Просто `--limit mihomo-new` для этого НЕ годится — лимит
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
# ansible-playbook playbooks/pve-mihomo.yml \
# -e pve_config_target=mihomo-new --limit mihomo-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('mihomo') }}"
gather_facts: true
vars:
ansible_become: false
mihomo_config_dir: /opt/mihomo/config
mihomo_ui_dir: /opt/mihomo/ui
mihomo_image: metacubex/mihomo:v1.19.29@sha256:5e7bcc5e7a866afcc8b007ef827c9ba773f2f34b6d7311b6d39ed1751f37cfd5
mihomo_ui_image: ghcr.io/metacubex/metacubexd:v1.270.6@sha256:156d55be885d4ba6254d840bd781b715c20c00afee6e6c24c76be4cfe5eb89d4
mihomo_container_name: mihomo
mihomo_ui_container_name: mihomo-ui
tasks:
- name: Install Docker and mihomo runtime packages
ansible.builtin.apt:
name:
- docker.io
- fuse-overlayfs
- ca-certificates
- curl
- git
state: present
update_cache: true
- name: Ensure Docker config directory exists
ansible.builtin.file:
path: /etc/docker
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: docker_daemon_config
- name: Ensure Docker service is enabled and running
ansible.builtin.systemd:
name: docker
state: "{{ 'restarted' if docker_daemon_config.changed else 'started' }}"
enabled: true
- name: Ensure mihomo directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- /opt/mihomo
- "{{ mihomo_config_dir }}"
- "{{ mihomo_ui_dir }}"
- name: Check if the configured Mihomo image is present
ansible.builtin.command: "docker image inspect {{ mihomo_image }}"
register: mihomo_image_inspect
changed_when: false
failed_when: false
- name: Pull the configured Mihomo image
ansible.builtin.command: "docker pull {{ mihomo_image }}"
when: mihomo_image_inspect.rc != 0
register: mihomo_image_pull
changed_when: true
- name: Check if the configured Mihomo UI image is present
ansible.builtin.command: "docker image inspect {{ mihomo_ui_image }}"
register: mihomo_ui_image_inspect
changed_when: false
failed_when: false
- name: Pull the configured Mihomo UI image
ansible.builtin.command: "docker pull {{ mihomo_ui_image }}"
when: mihomo_ui_image_inspect.rc != 0
register: mihomo_ui_image_pull
changed_when: true
- name: Install default mihomo config if missing
ansible.builtin.copy:
dest: "{{ mihomo_config_dir }}/config.yaml"
owner: root
group: root
mode: "0640"
force: false
content: |
mixed-port: 7890
socks-port: 7891
allow-lan: true
bind-address: 0.0.0.0
mode: rule
log-level: info
external-controller: 0.0.0.0:9090
dns:
enable: true
listen: 0.0.0.0:1053
enhanced-mode: fake-ip
nameserver:
- 1.1.1.1
- 8.8.8.8
proxies: []
proxy-groups:
- name: PROXY
type: select
proxies:
- DIRECT
rules:
- MATCH,DIRECT
register: mihomo_config
- name: Install mihomo systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/mihomo.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Mihomo proxy container
After=docker.service
Requires=docker.service
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f {{ mihomo_container_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ mihomo_container_name }} \
--pull never \
--cap-add NET_ADMIN \
--device /dev/net/tun \
-p 7890:7890 \
-p 7891:7891 \
-p 9090:9090 \
-v {{ mihomo_config_dir }}:/root/.config/mihomo \
{{ mihomo_image }}
ExecStop=/usr/bin/docker stop {{ mihomo_container_name }}
[Install]
WantedBy=multi-user.target
register: mihomo_unit
- name: Install mihomo UI systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/mihomo-ui.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Mihomo MetaCubeXD web UI container
After=docker.service mihomo.service
Requires=docker.service
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f {{ mihomo_ui_container_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ mihomo_ui_container_name }} \
--pull never \
-p 8080:80 \
{{ mihomo_ui_image }}
ExecStop=/usr/bin/docker stop {{ mihomo_ui_container_name }}
[Install]
WantedBy=multi-user.target
register: mihomo_ui_unit
- name: Reload systemd when mihomo units change
ansible.builtin.systemd:
daemon_reload: true
when: mihomo_unit.changed or mihomo_ui_unit.changed
- name: Enable and start mihomo
ansible.builtin.systemd:
name: mihomo
enabled: true
state: "{{ 'restarted' if mihomo_config.changed or mihomo_unit.changed or mihomo_image_pull is changed else 'started' }}"
- name: Enable and start mihomo UI
ansible.builtin.systemd:
name: mihomo-ui
enabled: true
state: "{{ 'restarted' if mihomo_ui_unit.changed or mihomo_ui_image_pull is changed else 'started' }}"
- name: Wait for Mihomo proxy TCP ports
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: started
timeout: 120
loop:
- 7890
- 7891
- 9090
- name: Check Mihomo UI endpoint
ansible.builtin.uri:
url: http://127.0.0.1:8080/
status_code: 200
register: mihomo_ui_health
retries: 24
delay: 5
until: mihomo_ui_health.status == 200
- name: Check Mihomo controller /version endpoint
ansible.builtin.uri:
url: http://127.0.0.1:9090/version
status_code: 200
register: mihomo_controller_health
retries: 24
delay: 5
until: mihomo_controller_health.status == 200
+63
View File
@@ -0,0 +1,63 @@
---
# После переезда на OpenTofu (tofu/svc-monitoring.tf, VMID 155) обе play здесь —
# legacy: они таргетят СТАРЫЙ VMID 146 на боевом адресе 192.168.1.30, а вторая
# делает pct reboot 146. Пропускаем, пока реестр говорит provisioner: tofu.
# Конфигурация (Uptime Kuma) в этот плейбук не входит — она в
# playbooks/uptime-kuma.yml. Замороженный Prometheus-стек в новый контейнер не
# разворачивался. Для намеренного legacy rollback:
# -e pve_monitoring_legacy_provisioning_enabled=true
- name: Create monitoring LXC on cloud-pc
hosts: localhost
connection: local
become: false
gather_facts: false
vars:
ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_monitoring_legacy_provisioning_enabled: >-
{{ homelab_services['monitoring'].provisioner != 'tofu' }}
pve_lxc_vmid: 146
pve_lxc_node: cloud-pc
pve_lxc_hostname: monitoring
pve_lxc_ip: 192.168.1.30/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_disk: data:24
pve_lxc_cores: 2
pve_lxc_memory: 4096
pve_lxc_swap: 512
pve_lxc_startup: order=80
pve_lxc_features:
- nesting=1
pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
pre_tasks:
- name: Skip legacy monitoring provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_monitoring_legacy_provisioning_enabled | bool)
roles:
- role: pve_lxc
- name: Enable Docker keyctl feature for monitoring LXC
hosts: cloud-pc
gather_facts: false
vars:
pve_monitoring_legacy_provisioning_enabled: >-
{{ homelab_services['monitoring'].provisioner != 'tofu' }}
pre_tasks:
- name: Skip legacy monitoring provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_monitoring_legacy_provisioning_enabled | bool)
tasks:
- name: Read monitoring LXC configuration
ansible.builtin.command: pct config 146
register: monitoring_lxc_config
changed_when: false
- name: Enable keyctl for Docker in monitoring LXC
ansible.builtin.command: pct set 146 --features nesting=1,keyctl=1
when: "'keyctl=1' not in monitoring_lxc_config.stdout"
register: monitoring_lxc_keyctl
- name: Restart monitoring LXC after feature change
ansible.builtin.command: pct reboot 146
when: monitoring_lxc_keyctl.changed
+61
View File
@@ -0,0 +1,61 @@
---
# После переезда на OpenTofu (tofu/svc-ovpn-mini.tf, VMID 160) обе play здесь —
# legacy: они таргетят СТАРЫЙ VMID 132, а их handlers сделали бы pct start 132,
# подняв остановленный откат на боевом адресе 192.168.1.23. Пропускаем, пока
# реестр говорит provisioner: tofu. Конфигурация OpenVPN-шлюза в этот плейбук
# никогда не входила — она в playbooks/openvpn-vps-mini.yml (роль
# openvpn_gateway, группа vpn_openvpn). Для намеренного legacy rollback:
# -e pve_ovpn_mini_legacy_provisioning_enabled=true
- name: Create ovpn-mini LXC on mini-pc
hosts: localhost
connection: local
gather_facts: false
vars:
pve_ovpn_mini_legacy_provisioning_enabled: >-
{{ homelab_services['ovpn-mini'].provisioner != 'tofu' }}
pre_tasks:
- name: Skip legacy ovpn-mini provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_ovpn_mini_legacy_provisioning_enabled | bool)
roles:
- role: pve_lxc
vars:
pve_lxc_vmid: 132
pve_lxc_node: mini-pc
pve_lxc_hostname: ovpn-mini
pve_lxc_ip: 192.168.1.23/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_storage: local-lvm
pve_lxc_disk: local-lvm:8
pve_lxc_ostemplate: "{{ lookup('env', 'PVE_LXC_OSTEMPLATE') | default('local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst', true) }}"
- name: Allow TUN device in ovpn-mini LXC config
hosts: mini-pc
gather_facts: false
become: true
vars:
pve_ovpn_mini_legacy_provisioning_enabled: >-
{{ homelab_services['ovpn-mini'].provisioner != 'tofu' }}
handlers:
- name: restart ovpn-mini lxc
ansible.builtin.shell: pct stop 132 || true; pct start 132
changed_when: true
pre_tasks:
- name: Skip legacy ovpn-mini provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_ovpn_mini_legacy_provisioning_enabled | bool)
tasks:
- name: Allow /dev/net/tun device
ansible.builtin.lineinfile:
path: /etc/pve/lxc/132.conf
line: "lxc.cgroup2.devices.allow: c 10:200 rwm"
state: present
notify: restart ovpn-mini lxc
- name: Bind mount /dev/net/tun
ansible.builtin.lineinfile:
path: /etc/pve/lxc/132.conf
line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
state: present
notify: restart ovpn-mini lxc
+70
View File
@@ -0,0 +1,70 @@
---
- name: Remove storage-level PBS prune policy from pbs storage
hosts: mini-pc
gather_facts: false
tasks:
- name: Read current PBS storage configuration
ansible.builtin.command:
argv:
- pvesh
- get
- /storage/pbs
- --output-format
- json
register: pbs_storage_current_raw
changed_when: false
no_log: true
- name: Parse current PBS storage configuration
ansible.builtin.set_fact:
pbs_storage_current: "{{ pbs_storage_current_raw.stdout | from_json }}"
no_log: true
- name: Assert current PBS storage is safe to modify
ansible.builtin.assert:
that:
- pbs_storage_current.storage | default('') == 'pbs'
- pbs_storage_current.type | default('') == 'pbs'
- pbs_storage_current.digest | default('') | length > 0
fail_msg: Refusing to modify /storage/pbs because the returned storage metadata is unexpected or missing a digest.
no_log: true
- name: Remove storage-level prune policy from PBS storage
ansible.builtin.command:
argv:
- pvesh
- set
- /storage/pbs
- --delete
- prune-backups
- --digest
- "{{ pbs_storage_current.digest }}"
when: pbs_storage_current.get('prune-backups') is not none
changed_when: true
no_log: true
- name: Read back PBS storage configuration
ansible.builtin.command:
argv:
- pvesh
- get
- /storage/pbs
- --output-format
- json
register: pbs_storage_after_raw
changed_when: false
no_log: true
- name: Parse read-back PBS storage configuration
ansible.builtin.set_fact:
pbs_storage_after: "{{ pbs_storage_after_raw.stdout | from_json }}"
no_log: true
- name: Assert storage-level prune policy is absent
ansible.builtin.assert:
that:
- pbs_storage_after.storage | default('') == 'pbs'
- pbs_storage_after.type | default('') == 'pbs'
- pbs_storage_after.get('prune-backups') is none
fail_msg: Storage-level prune policy still exists on /storage/pbs after the declarative fix.
no_log: true
+72 -4
View File
@@ -1,6 +1,38 @@
---
- name: Guard Vaultwarden VMID before API updates
hosts: mini-pc
gather_facts: false
pre_tasks:
- name: Skip legacy Vaultwarden provisioning for runtime-only calls
ansible.builtin.meta: end_play
when: not (pve_provisioning_enabled | default(true) | bool)
tasks:
- name: Read existing VMID 140 configuration
ansible.builtin.command: pct config 140
register: vaultwarden_existing_vmid
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID 140
ansible.builtin.assert:
that:
- vaultwarden_existing_vmid.rc != 0 or vaultwarden_existing_hostname == 'vaultwarden'
fail_msg: VMID 140 already exists and is not the Vaultwarden container.
vars:
vaultwarden_existing_hostname: >-
{{ vaultwarden_existing_vmid.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Create Vaultwarden LXC on mini-pc
hosts: mini-pc
gather_facts: false
pre_tasks:
- name: Skip legacy Vaultwarden provisioning for runtime-only calls
ansible.builtin.meta: end_play
when: not (pve_provisioning_enabled | default(true) | bool)
vars:
vaultwarden_vmid: 140
vaultwarden_hostname: vaultwarden
@@ -79,12 +111,21 @@
ansible_become: false
- name: Configure Docker and Vaultwarden inside LXC
hosts: vaultwarden
# Таргет переопределяем ради blue-green переезда на OpenTofu: во время
# миграции этот play нужно прогнать против нового контейнера на временном
# адресе. Просто `--limit vaultwarden-new` для этого НЕ годится — лимит
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
# ansible-playbook playbooks/pve-vaultwarden.yml \
# -e pve_config_target=vaultwarden-new --limit vaultwarden-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('vaultwarden') }}"
gather_facts: true
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 +174,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 +204,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 +223,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'
-38
View File
@@ -1,38 +0,0 @@
- name: Create wg-mini LXC on mini-pc
hosts: localhost
connection: local
gather_facts: false
roles:
- role: pve_lxc
vars:
pve_lxc_vmid: 132
pve_lxc_node: mini-pc
pve_lxc_hostname: wg-mini
pve_lxc_ip: 192.168.1.23/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_storage: local-lvm
pve_lxc_disk: local-lvm:8
pve_lxc_ostemplate: "{{ lookup('env', 'PVE_LXC_OSTEMPLATE') | default('local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst', true) }}"
- name: Allow TUN device in wg-mini LXC config
hosts: mini-pc
gather_facts: false
become: true
handlers:
- name: restart wg-mini lxc
ansible.builtin.shell: pct stop 132 || true; pct start 132
changed_when: true
tasks:
- name: Allow /dev/net/tun device
ansible.builtin.lineinfile:
path: /etc/pve/lxc/132.conf
line: "lxc.cgroup2.devices.allow: c 10:200 rwm"
state: present
notify: restart wg-mini lxc
- name: Bind mount /dev/net/tun
ansible.builtin.lineinfile:
path: /etc/pve/lxc/132.conf
line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
state: present
notify: restart wg-mini lxc
-65
View File
@@ -1,65 +0,0 @@
- name: Configure Gitea reverse proxy on ru-vps
hosts: ru-vps
gather_facts: false
vars:
caddy_dir: /opt/services/ru-vps/caddy
caddyfile_path: /opt/services/ru-vps/caddy/Caddyfile
gitea_domain: git.ada-dev.ru
gitea_upstream: 192.168.1.25:3000
tasks:
- name: Ensure Caddy service directory exists
ansible.builtin.file:
path: "{{ caddy_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Gitea Caddy site
ansible.builtin.blockinfile:
path: "{{ caddyfile_path }}"
create: true
owner: root
group: root
mode: "0644"
marker: "# {mark} ANSIBLE MANAGED GITEA SITE"
block: |
{{ gitea_domain }} {
reverse_proxy {{ gitea_upstream }}
}
register: gitea_caddy_site
- name: Remove legacy unmanaged Gitea Caddy site
ansible.builtin.replace:
path: "{{ caddyfile_path }}"
regexp: '(?ms)^git\.ada-dev\.ru \{\n\s*reverse_proxy 10\.122\.62\.51:3002\n\}\n+'
replace: ''
register: gitea_legacy_site
- name: Validate host Caddy config
ansible.builtin.command: caddy validate --config {{ caddyfile_path }}
changed_when: false
- name: Check Caddy container mounted config
ansible.builtin.command: docker exec caddy grep -F 'reverse_proxy {{ gitea_upstream }}' /etc/caddy/Caddyfile
register: gitea_container_caddyfile
changed_when: false
failed_when: false
- name: Restart Caddy when config changed or bind mount is stale
ansible.builtin.command: docker restart caddy
when: >-
gitea_caddy_site.changed or
gitea_legacy_site.changed or
gitea_container_caddyfile.rc != 0
- name: Validate Caddy container config after restart
ansible.builtin.command: docker exec caddy caddy validate --config /etc/caddy/Caddyfile
changed_when: false
- name: Check Gitea upstream from ru-vps
ansible.builtin.command: curl -fsS -o /dev/null -w '%{http_code}' http://{{ gitea_upstream }}/
register: gitea_upstream_http
changed_when: false
failed_when: gitea_upstream_http.stdout != '200'
@@ -1,65 +0,0 @@
- name: Configure Vaultwarden reverse proxy on ru-vps
hosts: ru-vps
gather_facts: false
vars:
caddy_dir: /opt/services/ru-vps/caddy
caddyfile_path: /opt/services/ru-vps/caddy/Caddyfile
vaultwarden_domain: pass.ada-dev.ru
vaultwarden_upstream: 192.168.1.24:80
tasks:
- name: Ensure Caddy service directory exists
ansible.builtin.file:
path: "{{ caddy_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Vaultwarden Caddy site
ansible.builtin.blockinfile:
path: "{{ caddyfile_path }}"
create: true
owner: root
group: root
mode: "0644"
marker: "# {mark} ANSIBLE MANAGED VAULTWARDEN SITE"
block: |
{{ vaultwarden_domain }} {
reverse_proxy {{ vaultwarden_upstream }}
}
register: vaultwarden_caddy_site
- name: Remove legacy unmanaged Vaultwarden Caddy site
ansible.builtin.replace:
path: "{{ caddyfile_path }}"
regexp: '(?ms)^pass\.ada-dev\.ru \{\n\s*reverse_proxy 10\.122\.62\.95:10380\n\}\n+'
replace: ''
register: vaultwarden_legacy_site
- name: Validate host Caddy config
ansible.builtin.command: caddy validate --config {{ caddyfile_path }}
changed_when: false
- name: Check Caddy container mounted config
ansible.builtin.command: docker exec caddy grep -F 'reverse_proxy {{ vaultwarden_upstream }}' /etc/caddy/Caddyfile
register: vaultwarden_container_caddyfile
changed_when: false
failed_when: false
- name: Restart Caddy when config changed or bind mount is stale
ansible.builtin.command: docker restart caddy
when: >-
vaultwarden_caddy_site.changed or
vaultwarden_legacy_site.changed or
vaultwarden_container_caddyfile.rc != 0
- name: Validate Caddy container config after restart
ansible.builtin.command: docker exec caddy caddy validate --config /etc/caddy/Caddyfile
changed_when: false
- name: Check Vaultwarden upstream from ru-vps
ansible.builtin.command: curl -fsS -o /dev/null -w '%{http_code}' http://{{ vaultwarden_upstream }}/
register: vaultwarden_upstream_http
changed_when: false
failed_when: vaultwarden_upstream_http.stdout != '200'
+182
View File
@@ -0,0 +1,182 @@
---
# ============================================================================
# Единый плейбук публикации сервисов через Caddy на ru-vps.
#
# Заменяет reverse-proxy-gitea.yml, reverse-proxy-vaultwarden.yml и
# reverse-proxy-grimmory.yml. Источник данных — реестр homelab_services
# (ansible/inventory/group_vars/all/services.yml): обрабатываются все сервисы,
# у которых определён блок `proxy`.
#
# Отличие от трёх старых плейбуков: контейнер Caddy перезапускается ОДИН раз
# в конце, если изменился хотя бы один сайт (раньше — по разу на плейбук).
#
# Запуск:
# ansible-playbook playbooks/reverse-proxy.yml
# ansible-playbook playbooks/reverse-proxy.yml -e 'reverse_proxy_only=[gitea]'
# ============================================================================
- name: Configure public reverse proxy sites on ru-vps
hosts: ru-vps
gather_facts: false
vars:
caddy_dir: "{{ homelab_reverse_proxy_dir | default('/opt/services/ru-vps/caddy') }}"
caddyfile_path: "{{ homelab_reverse_proxy_caddyfile | default('/opt/services/ru-vps/caddy/Caddyfile') }}"
caddy_container: "{{ homelab_reverse_proxy_container | default('caddy') }}"
caddyfile_container_path: /etc/caddy/Caddyfile
# Ограничить прогон подмножеством сервисов: -e 'reverse_proxy_only=[gitea]'
reverse_proxy_only: []
reverse_proxy_services: >-
{{ homelab_services | dict2items
| selectattr('value.proxy', 'defined')
| selectattr('key', 'in', reverse_proxy_only) | list
if reverse_proxy_only | length > 0
else homelab_services | dict2items
| selectattr('value.proxy', 'defined') | list }}
tasks:
- name: Require at least one service with a published domain
ansible.builtin.assert:
that:
- reverse_proxy_services | length > 0
fail_msg: >-
В homelab_services нет ни одного сервиса с блоком `proxy`
(или reverse_proxy_only отфильтровал всё).
# Циклы по именам, а не по записям целиком: иначе assert печатает весь
# словарь сервиса в вывод при каждом прогоне.
- name: Require every proxied service to declare domain and upstream
ansible.builtin.assert:
that:
- svc.domain | default('') | length > 0
- svc.upstream | default('') | length > 0
- svc.caddy_marker | default('') is search('{mark}')
fail_msg: >-
Сервис {{ item }}: в proxy обязаны быть domain, upstream и
caddy_marker с плейсхолдером {mark}.
quiet: true
loop: "{{ reverse_proxy_services | map(attribute='key') | list }}"
vars:
svc: "{{ homelab_services[item].proxy }}"
- name: Require a custom Caddy body to point at the declared upstream
ansible.builtin.assert:
that:
- svc.upstream in svc.caddy_body
fail_msg: >-
Сервис {{ item }}: caddy_body не содержит апстрим
{{ svc.upstream }} — реестр рассинхронизирован.
quiet: true
loop: >-
{{ reverse_proxy_services
| selectattr('value.proxy.caddy_body', 'defined')
| map(attribute='key') | list }}
vars:
svc: "{{ homelab_services[item].proxy }}"
- name: Ensure Caddy service directory exists
ansible.builtin.file:
path: "{{ caddy_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Caddy sites
ansible.builtin.blockinfile:
path: "{{ caddyfile_path }}"
create: true
owner: root
group: root
mode: "0644"
marker: "{{ item.value.proxy.caddy_marker }}"
block: |
{{ item.value.proxy.domain }} {
{% if item.value.proxy.caddy_body is defined %}
{{ item.value.proxy.caddy_body | trim | indent(2, first=True) }}
{% else %}
reverse_proxy {{ item.value.proxy.upstream }}
{% endif %}
}
loop: "{{ reverse_proxy_services }}"
loop_control:
label: "{{ item.key }} ({{ item.value.proxy.domain }})"
register: caddy_sites
# ------------------------------------------------------------------
# МИГРАЦИОННАЯ СЕКЦИЯ. Удаляет неуправляемые секции Caddyfile, оставшиеся
# от прежней инсталляции на 10.122.62.0/24. Держится только ради миграции:
# после подтверждённого прогона на ru-vps эту задачу и поля
# proxy.caddy_legacy_regexp в services.yml можно удалить.
# ------------------------------------------------------------------
- name: Remove legacy unmanaged Caddy sites
ansible.builtin.replace:
path: "{{ caddyfile_path }}"
regexp: "{{ item.value.proxy.caddy_legacy_regexp }}"
replace: ''
loop: "{{ reverse_proxy_services | selectattr('value.proxy.caddy_legacy_regexp', 'defined') | list }}"
loop_control:
label: "{{ item.key }}"
register: caddy_legacy
- name: Validate host Caddy config
ansible.builtin.command:
argv:
- caddy
- validate
- --config
- "{{ caddyfile_path }}"
changed_when: false
- name: Check Caddy container mounted config
ansible.builtin.command:
argv:
- docker
- exec
- "{{ caddy_container }}"
- grep
- -F
- >-
{{ item.value.proxy.caddy_container_check
| default('reverse_proxy ' ~ item.value.proxy.upstream) }}
- "{{ caddyfile_container_path }}"
loop: "{{ reverse_proxy_services }}"
loop_control:
label: "{{ item.key }}"
register: caddy_container_checks
changed_when: false
failed_when: false
- name: Restart Caddy once when any site changed or a bind mount is stale
ansible.builtin.command:
argv:
- docker
- restart
- "{{ caddy_container }}"
when: >-
caddy_sites.changed or
caddy_legacy.changed or
(caddy_container_checks.results
| selectattr('rc', 'defined')
| rejectattr('rc', 'equalto', 0)
| list | length > 0)
- name: Validate Caddy container config after restart
ansible.builtin.command:
argv:
- docker
- exec
- "{{ caddy_container }}"
- caddy
- validate
- --config
- "{{ caddyfile_container_path }}"
changed_when: false
- name: Check service upstreams from ru-vps
ansible.builtin.uri:
url: "http://{{ item.value.proxy.upstream }}{{ item.value.proxy.health.path | default('/') }}"
status_code: "{{ item.value.proxy.health.status_code | default([200]) }}"
follow_redirects: "{{ item.value.proxy.health.follow_redirects | default('none') }}"
return_content: false
loop: "{{ reverse_proxy_services }}"
loop_control:
label: "{{ item.key }} -> {{ item.value.proxy.upstream }}"
+137
View File
@@ -0,0 +1,137 @@
---
# Базовое состояние публичной VPS и стек Caddy.
#
# ОБЛАСТЬ ОТВЕТСТВЕННОСТИ
# Этот плейбук ПРИНИМАЕТ существующий ru-vps под управление Ansible, а не
# разворачивает его с нуля. Docker и compose-плагин уже установлены вручную
# (Docker CE 29.x из upstream-репозитория, не Ubuntu'шный docker.io), поэтому
# плейбук их проверяет, но не ставит: `apt install docker.io` поверх Docker CE
# на боевой VPS сломал бы все стеки сразу.
#
# Управляется здесь ТОЛЬКО стек Caddy. Остальные каталоги в
# /opt/services/ru-vps (gitea-runner, mihomo, resticprofile, squid, zerotier)
# развёрнуты вручную и осознанно оставлены вне управления — решение по каждому
# принимается отдельно, см. docs/ai/plan.md.
#
# ГРАНИЦА С reverse-proxy.yml
# Содержимое Caddyfile принадлежит playbooks/reverse-proxy.yml. Здесь файл
# только проверяется на наличие: стартовать Caddy без конфига бессмысленно,
# а перезаписать его отсюда — значит потерять все маршруты.
#
# ЧТО МЕНЯЕТСЯ НА ЖИВОМ ХОСТЕ ПРИ ПЕРВОМ ПРОГОНЕ
# 1. Образ caddy:2-alpine закрепляется по digest -> контейнер пересоздаётся.
# 2. Появляется systemd-юнит homelab-caddy.service: до сих пор жизненным
# циклом стека управляла только политика restart=unless-stopped, то есть
# после `docker compose down` его никто не поднимал.
# Это кратковременный перерыв в публичном HTTPS. Прогонять осознанно.
#
# ЗАМЕЧАНИЕ ПРО --check
# До первого реального прогона `--check` заканчивается ошибкой
# "Could not find the requested service homelab-caddy": в check-режиме юнит
# на диск не пишется, поэтому systemd его не видит. Это артефакт проверки,
# а не дефект плейбука. Diff'ы задач выше при этом достоверны.
- name: Adopt the ru-vps Caddy stack into Ansible
hosts: ru-vps
gather_facts: true
# roles: выполняются РАНЬШЕ tasks:, поэтому все проверки идут в pre_tasks —
# иначе стек разложился бы до проверки Caddyfile.
pre_tasks:
- name: Verify the container runtime is present
vars:
ru_vps_docker_binary: /usr/bin/docker
block:
- name: Check the Docker binary
ansible.builtin.command: "{{ ru_vps_docker_binary }} version --format '{{ '{{' }}.Server.Version{{ '}}' }}'"
register: ru_vps_docker_version
changed_when: false
# Read-only проверка, поэтому выполняется и в --check.
check_mode: false
- name: Check the Compose plugin
ansible.builtin.command: "{{ ru_vps_docker_binary }} compose version --short"
register: ru_vps_compose_version
changed_when: false
# Read-only проверка, поэтому выполняется и в --check.
check_mode: false
- name: Report the detected runtime
ansible.builtin.debug:
msg: >-
docker={{ ru_vps_docker_version.stdout | trim }}
compose={{ ru_vps_compose_version.stdout | trim }}
# --- Арбитр кластера ---------------------------------------------------
# corosync-qnetd на ru-vps даёт двухнодовому кластеру третий голос.
# Ноды подключаются к нему по публичному адресу (см. corosync.conf,
# host: 157.22.231.198), а старое правило пускало 5403 только из
# 10.122.62.0/24 — сети ZeroTier, выведенной в июле. После этого qdevice
# молча перестал голосовать.
- name: Allow corosync-qnetd from the PVE nodes
community.general.ufw:
rule: allow
port: "5403"
proto: tcp
src: "{{ homelab_pve_egress_ip }}"
comment: corosync-qnetd for the HomeLab PVE cluster
- name: Drop the qnetd rule for the retired ZeroTier network
community.general.ufw:
rule: allow
port: "5403"
proto: tcp
src: 10.122.62.0/24
delete: true
# Caddyfile принадлежит reverse-proxy.yml. Без него стек стартует пустым,
# поэтому сначала проверяем наличие и только потом раскладываем стек.
- name: Check the Caddyfile managed by reverse-proxy.yml
ansible.builtin.stat:
path: "{{ homelab_reverse_proxy_caddyfile }}"
register: ru_vps_caddyfile
- name: Require the Caddyfile before touching the stack
ansible.builtin.assert:
that:
- ru_vps_caddyfile.stat.exists
- ru_vps_caddyfile.stat.size > 0
fail_msg: >-
{{ homelab_reverse_proxy_caddyfile }} отсутствует или пуст.
Сначала прогони playbooks/reverse-proxy.yml, иначе Caddy стартует
без маршрутов и публичные сервисы лягут.
roles:
- role: compose_service
compose_service_name: "{{ homelab_reverse_proxy_unit }}"
compose_service_description: Caddy reverse proxy for public HomeLab services
compose_service_root: "{{ homelab_reverse_proxy_dir }}"
# Каталог создан вручную с 0755 — сохраняем, чтобы прогон не давал
# косметического diff'а на боевом хосте.
compose_service_root_mode: "0755"
# Имя файла сохраняем: имя compose-проекта выводится из каталога, а сам
# файл должен остаться тем же, иначе стек станет orphan и поднимется
# второй контейнер на тех же 80/443.
compose_service_compose_file: docker-compose.yml
# /opt/data/caddy и /opt/configs/caddy принадлежат ada:ada и содержат
# выпущенные сертификаты. Роль их владельца не трогает намеренно.
compose_service_directories: []
compose_service_compose_content: |
services:
caddy:
image: {{ homelab_reverse_proxy_image | trim }}
container_name: {{ homelab_reverse_proxy_container }}
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- /opt/data/caddy:/data
- /opt/configs/caddy:/config
networks: {}
# Caddy на голый IP отвечает 308 (редирект http -> https), а не 200.
compose_service_health_url: http://127.0.0.1:80/
compose_service_health_status: [308]
compose_service_health_follow_redirects: none
+280
View File
@@ -0,0 +1,280 @@
---
- name: Harden live Mihomo on ru-vps
hosts: ru-vps
gather_facts: false
vars:
ru_vps_mihomo_harden_confirm: false
mihomo_root: /opt/services/ru-vps/mihomo
mihomo_config_path: "{{ mihomo_root }}/config/config.yaml"
mihomo_compose_path: "{{ mihomo_root }}/docker-compose.yml"
mihomo_backup_dir: /var/backups/ru-vps-mihomo
mihomo_state_dir: /var/lib/ru-vps-mihomo
mihomo_state_path: "{{ mihomo_state_dir }}/auth.json"
mihomo_harden_script_path: /usr/local/sbin/ru-vps-mihomo-harden
mihomo_harden_script_src: "{{ playbook_dir }}/../files/ru-vps-mihomo-harden.py"
mihomo_backup_path: "{{ mihomo_backup_dir }}/config.yaml.{{ lookup('pipe', 'date -u +%Y%m%d%H%M%S') }}"
pre_tasks:
- name: Require explicit Mihomo hardening confirmation
ansible.builtin.assert:
that:
- ru_vps_mihomo_harden_confirm | bool
fail_msg: Run with -e ru_vps_mihomo_harden_confirm=true.
- name: Preflight Mihomo config file exists
ansible.builtin.stat:
path: "{{ mihomo_config_path }}"
register: mihomo_config_stat
- name: Preflight Mihomo compose file exists
ansible.builtin.stat:
path: "{{ mihomo_compose_path }}"
register: mihomo_compose_stat
- name: Preflight Mihomo state file exists
ansible.builtin.stat:
path: "{{ mihomo_state_path }}"
register: mihomo_state_stat
- name: Refuse to run without live Mihomo config and compose files
ansible.builtin.assert:
that:
- mihomo_config_stat.stat.exists
- mihomo_compose_stat.stat.exists
fail_msg: Live Mihomo config or compose file is missing.
- name: Preflight Mihomo container is running
ansible.builtin.command:
argv:
- docker
- inspect
- -f
- '{{ "{{" }}.State.Running{{ "}}" }}'
- mihomo
register: mihomo_container_state
changed_when: false
failed_when: mihomo_container_state.stdout.strip() != 'true'
- name: Preflight PyYAML is available on the target
ansible.builtin.command:
argv:
- python3
- -c
- import yaml
changed_when: false
- name: Preflight current Mihomo config validates
ansible.builtin.command:
argv:
- docker
- compose
- -f
- "{{ mihomo_compose_path }}"
- exec
- -T
- mihomo
- /mihomo
- -t
- -d
- /root/.config/mihomo
changed_when: false
- name: Ensure Mihomo backup directory exists
ansible.builtin.file:
path: "{{ mihomo_backup_dir }}"
state: directory
owner: root
group: root
mode: "0700"
- name: Install root-only Mihomo hardening helper
ansible.builtin.copy:
src: "{{ mihomo_harden_script_src }}"
dest: "{{ mihomo_harden_script_path }}"
owner: root
group: root
mode: "0700"
- name: Create fresh backup of the live Mihomo config after validation
ansible.builtin.copy:
src: "{{ mihomo_config_path }}"
dest: "{{ mihomo_backup_path }}"
remote_src: true
owner: root
group: root
mode: "0600"
tasks:
- block:
- name: Apply Mihomo hardening in place
ansible.builtin.command:
argv:
- "{{ mihomo_harden_script_path }}"
- apply
- --config
- "{{ mihomo_config_path }}"
- --state
- "{{ mihomo_state_path }}"
register: mihomo_harden_apply
changed_when: (mihomo_harden_apply.stdout | from_json).changed
no_log: true
- name: Validate hardened Mihomo config in the running container
ansible.builtin.command:
argv:
- docker
- compose
- -f
- "{{ mihomo_compose_path }}"
- exec
- -T
- mihomo
- /mihomo
- -t
- -d
- /root/.config/mihomo
changed_when: false
- name: Recreate Mihomo container after config hardening
ansible.builtin.command:
argv:
- docker
- compose
- -f
- "{{ mihomo_compose_path }}"
- up
- -d
- --force-recreate
- mihomo
changed_when: true
- name: Wait for hardened Mihomo listeners on loopback
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: started
timeout: 30
loop:
- 7890
- 7891
- name: Run authenticated Mihomo SOCKS probe to Telegram
ansible.builtin.command:
argv:
- "{{ mihomo_harden_script_path }}"
- probe
- --config
- "{{ mihomo_config_path }}"
- --state
- "{{ mihomo_state_path }}"
register: mihomo_harden_probe
changed_when: false
no_log: true
- name: Check anonymous SOCKS access fails
ansible.builtin.command:
argv:
- curl
- --silent
- --show-error
- --connect-timeout
- "5"
- --max-time
- "15"
- --proxy
- socks5h://127.0.0.1:7891
- https://api.telegram.org
- --output
- /dev/null
register: mihomo_anon_probe
changed_when: false
failed_when: false
- name: Remove public Mihomo UFW rules after successful hardening
community.general.ufw:
rule: allow
port: "{{ item.port }}"
proto: "{{ item.proto }}"
delete: true
loop:
- { port: 7890, proto: tcp }
- { port: 7890, proto: udp }
- { port: 7891, proto: tcp }
- { port: 7891, proto: udp }
- name: Verify Mihomo listeners are loopback only
ansible.builtin.command:
argv:
- ss
- -H
- -ltnp
- '( sport = :7890 or sport = :7891 )'
register: mihomo_ss
changed_when: false
- name: Confirm hardened Mihomo is bound to loopback only and anonymous access fails
ansible.builtin.assert:
that:
- mihomo_ss.stdout is search('127\\.0\\.0\\.1:7890')
- mihomo_ss.stdout is search('127\\.0\\.0\\.1:7891')
- mihomo_ss.stdout is not search('0\\.0\\.0\\.0:7890|:::7890|0\\.0\\.0\\.0:7891|:::7891')
- mihomo_anon_probe.rc != 0
fail_msg: Hardened Mihomo must listen on loopback only and reject anonymous SOCKS access.
rescue:
- name: Restore the live Mihomo config from backup
ansible.builtin.copy:
src: "{{ mihomo_backup_path }}"
dest: "{{ mihomo_config_path }}"
remote_src: true
owner: root
group: root
mode: "0640"
- name: Recreate Mihomo container after rollback
ansible.builtin.command:
argv:
- docker
- compose
- -f
- "{{ mihomo_compose_path }}"
- up
- -d
- --force-recreate
- mihomo
changed_when: true
- name: Remove newly created Mihomo credentials state after rollback
ansible.builtin.file:
path: "{{ mihomo_state_path }}"
state: absent
when: not mihomo_state_stat.stat.exists
- name: Wait for restored Mihomo listeners on loopback
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: started
timeout: 30
loop:
- 7890
- 7891
- name: Validate restored Mihomo config in the running container
ansible.builtin.command:
argv:
- docker
- compose
- -f
- "{{ mihomo_compose_path }}"
- exec
- -T
- mihomo
- /mihomo
- -t
- -d
- /root/.config/mihomo
changed_when: false
- name: Fail Mihomo hardening after restoring the backup
ansible.builtin.fail:
msg: Mihomo hardening failed and the live config was restored from backup.
@@ -0,0 +1,183 @@
---
# Вывод ZeroTier с ru-vps и чистка оставшихся от него правил UFW.
#
# ПОЧЕМУ ЭТО БЕЗОПАСНО
# Проверено 2026-09-02: ZT-интерфейса на хосте нет, маршрутов через него нет,
# на ZT-адресах никто не слушает. Контейнер `zerotier` работает, но подключён
# в никуда (потому и unhealthy). `ssh-zt22.service` в состоянии failed: его
# sshd настроен на ListenAddress 10.122.62.65, которого больше не существует.
#
# Из Ansible ZeroTier выведен ещё в июле 2026 — на хосте остался хвост.
#
# ЧТО НЕ УДАЛЯЕТСЯ
# Каталог /opt/services/ru-vps/zerotier, данные /opt/data/zerotier и файл
# /etc/ssh/sshd_config_zt22 остаются на месте. `docker compose down` идёт БЕЗ
# -v, то есть identity узла ZeroTier сохраняется. Удаление этих файлов —
# отдельный осознанный шаг оператора после того, как всё устоялось.
#
# make zerotier-decommission CONFIRM=1
- name: Decommission ZeroTier on ru-vps
hosts: ru-vps
gather_facts: false
vars:
zt_dir: /opt/services/ru-vps/zerotier
zt_interface: zt6q3dmi2d
zt_legacy_cidr: 10.122.62.0/24
# Правила UFW для сервисов, которые на хосте ничего не слушают (проверено
# через ss): squid не запущен, danted слушает 1081 а не 1080, на 993 и 7892
# слушателей нет. Это НЕ наследие ZeroTier, поэтому по умолчанию не трогаем.
zt_cleanup_unrelated_stale_rules: false
tasks:
# --- Предполётные проверки ---------------------------------------------
- name: List the network interfaces
ansible.builtin.command: ip -br addr show
register: zt_ifaces
changed_when: false
check_mode: false
- name: Read the current firewall rules
ansible.builtin.command: ufw status
register: zt_ufw
changed_when: false
check_mode: false
- name: Refuse to run while a ZeroTier interface is up
ansible.builtin.assert:
that:
- zt_interface not in zt_ifaces.stdout
fail_msg: >-
Интерфейс {{ zt_interface }} присутствует на хосте — значит ZeroTier
снова используется. Плейбук рассчитан на вывод мёртвого стека и
отказывается работать: сначала подтверди, что сеть больше не нужна.
# Управляющая сессия идёт по 3422/tcp. Если глобального разрешения на этот
# порт нет, чистка правил может оборвать доступ — лучше не начинать.
- name: Require the management SSH rule to survive the cleanup
ansible.builtin.assert:
that:
- "'3422/tcp' in zt_ufw.stdout"
fail_msg: >-
В UFW нет правила для 3422/tcp. Прерываюсь, чтобы не остаться без
управляющего доступа.
# --- Контейнер ----------------------------------------------------------
- name: Check whether the ZeroTier stack is present
ansible.builtin.stat:
path: "{{ zt_dir }}/docker-compose.yml"
register: zt_compose
- name: Stop and remove the ZeroTier stack
ansible.builtin.command:
cmd: /usr/bin/docker compose -f {{ zt_dir }}/docker-compose.yml down
chdir: "{{ zt_dir }}"
register: zt_down
when: zt_compose.stat.exists
changed_when: "'Removing' in zt_down.stderr or 'Stopping' in zt_down.stderr"
# --- sshd на ZeroTier ---------------------------------------------------
- name: Disable the ZeroTier-only sshd unit
ansible.builtin.systemd:
name: ssh-zt22.service
state: stopped
enabled: false
failed_when: false
# `stop` не снимает состояние failed, и юнит навсегда остался бы в секции
# FAILED SYSTEMD UNITS отчёта `make status`. homelab-pve-routes.service —
# фантом той же эпохи: файла юнита уже нет, а запись о падении осталась.
- name: List the units currently in a failed state
ansible.builtin.command: systemctl list-units --state=failed --no-legend --plain --no-pager
register: zt_failed
changed_when: false
check_mode: false
- name: Clear the leftover failed state of the ZeroTier-era units
ansible.builtin.command: "systemctl reset-failed {{ item }}"
loop:
- ssh-zt22.service
- homelab-pve-routes.service
# Без этого условия reset-failed рапортует changed на каждом прогоне:
# его код возврата нулевой и когда сбрасывать нечего.
when: item in zt_failed.stdout
register: zt_reset
changed_when: zt_reset.rc == 0
failed_when: false
# --- UFW ----------------------------------------------------------------
- name: Remove the ZeroTier interface rules
community.general.ufw:
rule: allow
direction: "{{ item }}"
interface: "{{ zt_interface }}"
delete: true
loop: [in, out]
- name: Remove the ZeroTier port rules
community.general.ufw:
rule: "{{ item.rule }}"
port: "{{ item.port }}"
proto: "{{ item.proto | default('any') }}"
interface: "{{ item.interface | default(omit) }}"
direction: "{{ 'in' if item.interface is defined else omit }}"
delete: true
loop:
- {rule: allow, port: "9993", proto: udp} # ZeroTier UDP
- {rule: deny, port: "9001"} # ZeroTier controller
- {rule: allow, port: "9001", interface: "zt+"} # он же, но с ZT-сетей
loop_control:
label: "{{ item.rule }} {{ item.port }}{{ '/' ~ item.proto if item.proto is defined else '' }}"
- name: Remove rules scoped to the retired ZeroTier subnet
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
src: "{{ zt_legacy_cidr }}"
delete: true
loop: ["22", "3422"]
- name: Remove stale rules for services that no longer listen
community.general.ufw:
rule: allow
port: "{{ item.port }}"
proto: tcp
delete: true
loop:
- {port: "3128"} # squid: стек есть, контейнер не запущен
- {port: "1080"} # danted слушает 1081, не 1080
- {port: "993"} # слушателей нет
- {port: "7892"} # слушателей нет
loop_control:
label: "{{ item.port }}/tcp"
when: zt_cleanup_unrelated_stale_rules | bool
# --- Проверка -----------------------------------------------------------
- name: List the running containers after the cleanup
ansible.builtin.command: docker ps --format {{ '{{' }}.Names{{ '}}' }}
register: zt_after_ps
changed_when: false
check_mode: false
- name: Read the firewall after the cleanup
ansible.builtin.command: ufw status
register: zt_after_ufw
changed_when: false
check_mode: false
- name: Confirm ZeroTier is gone and management access survived
ansible.builtin.assert:
that:
- "'zerotier' not in zt_after_ps.stdout_lines"
- zt_interface not in zt_after_ufw.stdout
- "'3422/tcp' in zt_after_ufw.stdout"
success_msg: ZeroTier выведен, правило для 3422/tcp на месте.
# В --check контейнер не останавливается и правила не удаляются, поэтому
# проверка результата заведомо не прошла бы. Diff'ы задач выше достоверны.
when: not ansible_check_mode
- name: Show the resulting firewall
ansible.builtin.debug:
var: zt_after_ufw.stdout_lines
+408
View File
@@ -0,0 +1,408 @@
---
# One-shot, human-readable "what is my HomeLab doing right now" report.
#
# ansible-playbook playbooks/status.yml --ask-vault-pass
# ansible-playbook playbooks/status.yml --limit lxc_infra
# ansible-playbook playbooks/status.yml --check # same output, changes nothing
#
# Note: inventory/host_vars/gyro/vault.yml is Ansible Vault encrypted, so any run
# that includes the `gyro` host needs --ask-vault-pass / --vault-password-file
# (same as playbooks/check.yml). Without the password use --limit '!gyro'.
#
# Strictly READ-ONLY: every command is `changed_when: false` + `check_mode: false`,
# nothing is started, installed or written. An unreachable host is a *result*
# (reported as DOWN), not a playbook failure.
- name: Collect HomeLab status
hosts: servers
gather_facts: false
ignore_unreachable: true
vars:
# Application units per host. Every name below is taken from the playbook or
# role that installs it:
# adguard.service playbooks/pve-adguard.yml
# emergency-bot.service roles/emergency_bot
# gitea.service playbooks/pve-gitea.yml
# grimmory{,-docker-firewall} playbooks/pve-grimmory.yml
# gyro.timer roles/gyro (gyro.service is oneshot -> see jobs)
# hermes-ai-tun-proxy.service playbooks/pve-hermes-ai.yml
# mihomo{,-ui}.service playbooks/pve-mihomo.yml
# uptime-kuma.service roles/uptime_kuma
# vaultwarden.service playbooks/pve-vaultwarden.yml
# docker.service installed by each of the compose-based playbooks
status_service_units:
adguard: [docker.service, adguard.service]
docker-test: [docker.service]
emergency-bot: [emergency-bot.service]
gitea: [docker.service, gitea.service]
grimmory: [docker.service, grimmory.service, grimmory-docker-firewall.service]
gyro: [gyro.timer]
hermes-ai: [docker.service, hermes-ai-tun-proxy.service]
mihomo: [docker.service, mihomo.service, mihomo-ui.service]
monitoring: [docker.service, uptime-kuma.service]
ru-vps: [docker.service]
vaultwarden: [docker.service, vaultwarden.service]
# Oneshot / timer-driven jobs. They are normally "inactive", so instead of
# is-active we report the last run (ExecMainExitTimestamp + Result).
# Running the audit scripts themselves (roles/backup_audit) would hit PBS and
# Yandex Disk over the network, so we only read what systemd already knows.
status_job_units:
cloud-pc:
- homelab-backup-audit-gitea.service
gitea:
- homelab-restic-offsite-gitea.service
grimmory:
- homelab-restic-offsite-grimmory.service
- homelab-backup-audit-grimmory.service
gyro: [gyro.service]
mini-pc: [homelab-backup-audit-pbs.service]
ru-vps: [homelab-monitoring-push.service]
vaultwarden:
- homelab-restic-offsite-vaultwarden.service
- homelab-backup-audit-vaultwarden.service
# Group-driven units: derived from inventory groups, not hardcoded per host.
status_units: >-
{{ (status_service_units[inventory_hostname] | default([]))
+ (['prometheus-node-exporter.service']
if inventory_hostname in (groups['monitoring_exporters'] | default([])) else [])
+ (['homelab-smartctl-metrics.timer']
if inventory_hostname in (groups['monitoring_smart_exporters'] | default([])) else [])
+ ([(openvpn_service_name | default('homelab-openvpn')) ~ '.service']
if inventory_hostname in (groups['vpn_openvpn'] | default([])) else []) }}
status_jobs: "{{ status_job_units[inventory_hostname] | default([]) }}"
# Root plus whatever extra storage the inventory declares for this host.
status_disk_paths: "{{ ['/'] + (storage_mounts | default([]) | map(attribute='path') | list) }}"
# Same idea as playbooks/openvpn-check.yml, but built from the inventory:
# PVE web UI on every pve_nodes member, PBS web UI on pbs.
status_vpn_probe_targets: >-
{{ (groups['pve_nodes'] | default([]) | map('extract', hostvars, 'ansible_host')
| map('regex_replace', '^(.+)$', '\1:8006') | list)
+ (groups['lxc_infra'] | default([]) | select('eq', 'pbs')
| map('extract', hostvars, 'ansible_host')
| map('regex_replace', '^(.+)$', '\1:8007') | list) }}
tasks:
- name: Probe host reachability
ansible.builtin.ping:
register: status_ping
ignore_unreachable: true
ignore_errors: true
check_mode: false
- name: Record reachability
ansible.builtin.set_fact:
status_reachable: >-
{{ (not (status_ping.unreachable | default(false)))
and (not (status_ping.failed | default(false))) }}
- name: Collect host state
when: status_reachable | bool
check_mode: false
ignore_unreachable: true
block:
- name: Gather hardware facts (uptime, memory)
ansible.builtin.setup:
gather_subset:
- hardware
failed_when: false
- name: Read root filesystem usage
ansible.builtin.shell:
cmd: >-
LC_ALL=C df -hP / | awk 'NR == 2 {print $5 " used, " $4 " free"}'
register: status_root_df
changed_when: false
failed_when: false
- name: Read declared storage mount usage
ansible.builtin.command:
argv: "{{ ['df', '-h', '--output=target,size,used,avail,pcent'] + status_disk_paths }}"
register: status_disk_df
changed_when: false
failed_when: false
when: status_disk_paths | length > 1
- name: Check service unit states
ansible.builtin.command:
argv: "{{ ['systemctl', 'is-active'] + status_units }}"
register: status_units_state
changed_when: false
failed_when: false
when: status_units | length > 0
# Двухнодовый кластер держится на арбитре corosync-qnetd (ru-vps:5403).
# Когда арбитр молчит, Total votes < Expected votes и падение ЛЮБОЙ ноды
# оставляет выжившую без кворума. Один раз это уже сломалось молча —
# после вывода ZeroTier правило UFW для 5403 осталось на мёртвой сети.
- name: Read cluster quorum state
ansible.builtin.shell:
cmd: LC_ALL=C pvecm status 2>/dev/null | grep -E 'Quorate:|Expected votes|Total votes' | tr -s ' ' | tr '\n' ' '
register: status_quorum
changed_when: false
failed_when: false
when: inventory_hostname in (groups['pve_nodes'] | default([]))
- name: List failed systemd units
ansible.builtin.shell:
cmd: >-
systemctl list-units --state=failed --no-legend --plain --no-pager
| awk '{print $1}' | head -n 5
register: status_failed_units
changed_when: false
failed_when: false
- name: Read last run of backup and oneshot jobs
ansible.builtin.shell:
cmd: |
set -u
now=$(date +%s)
for unit in {{ status_jobs | map('quote') | join(' ') }}; do
ts=$(systemctl show -p ExecMainExitTimestamp --value "$unit" 2>/dev/null || true)
res=$(systemctl show -p Result --value "$unit" 2>/dev/null || true)
if [ -n "$ts" ]; then
epoch=$(date -d "$ts" +%s 2>/dev/null || true)
if [ -n "${epoch:-}" ]; then
age="$(( (now - epoch) / 3600 ))h ago"
else
age="?"
fi
else
ts="never"
age="-"
res="-"
fi
printf '%-44s %-32s %-10s %s\n' "$unit" "$ts" "$age" "${res:-unknown}"
done
register: status_job_state
changed_when: false
failed_when: false
when: status_jobs | length > 0
- name: List Proxmox containers
ansible.builtin.shell:
cmd: >-
pct list | awk 'NR > 1 {printf "%-6s %-9s %s\n", $1, $2, $NF}'
register: status_pct
changed_when: false
failed_when: false
when: inventory_hostname in (groups['pve_nodes'] | default([]))
- name: Ping the OpenVPN peer
ansible.builtin.command:
argv: [ping, -c, '2', -W, '2', "{{ openvpn_peer_ip | default('') }}"]
register: status_vpn_ping
changed_when: false
failed_when: false
when:
- inventory_hostname in (groups['vpn_openvpn'] | default([]))
- openvpn_peer_ip is defined
- name: Probe LAN services through the OpenVPN tunnel
ansible.builtin.shell:
cmd: |
for target in {{ status_vpn_probe_targets | map('quote') | join(' ') }}; do
host=${target%:*}
port=${target##*:}
if nc -z -w 3 "$host" "$port" 2>/dev/null; then
printf '%-24s OK\n' "$target"
else
printf '%-24s FAIL\n' "$target"
fi
done
register: status_vpn_probe
changed_when: false
failed_when: false
when:
- (openvpn_role | default('')) == 'server'
- status_vpn_probe_targets | length > 0
- name: Build status record
ansible.builtin.set_fact:
status_record:
state: "{{ 'UP' if (status_reachable | bool) else 'DOWN' }}"
uptime: >-
{{ ((ansible_facts.uptime_seconds | int) // 86400) ~ 'd '
~ (((ansible_facts.uptime_seconds | int) % 86400) // 3600) ~ 'h'
if ansible_facts.uptime_seconds is defined else '-' }}
root_disk: "{{ status_root_df.stdout | default('-') | trim }}"
units: >-
{{ status_units
| zip(status_units_state.stdout_lines | default([]))
| map('join', '=') | join(' ') }}
failed_units: "{{ status_failed_units.stdout_lines | default([]) }}"
jobs: "{{ status_job_state.stdout_lines | default([]) }}"
disk_lines: "{{ status_disk_df.stdout_lines | default([]) }}"
containers: "{{ status_pct.stdout_lines | default([]) }}"
vpn_peer: >-
{{ ('OK' if (status_vpn_ping.rc | default(1)) == 0 else 'FAIL')
if inventory_hostname in (groups['vpn_openvpn'] | default([])) else '-' }}
vpn_peer_ip: "{{ openvpn_peer_ip | default('-') }}"
vpn_probes: "{{ status_vpn_probe.stdout_lines | default([]) }}"
quorum: "{{ status_quorum.stdout | default('') | trim }}"
- name: Print HomeLab status summary
# Runs once, on the controller, so it also works with --limit.
hosts: servers
gather_facts: false
become: false
ignore_unreachable: true
tasks:
- name: Render the summary
run_once: true
delegate_to: localhost
block:
- name: Select hosts that produced a record
ansible.builtin.set_fact:
status_hosts: >-
{{ ansible_play_hosts_all | sort
| map('extract', hostvars)
| selectattr('status_record', 'defined')
| map(attribute='inventory_hostname') | list }}
status_states: >-
{{ ansible_play_hosts_all | sort
| map('extract', hostvars)
| selectattr('status_record', 'defined')
| map(attribute='status_record.state') | list }}
- name: Start the report
ansible.builtin.set_fact:
status_report:
- ""
- "================ HOMELAB STATUS ================================================"
- >-
hosts: {{ status_hosts | length }}
up: {{ status_states | select('equalto', 'UP') | list | length }}
down: {{ status_states | select('equalto', 'DOWN') | list | length }}
- ""
- "{{ '%-13s %-5s %-9s %-24s %s' | format('HOST', 'STATE', 'UPTIME', 'ROOT FS', 'UNITS') }}"
- "{{ '-' * 80 }}"
- name: Add one line per host
ansible.builtin.set_fact:
status_report: "{{ status_report + [line] }}"
vars:
rec: "{{ hostvars[item].status_record }}"
line: >-
{{ '%-13s %-5s %-9s %-24s %s' | format(
item,
rec.state,
rec.uptime | trim | default('-', true),
rec.root_disk | trim | default('-', true),
rec.units | trim | default('-', true)) }}
loop: "{{ status_hosts }}"
- name: Add Proxmox container section header
ansible.builtin.set_fact:
status_report: "{{ status_report + ['', 'PROXMOX CONTAINERS (pct list)', '-' * 80] }}"
- name: Add containers per Proxmox node
ansible.builtin.set_fact:
status_report: >-
{{ status_report
+ [' ' ~ item ~ ': running=' ~ (running | length)
~ ' other=' ~ (stopped | length)]
+ (stopped | map('regex_replace', '^\s*', ' not running: ') | list) }}
vars:
lines: "{{ hostvars[item].status_record.containers }}"
running: "{{ lines | select('search', ' running ') | list }}"
stopped: "{{ lines | reject('search', ' running ') | list }}"
loop: "{{ status_hosts }}"
when: hostvars[item].status_record.containers | length > 0
- name: Add storage section header
ansible.builtin.set_fact:
status_report: "{{ status_report + ['', 'STORAGE (root + declared mounts)', '-' * 80] }}"
- name: Add storage lines per host
ansible.builtin.set_fact:
status_report: >-
{{ status_report + [' ' ~ item ~ ':']
+ (hostvars[item].status_record.disk_lines
| map('regex_replace', '^', ' ') | list) }}
loop: "{{ status_hosts }}"
when: hostvars[item].status_record.disk_lines | length > 0
- name: Add OpenVPN transport section header
ansible.builtin.set_fact:
status_report: "{{ status_report + ['', 'OPENVPN TRANSPORT', '-' * 80] }}"
- name: Add OpenVPN peer results
ansible.builtin.set_fact:
status_report: >-
{{ status_report
+ [' ' ~ item ~ ' -> peer ' ~ hostvars[item].status_record.vpn_peer_ip
~ ' : ' ~ hostvars[item].status_record.vpn_peer]
+ (hostvars[item].status_record.vpn_probes
| map('regex_replace', '^', ' via tunnel: ') | list) }}
loop: "{{ status_hosts }}"
when: hostvars[item].status_record.vpn_peer != '-'
- name: Add cluster quorum section header
ansible.builtin.set_fact:
status_report: >-
{{ status_report + ['', 'CLUSTER QUORUM (qdevice = третий голос)', '-' * 80] }}
- name: Add quorum line per PVE node
ansible.builtin.set_fact:
status_report: >-
{{ status_report + [' ' ~ item ~ ': '
~ (hostvars[item].status_record.quorum | default('') | trim | default('нет данных', true))] }}
loop: "{{ status_hosts }}"
when: hostvars[item].status_record.quorum | default('') | length > 0
- name: Add backup and job section header
ansible.builtin.set_fact:
status_report: >-
{{ status_report + ['', 'BACKUP / SCHEDULED JOBS (last run, from systemd)', '-' * 80] }}
- name: Add job lines per host
ansible.builtin.set_fact:
status_report: >-
{{ status_report + [' ' ~ item ~ ':']
+ (hostvars[item].status_record.jobs
| map('regex_replace', '^', ' ') | list) }}
loop: "{{ status_hosts }}"
when: hostvars[item].status_record.jobs | length > 0
- name: Add failed units section header
ansible.builtin.set_fact:
status_report: "{{ status_report + ['', 'FAILED SYSTEMD UNITS', '-' * 80] }}"
- name: Add failed units per host
ansible.builtin.set_fact:
status_report: >-
{{ status_report + [' ' ~ item ~ ': '
~ (hostvars[item].status_record.failed_units | join(', '))] }}
loop: "{{ status_hosts }}"
when: hostvars[item].status_record.failed_units | length > 0
- name: Note when nothing failed
ansible.builtin.set_fact:
status_report: "{{ status_report + [' none'] }}"
when: >-
(status_hosts | map('extract', hostvars)
| map(attribute='status_record.failed_units', default=[])
| flatten | length) == 0
- name: Add unreachable section
ansible.builtin.set_fact:
status_report: >-
{{ status_report + ['', 'UNREACHABLE', '-' * 80]
+ (down_hosts | map('regex_replace', '^', ' ') | list
if (down_hosts | length) > 0 else [' none'])
+ ['', '=' * 80, ''] }}
vars:
down_hosts: >-
{{ status_hosts | map('extract', hostvars)
| selectattr('status_record.state', 'equalto', 'DOWN')
| map(attribute='inventory_hostname') | list }}
- name: Print HomeLab status
ansible.builtin.debug:
msg: "{{ status_report | join('\n') }}"
@@ -0,0 +1,16 @@
#jinja2: trim_blocks: True, lstrip_blocks: True
# {{ ansible_managed }}
# Статические админ-ссылки (не из реестра). Рендерится playbooks/dashboard.yml.
- Управление:
- Proxmox cloud-pc:
- abbr: PVE
href: https://192.168.1.5:8006
- Proxmox mini-pc:
- abbr: PVE
href: https://192.168.1.10:8006
- Proxmox Backup Server:
- abbr: PBS
href: https://192.168.1.20:8007
- Uptime Kuma:
- abbr: UK
href: http://{{ dash_bind }}:3001
@@ -0,0 +1,21 @@
#jinja2: trim_blocks: True, lstrip_blocks: True
# {{ ansible_managed }}
# Рендерится playbooks/dashboard.yml (роль compose_service). Правь шаблон,
# а не файл на хосте: следующий прогон перезапишет.
services:
homepage:
image: {{ dash_image }}
container_name: homelab-homepage
restart: unless-stopped
env_file:
- ./homepage.env
environment:
HOMEPAGE_ALLOWED_HOSTS: "{{ dash_allowed_hosts | trim }}"
ports:
# Только LAN-адрес CT 155 -> контейнерный 3000. Наружу не публикуется.
- "{{ dash_bind }}:{{ dash_port }}:3000"
volumes:
- ./config:/app/config
# Docker-сокет намеренно НЕ монтируется: сервисы живут на других хостах,
# а лишний доступ к сокету на LAN-видимом контейнере не нужен.
networks: {}
@@ -0,0 +1,70 @@
#jinja2: trim_blocks: True, lstrip_blocks: True
# {{ ansible_managed }}
# Рендерится playbooks/dashboard.yml ИЗ реестра homelab_services. НЕ править
# на хосте: меняй сервисы в ansible/inventory/group_vars/all/services.yml,
# затем `make dashboard`.
#
# Правило ссылки на сервис:
# * есть proxy.domain -> https://<domain> (+ siteMonitor)
# * иначе первый веб-порт -> http(s)://<ip>:<port> (+ siteMonitor для http)
# приоритет имён портов: http > ui > setup > uptime-kuma > grafana >
# controller > pbs-api
# * ни того, ни другого -> группа Headless, только ICMP ping по ip
{% set port_priority = ['http', 'ui', 'setup', 'uptime-kuma', 'grafana', 'controller', 'pbs-api'] %}
{% if homelab_dashboard_proxmox_url | default('') | length > 0 %}
- Infrastructure:
- Proxmox кластер:
description: cloud-pc + mini-pc
widget:
type: proxmox
url: {{ homelab_dashboard_proxmox_url }}
username: "{{ '{{HOMEPAGE_VAR_PROXMOX_USER}}' }}"
password: "{{ '{{HOMEPAGE_VAR_PROXMOX_TOKEN}}' }}"
{% endif %}
{% for node in dashboard_nodes %}
- {{ node }}:
{% for name, svc in homelab_services | dictsort %}
{% if svc.node == node %}
{% set proxy = svc.proxy | default({}) %}
{% set ns = namespace(scheme='', port=0) %}
{% for pref in port_priority %}
{% for p in svc.ports | default([]) %}
{% if ns.port == 0 and p.name == pref %}
{% set ns.port = p.port %}
{% set ns.scheme = 'https' if pref == 'pbs-api' else 'http' %}
{% endif %}
{% endfor %}
{% endfor %}
{% if proxy.domain is defined %}
- {{ name }}:
href: https://{{ proxy.domain }}
description: {{ svc.role }}
siteMonitor: https://{{ proxy.domain }}
{% elif ns.port > 0 %}
- {{ name }}:
href: {{ ns.scheme }}://{{ svc.ip }}:{{ ns.port }}
description: {{ svc.role }}
{% if ns.scheme == 'http' %}
siteMonitor: http://{{ svc.ip }}:{{ ns.port }}
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
{% endfor %}
- Headless:
{% for name, svc in homelab_services | dictsort %}
{% set proxy = svc.proxy | default({}) %}
{% set ns = namespace(web=false) %}
{% for pref in port_priority %}
{% for p in svc.ports | default([]) %}
{% if p.name == pref %}
{% set ns.web = true %}
{% endif %}
{% endfor %}
{% endfor %}
{% if proxy.domain is not defined and not ns.web %}
- {{ name }}:
description: {{ svc.role }}
ping: {{ svc.ip }}
{% endif %}
{% endfor %}
@@ -0,0 +1,21 @@
#jinja2: trim_blocks: True, lstrip_blocks: True
# {{ ansible_managed }}
# Рендерится playbooks/dashboard.yml. Порядок и имена групп ОБЯЗАНЫ совпадать
# с группами в services.yaml — иначе Homepage покажет их в алфавитном порядке.
title: HomeLab
headerStyle: boxed
color: slate
layout:
{% if homelab_dashboard_proxmox_url | default('') | length > 0 %}
Infrastructure:
style: row
columns: 3
{% endif %}
{% for node in dashboard_nodes %}
{{ node }}:
style: row
columns: 4
{% endfor %}
Headless:
style: row
columns: 4
@@ -0,0 +1,18 @@
#jinja2: trim_blocks: True, lstrip_blocks: True
# {{ ansible_managed }}
# Верхняя панель Homepage. Рендерится playbooks/dashboard.yml.
- datetime:
text_size: xl
format:
timeStyle: short
dateStyle: short
- resources:
label: monitoring CT
cpu: true
memory: true
disk: /
- uptimekuma:
# Требует опубликованной Status Page в Uptime Kuma с этим slug
# (мониторы и статус-страницы Kuma живут только в её UI).
url: http://{{ dash_bind }}:3001
slug: {{ homelab_dashboard_kuma_slug | default('homelab') }}
@@ -0,0 +1,12 @@
#jinja2: trim_blocks: True, lstrip_blocks: True
# {{ ansible_managed }}
# Секреты виджетов Homepage. mode 0600, в Git не попадает. Значения берутся
# из корневого .env через lookup('env', ...) в playbooks/dashboard.yml.
# Homepage подставляет их в widgets/services по имени {{ '{{HOMEPAGE_VAR_*}}' }}.
HOMEPAGE_VAR_PROXMOX_URL={{ homelab_dashboard_proxmox_url | default('') }}
{% if dash_pve_user | length > 0 and dash_pve_token_id | length > 0 %}
HOMEPAGE_VAR_PROXMOX_USER={{ dash_pve_user }}!{{ dash_pve_token_id }}
{% else %}
HOMEPAGE_VAR_PROXMOX_USER=
{% endif %}
HOMEPAGE_VAR_PROXMOX_TOKEN={{ dash_pve_token_secret | default('') }}
+13
View File
@@ -0,0 +1,13 @@
---
- name: Freeze Prometheus monitoring and configure Uptime Kuma
# Таргет переопределяем ради blue-green переезда на OpenTofu: конфигурацию
# нужно прогнать против нового контейнера на временном адресе. `--limit`
# сам по себе не перенацеливает play, а обнуляет его (проверено 2026-09-02).
# Использовать вместе с --limit:
# ansible-playbook playbooks/uptime-kuma.yml \
# -e pve_config_target=monitoring-new --limit monitoring-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('monitoring_server') }}"
gather_facts: false
roles:
- role: uptime_kuma
+52
View File
@@ -0,0 +1,52 @@
---
- name: Install user SSH public key on managed hosts
hosts: servers
gather_facts: false
vars:
homelab_user_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
homelab_user_pubkey: "{{ lookup('file', homelab_user_pubkey_file) }}"
tasks:
- name: Install user SSH key for connection user
ansible.posix.authorized_key:
user: "{{ ansible_user }}"
key: "{{ homelab_user_pubkey }}"
state: present
- name: Install user SSH key for root
ansible.posix.authorized_key:
user: root
key: "{{ homelab_user_pubkey }}"
state: present
become: true
when: ansible_user != 'root'
- name: Bootstrap user SSH public key into legacy LXC containers via Proxmox
hosts: pve_nodes
gather_facts: false
vars:
homelab_user_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
homelab_user_pubkey: "{{ lookup('file', homelab_user_pubkey_file) }}"
legacy_lxc_key_targets:
- node: cloud-pc
vmid: 120
name: pbs
tasks:
- name: Install user SSH key for root inside legacy LXC
ansible.builtin.shell: |
pct exec {{ item.vmid }} -- sh -c '
key=$(printf "%s" {{ homelab_user_pubkey | b64encode | quote }} | base64 -d)
mkdir -p /root/.ssh
chmod 700 /root/.ssh
touch /root/.ssh/authorized_keys
if grep -qxF "$key" /root/.ssh/authorized_keys; then
echo present
else
printf "%s\n" "$key" >> /root/.ssh/authorized_keys
echo added
fi
chmod 600 /root/.ssh/authorized_keys
'
loop: "{{ legacy_lxc_key_targets }}"
when: item.node == inventory_hostname
register: legacy_lxc_key_install
changed_when: "'added' in legacy_lxc_key_install.stdout"
+85
View File
@@ -0,0 +1,85 @@
---
# Дрейф между реестром homelab_services и фактическим состоянием Proxmox.
#
# ЗАЧЕМ
# Реестр — источник правды, но программно его потребляют лишь несколько мест.
# VMID, адрес и ресурсы легко разъезжаются с реальностью, и заметить это
# раньше было нечем: `make status` показывает состояние, но не сверяет его
# с задекларированным, и его exit code намеренно не является gate'ом.
#
# ОТЛИЧИЕ ОТ status.yml
# Этот плейбук — именно gate: при любом расхождении он завершается ошибкой.
# Годится для cron и CI. Строго read-only: только `pct config`.
#
# make validate
# make validate EXTRA="--limit cloud-pc"
- name: Read actual LXC configuration from the Proxmox nodes
hosts: pve_nodes
gather_facts: false
tasks:
- name: Read pct config for the services declared on this node
ansible.builtin.command: "pct config {{ item.value.vmid }}"
loop: >-
{{ homelab_services | dict2items
| selectattr('value.node', 'eq', inventory_hostname) | list }}
loop_control:
label: "{{ item.key }} ({{ item.value.vmid }})"
register: validate_pct
changed_when: false
failed_when: false
check_mode: false
# Сравниваются только однозначные поля. rootfs намеренно пропущен: в реестре
# он записан как "data:32", а pct отдаёт "data:vm-141-disk-0,size=32G" —
# это разные представления, и их сверка требует отдельного парсера.
- name: Collect drift for this node
ansible.builtin.set_fact:
validate_drift: "{{ validate_drift | default([]) + item_drift }}"
loop: "{{ validate_pct.results }}"
loop_control:
label: "{{ item.item.key }}"
vars:
svc: "{{ item.item.value }}"
sname: "{{ item.item.key }}"
gone: "{{ item.rc | default(1) != 0 }}"
out: "{{ item.stdout | default('') }}"
a_host: "{{ out | regex_search('(?m)^hostname: (\\S+)', '\\1') | default([''], true) | first }}"
a_cores: "{{ out | regex_search('(?m)^cores: (\\d+)', '\\1') | default([''], true) | first }}"
a_mem: "{{ out | regex_search('(?m)^memory: (\\d+)', '\\1') | default([''], true) | first }}"
a_swap: "{{ out | regex_search('(?m)^swap: (\\d+)', '\\1') | default([''], true) | first }}"
a_ip: "{{ out | regex_search('ip=([0-9.]+)', '\\1') | default([''], true) | first }}"
item_drift: >-
{{ ([sname ~ ': VMID ' ~ svc.vmid ~ ' отсутствует на узле ' ~ inventory_hostname] if gone else [])
+ ([sname ~ ': hostname=' ~ a_host ~ ', в реестре ' ~ svc.hostname]
if (not gone and a_host != svc.hostname) else [])
+ ([sname ~ ': ip=' ~ a_ip ~ ', в реестре ' ~ svc.ip]
if (not gone and a_ip != svc.ip) else [])
+ ([sname ~ ': cores=' ~ a_cores ~ ', в реестре ' ~ svc.lxc.cores]
if (not gone and svc.lxc.cores is not none and a_cores | string != svc.lxc.cores | string) else [])
+ ([sname ~ ': memory=' ~ a_mem ~ ', в реестре ' ~ svc.lxc.memory]
if (not gone and svc.lxc.memory is not none and a_mem | string != svc.lxc.memory | string) else [])
+ ([sname ~ ': swap=' ~ a_swap ~ ', в реестре ' ~ svc.lxc.swap]
if (not gone and svc.lxc.swap is not none and a_swap | string != svc.lxc.swap | string) else []) }}
- name: Report registry drift
hosts: pve_nodes
gather_facts: false
run_once: true
tasks:
- name: Fail when the registry disagrees with Proxmox
ansible.builtin.assert:
that:
- all_drift | length == 0
success_msg: >-
Реестр совпадает с Proxmox: проверено сервисов —
{{ homelab_services | dict2items
| selectattr('value.node', 'in', groups['pve_nodes']) | list | length }}.
fail_msg: >-
{{ ['Реестр разошёлся с Proxmox:']
+ (all_drift | map('regex_replace', '^', ' - ') | list) }}
vars:
all_drift: >-
{{ groups['pve_nodes']
| map('extract', hostvars, 'validate_drift')
| select('defined') | flatten }}
+102
View File
@@ -0,0 +1,102 @@
---
- name: Create and verify Vaultwarden backup before update
hosts: vaultwarden
gather_facts: false
tasks:
- name: Create a fresh Vaultwarden offsite backup
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-restic-offsite-vaultwarden.service
changed_when: true
- name: Run Vaultwarden offsite backup audit
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-vaultwarden.service
changed_when: true
- name: Create and verify Vaultwarden PBS backup before update
hosts: mini-pc
gather_facts: false
vars:
vaultwarden_vmid: "{{ homelab_services['vaultwarden'].vmid }}"
tasks:
- name: Read existing Vaultwarden VMID configuration
ansible.builtin.command: "pct config {{ vaultwarden_vmid }}"
register: vaultwarden_existing_vmid
changed_when: false
- name: Refuse to modify a foreign Vaultwarden VMID
ansible.builtin.assert:
that:
- vaultwarden_existing_vmid.rc == 0
- vaultwarden_existing_hostname == 'vaultwarden'
fail_msg: >-
VMID {{ vaultwarden_vmid }} from the service registry is not the
Vaultwarden container.
vars:
vaultwarden_existing_hostname: >-
{{ vaultwarden_existing_vmid.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Check for active Proxmox backup before Vaultwarden PBS backup
ansible.builtin.command: pgrep -x vzdump
register: vaultwarden_vzdump_preflight
changed_when: false
failed_when: false
- name: Require no active Proxmox backup before Vaultwarden PBS backup
ansible.builtin.assert:
that:
- vaultwarden_vzdump_preflight.rc != 0
fail_msg: >-
A Proxmox backup is already running on mini-pc.
Retry after the existing backup completes.
- name: Create a fresh Vaultwarden PBS backup
ansible.builtin.command:
argv:
- vzdump
- "{{ vaultwarden_vmid }}"
- --storage
- pbs
- --mode
- snapshot
- --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged
- name: Run current PBS backup audit on mini-pc
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-pbs.service
changed_when: true
- import_playbook: pve-vaultwarden.yml
vars:
pve_provisioning_enabled: false
- name: Verify Vaultwarden public endpoint after update
hosts: ru-vps
gather_facts: false
tasks:
- name: Check Vaultwarden HTTPS endpoint
ansible.builtin.uri:
url: https://pass.ada-dev.ru/
status_code: 200
return_content: false
register: vaultwarden_public_health
retries: 24
delay: 5
until: vaultwarden_public_health.status == 200
+2
View File
@@ -4,3 +4,5 @@ collections:
version: ">=2.0.0"
- name: community.proxmox
version: ">=2.0.0"
- name: community.general
version: ">=10.0.0"
+58
View File
@@ -0,0 +1,58 @@
# Роли Ansible
| Роль | Назначение |
|---|---|
| `backup_audit` | Аудит бэкапов PBS и restic, метрики в node-exporter |
| `bash_config` | Единый bash-конфиг для shell-хостов |
| `compose_service` | **(новая)** Docker Compose стек под systemd oneshot-юнитом |
| `emergency_access` | Аварийный доступ |
| `emergency_bot` | Telegram-бот аварийного доступа |
| `gyro` | Сервис gyro в изолированном LXC |
| `lxc_docker_host` | **(новая)** Подготовка непривилегированного LXC под Docker |
| `monitoring_blackbox` | Внешние HTTP-пробы с ru-vps |
| `monitoring_exporter` | node-exporter / smartctl-exporter |
| `monitoring_server` | Prometheus + Alertmanager + Grafana |
| `openvpn_gateway` | OpenVPN сервер/клиент |
| `pve_lxc` | Создание LXC через Proxmox API |
| `uptime_kuma` | Uptime Kuma |
| `base`, `docker`, `ufw` | Пустые каталоги, оставшиеся от ранней структуры |
## Новые роли
### `lxc_docker_host`
Пакеты, проверка `/dev/fuse`, `daemon.json` со `storage-driver:
fuse-overlayfs`, запуск docker, базовые правила UFW (SSH из LAN и OpenVPN,
node-exporter 9100 с 192.168.1.30, `deny incoming`), проверка эффективного
драйвера хранилища. Вынесено из восьми `pve-*.yml` — около 350 строк
копипасты. Подробности и пример: [`lxc_docker_host/README.md`](lxc_docker_host/README.md).
### `compose_service`
`compose.yml` + `.env` (генерация секретов один раз, `no_log`, `0600`) +
systemd-юнит `Type=oneshot` с `docker compose up -d --remove-orphans`,
`daemon-reload` через handler, валидация `docker compose config --quiet`,
рестарт только при изменениях, health-check по URL с `retries`/`until`.
Подробности и пример плейбука Gitea на новых ролях:
[`compose_service/README.md`](compose_service/README.md).
**Статус:** `compose_service` подключён в `playbooks/ru-vps-base.yml` (стек
Caddy на ru-vps) — это его первый и пока единственный потребитель.
`lxc_docker_host` не подключён нигде. Перевод `pve-*.yml` на обе роли —
отдельный этап.
## Источник данных
Факты о сервисах (vmid, узел, адрес, порты, домен, образы с digest, ресурсы,
бэкап, мониторинг, порядок автозапуска) собраны в реестре
`ansible/inventory/group_vars/all/services.yml` (`homelab_services`).
Программные потребители реестра:
- `playbooks/reverse-proxy.yml` — сборка Caddyfile;
- `playbooks/ru-vps-base.yml` — стек Caddy и его закреплённый образ;
- `playbooks/pve-backup-jobs.yml` — списки VMID заданий PBS (поле `backup.job`);
- `roles/backup_audit` — VMID для аудита (флаг `monitoring.backup_audit_vmid`);
- `playbooks/validate.yml` — сверка реестра с фактическим состоянием Proxmox.
Остальное (`pve-*.yml`, `status.yml`, monitoring, SSH config) по-прежнему
дублирует значения и должно меняться согласованно.
@@ -0,0 +1,24 @@
---
backup_audit_log_file: /var/log/homelab-backup-audit.log
backup_audit_timer_oncalendar: "*-*-* 06:00:00"
backup_audit_timer_randomized_delay: 10m
# Единый порог свежести для всех PBS-снапшотов. Вынесен отдельно, потому что
# сам список VMID теперь выводится из реестра и per-vmid значения в нём нет.
backup_audit_pbs_max_age_hours: 48
# VMID берутся из homelab_services по флагу monitoring.backup_audit_vmid, а не
# перечисляются вручную. Форма записи ({vmid, max_age_hours}) сохранена, чтобы
# вызывающий playbook при необходимости мог передать свой список с иными
# порогами — шаблон audit-pbs.sh.j2 читает именно эти два поля.
backup_audit_pbs_vmids: >-
{{ homelab_services.values()
| selectattr('monitoring.backup_audit_vmid', 'defined')
| selectattr('monitoring.backup_audit_vmid')
| map(attribute='vmid') | sort
| map('community.general.dict_kv', 'vmid')
| map('combine', {'max_age_hours': backup_audit_pbs_max_age_hours})
| list }}
backup_audit_restic_profiles: []
backup_audit_metrics_dir: /var/lib/node_exporter/textfile_collector
+146
View File
@@ -0,0 +1,146 @@
---
- name: Install audit dependencies
ansible.builtin.apt:
name:
- jq
- sqlite3
state: present
update_cache: true
- name: Ensure audit directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- /etc/homelab-backup-audit
- /var/lib/homelab-backup-audit
- "{{ backup_audit_metrics_dir }}"
- name: Install PBS audit script
ansible.builtin.template:
src: audit-pbs.sh.j2
dest: /usr/local/sbin/homelab-backup-audit-pbs
owner: root
group: root
mode: "0755"
when: backup_audit_type | default('') == 'pbs'
- name: Install restic audit script
ansible.builtin.template:
src: audit-restic.sh.j2
dest: /usr/local/sbin/homelab-backup-audit-restic
owner: root
group: root
mode: "0755"
when: backup_audit_type | default('') == 'restic'
- name: Install restic audit env files
ansible.builtin.copy:
dest: "/etc/homelab-backup-audit/{{ item.name }}.env"
owner: root
group: root
mode: "0600"
content: |
HOMELAB_AUDIT_PROFILE={{ item.name }}
HOMELAB_AUDIT_MAX_AGE_HOURS={{ item.max_age_hours | default(36) }}
HOMELAB_AUDIT_SQLITE_NAME={{ item.sqlite_name | default('') }}
HOMELAB_AUDIT_EXPECTED_NAME={{ item.expected_name | default('') }}
loop: "{{ backup_audit_restic_profiles }}"
when: backup_audit_type | default('') == 'restic'
no_log: true
- name: Install PBS audit systemd service
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-backup-audit-pbs.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=HomeLab PBS backup audit
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/homelab-backup-audit-pbs
when: backup_audit_type | default('') == 'pbs'
- name: Install restic audit systemd services
ansible.builtin.copy:
dest: "/etc/systemd/system/homelab-backup-audit-{{ item.name }}.service"
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=HomeLab restic offsite backup audit ({{ item.name }})
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/flock -w 1800 /var/lock/homelab-restic-{{ item.name }}.lock /usr/local/sbin/homelab-backup-audit-restic {{ item.name }}
loop: "{{ backup_audit_restic_profiles }}"
when: backup_audit_type | default('') == 'restic'
- name: Install PBS audit systemd timer
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-backup-audit-pbs.timer
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Run HomeLab PBS backup audit
[Timer]
OnCalendar={{ backup_audit_timer_oncalendar }}
Persistent=true
RandomizedDelaySec={{ backup_audit_timer_randomized_delay }}
[Install]
WantedBy=timers.target
when: backup_audit_type | default('') == 'pbs'
- name: Install restic audit systemd timers
ansible.builtin.copy:
dest: "/etc/systemd/system/homelab-backup-audit-{{ item.name }}.timer"
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Run HomeLab restic backup audit ({{ item.name }})
[Timer]
OnCalendar={{ backup_audit_timer_oncalendar }}
Persistent=true
RandomizedDelaySec={{ backup_audit_timer_randomized_delay }}
[Install]
WantedBy=timers.target
loop: "{{ backup_audit_restic_profiles }}"
when: backup_audit_type | default('') == 'restic'
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Enable PBS audit timer
ansible.builtin.systemd:
name: homelab-backup-audit-pbs.timer
enabled: true
state: started
when: backup_audit_type | default('') == 'pbs'
- name: Enable restic audit timers
ansible.builtin.systemd:
name: "homelab-backup-audit-{{ item.name }}.timer"
enabled: true
state: started
loop: "{{ backup_audit_restic_profiles }}"
when: backup_audit_type | default('') == 'restic'
@@ -0,0 +1,70 @@
#!/bin/sh
# Managed by Ansible: HomeLab PBS backup audit (L0 freshness)
set -eu
LOG_FILE="{{ backup_audit_log_file }}"
METRICS_DIR="{{ backup_audit_metrics_dir }}"
METRICS_FILE="$METRICS_DIR/homelab_backup_audit_pbs.prom"
METRICS_TMP=$(mktemp "$METRICS_FILE.XXXXXX")
STATUS=OK
NOW_EPOCH=$(date +%s)
trap 'rm -f "$METRICS_TMP"' EXIT
publish_metrics() {
chmod 0644 "$METRICS_TMP"
mv "$METRICS_TMP" "$METRICS_FILE"
}
ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
log() { echo "[$(ts)] [pbs] $*" | tee -a "$LOG_FILE"; }
get_latest_snapshot() {
pvesm list pbs --vmid "$1" 2>/dev/null \
| tail -n +2 \
| awk '{print $1}' \
| sort -t/ -k4 \
| tail -1
}
{% for entry in backup_audit_pbs_vmids %}
audit_vmid_{{ entry.vmid }}() {
vmid={{ entry.vmid }}
max_age={{ entry.max_age_hours }}
latest=$(get_latest_snapshot "$vmid")
if [ -z "$latest" ]; then
log "FAIL L0: vmid $vmid — no snapshots in PBS"
printf 'homelab_backup_audit_snapshot_age_hours{profile="pbs",vmid="%s"} -1\n' "$vmid" >> "$METRICS_TMP"
printf 'homelab_backup_audit_snapshot_success{profile="pbs",vmid="%s"} 0\n' "$vmid" >> "$METRICS_TMP"
STATUS=FAIL
return
fi
ts_str=$(printf '%s' "$latest" | sed -n 's#.*/\([0-9T:Z-]*\)$#\1#p')
snap_epoch=$(date -d "$ts_str" +%s 2>/dev/null || echo 0)
age_hours=$(( (NOW_EPOCH - snap_epoch) / 3600 ))
if [ "$age_hours" -gt "$max_age" ]; then
log "FAIL L0: vmid $vmid — latest snapshot age ${age_hours}h > ${max_age}h (snapshot: $ts_str)"
printf 'homelab_backup_audit_snapshot_success{profile="pbs",vmid="%s"} 0\n' "$vmid" >> "$METRICS_TMP"
STATUS=FAIL
else
log "OK L0: vmid $vmid — latest snapshot age ${age_hours}h"
printf 'homelab_backup_audit_snapshot_success{profile="pbs",vmid="%s"} 1\n' "$vmid" >> "$METRICS_TMP"
fi
printf 'homelab_backup_audit_snapshot_age_hours{profile="pbs",vmid="%s"} %s\n' "$vmid" "$age_hours" >> "$METRICS_TMP"
}
audit_vmid_{{ entry.vmid }}
{% endfor %}
if [ "$STATUS" = "OK" ]; then
log "AUDIT PASSED"
printf 'homelab_backup_audit_success{profile="pbs"} 1\n' >> "$METRICS_TMP"
printf 'homelab_backup_audit_timestamp_seconds{profile="pbs"} %s\n' "$NOW_EPOCH" >> "$METRICS_TMP"
publish_metrics
exit 0
else
log "AUDIT FAILED"
printf 'homelab_backup_audit_success{profile="pbs"} 0\n' >> "$METRICS_TMP"
printf 'homelab_backup_audit_timestamp_seconds{profile="pbs"} %s\n' "$NOW_EPOCH" >> "$METRICS_TMP"
publish_metrics
exit 1
fi
@@ -0,0 +1,167 @@
#!/bin/sh
# Managed by Ansible: HomeLab restic offsite backup audit (L0+L1+L2)
set -eu
if [ "$#" -ne 1 ]; then
echo "usage: $0 <profile>" >&2
exit 64
fi
PROFILE="$1"
case "$PROFILE" in
''|*[!A-Za-z0-9_-]*)
echo "invalid profile name" >&2
exit 64
;;
esac
RESTIC_ENV="/etc/homelab-restic/${PROFILE}.env"
AUDIT_ENV="/etc/homelab-backup-audit/${PROFILE}.env"
LOG_FILE="{{ backup_audit_log_file }}"
TMP="/var/lib/homelab-backup-audit/${PROFILE}"
METRICS_DIR="{{ backup_audit_metrics_dir }}"
METRICS_FILE="$METRICS_DIR/homelab_backup_audit_${PROFILE}.prom"
METRICS_TMP=$(mktemp "$METRICS_FILE.XXXXXX")
STATUS=OK
trap 'rm -f "$METRICS_TMP"' EXIT
publish_metrics() {
chmod 0644 "$METRICS_TMP"
mv "$METRICS_TMP" "$METRICS_FILE"
}
for f in "$RESTIC_ENV" "$AUDIT_ENV"; do
if [ ! -f "$f" ]; then
echo "[$(date '+%Y-%m-%dT%H:%M:%S%z')] [${PROFILE}] FAIL: missing env file $f" | tee -a "$LOG_FILE"
printf 'homelab_backup_audit_success{profile="%s"} 0\n' "$PROFILE" > "$METRICS_TMP"
printf 'homelab_backup_audit_timestamp_seconds{profile="%s"} %s\n' "$PROFILE" "$(date +%s)" >> "$METRICS_TMP"
publish_metrics
exit 66
fi
done
set -a
. "$RESTIC_ENV"
. "$AUDIT_ENV"
set +a
export RESTIC_REPOSITORY RESTIC_PASSWORD_FILE RCLONE_CONFIG
ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
log() { echo "[$(ts)] [${PROFILE}] $*" | tee -a "$LOG_FILE"; }
# ── L0: freshness ──────────────────────────────────────────────────────────
latest_time=$(restic snapshots --latest 1 --json 2>/dev/null | jq -r '.[0].time // empty')
if [ -z "$latest_time" ]; then
log "FAIL L0: no snapshots found in repository"
printf 'homelab_backup_audit_success{profile="%s"} 0\n' "$PROFILE" > "$METRICS_TMP"
printf 'homelab_backup_audit_timestamp_seconds{profile="%s"} %s\n' "$PROFILE" "$(date +%s)" >> "$METRICS_TMP"
publish_metrics
exit 1
fi
now_epoch=$(date +%s)
snap_epoch=$(date -d "$latest_time" +%s 2>/dev/null || echo 0)
age_hours=$(( (now_epoch - snap_epoch) / 3600 ))
max_age="${HOMELAB_AUDIT_MAX_AGE_HOURS:-36}"
if [ "$age_hours" -gt "$max_age" ]; then
log "FAIL L0: latest snapshot age ${age_hours}h > ${max_age}h (snapshot: $latest_time)"
STATUS=FAIL
else
log "OK L0: latest snapshot age ${age_hours}h"
fi
printf 'homelab_backup_audit_snapshot_age_hours{profile="%s"} %s\n' "$PROFILE" "$age_hours" >> "$METRICS_TMP"
# ── L1: repository integrity ───────────────────────────────────────────────
if restic check 2>&1 | tee -a "$LOG_FILE"; then
log "OK L1: restic check passed"
printf 'homelab_backup_audit_level_success{profile="%s",level="l1"} 1\n' "$PROFILE" >> "$METRICS_TMP"
else
log "FAIL L1: restic check failed"
printf 'homelab_backup_audit_level_success{profile="%s",level="l1"} 0\n' "$PROFILE" >> "$METRICS_TMP"
STATUS=FAIL
fi
# ── L2: SQLite restore + integrity ─────────────────────────────────────────
if [ -n "${HOMELAB_AUDIT_SQLITE_NAME:-}" ]; then
case "$TMP" in
/var/lib/homelab-backup-audit/[A-Za-z0-9_-]*) ;;
*)
log "FAIL L2: unsafe temporary path"
exit 64
;;
esac
rm -rf "$TMP"
mkdir -p "$TMP"
if restic restore latest --target "$TMP" --include "**/${HOMELAB_AUDIT_SQLITE_NAME}" 2>&1 | tee -a "$LOG_FILE"; then
db=$(find "$TMP" -name "$HOMELAB_AUDIT_SQLITE_NAME" -type f | head -1)
if [ -z "$db" ] || [ ! -f "$db" ]; then
log "FAIL L2: $HOMELAB_AUDIT_SQLITE_NAME not found in restored data"
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 0\n' "$PROFILE" >> "$METRICS_TMP"
STATUS=FAIL
else
check=$(sqlite3 "$db" "PRAGMA integrity_check;" 2>&1)
if [ "$check" = "ok" ]; then
log "OK L2: SQLite integrity_check ok ($db)"
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 1\n' "$PROFILE" >> "$METRICS_TMP"
else
log "FAIL L2: SQLite integrity_check: $check"
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 0\n' "$PROFILE" >> "$METRICS_TMP"
STATUS=FAIL
fi
fi
else
log "FAIL L2: restic restore failed for $HOMELAB_AUDIT_SQLITE_NAME"
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 0\n' "$PROFILE" >> "$METRICS_TMP"
STATUS=FAIL
fi
rm -rf "$TMP"
fi
# ── L2: expected file restore ───────────────────────────────────────────────
if [ -n "${HOMELAB_AUDIT_EXPECTED_NAME:-}" ]; then
case "$TMP" in
/var/lib/homelab-backup-audit/[A-Za-z0-9_-]*) ;;
*)
log "FAIL L2: unsafe temporary path"
exit 64
;;
esac
rm -rf "$TMP"
mkdir -p "$TMP"
if restic restore latest --target "$TMP" --include "**/${HOMELAB_AUDIT_EXPECTED_NAME}" 2>&1 | tee -a "$LOG_FILE"; then
expected=$(find "$TMP" -name "$HOMELAB_AUDIT_EXPECTED_NAME" -type f | head -1)
if [ -n "$expected" ] && [ -s "$expected" ]; then
log "OK L2: restored non-empty expected file ($expected)"
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 1\n' "$PROFILE" >> "$METRICS_TMP"
else
log "FAIL L2: $HOMELAB_AUDIT_EXPECTED_NAME not found or empty"
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 0\n' "$PROFILE" >> "$METRICS_TMP"
STATUS=FAIL
fi
else
log "FAIL L2: restic restore failed for $HOMELAB_AUDIT_EXPECTED_NAME"
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 0\n' "$PROFILE" >> "$METRICS_TMP"
STATUS=FAIL
fi
rm -rf "$TMP"
fi
if [ "$STATUS" = "OK" ]; then
log "AUDIT PASSED"
printf 'homelab_backup_audit_success{profile="%s"} 1\n' "$PROFILE" >> "$METRICS_TMP"
printf 'homelab_backup_audit_timestamp_seconds{profile="%s"} %s\n' "$PROFILE" "$(date +%s)" >> "$METRICS_TMP"
publish_metrics
exit 0
else
log "AUDIT FAILED"
printf 'homelab_backup_audit_success{profile="%s"} 0\n' "$PROFILE" >> "$METRICS_TMP"
printf 'homelab_backup_audit_timestamp_seconds{profile="%s"} %s\n' "$PROFILE" "$(date +%s)" >> "$METRICS_TMP"
publish_metrics
exit 1
fi
@@ -30,6 +30,23 @@ alias gs='git status --short --branch'
alias gd='git diff'
alias 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'
+133
View File
@@ -0,0 +1,133 @@
# compose_service
Раскладывает Docker Compose стек и systemd-юнит, который им управляет.
Обобщение того, что делает `playbooks/pve-grimmory.yml`; такой же по форме
код продублирован в `roles/monitoring_server` и `roles/uptime_kuma`
(`grep -rn "up -d --remove-orphans"` — четыре копии одного юнита).
## Что делает
1. Создаёт корневой каталог сервиса и, при необходимости, каталоги данных
с нужными owner/group/mode.
2. Генерирует `.env` **один раз** под `umask 077`: статические пары из
`compose_service_env_static` и случайные секреты
(`openssl rand -hex`) для имён из `compose_service_env_generated`.
Задача целиком под `no_log: true`; права форсируются в `0600`.
Повторный прогон существующий `.env` не перетирает — иначе поменялся бы
пароль работающей БД.
3. Кладёт `compose.yml` из inline-строки или из Jinja-шаблона.
4. Ставит systemd-юнит `Type=oneshot`, `RemainAfterExit=yes`,
`ExecStart=docker compose up -d --remove-orphans`,
`ExecStop=docker compose down`.
5. `daemon-reload` через handler + немедленный `meta: flush_handlers`
(перечитать юнит надо ДО `systemctl start`, а не в конце play).
6. Валидирует конфигурацию: `docker compose config --quiet`, `no_log: true`
— при ошибке вывод подставляет значения из `.env`.
7. Стартует сервис, перезапуская его только если изменился `compose.yml`,
юнит или сработал внешний триггер (`compose_service_restart_triggers`).
8. Ждёт health-endpoint через `uri` с `retries`/`until`.
## Переменные
Полный список — в `defaults/main.yml`. Обязательные: `compose_service_name`
и ровно один из `compose_service_compose_content` /
`compose_service_compose_template` (проверяется `assert` в начале роли).
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `compose_service_name` | — | имя сервиса и systemd-юнита |
| `compose_service_root` | `/opt/<name>` | корень стека |
| `compose_service_compose_content` | `""` | inline `compose.yml` |
| `compose_service_compose_template` | `""` | путь к Jinja-шаблону |
| `compose_service_directories` | `[]` | каталоги данных |
| `compose_service_env_static` | `{}` | пары для `.env` |
| `compose_service_env_generated` | `[]` | имена случайных секретов |
| `compose_service_after` / `_requires` | `[]` | доп. юниты в `After=`/`Requires=` |
| `compose_service_restart_triggers` | `[]` | внешние причины рестарта |
| `compose_service_health_url` | `""` | URL health-check (пусто — пропустить) |
## Пример использования — как выглядел бы Gitea на новых ролях
`playbooks/pve-gitea.yml` сейчас 231 строка. На ролях `pve_lxc` +
`lxc_docker_host` + `compose_service` содержательная часть сводится
примерно к такому (сам стек Gitea пока НЕ мигрирован — это следующий этап):
```yaml
---
- name: Create the Gitea LXC on cloud-pc
hosts: localhost
connection: local
gather_facts: false
vars:
gitea: "{{ homelab_services.gitea }}"
roles:
- role: pve_lxc
pve_lxc_vmid: "{{ gitea.vmid }}"
pve_lxc_node: "{{ gitea.node }}"
pve_lxc_hostname: "{{ gitea.hostname }}"
pve_lxc_ip: "{{ gitea.ip }}/24"
pve_lxc_disk: "{{ gitea.lxc.disk }}"
pve_lxc_cores: "{{ gitea.lxc.cores }}"
pve_lxc_memory: "{{ gitea.lxc.memory }}"
pve_lxc_swap: "{{ gitea.lxc.swap }}"
pve_lxc_startup: "{{ gitea.lxc.startup }}"
pve_lxc_ostemplate: "{{ gitea.lxc.ostemplate }}"
- name: Configure the Gitea service
hosts: gitea
gather_facts: true
vars:
gitea: "{{ homelab_services.gitea }}"
roles:
- role: lxc_docker_host
lxc_docker_host_extra_packages: [sqlite3, rsync]
lxc_docker_host_ufw_service_rules:
- port: "3000"
sources: ["{{ homelab_lan_cidr }}", "{{ openvpn_network_cidr }}"]
- port: "2222"
sources: ["{{ homelab_lan_cidr }}", "{{ openvpn_network_cidr }}"]
- role: compose_service
compose_service_name: gitea
compose_service_root: /opt/gitea
compose_service_description: Gitea Compose stack
compose_service_directories:
- {path: /opt/gitea/data, owner: "1000", group: "1000", mode: "0750"}
compose_service_env_static:
USER_UID: "1000"
USER_GID: "1000"
compose_service_compose_content: |
services:
gitea:
image: {{ gitea.images[0] }}
container_name: gitea
environment:
USER_UID: "${USER_UID}"
USER_GID: "${USER_GID}"
ports:
- "3000:3000"
- "2222:22"
volumes:
- /opt/gitea/data:/data
restart: unless-stopped
compose_service_health_url: http://127.0.0.1:3000/api/healthz
compose_service_health_retries: 24
compose_service_health_delay: 5
```
Около 30 строк `vars` вместо 231 строки процедурного кода, и все факты
(vmid, узел, адрес, ресурсы, digest образа) берутся из реестра
`homelab_services`, а не дублируются в плейбуке.
### Что при такой миграции меняется на живом хосте
Это не чистый рефакторинг, поэтому мигрировать нужно осознанно:
* `docker run` в `ExecStart=` заменяется на compose-стек — контейнер
пересоздаётся, юнит `gitea.service` меняет тип на `oneshot`.
* появляется `/opt/gitea/.env`, которого раньше не было;
* `--pull never` и явный `docker pull` по digest заменяются на `image:`
в compose — политику закрепления образов надо перенести отдельно.
Поэтому перевод существующих сервисов вынесен в отдельный этап и делается
по одному сервису, с бэкапом и `--check --diff` перед реальным прогоном.
@@ -0,0 +1,70 @@
---
# ============================================================================
# roles/compose_service — раскладка Docker Compose стека + systemd-юнита.
# Обобщает то, что делает pve-grimmory.yml (compose.yml, .env с секретами,
# oneshot-юнит, валидация, health-check).
# ============================================================================
# --- Обязательное ----------------------------------------------------------
# Имя сервиса. Оно же имя systemd-юнита (<name>.service) и имя каталога
# по умолчанию. Роль падает с понятным сообщением, если не задано.
compose_service_name: ""
# Содержимое compose.yml. Ровно один из двух способов:
# compose_service_compose_content — готовая строка (можно собрать в vars);
# compose_service_compose_template — путь к Jinja-шаблону в вызывающей роли
# или в playbooks/templates.
compose_service_compose_content: ""
compose_service_compose_template: ""
# --- Раскладка на диске ----------------------------------------------------
compose_service_root: "/opt/{{ compose_service_name }}"
compose_service_root_mode: "0750"
compose_service_owner: root
compose_service_group: root
compose_service_compose_file: compose.yml
compose_service_compose_mode: "0644"
# Дополнительные каталоги данных. Формат:
# - {path: /opt/grimmory/data, owner: "1000", group: "1000", mode: "0750"}
compose_service_directories: []
# --- Файл окружения --------------------------------------------------------
compose_service_env_path: "{{ compose_service_root }}/.env"
compose_service_env_mode: "0600"
# Пары ключ-значение, записываемые в .env как есть.
compose_service_env_static: {}
# Имена переменных, значения которых генерируются `openssl rand -hex` ОДИН РАЗ.
# Файл .env создаётся только если его ещё нет: повторный прогон не перетирает
# уже используемые пароли. Чтобы поменять секрет — удалите .env вручную.
compose_service_env_generated: []
compose_service_env_secret_bytes: 32
# Необязательный .env.example для документирования формата (без секретов!).
compose_service_env_example: ""
compose_service_env_example_mode: "0644"
# --- systemd ---------------------------------------------------------------
compose_service_description: "{{ compose_service_name }} Compose stack"
# Дополнительные юниты в After= / Requires= (docker.service уже включён).
compose_service_after: []
compose_service_requires: []
compose_service_unit_path: "/etc/systemd/system/{{ compose_service_name }}.service"
compose_service_docker_binary: /usr/bin/docker
compose_service_enabled: true
# Дополнительные условия рестарта: список булевых значений от вызывающего
# (например, результат pull образа).
compose_service_restart_triggers: []
# --- Валидация и health-check ---------------------------------------------
# `docker compose config --quiet` перед стартом. Выполняется с no_log,
# потому что вывод при ошибке может содержать значения из .env.
compose_service_validate: true
# URL health-check после старта. Пустая строка — проверка пропускается.
compose_service_health_url: ""
compose_service_health_status: [200]
compose_service_health_retries: 24
compose_service_health_delay: 5
compose_service_health_follow_redirects: safe
@@ -0,0 +1,7 @@
---
# Юнит должен быть перечитан ДО задачи enable/start, поэтому вызывающая роль
# сразу после установки юнита делает `meta: flush_handlers`.
- name: Reload systemd for compose services
ansible.builtin.systemd:
daemon_reload: true
listen: compose_service_daemon_reload
@@ -0,0 +1,4 @@
---
# Зависимостей у роли нет: подготовка хоста и раскладка стека независимы
# и подключаются в нужном порядке из плейбука.
dependencies: []
@@ -0,0 +1,173 @@
---
- name: Validate compose_service parameters
ansible.builtin.assert:
that:
- compose_service_name | length > 0
- (compose_service_compose_content | length > 0)
!= (compose_service_compose_template | length > 0)
fail_msg: >-
Задайте compose_service_name и ровно один из
compose_service_compose_content / compose_service_compose_template.
- name: Ensure the service root directory exists
ansible.builtin.file:
path: "{{ compose_service_root }}"
state: directory
owner: "{{ compose_service_owner }}"
group: "{{ compose_service_group }}"
mode: "{{ compose_service_root_mode }}"
- name: Ensure the service data directories exist
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner | default(compose_service_owner) }}"
group: "{{ item.group | default(compose_service_group) }}"
mode: "{{ item.mode | default('0750') }}"
loop: "{{ compose_service_directories }}"
loop_control:
label: "{{ item.path }}"
# --- Файл окружения --------------------------------------------------------
# Создаётся один раз под umask 077. Секреты никогда не попадают в вывод:
# задача целиком под no_log.
- name: Generate the environment file once
ansible.builtin.shell: |
set -eu
umask 077
if [ -e {{ compose_service_env_path | quote }} ]; then
exit 0
fi
: > {{ compose_service_env_path | quote }}
{% for key, value in compose_service_env_static.items() %}
printf '%s\n' {{ (key ~ '=' ~ value) | quote }} >> {{ compose_service_env_path | quote }}
{% endfor %}
{% for key in compose_service_env_generated %}
printf '%s=%s\n' {{ key | quote }} "$(openssl rand -hex {{ compose_service_env_secret_bytes }})" >> {{ compose_service_env_path | quote }}
{% endfor %}
printf created
args:
executable: /bin/sh
register: compose_service_env_result
changed_when: compose_service_env_result.stdout == 'created'
no_log: true
when: >-
compose_service_env_static | length > 0 or
compose_service_env_generated | length > 0
- name: Enforce the environment file permissions
ansible.builtin.file:
path: "{{ compose_service_env_path }}"
owner: "{{ compose_service_owner }}"
group: "{{ compose_service_group }}"
mode: "{{ compose_service_env_mode }}"
when: >-
compose_service_env_static | length > 0 or
compose_service_env_generated | length > 0
- name: Install the environment example
ansible.builtin.copy:
dest: "{{ compose_service_env_path }}.example"
owner: "{{ compose_service_owner }}"
group: "{{ compose_service_group }}"
mode: "{{ compose_service_env_example_mode }}"
content: "{{ compose_service_env_example }}"
when: compose_service_env_example | length > 0
# --- compose.yml -----------------------------------------------------------
- name: Install the Compose configuration from inline content
ansible.builtin.copy:
dest: "{{ compose_service_root }}/{{ compose_service_compose_file }}"
owner: "{{ compose_service_owner }}"
group: "{{ compose_service_group }}"
mode: "{{ compose_service_compose_mode }}"
content: "{{ compose_service_compose_content }}"
when: compose_service_compose_content | length > 0
register: compose_service_compose_inline
- name: Install the Compose configuration from a template
ansible.builtin.template:
src: "{{ compose_service_compose_template }}"
dest: "{{ compose_service_root }}/{{ compose_service_compose_file }}"
owner: "{{ compose_service_owner }}"
group: "{{ compose_service_group }}"
mode: "{{ compose_service_compose_mode }}"
when: compose_service_compose_template | length > 0
register: compose_service_compose_templated
- name: Record whether the Compose configuration changed
ansible.builtin.set_fact:
compose_service_compose_changed: >-
{{ (compose_service_compose_inline.changed | default(false)) or
(compose_service_compose_templated.changed | default(false)) }}
# --- systemd ---------------------------------------------------------------
- name: Install the systemd unit
ansible.builtin.copy:
dest: "{{ compose_service_unit_path }}"
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description={{ compose_service_description }}
Wants=network-online.target
After=network-online.target docker.service{{ (' ' ~ compose_service_after | join(' ')) if compose_service_after else '' }}
Requires=docker.service{{ (' ' ~ compose_service_requires | join(' ')) if compose_service_requires else '' }}
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory={{ compose_service_root }}
ExecStart={{ compose_service_docker_binary }} compose -f {{ compose_service_root }}/{{ compose_service_compose_file }} up -d --remove-orphans
ExecStop={{ compose_service_docker_binary }} compose -f {{ compose_service_root }}/{{ compose_service_compose_file }} down
[Install]
WantedBy=multi-user.target
register: compose_service_unit
notify: compose_service_daemon_reload
- name: Apply the pending systemd daemon reload
ansible.builtin.meta: flush_handlers
# --- Валидация -------------------------------------------------------------
# no_log: сообщение об ошибке `docker compose config` подставляет значения
# переменных из .env.
- name: Validate the Compose configuration
ansible.builtin.command:
argv:
- "{{ compose_service_docker_binary }}"
- compose
- -f
- "{{ compose_service_root }}/{{ compose_service_compose_file }}"
- config
- --quiet
args:
chdir: "{{ compose_service_root }}"
changed_when: false
no_log: true
when: compose_service_validate
# --- Запуск ----------------------------------------------------------------
- name: Enable and start the service
ansible.builtin.systemd:
name: "{{ compose_service_name }}"
enabled: "{{ compose_service_enabled }}"
state: "{{ 'restarted' if compose_service_needs_restart else 'started' }}"
vars:
compose_service_needs_restart: >-
{{ (compose_service_compose_changed | bool) or
(compose_service_unit.changed | default(false)) or
(compose_service_restart_triggers | select | list | length > 0) }}
- name: Wait for the service health endpoint
ansible.builtin.uri:
url: "{{ compose_service_health_url }}"
status_code: "{{ compose_service_health_status }}"
follow_redirects: "{{ compose_service_health_follow_redirects }}"
return_content: false
register: compose_service_health
retries: "{{ compose_service_health_retries }}"
delay: "{{ compose_service_health_delay }}"
until: compose_service_health.status in (compose_service_health_status | map('int') | list)
when: compose_service_health_url | length > 0
@@ -0,0 +1,11 @@
---
emergency_reverse_user: homelab-rescue
emergency_reverse_port: 22010
emergency_reverse_bind_address: 127.0.0.1
emergency_reverse_tunnel_user: emergency-tunnel
emergency_control_user: emergency-control
emergency_control_key_path: /etc/emergency-access/control_ed25519
emergency_tunnel_state_dir: /var/lib/emergency-reverse-ssh
emergency_reverse_key_path: /var/lib/emergency-reverse-ssh/reverse_ed25519
emergency_access_dir: /etc/emergency-access
emergency_tunnel_ttl: 60m
@@ -0,0 +1,8 @@
---
- name: Reload systemd
ansible.builtin.systemd_service:
daemon_reload: true
- name: Validate and reload SSH
ansible.builtin.shell: sshd -t && systemctl reload ssh
changed_when: true
@@ -0,0 +1,134 @@
---
- name: Validate emergency access client inputs
ansible.builtin.assert:
that:
- emergency_vps_host_key is match('^ssh-(ed25519|rsa|ecdsa-[^ ]+) [A-Za-z0-9+/=]+( [A-Za-z0-9@._:-]+)?$')
- emergency_bot_control_public_key | length > 0
fail_msg: Set EMERGENCY_VPS_HOST_KEY and provision the emergency-bot control key first.
no_log: true
- name: Install reverse SSH client
ansible.builtin.apt:
name:
- openssh-client
- sudo
state: present
update_cache: true
- name: Create emergency access service accounts
ansible.builtin.user:
name: "{{ item }}"
system: true
# SSHD must start the forced command under a valid shell; authorized_keys
# still prevents this account from receiving an arbitrary command or PTY.
shell: "{{ '/bin/sh' if item == emergency_control_user else '/usr/sbin/nologin' }}"
create_home: true
loop:
- "{{ emergency_reverse_tunnel_user }}"
- "{{ emergency_control_user }}"
- name: Create emergency access configuration directory
ansible.builtin.file:
path: "{{ emergency_access_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Create reverse SSH state directory
ansible.builtin.file:
path: "{{ emergency_tunnel_state_dir }}"
state: directory
owner: "{{ emergency_reverse_tunnel_user }}"
group: "{{ emergency_reverse_tunnel_user }}"
mode: "0700"
- name: Generate dedicated reverse SSH key
ansible.builtin.command:
cmd: "ssh-keygen -q -t ed25519 -N '' -f {{ emergency_reverse_key_path }}"
creates: "{{ emergency_reverse_key_path }}"
become_user: "{{ emergency_reverse_tunnel_user }}"
no_log: true
- name: Read reverse SSH public key
ansible.builtin.slurp:
src: "{{ emergency_reverse_key_path }}.pub"
register: emergency_reverse_key
no_log: true
- name: Store reverse SSH public key for VPS configuration
ansible.builtin.set_fact:
emergency_reverse_public_key: "{{ emergency_reverse_key.content | b64decode | trim }}"
no_log: true
- name: Pin ru-vps SSH host key for the tunnel user
ansible.builtin.copy:
dest: "{{ emergency_tunnel_state_dir }}/known_hosts"
content: "ru-vps-emergency {{ emergency_vps_host_key }}\n"
owner: "{{ emergency_reverse_tunnel_user }}"
group: "{{ emergency_reverse_tunnel_user }}"
mode: "0600"
no_log: true
- name: Install reverse SSH systemd unit
ansible.builtin.template:
src: emergency-reverse-ssh.service.j2
dest: /etc/systemd/system/emergency-reverse-ssh.service
owner: root
group: root
mode: "0644"
notify: Reload systemd
- name: Install emergency tunnel expiry unit
ansible.builtin.template:
src: emergency-reverse-ssh-expire.service.j2
dest: /etc/systemd/system/emergency-reverse-ssh-expire.service
owner: root
group: root
mode: "0644"
notify: Reload systemd
- name: Install emergency tunnel expiry timer
ansible.builtin.template:
src: emergency-reverse-ssh.timer.j2
dest: /etc/systemd/system/emergency-reverse-ssh.timer
owner: root
group: root
mode: "0644"
notify: Reload systemd
- name: Install restricted emergency control helper
ansible.builtin.template:
src: emergency-control.j2
dest: /usr/local/libexec/emergency-control
owner: root
group: root
mode: "0755"
- name: Install emergency control sudo policy
ansible.builtin.template:
src: emergency-control.sudoers.j2
dest: /etc/sudoers.d/emergency-control
owner: root
group: root
mode: "0440"
validate: visudo -cf %s
- name: Authorize emergency-bot control key with forced command
ansible.posix.authorized_key:
user: "{{ emergency_control_user }}"
key: "command=\"/usr/local/libexec/emergency-control\",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty {{ emergency_bot_control_public_key }}"
state: present
exclusive: true
no_log: true
- name: Reload systemd before managing emergency units
ansible.builtin.meta: flush_handlers
- name: Keep emergency tunnel disabled at boot
ansible.builtin.systemd_service:
name: "{{ item }}"
enabled: false
loop:
- emergency-reverse-ssh.service
- emergency-reverse-ssh.timer
@@ -0,0 +1,24 @@
---
- name: Validate emergency access endpoint input
ansible.builtin.assert:
that:
- emergency_reverse_public_key | length > 0
fail_msg: Configure the mini-pc reverse SSH client before ru-vps.
no_log: true
- name: Create restricted reverse SSH endpoint user
ansible.builtin.user:
name: "{{ emergency_reverse_user }}"
system: true
# Remote forwarding is accepted before a session exists. A valid shell is
# required by SSHD; the forced command below rejects every session.
shell: /bin/sh
create_home: true
- name: Authorize only the dedicated reverse SSH key
ansible.posix.authorized_key:
user: "{{ emergency_reverse_user }}"
key: "command=\"/usr/bin/false\",restrict,port-forwarding,permitlisten=\"{{ emergency_reverse_bind_address }}:{{ emergency_reverse_port }}\" {{ emergency_reverse_public_key }}"
state: present
exclusive: true
no_log: true
@@ -0,0 +1,27 @@
#!/bin/sh
set -eu
case "${SSH_ORIGINAL_COMMAND:-}" in
start)
/usr/bin/sudo /usr/bin/systemctl start emergency-reverse-ssh.service
/usr/bin/sudo /usr/bin/systemctl restart emergency-reverse-ssh.timer
/usr/bin/systemctl is-active --quiet emergency-reverse-ssh.service
printf 'started; expires in {{ emergency_tunnel_ttl }}\nConnect:\nssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -J vps -p 22010 ansible@127.0.0.1\n'
;;
stop)
/usr/bin/sudo /usr/bin/systemctl stop emergency-reverse-ssh.timer
/usr/bin/sudo /usr/bin/systemctl stop emergency-reverse-ssh.service
printf 'stopped\n'
;;
status)
if /usr/bin/systemctl is-active --quiet emergency-reverse-ssh.service; then
/usr/bin/systemctl show --property=ActiveState --property=ActiveEnterTimestamp --value emergency-reverse-ssh.service
else
printf 'stopped\n'
fi
;;
*)
printf 'unsupported command\n' >&2
exit 64
;;
esac
@@ -0,0 +1 @@
{{ emergency_control_user }} ALL=(root) NOPASSWD: /usr/bin/systemctl start emergency-reverse-ssh.service, /usr/bin/systemctl restart emergency-reverse-ssh.timer, /usr/bin/systemctl stop emergency-reverse-ssh.timer, /usr/bin/systemctl stop emergency-reverse-ssh.service
@@ -0,0 +1,6 @@
[Unit]
Description=Close expired HomeLab reverse SSH rescue tunnel
[Service]
Type=oneshot
ExecStart=/usr/bin/systemctl stop emergency-reverse-ssh.service
@@ -0,0 +1,19 @@
[Unit]
Description=Temporary HomeLab reverse SSH rescue tunnel
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ emergency_reverse_tunnel_user }}
ExecStart=/usr/bin/ssh -N -i {{ emergency_reverse_key_path }} -o BatchMode=yes -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o StrictHostKeyChecking=yes -o UserKnownHostsFile={{ emergency_tunnel_state_dir }}/known_hosts -o HostKeyAlias=ru-vps-emergency -p 3422 -R {{ emergency_reverse_bind_address }}:{{ emergency_reverse_port }}:127.0.0.1:22 {{ emergency_reverse_user }}@157.22.231.198
Restart=on-failure
RestartSec=10
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths={{ emergency_tunnel_state_dir }}
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,10 @@
[Unit]
Description=Expire HomeLab reverse SSH rescue tunnel after {{ emergency_tunnel_ttl }}
[Timer]
OnActiveSec={{ emergency_tunnel_ttl }}
AccuracySec=1s
Unit=emergency-reverse-ssh-expire.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,8 @@
---
emergency_bot_user: emergency-bot
emergency_bot_state_dir: /var/lib/emergency-bot
emergency_bot_config_dir: /etc/emergency-bot
emergency_bot_control_key_path: /var/lib/emergency-bot/control_ed25519
emergency_bot_mini_pc_host: 192.168.1.10
emergency_bot_mini_pc_user: emergency-control
emergency_telegram_proxy: http://192.168.1.27:7890
@@ -0,0 +1,9 @@
---
- name: Reload systemd
ansible.builtin.systemd_service:
daemon_reload: true
- name: Restart emergency bot
ansible.builtin.systemd_service:
name: emergency-bot.service
state: restarted
@@ -0,0 +1,48 @@
---
- name: Create emergency bot user
ansible.builtin.user:
name: "{{ emergency_bot_user }}"
system: true
shell: /usr/sbin/nologin
create_home: false
- name: Create emergency bot state directory
ansible.builtin.file:
path: "{{ emergency_bot_state_dir }}"
state: directory
owner: "{{ emergency_bot_user }}"
group: "{{ emergency_bot_user }}"
mode: "0700"
- name: Generate emergency bot control key
ansible.builtin.command:
cmd: "ssh-keygen -q -t ed25519 -N '' -f {{ emergency_bot_control_key_path }}"
creates: "{{ emergency_bot_control_key_path }}"
become: true
become_user: "{{ emergency_bot_user }}"
no_log: true
- name: Set emergency bot control key ownership
ansible.builtin.file:
path: "{{ item.path }}"
state: file
owner: "{{ emergency_bot_user }}"
group: "{{ emergency_bot_user }}"
mode: "{{ item.mode }}"
loop:
- path: "{{ emergency_bot_control_key_path }}"
mode: "0600"
- path: "{{ emergency_bot_control_key_path }}.pub"
mode: "0644"
no_log: true
- name: Read emergency bot control public key
ansible.builtin.slurp:
src: "{{ emergency_bot_control_key_path }}.pub"
register: emergency_bot_control_key
no_log: true
- name: Store emergency bot control public key for mini-pc configuration
ansible.builtin.set_fact:
emergency_bot_control_public_key: "{{ emergency_bot_control_key.content | b64decode | trim }}"
no_log: true
@@ -0,0 +1,77 @@
---
- name: Bootstrap emergency bot control identity
ansible.builtin.import_tasks: bootstrap.yml
- name: Validate emergency bot inputs
ansible.builtin.assert:
that:
- emergency_bot_token is match('^[0-9]+:[A-Za-z0-9_-]+$')
- emergency_bot_allowed_user_ids is match('^[0-9]+(,[0-9]+)*$')
- emergency_mini_pc_host_key is match('^ssh-(ed25519|rsa|ecdsa-[^ ]+) [A-Za-z0-9+/=]+( [A-Za-z0-9@._:-]+)?$')
fail_msg: Set EMERGENCY_BOT_TOKEN, EMERGENCY_ALLOWED_USER_IDS and EMERGENCY_MINI_PC_HOST_KEY.
no_log: true
- name: Install emergency bot dependencies
ansible.builtin.apt:
name:
- ca-certificates
- openssh-client
- python3
state: present
update_cache: true
- name: Create emergency bot configuration directory
ansible.builtin.file:
path: "{{ emergency_bot_config_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Pin mini-pc SSH host key
ansible.builtin.copy:
dest: "{{ emergency_bot_state_dir }}/known_hosts"
content: "mini-pc-emergency {{ emergency_mini_pc_host_key }}\n"
owner: "{{ emergency_bot_user }}"
group: "{{ emergency_bot_user }}"
mode: "0600"
no_log: true
- name: Install emergency bot runtime
ansible.builtin.template:
src: emergency_bot.py.j2
dest: /usr/local/libexec/emergency-bot
owner: root
group: root
mode: "0755"
notify: Restart emergency bot
- name: Install emergency bot environment
ansible.builtin.template:
src: emergency-bot.env.j2
dest: "{{ emergency_bot_config_dir }}/bot.env"
owner: root
group: root
mode: "0600"
no_log: true
notify: Restart emergency bot
- name: Install emergency bot systemd unit
ansible.builtin.template:
src: emergency-bot.service.j2
dest: /etc/systemd/system/emergency-bot.service
owner: root
group: root
mode: "0644"
notify:
- Reload systemd
- Restart emergency bot
- name: Enable and start emergency bot
ansible.builtin.meta: flush_handlers
- name: Enable and start emergency bot
ansible.builtin.systemd_service:
name: emergency-bot.service
enabled: true
state: started

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