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
This commit is contained in:
Dmitry
2026-08-26 21:39:28 +03:00
co-authored by Claude Opus 5
parent 4bafa7d09e
commit c676be81ec
126 changed files with 10583 additions and 44 deletions
+17
View File
@@ -3,6 +3,23 @@ export PROXMOX_USER='ansible@pve'
export PROXMOX_TOKEN_ID='homelab'
export PROXMOX_TOKEN_SECRET='replace-me'
export PROXMOX_VALIDATE_CERTS=false
# Optional local SSH-forward port when the control node reaches PVE through ru-vps.
# export PROXMOX_PORT=18006
# Override if the downloaded template name differs.
export PVE_LXC_OSTEMPLATE='local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst'
# Monitoring secrets. Keep actual values in ignored ansible/.env or Ansible Vault.
export MONITORING_TELEGRAM_BOT_TOKEN='replace-me'
export MONITORING_TELEGRAM_CHAT_ID='replace-me'
export MONITORING_GRAFANA_ADMIN_PASSWORD='replace-me'
export MONITORING_PVE_API_USER='monitoring@pve'
export MONITORING_PVE_API_TOKEN_ID='prometheus'
export MONITORING_PVE_API_TOKEN_SECRET='replace-me'
# Emergency reverse SSH Telegram bot. Keep actual values in ignored ansible/.env
# or Ansible Vault. Host keys are pinned public keys in the form "ssh-ed25519 AAAA...".
export EMERGENCY_BOT_TOKEN='replace-me'
export EMERGENCY_ALLOWED_USER_IDS='replace-me'
export EMERGENCY_VPS_HOST_KEY='ssh-ed25519 replace-me'
export EMERGENCY_MINI_PC_HOST_KEY='ssh-ed25519 replace-me'
+106 -4
View File
@@ -17,10 +17,13 @@ Ansible is the control plane for HomeLab infrastructure changes.
## Current Groups
- `ru-vps` — public VPS, JumpHost, qdevice, ZeroTier member.
- `ru-vps` — public VPS, JumpHost, qdevice, OpenVPN server.
- `pve_nodes` — Proxmox hosts: `cloud-pc`, `mini-pc`.
- `lxc_infra` — infrastructure LXC containers: `pbs`, `zt-cloud`, `zt-mini`.
- `vpn_openvpn` — OpenVPN transport hosts: `ru-vps`, `wg-mini`.
- `lxc_infra` — infrastructure LXC containers, including the outbound-only `gyro` investment allocator host.
- `monitoring_server` — monitoring LXC; Prometheus stack is frozen and Uptime Kuma is active.
- `monitoring_exporters` — hosts exposing Node Exporter metrics.
- `monitoring_smart_exporters` — Proxmox nodes exposing SMART metrics.
- `vpn_openvpn` — OpenVPN transport hosts: `ru-vps`, `ovpn-mini`.
- `shell_hosts` — hosts with unified bash config: `ru-vps`, `cloud-pc`, `mini-pc`.
- `servers` — all managed hosts.
@@ -41,12 +44,44 @@ Run from `ansible/`:
ansible-playbook playbooks/check.yml
```
## Controlled Updates
Service updates are manual and use pinned `tag@sha256:digest` image references only; floating tags and auto-update agents are not used.
Run the dedicated playbook for the target service from `ansible/`:
```bash
.venv/bin/ansible-playbook playbooks/vaultwarden-update.yml
.venv/bin/ansible-playbook playbooks/gitea-update.yml
.venv/bin/ansible-playbook playbooks/adguard-update.yml
.venv/bin/ansible-playbook playbooks/mihomo-update.yml
.venv/bin/ansible-playbook playbooks/grimmory-update.yml
```
Update flow is always: fresh backup/audit first, then the update playbook, then health verification.
- Gitea, Vaultwarden and Grimmory use app-aware offsite restic backups/audits before update.
- AdGuard and Mihomo use fresh PBS LXC backups before update.
- `grimmory-update.yml` currently validates the existing pinned release; any future actual app image upgrade must be preceded by release-note and migration review.
## Mihomo Hardening
Use the dedicated hardening playbook only when explicitly approved:
```bash
.venv/bin/ansible-playbook playbooks/ru-vps-mihomo-harden.yml -e ru_vps_mihomo_harden_confirm=true
```
It rotates the live Mihomo SOCKS credentials on `ru-vps`, locks the proxy to loopback, and removes the public UFW exposure for ports `7890` and `7891`.
The rotated credentials are not recoverable for clients unless you roll back the saved config backup.
For Proxmox API playbooks, create ignored `.env` from `.env.example` and load it:
```bash
cp .env.example .env
. ./.env
.venv/bin/ansible-playbook playbooks/pve-wg-mini.yml
.venv/bin/ansible-playbook playbooks/pve-ovpn-mini.yml
```
Or bootstrap the token from `mini-pc` with sudo:
@@ -55,6 +90,12 @@ Or bootstrap the token from `mini-pc` with sudo:
.venv/bin/ansible-playbook playbooks/bootstrap-pve-api-token.yml -K
```
Create the separate read-only PVE token used by the monitoring exporter:
```bash
.venv/bin/ansible-playbook playbooks/bootstrap-monitoring-pve-token.yml
```
OpenVPN transport:
```bash
@@ -62,6 +103,67 @@ OpenVPN transport:
.venv/bin/ansible-playbook playbooks/openvpn-check.yml
```
Monitoring is provisioned in two steps after loading the monitoring secrets from ignored `.env` or Ansible Vault:
```bash
. ./.env
.venv/bin/ansible-playbook playbooks/pve-monitoring.yml
.venv/bin/ansible-playbook playbooks/monitoring.yml
```
`pve-monitoring.yml` creates CT `146` (`monitoring`, `192.168.1.30`) on `cloud-pc`. `monitoring.yml` configures exporters, the `ru-vps` probe vantage point, and the central Prometheus stack. It is frozen while Uptime Kuma is in use; do not run it unless restoring Prometheus monitoring.
## Gyro Investment Allocator
`pve-gyro.yml` creates unprivileged CT `150` (`gyro`, `192.168.1.35`) on `mini-pc`. `gyro.yml` installs Python 3.13+, pinned `uv`, the `gyro` service user, a container-local GitHub deploy key, restrictive firewall rules, and a weekday systemd timer.
UFW is the currently enforced isolation layer: inbound is denied except SSH from LAN/OpenVPN, and east-west outbound is denied except the Mihomo HTTP proxy. The equivalent CT `150` Proxmox firewall is staged, but the cluster-wide PVE firewall remains disabled; do not enable it without auditing every node and guest with `firewall=1`.
The role keeps deployment and the timer disabled by default. The active host vars deploy `git@github.com:ada-dmitry/t_tech-gyro.git` with GitHub's verified ED25519 host key; the timer still requires the ignored Vault file:
```bash
cp inventory/host_vars/gyro/vault.yml.example inventory/host_vars/gyro/vault.yml
ansible-vault encrypt inventory/host_vars/gyro/vault.yml
```
After encrypting the secrets, set `gyro_timer_enabled: true` in `main.yml` and apply with `--ask-vault-pass`. `DRY_RUN_OVERRIDE` remains `true` until real trading is explicitly approved.
```bash
. ./.env
.venv/bin/ansible-playbook playbooks/pve-gyro.yml
.venv/bin/ansible-playbook playbooks/gyro.yml --ask-vault-pass
```
The timer runs at 11:00 Europe/Moscow from Monday through Friday and uses `OnFailure=` for a best-effort Telegram alert. CT `150` is included in the daily mini-pc PBS job and backup freshness audit.
Uptime Kuma uses the existing monitoring LXC and stops/disables `homelab-monitoring` without deleting its configuration or data. Its UI is available only from the LAN at `http://192.168.1.30:3001`; create monitors and notification settings in the UI.
```bash
.venv/bin/ansible-playbook playbooks/uptime-kuma.yml
```
## Emergency Reverse SSH
`pve-emergency-bot.yml` creates CT `148` (`emergency-bot`, `192.168.1.32`) on `mini-pc`. `emergency-access.yml` configures the bot, a TTL-limited reverse tunnel from `mini-pc` to `ru-vps`, and the restricted SSH identities used between them.
Before applying, set `EMERGENCY_BOT_TOKEN`, `EMERGENCY_ALLOWED_USER_IDS`, `EMERGENCY_VPS_HOST_KEY`, and `EMERGENCY_MINI_PC_HOST_KEY` in ignored `.env` or Ansible Vault. The host-key variables must be verified public host keys, not values obtained during deployment.
```bash
. ./.env
.venv/bin/ansible-playbook playbooks/pve-emergency-bot.yml
.venv/bin/ansible-playbook playbooks/emergency-access.yml
```
From an authorized private Telegram chat, use the `Enable SSH`, `Status`, and `Stop` buttons or `/emergency ssh`, `/emergency status`, and `/emergency stop`. `/emergency ssh` enables a 60-minute tunnel only; it does not expose a public port. Connect while it is active with:
The bot uses the LAN Mihomo HTTP proxy at `192.168.1.27:7890` because direct Telegram TCP access is unavailable from HomeLab.
```bash
ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -J vps -p 22010 ansible@127.0.0.1
```
The target account is `ansible`; it has no password login. Use the existing private key `~/.ssh/id_ed25519_homelab_ansible`.
Bootstrap the Ansible service account on shell hosts:
```bash
+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())
@@ -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
+121 -15
View File
@@ -6,15 +6,21 @@ all:
ansible_become: true
homelab_lan_cidr: 192.168.1.0/24
homelab_service_range: 192.168.1.5-192.168.1.40
homelab_zerotier_network_id: 743993800fa5a34c
openvpn_network_cidr: 10.78.0.0/30
openvpn_listen_port: 8443
openvpn_forwarded_services:
- name: satisfactory
protocol: udp
public_port: 7777
target_host: 192.168.1.100
target_port: 7777
children:
homelab:
hosts:
ru-vps:
ansible_host: vps
monitoring_exporter_node_listen_address: 127.0.0.1:9100
openvpn_local_ip: 10.78.0.1
openvpn_peer_ip: 10.78.0.2
openvpn_role: server
@@ -44,29 +50,39 @@ all:
hosts:
pbs:
ansible_host: 192.168.1.20
ansible_user: root
ansible_become: false
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
expected_lan_ip: 192.168.1.20
zt-cloud:
ansible_host: 192.168.1.21
expected_lan_ip: 192.168.1.21
zerotier_ip: 10.122.62.206
zerotier_node_id: c606e6d181
zt-mini:
ansible_host: 192.168.1.22
expected_lan_ip: 192.168.1.22
zerotier_ip: 10.122.62.213
zerotier_node_id: 022ac284e1
wg-mini:
ovpn-mini:
ansible_host: 192.168.1.23
ansible_user: root
ansible_become: false
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
expected_lan_ip: 192.168.1.23
openvpn_local_ip: 10.78.0.2
openvpn_peer_ip: 10.78.0.1
openvpn_role: gateway
adguard:
ansible_host: 192.168.1.28
ansible_user: root
ansible_become: false
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
ansible_ssh_common_args: >-
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
expected_lan_ip: 192.168.1.28
docker-test:
ansible_host: 192.168.1.29
ansible_user: root
ansible_become: false
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
ansible_ssh_common_args: >-
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
expected_lan_ip: 192.168.1.29
vaultwarden:
ansible_host: 192.168.1.24
ansible_user: root
@@ -85,16 +101,106 @@ all:
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
expected_lan_ip: 192.168.1.25
memoir-bot:
ansible_host: 192.168.1.26
ansible_user: root
ansible_become: false
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
ansible_ssh_common_args: >-
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
expected_lan_ip: 192.168.1.26
mihomo:
ansible_host: 192.168.1.27
ansible_user: root
ansible_become: false
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
ansible_ssh_common_args: >-
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
expected_lan_ip: 192.168.1.27
monitoring:
ansible_host: 192.168.1.30
ansible_user: root
ansible_become: false
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
ansible_ssh_common_args: >-
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
expected_lan_ip: 192.168.1.30
hermes-ai:
ansible_host: 192.168.1.31
ansible_user: root
ansible_become: false
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
ansible_ssh_common_args: >-
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
expected_lan_ip: 192.168.1.31
bash_config_proxy_http_url: http://192.168.1.27:7890
bash_config_proxy_socks_url: socks5h://192.168.1.27:7890
emergency-bot:
ansible_host: 192.168.1.32
ansible_user: root
ansible_become: false
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
ansible_ssh_common_args: >-
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
expected_lan_ip: 192.168.1.32
grimmory:
ansible_host: 192.168.1.34
ansible_user: root
ansible_become: false
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
ansible_ssh_common_args: >-
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
expected_lan_ip: 192.168.1.34
gyro:
ansible_host: 192.168.1.35
ansible_user: root
ansible_become: false
ansible_ssh_private_key_file: ~/.ssh/id_ed25519_homelab
ansible_ssh_common_args: >-
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p 3422 -W %h:%p ansible@157.22.231.198"
expected_lan_ip: 192.168.1.35
monitoring_exporters:
hosts:
ru-vps:
cloud-pc:
mini-pc:
pbs:
ovpn-mini:
vaultwarden:
gitea:
memoir-bot:
mihomo:
adguard:
monitoring:
grimmory:
monitoring_smart_exporters:
hosts:
cloud-pc:
mini-pc:
monitoring_server:
hosts:
monitoring:
vpn_openvpn:
hosts:
ru-vps:
wg-mini:
ovpn-mini:
shell_hosts:
hosts:
ru-vps:
cloud-pc:
mini-pc:
hermes-ai:
servers:
children:
+110
View File
@@ -0,0 +1,110 @@
---
- name: Create and verify current PBS audit before AdGuard update
hosts: mini-pc
gather_facts: false
tasks:
- name: Verify AdGuard VMID ownership before backup
ansible.builtin.command: pct config 144
register: adguard_pct_config
changed_when: false
failed_when: false
- name: Refuse to back up a foreign VMID 144
ansible.builtin.assert:
that:
- adguard_pct_config.rc == 0
- adguard_existing_hostname == 'adguard'
fail_msg: VMID 144 is not the AdGuard container.
vars:
adguard_existing_hostname: >-
{{ adguard_pct_config.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Check for active Proxmox backup before AdGuard PBS backup
ansible.builtin.command: pgrep -x vzdump
register: adguard_vzdump_preflight
changed_when: false
failed_when: false
- name: Require no active Proxmox backup before AdGuard PBS backup
ansible.builtin.assert:
that:
- adguard_vzdump_preflight.rc != 0
fail_msg: >-
A Proxmox backup is already running on mini-pc.
Retry after the existing backup completes.
- name: Create a fresh AdGuard PBS backup
ansible.builtin.command:
argv:
- vzdump
- "144"
- --storage
- pbs
- --mode
- snapshot
- --prune-backups
- keep-all=1
- --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged
- name: Run current PBS audit service
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-pbs.service
changed_when: true
- import_playbook: pve-adguard.yml
- name: Verify AdGuard public services after update
hosts: adguard
gather_facts: false
tasks:
- name: Check AdGuard HTTP root
ansible.builtin.uri:
url: http://127.0.0.1/
status_code: [200, 302]
return_content: false
register: adguard_http_root_check
retries: 24
delay: 5
until: adguard_http_root_check.status in [200, 302]
- name: Check AdGuard DNS over UDP
ansible.builtin.command:
argv:
- dig
- "@127.0.0.1"
- localhost
- A
- +time=2
- +tries=1
- +short
register: adguard_dns_udp_check
changed_when: false
retries: 12
delay: 5
until: adguard_dns_udp_check.rc == 0 and '127.0.0.1' in adguard_dns_udp_check.stdout
- name: Check AdGuard DNS over TCP
ansible.builtin.command:
argv:
- dig
- "@127.0.0.1"
- localhost
- A
- +tcp
- +time=2
- +tries=1
- +short
register: adguard_dns_tcp_check
changed_when: false
retries: 12
delay: 5
until: adguard_dns_tcp_check.rc == 0 and '127.0.0.1' in adguard_dns_tcp_check.stdout
+45
View File
@@ -0,0 +1,45 @@
- name: Configure PBS backup audit on mini-pc
hosts: mini-pc
gather_facts: false
roles:
- role: backup_audit
vars:
backup_audit_type: pbs
- name: Configure restic offsite audit on cloud-pc (Gitea)
hosts: cloud-pc
gather_facts: false
roles:
- role: backup_audit
vars:
backup_audit_type: restic
backup_audit_restic_profiles:
- name: gitea
sqlite_name: gitea.db
max_age_hours: 36
- name: Configure restic offsite audit on vaultwarden
hosts: vaultwarden
gather_facts: false
roles:
- role: backup_audit
vars:
backup_audit_type: restic
backup_audit_restic_profiles:
- name: vaultwarden
sqlite_name: db.sqlite3
max_age_hours: 36
- name: Configure restic offsite audit on Grimmory
hosts: grimmory
gather_facts: false
vars:
ansible_become: false
roles:
- role: backup_audit
vars:
backup_audit_type: restic
backup_audit_restic_profiles:
- name: grimmory
expected_name: grimmory.sql
max_age_hours: 36
@@ -0,0 +1,76 @@
---
- name: Create read-only Proxmox token for monitoring
hosts: mini-pc
gather_facts: false
vars:
monitoring_pve_user: monitoring@pve
monitoring_pve_token_id: prometheus
monitoring_pve_env_file: "{{ playbook_dir }}/../.env"
monitoring_pve_rotate_existing_token: false
tasks:
- name: Read existing Proxmox users
ansible.builtin.command: pveum user list --output-format json
register: monitoring_pve_users_raw
changed_when: false
- name: Create monitoring Proxmox user
ansible.builtin.command: >-
pveum user add {{ monitoring_pve_user }}
--comment 'Read-only Prometheus monitoring user'
when: monitoring_pve_user not in (monitoring_pve_users_raw.stdout | from_json | map(attribute='userid') | list)
- name: Grant PVEAuditor role to monitoring user
ansible.builtin.command: >-
pveum acl modify / -user {{ monitoring_pve_user }} -role PVEAuditor
changed_when: false
- name: Read monitoring user tokens
ansible.builtin.command: >-
pveum user token list {{ monitoring_pve_user }} --output-format json
register: monitoring_pve_tokens_raw
changed_when: false
- name: Refuse to overwrite an existing monitoring token
ansible.builtin.assert:
that:
- monitoring_pve_token_id not in (monitoring_pve_tokens_raw.stdout | from_json | map(attribute='tokenid') | list)
fail_msg: Existing monitoring token secret cannot be recovered safely. Rotate it explicitly before rerunning this playbook.
when: not monitoring_pve_rotate_existing_token | bool
- name: Rotate existing monitoring token explicitly
ansible.builtin.command: >-
pveum user token remove {{ monitoring_pve_user }} {{ monitoring_pve_token_id }}
when:
- monitoring_pve_rotate_existing_token | bool
- monitoring_pve_token_id in (monitoring_pve_tokens_raw.stdout | from_json | map(attribute='tokenid') | list)
- name: Create separated monitoring token
ansible.builtin.command: >-
pveum user token add {{ monitoring_pve_user }} {{ monitoring_pve_token_id }}
--privsep 1 --comment 'Prometheus PVE exporter' --output-format json
register: monitoring_pve_token_created
no_log: true
- name: Grant PVEAuditor role to separated monitoring token
ansible.builtin.command: >-
pveum acl modify / -token {{ monitoring_pve_user }}!{{ monitoring_pve_token_id }} -role PVEAuditor
changed_when: false
- name: Store monitoring token variables locally
ansible.builtin.lineinfile:
path: "{{ monitoring_pve_env_file }}"
regexp: "^export {{ item.name }}="
line: "export {{ item.name }}='{{ item.value }}'"
create: false
loop:
- name: MONITORING_PVE_API_USER
value: "{{ monitoring_pve_user }}"
- name: MONITORING_PVE_API_TOKEN_ID
value: "{{ monitoring_pve_token_id }}"
- name: MONITORING_PVE_API_TOKEN_SECRET
value: "{{ (monitoring_pve_token_created.stdout | from_json).value }}"
delegate_to: localhost
vars:
ansible_connection: local
ansible_become: false
no_log: true
+42
View File
@@ -0,0 +1,42 @@
---
- name: Bootstrap emergency-bot control identity
hosts: emergency-bot
gather_facts: false
tasks:
- name: Create emergency-bot control identity
ansible.builtin.include_role:
name: emergency_bot
tasks_from: bootstrap
- name: Configure mini-pc reverse SSH client and restricted control path
hosts: mini-pc
gather_facts: false
vars:
emergency_vps_host_key: "{{ lookup('env', 'EMERGENCY_VPS_HOST_KEY') }}"
emergency_bot_control_public_key: "{{ hostvars['emergency-bot'].emergency_bot_control_public_key }}"
tasks:
- name: Configure mini-pc emergency access client
ansible.builtin.include_role:
name: emergency_access
tasks_from: client
- name: Configure ru-vps reverse SSH endpoint
hosts: ru-vps
gather_facts: false
vars:
emergency_reverse_public_key: "{{ hostvars['mini-pc'].emergency_reverse_public_key }}"
tasks:
- name: Configure ru-vps emergency access endpoint
ansible.builtin.include_role:
name: emergency_access
tasks_from: endpoint
- name: Configure and enable emergency Telegram bot
hosts: emergency-bot
gather_facts: false
vars:
emergency_bot_token: "{{ lookup('env', 'EMERGENCY_BOT_TOKEN') }}"
emergency_bot_allowed_user_ids: "{{ lookup('env', 'EMERGENCY_ALLOWED_USER_IDS') }}"
emergency_mini_pc_host_key: "{{ lookup('env', 'EMERGENCY_MINI_PC_HOST_KEY') }}"
roles:
- role: emergency_bot
+45
View File
@@ -0,0 +1,45 @@
---
- name: Create and verify Gitea backup before update
hosts: cloud-pc
gather_facts: false
tasks:
- name: Create a fresh Gitea offsite backup
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-restic-offsite-gitea.service
changed_when: true
- name: Run Gitea offsite backup audit
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-gitea.service
changed_when: true
- import_playbook: pve-gitea.yml
- name: Verify Gitea public endpoints after update
hosts: ru-vps
gather_facts: false
tasks:
- name: Check Gitea HTTPS endpoint
ansible.builtin.uri:
url: https://git.ada-dev.ru/
status_code: 200
return_content: false
register: gitea_https_check
retries: 24
delay: 5
until: gitea_https_check.status == 200
- name: Check Gitea SSH port from ru-vps
ansible.builtin.wait_for:
host: 192.168.1.25
port: 2222
state: started
timeout: 30
+106
View File
@@ -0,0 +1,106 @@
---
- name: Create and verify Grimmory backup before update
hosts: grimmory
gather_facts: false
tasks:
- name: Create a fresh Grimmory offsite backup
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-restic-offsite-grimmory.service
changed_when: true
- name: Run Grimmory offsite backup audit
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-grimmory.service
changed_when: true
- name: Create a fresh Grimmory PBS backup
hosts: cloud-pc
gather_facts: false
tasks:
- name: Read Grimmory LXC config
ansible.builtin.command:
argv:
- pct
- config
- "149"
register: grimmory_pct_config
changed_when: false
- name: Assert VMID 149 belongs to Grimmory
ansible.builtin.assert:
that:
- grimmory_pct_hostname_line != ""
- grimmory_pct_hostname == "grimmory"
fail_msg: >-
Refusing to run vzdump 149 because pct config hostname is not grimmory:
{{ grimmory_pct_hostname_line | default('missing hostname line') }}
vars:
grimmory_pct_hostname_line: >-
{{ (grimmory_pct_config.stdout_lines | select('match', '^hostname:\\s+') | list | first | default('')) }}
grimmory_pct_hostname: >-
{{ grimmory_pct_hostname_line | regex_replace('^hostname:\\s*', '') }}
- name: Check for active Proxmox backup before Grimmory PBS backup
ansible.builtin.command: pgrep -x vzdump
register: grimmory_vzdump_preflight
changed_when: false
failed_when: false
- name: Require no active Proxmox backup before Grimmory PBS backup
ansible.builtin.assert:
that:
- grimmory_vzdump_preflight.rc != 0
fail_msg: >-
A Proxmox backup is already running on cloud-pc.
Retry after the existing backup completes.
- name: Create a fresh Grimmory PBS backup
ansible.builtin.command:
argv:
- vzdump
- "149"
- --storage
- pbs
- --mode
- snapshot
- --prune-backups
- keep-all=1
- --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged
- name: Run PBS backup audit on mini-pc
hosts: mini-pc
gather_facts: false
tasks:
- name: Run current PBS backup audit on mini-pc
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-pbs.service
changed_when: true
- import_playbook: pve-grimmory.yml
- name: Verify Grimmory public endpoint after update
hosts: ru-vps
gather_facts: false
tasks:
- name: Check Grimmory public health endpoint
ansible.builtin.uri:
url: https://books.ada-dev.ru/api/v1/healthcheck
status_code: 200
return_content: false
register: grimmory_public_health
retries: 24
delay: 5
until: grimmory_public_health.status == 200
+6
View File
@@ -0,0 +1,6 @@
---
- name: Configure Gyro investment allocator host
hosts: gyro
gather_facts: true
roles:
- role: gyro
+96
View File
@@ -0,0 +1,96 @@
---
- name: Verify Mihomo PBS audit before update
hosts: mini-pc
gather_facts: false
tasks:
- name: Read VMID 143 configuration
ansible.builtin.command: "pct config 143"
register: mihomo_pct_config
changed_when: false
failed_when: false
- name: Refuse to run backup unless VMID 143 is Mihomo
ansible.builtin.assert:
that:
- mihomo_pct_config.rc == 0
- mihomo_update_hostname == 'mihomo'
fail_msg: VMID 143 must be the Mihomo container before backup.
vars:
mihomo_update_hostname: >-
{{ mihomo_pct_config.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Check for active Proxmox backup before Mihomo PBS backup
ansible.builtin.command: pgrep -x vzdump
register: mihomo_vzdump_preflight
changed_when: false
failed_when: false
- name: Require no active Proxmox backup before Mihomo PBS backup
ansible.builtin.assert:
that:
- mihomo_vzdump_preflight.rc != 0
fail_msg: >-
A Proxmox backup is already running on mini-pc.
Retry after the existing backup completes.
- name: Create a fresh Mihomo PBS backup
ansible.builtin.command:
argv:
- vzdump
- "143"
- --storage
- pbs
- --mode
- snapshot
- --prune-backups
- keep-all=1
- --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged
- name: Run current PBS backup audit on mini-pc
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-pbs.service
changed_when: true
- import_playbook: pve-mihomo.yml
- name: Verify Mihomo after update
hosts: mihomo
gather_facts: false
tasks:
- name: Wait for Mihomo proxy TCP ports
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: started
timeout: 120
loop:
- 7890
- 7891
- 9090
- name: Check Mihomo UI endpoint
ansible.builtin.uri:
url: http://127.0.0.1:8080/
status_code: 200
register: mihomo_ui_health
retries: 24
delay: 5
until: mihomo_ui_health.status == 200
- name: Check Mihomo controller /version endpoint
ansible.builtin.uri:
url: http://127.0.0.1:9090/version
status_code: 200
register: mihomo_controller_health
retries: 24
delay: 5
until: mihomo_controller_health.status == 200
+26
View File
@@ -0,0 +1,26 @@
---
- name: Configure Prometheus exporters
hosts: monitoring_exporters
gather_facts: false
roles:
- role: monitoring_exporter
- name: Configure SMART exporters on Proxmox nodes
hosts: monitoring_smart_exporters
gather_facts: false
roles:
- role: monitoring_exporter
monitoring_exporter_install_node: false
monitoring_exporter_install_smart: true
- name: Configure ru-vps external probes
hosts: ru-vps
gather_facts: false
roles:
- role: monitoring_blackbox
- name: Configure central monitoring stack
hosts: monitoring_server
gather_facts: false
roles:
- role: monitoring_server
@@ -0,0 +1,74 @@
- name: Configure Gitea offsite backup to Yandex Disk
hosts: cloud-pc
gather_facts: false
vars:
offsite_profile: gitea
offsite_repository: rclone:yadisk:System/Backups/HomeLab/restic/gitea
offsite_source_path: /opt/data/gitea
offsite_sqlite_db: /opt/data/gitea/gitea/gitea.db
offsite_backup_tag: gitea,cloud-pc,yadisk
offsite_timer_oncalendar: "*-*-* 04:15:00"
offsite_rclone_config_local: ~/.config/rclone/rclone.conf
offsite_restic_password_local: "{{ playbook_dir }}/../generated/restic-offsite-password"
offsite_excludes:
- /opt/data/gitea/gitea/gitea.db
- /opt/data/gitea/gitea/gitea.db-shm
- /opt/data/gitea/gitea/gitea.db-wal
- /opt/data/gitea/gitea/log/**
- /opt/data/gitea/gitea/sessions/**
- /opt/data/gitea/gitea/queues/**
- /opt/data/gitea/gitea/tmp/**
tasks:
- name: Configure restic offsite profile
ansible.builtin.include_tasks: ../tasks/offsite-restic-profile.yml
- name: Configure Vaultwarden offsite backup to Yandex Disk
hosts: vaultwarden
gather_facts: false
vars:
ansible_become: false
offsite_profile: vaultwarden
offsite_repository: rclone:yadisk:System/Backups/HomeLab/restic/vaultwarden
offsite_source_path: /opt/vaultwarden/data
offsite_sqlite_db: /opt/vaultwarden/data/db.sqlite3
offsite_backup_tag: vaultwarden,mini-pc,yadisk
offsite_timer_oncalendar: "*-*-* 04:45:00"
offsite_rclone_config_local: ~/.config/rclone/rclone.conf
offsite_restic_password_local: "{{ playbook_dir }}/../generated/restic-offsite-password"
offsite_excludes:
- /opt/vaultwarden/data/db.sqlite3
- /opt/vaultwarden/data/db.sqlite3-shm
- /opt/vaultwarden/data/db.sqlite3-wal
- /opt/vaultwarden/data/tmp/**
tasks:
- name: Configure restic offsite profile
ansible.builtin.include_tasks: ../tasks/offsite-restic-profile.yml
- name: Configure Grimmory offsite backup to Yandex Disk
hosts: grimmory
gather_facts: false
vars:
ansible_become: false
offsite_profile: grimmory
offsite_repository: rclone:yadisk:System/Backups/HomeLab/restic/grimmory
offsite_source_path: /opt/grimmory
offsite_sqlite_db: ""
offsite_mariadb_container: grimmory-mariadb
offsite_mariadb_database: grimmory
offsite_mariadb_user: grimmory
offsite_mariadb_env: /opt/grimmory/.env
offsite_mariadb_dump_name: grimmory.sql
offsite_backup_tag: grimmory,cloud-pc,yadisk
offsite_timer_oncalendar: "*-*-* 05:15:00"
offsite_rclone_config_local: ~/.config/rclone/rclone.conf
offsite_restic_password_local: "{{ playbook_dir }}/../generated/restic-offsite-password"
offsite_excludes:
- /opt/grimmory/books/**
- /opt/grimmory/bookdrop/**
- /opt/grimmory/mariadb/**
- /opt/grimmory/backup-staging/**
- /opt/grimmory/**/heapdump*.hprof
- /opt/grimmory/**/cache/**
tasks:
- name: Configure restic offsite profile
ansible.builtin.include_tasks: ../tasks/offsite-restic-profile.yml
+12
View File
@@ -10,10 +10,22 @@
ansible.builtin.command: nc -vz -w 5 192.168.1.10 8006
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
+247
View File
@@ -0,0 +1,247 @@
---
- name: Create AdGuard Home LXC on mini-pc
hosts: mini-pc
gather_facts: false
vars:
adguard_vmid: 144
adguard_hostname: adguard
adguard_ip: 192.168.1.28/24
adguard_gateway: 192.168.1.1
adguard_rootfs: local-lvm:8
adguard_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
adguard_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
adguard_image: adguard/adguardhome:v0.107.78@sha256:2c127294fa5f96151d9d3a433fb9d66c17e4d18cf698c2b04372a80e26fdd26f
handlers:
- name: restart adguard lxc
ansible.builtin.shell: "pct stop {{ adguard_vmid }} || true; pct start {{ adguard_vmid }}"
changed_when: true
tasks:
- name: Check if AdGuard LXC exists
ansible.builtin.command: "pct config {{ adguard_vmid }}"
register: adguard_pct_config
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID {{ adguard_vmid }}
ansible.builtin.assert:
that:
- adguard_pct_config.rc != 0 or adguard_existing_hostname == adguard_hostname
fail_msg: VMID {{ adguard_vmid }} already exists and is not the AdGuard container.
vars:
adguard_existing_hostname: >-
{{ adguard_pct_config.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Install AdGuard LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/adguard-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', adguard_pubkey_file) }}\n"
when: adguard_pct_config.rc != 0
- name: Create AdGuard LXC
ansible.builtin.command: >-
pct create {{ adguard_vmid }} {{ adguard_ostemplate }}
--hostname {{ adguard_hostname }}
--rootfs {{ adguard_rootfs }}
--cores 1
--memory 512
--swap 512
--net0 name=eth0,bridge=vmbr0,gw={{ adguard_gateway }},ip={{ adguard_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=40
--cmode shell
--ssh-public-keys /tmp/adguard-lxc.pub
when: adguard_pct_config.rc != 0
- name: Start AdGuard LXC
ansible.builtin.command: "pct start {{ adguard_vmid }}"
register: adguard_pct_start
changed_when: adguard_pct_start.rc == 0
failed_when: adguard_pct_start.rc not in [0, 255]
- name: Allow FUSE device in AdGuard LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ adguard_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart adguard lxc
- name: Bind mount FUSE device in AdGuard LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ adguard_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart adguard lxc
- name: Apply pending LXC config changes
ansible.builtin.meta: flush_handlers
- name: Wait for AdGuard SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: adguard
vars:
ansible_become: false
- name: Configure AdGuard Home inside LXC
hosts: adguard
gather_facts: true
vars:
ansible_become: false
adguard_image: adguard/adguardhome:v0.107.78@sha256:2c127294fa5f96151d9d3a433fb9d66c17e4d18cf698c2b04372a80e26fdd26f
tasks:
- name: Install Docker packages
ansible.builtin.apt:
name:
- docker.io
- fuse-overlayfs
- ca-certificates
- curl
- dnsutils
state: present
update_cache: true
- name: Ensure Docker config directory exists
ansible.builtin.file:
path: /etc/docker
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: adguard_docker_daemon_config
- name: Enable Docker service
ansible.builtin.systemd:
name: docker
state: "{{ 'restarted' if adguard_docker_daemon_config.changed else 'started' }}"
enabled: true
- name: Ensure AdGuard data directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- /opt/adguard/work
- /opt/adguard/conf
- name: Check if the configured AdGuard image is present
ansible.builtin.command: "docker image inspect {{ adguard_image }}"
register: adguard_image_inspect
changed_when: false
failed_when: false
- name: Pull the configured AdGuard image
ansible.builtin.command: "docker pull {{ adguard_image }}"
when: adguard_image_inspect.rc != 0
register: adguard_image_pull
changed_when: true
- name: Install AdGuard Home systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/adguard.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=AdGuard Home container
After=docker.service
Requires=docker.service
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f adguard
ExecStart=/usr/bin/docker run --rm --name adguard --pull never \
-p 53:53/tcp -p 53:53/udp -p 80:80/tcp -p 3000:3000/tcp \
-v /opt/adguard/work:/opt/adguardhome/work \
-v /opt/adguard/conf:/opt/adguardhome/conf \
{{ adguard_image }}
ExecStop=/usr/bin/docker stop adguard
[Install]
WantedBy=multi-user.target
register: adguard_unit
- name: Remove obsolete AdGuard post-start validation unit
ansible.builtin.file:
path: /etc/systemd/system/adguard-post-start.service
state: absent
register: adguard_post_start_unit_removed
- name: Reload systemd when AdGuard units change
ansible.builtin.systemd:
daemon_reload: true
when: adguard_unit.changed or adguard_post_start_unit_removed.changed
- name: Enable and start AdGuard Home
ansible.builtin.systemd:
name: adguard
state: "{{ 'restarted' if adguard_unit.changed or adguard_image_pull.changed else 'started' }}"
enabled: true
- name: Wait for AdGuard HTTP root
ansible.builtin.uri:
url: http://127.0.0.1/
status_code: [200, 302]
return_content: false
register: adguard_http_root
retries: 24
delay: 5
until: adguard_http_root.status in [200, 302]
- name: Verify AdGuard DNS over UDP
ansible.builtin.command:
argv:
- dig
- "@127.0.0.1"
- localhost
- A
- +time=2
- +tries=1
- +short
register: adguard_dns_udp
changed_when: false
retries: 12
delay: 5
until: adguard_dns_udp.rc == 0 and '127.0.0.1' in adguard_dns_udp.stdout
- name: Verify AdGuard DNS over TCP
ansible.builtin.command:
argv:
- dig
- "@127.0.0.1"
- localhost
- A
- +tcp
- +time=2
- +tries=1
- +short
register: adguard_dns_tcp
changed_when: false
retries: 12
delay: 5
until: adguard_dns_tcp.rc == 0 and '127.0.0.1' in adguard_dns_tcp.stdout
+114
View File
@@ -0,0 +1,114 @@
- name: Configure Proxmox backup jobs
hosts: mini-pc
gather_facts: false
vars:
pve_backup_jobs:
- id: homelab-pbs-daily-cloud
comment: Daily PBS backup for cloud-pc service containers
node: cloud-pc
vmid: 141,145,146,147,149
storage: pbs
schedule: "02:10"
mode: snapshot
exclude_path: /var/lib/docker/fuse-overlayfs/*/merged
- id: homelab-pbs-daily-mini
comment: Daily PBS backup for mini-pc service containers
node: mini-pc
vmid: 132,140,142,143,144,150
storage: pbs
schedule: "02:40"
mode: snapshot
exclude_path: /var/lib/docker/fuse-overlayfs/*/merged
- id: homelab-local-weekly-pbs
comment: Weekly local backup for PBS container rootfs/config
node: cloud-pc
vmid: 120
storage: backup
schedule: "Sun 03:30"
mode: snapshot
prune_backups: keep-last=2
tasks:
- name: Get existing Proxmox backup jobs
ansible.builtin.command: pvesh get /cluster/backup --output-format json
register: pve_backup_jobs_existing_raw
changed_when: false
- name: Parse existing Proxmox backup jobs
ansible.builtin.set_fact:
pve_backup_jobs_existing: "{{ pve_backup_jobs_existing_raw.stdout | from_json }}"
- name: Create missing Proxmox backup jobs
ansible.builtin.command: >-
pvesh create /cluster/backup
--id {{ item.id }}
--enabled 1
--node {{ item.node }}
--vmid {{ item.vmid }}
--storage {{ item.storage }}
--schedule {{ item.schedule | quote }}
--mode {{ item.mode }}
{% if item.prune_backups is defined %}
--prune-backups {{ item.prune_backups }} --remove 1
{% else %}
--remove 0
{% endif %}
{% if item.exclude_path is defined %}
--exclude-path {{ item.exclude_path | quote }}
{% endif %}
--notes-template {{ '{{' }}guestname{{ '}}' }}
--comment {{ item.comment | quote }}
loop: "{{ pve_backup_jobs }}"
when: item.id not in (pve_backup_jobs_existing | map(attribute='id') | list)
- name: Update existing Proxmox backup jobs
ansible.builtin.command: >-
pvesh set /cluster/backup/{{ item.id }}
--enabled 1
--node {{ item.node }}
--vmid {{ item.vmid }}
--storage {{ item.storage }}
--schedule {{ item.schedule | quote }}
--mode {{ item.mode }}
{% if item.prune_backups is defined %}
--prune-backups {{ item.prune_backups }} --remove 1
{% else %}
--delete prune-backups --remove 0
{% endif %}
{% if item.exclude_path is defined %}
--exclude-path {{ item.exclude_path | quote }}
{% else %}
--delete exclude-path
{% endif %}
--notes-template {{ '{{' }}guestname{{ '}}' }}
--comment {{ item.comment | quote }}
loop: "{{ pve_backup_jobs }}"
vars:
current_job: >-
{{ pve_backup_jobs_existing | selectattr('id', 'equalto', item.id) | first }}
when:
- item.id in (pve_backup_jobs_existing | map(attribute='id') | list)
- >-
current_job.enabled | int != 1 or
current_job.node != item.node or
current_job.vmid | string != item.vmid | string or
current_job.storage != item.storage or
current_job.schedule != item.schedule or
current_job.mode != item.mode or
current_job.comment != item.comment or
current_job.remove | int != (1 if item.prune_backups is defined else 0) or
current_job['exclude-path'] | default([]) != ([item.exclude_path] if item.exclude_path is defined else []) or
(item.prune_backups is not defined and current_job['prune-backups'] is defined) or
(item.prune_backups is defined and
(current_job['prune-backups'] | default({})).get('keep-last', 0) | int !=
item.prune_backups | regex_replace('^keep-last=', '') | int)
changed_when: true
- name: Show configured Proxmox backup jobs
ansible.builtin.command: pvesh get /cluster/backup --output-format yaml
register: pve_backup_jobs_configured
changed_when: false
- name: Print configured Proxmox backup jobs
ansible.builtin.debug:
var: pve_backup_jobs_configured.stdout_lines
+125
View File
@@ -0,0 +1,125 @@
---
- name: Create Docker test LXC on cloud-pc
hosts: cloud-pc
gather_facts: false
vars:
docker_test_vmid: 145
docker_test_hostname: docker-test
docker_test_ip: 192.168.1.29/24
docker_test_gateway: 192.168.1.1
docker_test_rootfs: data:8
docker_test_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
docker_test_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
handlers:
- name: restart docker test lxc
ansible.builtin.shell: "pct stop {{ docker_test_vmid }} || true; pct start {{ docker_test_vmid }}"
changed_when: true
tasks:
- name: Check if Docker test LXC exists
ansible.builtin.command: "pct config {{ docker_test_vmid }}"
register: docker_test_pct_config
changed_when: false
failed_when: false
- name: Install Docker test LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/docker-test-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', docker_test_pubkey_file) }}\n"
when: docker_test_pct_config.rc != 0
- name: Create Docker test LXC
ansible.builtin.command: >-
pct create {{ docker_test_vmid }} {{ docker_test_ostemplate }}
--hostname {{ docker_test_hostname }}
--rootfs {{ docker_test_rootfs }}
--cores 1
--memory 512
--swap 512
--net0 name=eth0,bridge=vmbr0,gw={{ docker_test_gateway }},ip={{ docker_test_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=50
--cmode shell
--ssh-public-keys /tmp/docker-test-lxc.pub
when: docker_test_pct_config.rc != 0
- name: Start Docker test LXC
ansible.builtin.command: "pct start {{ docker_test_vmid }}"
register: docker_test_pct_start
changed_when: docker_test_pct_start.rc == 0
failed_when: docker_test_pct_start.rc not in [0, 255]
- name: Allow FUSE device in Docker test LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ docker_test_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart docker test lxc
- name: Bind mount FUSE device in Docker test LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ docker_test_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart docker test lxc
- name: Apply pending LXC config changes
ansible.builtin.meta: flush_handlers
- name: Wait for Docker test SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: docker-test
vars:
ansible_become: false
- name: Configure Docker test host
hosts: docker-test
gather_facts: true
vars:
ansible_become: false
tasks:
- name: Install Docker packages
ansible.builtin.apt:
name:
- docker.io
- fuse-overlayfs
- ca-certificates
- curl
state: present
update_cache: true
- name: Ensure Docker config directory exists
ansible.builtin.file:
path: /etc/docker
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: docker_test_daemon_config
- name: Enable Docker service
ansible.builtin.systemd:
name: docker
state: "{{ 'restarted' if docker_test_daemon_config.changed else 'started' }}"
enabled: true
- name: Verify Docker can run a container
ansible.builtin.command: docker run --rm hello-world
changed_when: false
+30
View File
@@ -0,0 +1,30 @@
---
- name: Create emergency-bot LXC on mini-pc
hosts: localhost
connection: local
become: false
gather_facts: false
vars:
ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_lxc_vmid: 148
pve_lxc_node: mini-pc
pve_lxc_hostname: emergency-bot
pve_lxc_ip: 192.168.1.32/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_disk: local-lvm:4
pve_lxc_cores: 1
pve_lxc_memory: 512
pve_lxc_swap: 256
pve_lxc_startup: order=70
pve_lxc_ostemplate: "{{ lookup('env', 'PVE_LXC_OSTEMPLATE') | default('local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst', true) }}"
roles:
- role: pve_lxc
- name: Wait for emergency-bot SSH
hosts: emergency-bot
gather_facts: false
tasks:
- name: Wait for emergency-bot to accept SSH connections
ansible.builtin.wait_for_connection:
timeout: 120
+37 -3
View File
@@ -25,6 +25,19 @@
changed_when: false
failed_when: false
- name: Refuse to modify a foreign Gitea VMID
ansible.builtin.assert:
that:
- gitea_pct_config.rc != 0 or gitea_existing_hostname == 'gitea'
fail_msg: VMID {{ gitea_vmid }} already exists and is not the Gitea container.
vars:
gitea_existing_hostname: >-
{{ gitea_pct_config.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Install Gitea LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/gitea-lxc.pub
@@ -103,7 +116,7 @@
vars:
ansible_become: false
gitea_data_dir: /opt/gitea/data
gitea_image: gitea/gitea:latest
gitea_image: gitea/gitea:1.27.1@sha256:b64126cf5c3f4e5f0f231b510bb13715f6cb8e508188b44de90bdb9a04f3055d
gitea_container_name: gitea
gitea_http_port: 3000
gitea_ssh_port: 2222
@@ -154,6 +167,18 @@
group: "1000"
mode: "0750"
- name: Check if the configured Gitea image is present
ansible.builtin.command: "docker image inspect {{ gitea_image }}"
register: gitea_image_inspect
changed_when: false
failed_when: false
- name: Pull the configured Gitea image
ansible.builtin.command: "docker pull {{ gitea_image }}"
when: gitea_image_inspect.rc != 0
register: gitea_image_pull
changed_when: true
- name: Install Gitea systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/gitea.service
@@ -172,7 +197,7 @@
ExecStartPre=-/usr/bin/docker rm -f {{ gitea_container_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ gitea_container_name }} \
--pull always \
--pull never \
-p {{ gitea_http_port }}:3000 \
-p {{ gitea_ssh_port }}:22 \
-v {{ gitea_data_dir }}:/data \
@@ -193,5 +218,14 @@
- name: Enable and start Gitea
ansible.builtin.systemd:
name: gitea
state: started
state: "{{ 'restarted' if gitea_unit.changed or gitea_image_pull.changed else 'started' }}"
enabled: true
- name: Wait for Gitea HTTP health endpoint
ansible.builtin.uri:
url: "http://127.0.0.1:{{ gitea_http_port }}/api/healthz"
status_code: 200
register: gitea_health
retries: 24
delay: 5
until: gitea_health.status == 200
+431
View File
@@ -0,0 +1,431 @@
---
- name: Guard Grimmory VMID before API updates
hosts: cloud-pc
gather_facts: false
tasks:
- name: Read existing VMID 149 configuration
ansible.builtin.command: pct config 149
register: grimmory_existing_vmid
check_mode: false
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID 149
ansible.builtin.assert:
that:
- grimmory_existing_vmid.rc != 0 or grimmory_existing_hostname == 'grimmory'
fail_msg: VMID 149 already exists and is not the Grimmory container.
vars:
grimmory_existing_hostname: >-
{{ grimmory_existing_vmid.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Create Grimmory LXC on cloud-pc
hosts: localhost
connection: local
become: false
gather_facts: false
vars:
ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_lxc_vmid: 149
pve_lxc_node: cloud-pc
pve_lxc_hostname: grimmory
pve_lxc_ip: 192.168.1.34/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_disk: data:64
pve_lxc_cores: 2
pve_lxc_memory: 4096
pve_lxc_swap: 1024
pve_lxc_startup: order=100
pve_lxc_unprivileged: true
pve_lxc_update: false
pve_lxc_features:
- nesting=1
pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
roles:
- role: pve_lxc
- name: Configure Grimmory LXC devices
hosts: cloud-pc
gather_facts: false
vars:
grimmory_vmid: 149
handlers:
- name: restart Grimmory LXC
ansible.builtin.command: "pct reboot {{ grimmory_vmid }}"
changed_when: true
tasks:
- name: Read Grimmory LXC configuration
ansible.builtin.command: "pct config {{ grimmory_vmid }}"
register: grimmory_lxc_config
changed_when: false
- name: Require expected Grimmory LXC properties
ansible.builtin.assert:
that:
- "'unprivileged: 1' in grimmory_lxc_config.stdout"
- "'rootfs: data:' in grimmory_lxc_config.stdout"
- "'onboot: 1' in grimmory_lxc_config.stdout"
- name: Configure Grimmory LXC network
ansible.builtin.command: >-
pct set {{ grimmory_vmid }}
--net0 name=eth0,bridge=vmbr0,firewall=1,gw=192.168.1.1,ip=192.168.1.34/24
vars:
grimmory_net0: >-
{{ grimmory_lxc_config.stdout_lines
| select('match', '^net0: ')
| first
| default('') }}
when: >-
'name=eth0' not in grimmory_net0 or
'bridge=vmbr0' not in grimmory_net0 or
'firewall=1' not in grimmory_net0 or
'gw=192.168.1.1' not in grimmory_net0 or
'ip=192.168.1.34/24' not in grimmory_net0
notify: restart Grimmory LXC
- name: Enable keyctl for Docker in Grimmory LXC
ansible.builtin.command: "pct set {{ grimmory_vmid }} --features nesting=1,keyctl=1"
when: "'nesting=1' not in grimmory_lxc_config.stdout or 'keyctl=1' not in grimmory_lxc_config.stdout"
notify: restart Grimmory LXC
- name: Allow FUSE device in Grimmory LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ grimmory_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart Grimmory LXC
- name: Bind mount FUSE device in Grimmory LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ grimmory_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart Grimmory LXC
- name: Apply pending Grimmory LXC configuration
ansible.builtin.meta: flush_handlers
- name: Wait for Grimmory SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 180
delegate_to: grimmory
vars:
ansible_become: false
- name: Configure Grimmory runtime
hosts: grimmory
gather_facts: true
vars:
ansible_become: false
grimmory_root: /opt/grimmory
grimmory_image: grimmory/grimmory:v3.2.4@sha256:dfa7afdfcf25d649fd664497a62385dd00cd9678c37546e182c172e41c8e80cb
grimmory_mariadb_image: lscr.io/linuxserver/mariadb:11.4.8@sha256:91de7f701bc7fc3a424b81beafca7a7c6c4c5b7c8be6afd2ae148698695c0b0c
tasks:
- name: Install Grimmory runtime packages
ansible.builtin.apt:
name:
- ca-certificates
- curl
- docker-compose
- docker.io
- fuse-overlayfs
- mariadb-client
- openssl
- prometheus-node-exporter
- ufw
state: present
update_cache: true
- name: Verify FUSE device exists
ansible.builtin.stat:
path: /dev/fuse
register: grimmory_fuse
- name: Require FUSE device
ansible.builtin.assert:
that:
- grimmory_fuse.stat.exists
- grimmory_fuse.stat.ischr
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: grimmory_docker_config
- name: Enable Docker service
ansible.builtin.systemd:
name: docker
enabled: true
state: "{{ 'restarted' if grimmory_docker_config.changed else 'started' }}"
- name: Allow SSH from the HomeLab LAN
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ homelab_lan_cidr }}"
- name: Allow SSH from the OpenVPN network
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ openvpn_network_cidr }}"
- name: Allow Grimmory from the HomeLab LAN
community.general.ufw:
rule: allow
port: "6060"
proto: tcp
src: "{{ homelab_lan_cidr }}"
- name: Allow Grimmory from the OpenVPN network
community.general.ufw:
rule: allow
port: "6060"
proto: tcp
src: "{{ openvpn_network_cidr }}"
- name: Allow Node Exporter from the monitoring LXC
community.general.ufw:
rule: allow
port: "9100"
proto: tcp
src: 192.168.1.30
- name: Enable restrictive Grimmory firewall
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Create Grimmory directories
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
group: "{{ item.group }}"
mode: "{{ item.mode }}"
loop:
- { path: "{{ grimmory_root }}", owner: root, group: root, mode: "0750" }
- { path: "{{ grimmory_root }}/data", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/books", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/bookdrop", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/mariadb", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/backup-staging", owner: root, group: root, mode: "0700" }
- name: Generate Grimmory secrets once
ansible.builtin.shell: |
set -eu
umask 077
if [ -e {{ grimmory_root }}/.env ]; then
exit 0
fi
db_password=$(openssl rand -hex 32)
root_password=$(openssl rand -hex 32)
cat > {{ grimmory_root }}/.env <<EOF
TZ=Europe/Moscow
APP_USER_ID=1000
APP_GROUP_ID=1000
DB_USER_ID=1000
DB_GROUP_ID=1000
DB_PASSWORD=$db_password
MYSQL_ROOT_PASSWORD=$root_password
EOF
printf created
args:
executable: /bin/sh
register: grimmory_secrets
changed_when: grimmory_secrets.stdout == 'created'
no_log: true
- name: Enforce Grimmory secret file permissions
ansible.builtin.file:
path: "{{ grimmory_root }}/.env"
owner: root
group: root
mode: "0600"
- name: Install Grimmory environment example
ansible.builtin.copy:
dest: "{{ grimmory_root }}/.env.example"
owner: root
group: root
mode: "0644"
content: |
TZ=Europe/Moscow
APP_USER_ID=1000
APP_GROUP_ID=1000
DB_USER_ID=1000
DB_GROUP_ID=1000
DB_PASSWORD=replace-with-random-password
MYSQL_ROOT_PASSWORD=replace-with-separate-random-password
- name: Install Grimmory Compose configuration
ansible.builtin.copy:
dest: "{{ grimmory_root }}/compose.yml"
owner: root
group: root
mode: "0644"
content: |
services:
grimmory:
image: {{ grimmory_image }}
container_name: grimmory
environment:
USER_ID: "${APP_USER_ID}"
GROUP_ID: "${APP_GROUP_ID}"
TZ: "${TZ}"
DATABASE_URL: jdbc:mariadb://mariadb:3306/grimmory
DATABASE_USERNAME: grimmory
DATABASE_PASSWORD: "${DB_PASSWORD}"
DISK_TYPE: LOCAL
ALLOWED_ORIGINS: https://books.ada-dev.ru
depends_on:
mariadb:
condition: service_healthy
ports:
- "192.168.1.34:6060:6060"
volumes:
- ./data:/app/data
- ./books:/books
- ./bookdrop:/bookdrop
restart: unless-stopped
mariadb:
image: {{ grimmory_mariadb_image }}
container_name: grimmory-mariadb
environment:
PUID: "${DB_USER_ID}"
PGID: "${DB_GROUP_ID}"
TZ: "${TZ}"
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
MYSQL_DATABASE: grimmory
MYSQL_USER: grimmory
MYSQL_PASSWORD: "${DB_PASSWORD}"
volumes:
- ./mariadb:/config
healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
register: grimmory_compose
- name: Install Docker firewall script
ansible.builtin.copy:
dest: /usr/local/sbin/grimmory-docker-firewall
owner: root
group: root
mode: "0755"
content: |
#!/bin/sh
set -eu
iptables -N GRIMMORY-FILTER 2>/dev/null || true
iptables -F GRIMMORY-FILTER
iptables -A GRIMMORY-FILTER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A GRIMMORY-FILTER -s {{ homelab_lan_cidr }} -p tcp -m conntrack --ctorigdst 192.168.1.34 --ctorigdstport 6060 -j ACCEPT
iptables -A GRIMMORY-FILTER -s {{ openvpn_network_cidr }} -p tcp -m conntrack --ctorigdst 192.168.1.34 --ctorigdstport 6060 -j ACCEPT
iptables -A GRIMMORY-FILTER -p tcp -m conntrack --ctorigdst 192.168.1.34 --ctorigdstport 6060 -j DROP
iptables -A GRIMMORY-FILTER -j RETURN
iptables -C DOCKER-USER -j GRIMMORY-FILTER 2>/dev/null || iptables -I DOCKER-USER 1 -j GRIMMORY-FILTER
register: grimmory_firewall_script
- name: Install Docker firewall service
ansible.builtin.copy:
dest: /etc/systemd/system/grimmory-docker-firewall.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Restrict Grimmory Docker published port
After=docker.service
Requires=docker.service
Before=grimmory.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/grimmory-docker-firewall
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
register: grimmory_firewall_unit
- name: Install Grimmory systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/grimmory.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Grimmory Compose stack
Wants=network-online.target
After=network-online.target docker.service grimmory-docker-firewall.service
Requires=docker.service grimmory-docker-firewall.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory={{ grimmory_root }}
ExecStart=/usr/bin/docker compose -f {{ grimmory_root }}/compose.yml up -d --remove-orphans
ExecStop=/usr/bin/docker compose -f {{ grimmory_root }}/compose.yml down
[Install]
WantedBy=multi-user.target
register: grimmory_unit
- name: Reload systemd for Grimmory units
ansible.builtin.systemd:
daemon_reload: true
when: grimmory_firewall_unit.changed or grimmory_unit.changed
- name: Enable and apply Docker firewall
ansible.builtin.systemd:
name: grimmory-docker-firewall
enabled: true
state: "{{ 'restarted' if grimmory_firewall_script.changed or grimmory_firewall_unit.changed else 'started' }}"
- name: Validate Grimmory Compose configuration
ansible.builtin.command: docker compose -f {{ grimmory_root }}/compose.yml config --quiet
args:
chdir: "{{ grimmory_root }}"
changed_when: false
no_log: true
- name: Enable and start Grimmory
ansible.builtin.systemd:
name: grimmory
enabled: true
state: "{{ 'restarted' if grimmory_compose.changed or grimmory_unit.changed else 'started' }}"
- name: Wait for Grimmory health endpoint
ansible.builtin.uri:
url: http://192.168.1.34:6060/api/v1/healthcheck
status_code: 200
register: grimmory_health
retries: 120
delay: 5
until: grimmory_health.status == 200
- name: Verify Docker storage driver
ansible.builtin.command: docker info --format '{{ "{{" }}.Driver{{ "}}" }}'
register: grimmory_docker_driver
changed_when: false
failed_when: grimmory_docker_driver.stdout != 'fuse-overlayfs'
+189
View File
@@ -0,0 +1,189 @@
---
- name: Guard Gyro VMID before API updates
hosts: mini-pc
gather_facts: false
tasks:
- name: Read existing VMID 150 configuration
ansible.builtin.command: pct config 150
register: gyro_existing_vmid
check_mode: false
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID 150
ansible.builtin.assert:
that:
- gyro_existing_vmid.rc != 0 or gyro_existing_hostname == 'gyro'
fail_msg: VMID 150 already exists and is not the Gyro container.
vars:
gyro_existing_hostname: >-
{{ gyro_existing_vmid.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Create Gyro LXC on mini-pc
hosts: localhost
connection: local
become: false
gather_facts: false
vars:
ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_lxc_vmid: 150
pve_lxc_node: mini-pc
pve_lxc_hostname: gyro
pve_lxc_ip: 192.168.1.35/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_disk: local-lvm:2
pve_lxc_cores: 1
pve_lxc_memory: 512
pve_lxc_swap: 256
pve_lxc_startup: order=80
pve_lxc_unprivileged: true
pve_lxc_update: false
pve_lxc_features: []
pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
roles:
- role: pve_lxc
- name: Configure Gyro LXC isolation
hosts: mini-pc
gather_facts: false
vars:
gyro_vmid: 150
tasks:
- name: Read Gyro LXC configuration
ansible.builtin.command: "pct config {{ gyro_vmid }}"
register: gyro_lxc_config
changed_when: false
- name: Require expected Gyro LXC properties
ansible.builtin.assert:
that:
- "'hostname: gyro' in gyro_lxc_config.stdout"
- "'unprivileged: 1' in gyro_lxc_config.stdout"
- "'rootfs: local-lvm:' in gyro_lxc_config.stdout"
- "'onboot: 1' in gyro_lxc_config.stdout"
- name: Check for an existing Gyro firewall file
ansible.builtin.stat:
path: "/etc/pve/firewall/{{ gyro_vmid }}.fw"
register: gyro_firewall_file
- name: Preserve the existing Gyro firewall file
ansible.builtin.slurp:
src: "/etc/pve/firewall/{{ gyro_vmid }}.fw"
register: gyro_previous_firewall
when: gyro_firewall_file.stat.exists
- name: Apply and verify Gyro firewall
block:
- name: Render Proxmox firewall for Gyro
ansible.builtin.copy:
dest: "/tmp/gyro-{{ gyro_vmid }}.fw"
owner: root
group: root
mode: "0640"
content: |
[OPTIONS]
enable: 1
policy_in: DROP
policy_out: ACCEPT
[RULES]
IN ACCEPT -source {{ homelab_lan_cidr }} -p tcp -dport 22 -log nolog
IN ACCEPT -source {{ openvpn_network_cidr }} -p tcp -dport 22 -log nolog
OUT ACCEPT -dest 192.168.1.27 -p tcp -dport 7890 -log nolog
OUT DROP -dest {{ homelab_lan_cidr }} -log nolog
register: gyro_rendered_firewall
changed_when: false
- name: Compare rendered and active Gyro firewall
ansible.builtin.command: >-
cmp -s /tmp/gyro-{{ gyro_vmid }}.fw /etc/pve/firewall/{{ gyro_vmid }}.fw
register: gyro_firewall_comparison
changed_when: false
failed_when: gyro_firewall_comparison.rc not in [0, 1]
when: gyro_firewall_file.stat.exists
- name: Install Proxmox firewall for Gyro
ansible.builtin.command: >-
cp /tmp/gyro-{{ gyro_vmid }}.fw /etc/pve/firewall/{{ gyro_vmid }}.fw
when: not gyro_firewall_file.stat.exists or gyro_firewall_comparison.rc != 0
changed_when: true
- name: Validate Proxmox firewall configuration
ansible.builtin.command: pve-firewall compile
changed_when: false
- name: Read cluster firewall options
ansible.builtin.command: pvesh get /cluster/firewall/options --output-format json
register: gyro_cluster_firewall_options
changed_when: false
- name: Report staged Proxmox firewall state
ansible.builtin.debug:
msg: >-
{{ 'Gyro Proxmox firewall is active.'
if (gyro_cluster_firewall_options.stdout | from_json).enable | default(0) | int == 1
else 'Gyro Proxmox firewall is staged but inactive because the cluster firewall is disabled; UFW remains the enforced isolation layer.' }}
- name: Verify SSH remains reachable through the firewall
ansible.builtin.wait_for_connection:
timeout: 180
delegate_to: gyro
vars:
ansible_become: false
rescue:
- name: Restore the previous Gyro firewall file
ansible.builtin.copy:
dest: "/tmp/gyro-{{ gyro_vmid }}-previous.fw"
content: "{{ gyro_previous_firewall.content | b64decode }}"
owner: root
group: root
mode: "0640"
when: gyro_firewall_file.stat.exists
- name: Reinstall the previous Gyro firewall file
ansible.builtin.command: >-
cp /tmp/gyro-{{ gyro_vmid }}-previous.fw /etc/pve/firewall/{{ gyro_vmid }}.fw
when: gyro_firewall_file.stat.exists
changed_when: true
- name: Remove the failed new Gyro firewall file
ansible.builtin.file:
path: "/etc/pve/firewall/{{ gyro_vmid }}.fw"
state: absent
when: not gyro_firewall_file.stat.exists
- name: Recompile restored Proxmox firewall configuration
ansible.builtin.command: pve-firewall compile
changed_when: false
- name: Remove temporary Gyro firewall files after rollback
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- "/tmp/gyro-{{ gyro_vmid }}.fw"
- "/tmp/gyro-{{ gyro_vmid }}-previous.fw"
- name: Stop after rolling back the Gyro firewall
ansible.builtin.fail:
msg: Gyro firewall validation or SSH reachability failed; the previous firewall state was restored.
- name: Remove temporary Gyro firewall file
ansible.builtin.file:
path: "/tmp/gyro-{{ gyro_vmid }}.fw"
state: absent
changed_when: false
- name: Wait for Gyro SSH
hosts: gyro
gather_facts: false
tasks:
- name: Wait for Gyro to accept SSH connections
ansible.builtin.wait_for_connection:
timeout: 180
+334
View File
@@ -0,0 +1,334 @@
---
- name: Create Hermes AI LXC on cloud-pc
hosts: cloud-pc
gather_facts: false
vars:
hermes_ai_vmid: 147
hermes_ai_hostname: hermes-ai
hermes_ai_ip: 192.168.1.31/24
hermes_ai_gateway: 192.168.1.1
hermes_ai_rootfs: data:24
hermes_ai_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
hermes_ai_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
tasks:
- name: Check if Hermes AI LXC exists
ansible.builtin.command: "pct config {{ hermes_ai_vmid }}"
register: hermes_ai_pct_config
changed_when: false
failed_when: false
- name: Install Hermes AI LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/hermes-ai-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', hermes_ai_pubkey_file) }}\n"
when: hermes_ai_pct_config.rc != 0
- name: Create Hermes AI LXC
ansible.builtin.command: >-
pct create {{ hermes_ai_vmid }} {{ hermes_ai_ostemplate }}
--hostname {{ hermes_ai_hostname }}
--rootfs {{ hermes_ai_rootfs }}
--cores 2
--memory 4096
--swap 512
--net0 name=eth0,bridge=vmbr0,gw={{ hermes_ai_gateway }},ip={{ hermes_ai_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=90
--cmode shell
--ssh-public-keys /tmp/hermes-ai-lxc.pub
when: hermes_ai_pct_config.rc != 0
- name: Start Hermes AI LXC
ansible.builtin.command: "pct start {{ hermes_ai_vmid }}"
register: hermes_ai_pct_start
changed_when: hermes_ai_pct_start.rc == 0
failed_when: hermes_ai_pct_start.rc not in [0, 255]
- name: Configure Hermes AI runtime
hosts: cloud-pc
gather_facts: false
vars:
hermes_ai_vmid: 147
handlers:
- name: restart Hermes AI LXC
ansible.builtin.command: "pct reboot {{ hermes_ai_vmid }}"
changed_when: true
tasks:
- name: Read Hermes AI LXC configuration
ansible.builtin.command: "pct config {{ hermes_ai_vmid }}"
register: hermes_ai_lxc_config
changed_when: false
- name: Enable keyctl for Docker in Hermes AI LXC
ansible.builtin.command: "pct set {{ hermes_ai_vmid }} --features nesting=1,keyctl=1"
when: "'keyctl=1' not in hermes_ai_lxc_config.stdout"
notify: restart Hermes AI LXC
- name: Allow FUSE device in Hermes AI LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart Hermes AI LXC
- name: Bind mount FUSE device in Hermes AI LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart Hermes AI LXC
- name: Check TUN device on PVE host
ansible.builtin.stat:
path: /dev/net/tun
register: hermes_ai_tun_device
- name: Require TUN device on PVE host
ansible.builtin.assert:
that:
- hermes_ai_tun_device.stat.exists
- hermes_ai_tun_device.stat.ischr
fail_msg: /dev/net/tun must exist on cloud-pc before enabling the Hermes AI transparent proxy.
- name: Allow TUN device in Hermes AI LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:200 rwm"
state: present
notify: restart Hermes AI LXC
- name: Bind mount TUN device in Hermes AI LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
state: present
notify: restart Hermes AI LXC
- name: Apply pending Hermes AI LXC configuration
ansible.builtin.meta: flush_handlers
- name: Wait for Hermes AI SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: hermes-ai
vars:
ansible_become: false
- name: Install Hermes AI base runtime
hosts: hermes-ai
gather_facts: false
vars:
ansible_become: false
hermes_ai_root: /opt/hermes-ai
hermes_ai_state: /srv/hermes-ai
hermes_ai_proxy_dir: /opt/hermes-ai/tun-proxy
hermes_ai_proxy_image: metacubex/mihomo@sha256:e6acd921addecfd59a8e2d38203f88356d635b54de6c0673db0e015139989312
hermes_ai_proxy_name: hermes-ai-tun-proxy
tasks:
- name: Install Hermes AI runtime packages
ansible.builtin.apt:
name:
- ca-certificates
- curl
- docker-compose
- docker.io
- fuse-overlayfs
- git
- ufw
state: present
update_cache: true
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: hermes_ai_docker_config
- name: Enable Docker service
ansible.builtin.systemd:
name: docker
enabled: true
state: "{{ 'restarted' if hermes_ai_docker_config.changed else 'started' }}"
- name: Allow SSH only from the HomeLab LAN
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ homelab_lan_cidr }}"
- name: Allow SSH from the OpenVPN network
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ openvpn_network_cidr }}"
- name: Enable restrictive Hermes AI firewall
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Create Hermes AI application directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ hermes_ai_root }}"
- "{{ hermes_ai_state }}"
- "{{ hermes_ai_proxy_dir }}"
- name: Configure Hermes AI transparent proxy
ansible.builtin.copy:
dest: "{{ hermes_ai_proxy_dir }}/config.yaml"
owner: root
group: root
mode: "0640"
content: |
ipv6: false
tun:
enable: true
stack: system
device: hermes-tun
auto-route: true
auto-redirect: true
auto-detect-interface: true
route-exclude-address:
- 192.168.1.27/32
dns-hijack:
- any:53
dns:
enable: true
enhanced-mode: redir-host
nameserver:
- https://cloudflare-dns.com/dns-query
sniffer:
enable: true
force-dns-mapping: true
parse-pure-ip: true
proxies:
- name: mihomo-lan
type: socks5
server: 192.168.1.27
port: 7891
proxy-groups:
- name: PROXY
type: select
proxies:
- mihomo-lan
rules:
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
- IP-CIDR,169.254.0.0/16,DIRECT,no-resolve
- MATCH,PROXY
register: hermes_ai_proxy_config
- name: Wait for Mihomo SOCKS5 upstream
ansible.builtin.wait_for:
host: 192.168.1.27
port: 7891
timeout: 15
- name: Validate Hermes AI transparent proxy config
ansible.builtin.command: >-
docker run --rm --network none
-v {{ hermes_ai_proxy_dir }}:/root/.config/mihomo:ro
{{ hermes_ai_proxy_image }} -t -d /root/.config/mihomo
changed_when: false
- name: Install Hermes AI transparent proxy service
ansible.builtin.copy:
dest: /etc/systemd/system/hermes-ai-tun-proxy.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Hermes AI transparent Mihomo proxy
After=docker.service network-online.target
Requires=docker.service
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f {{ hermes_ai_proxy_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ hermes_ai_proxy_name }} \
--network host \
--cap-drop ALL \
--cap-add NET_ADMIN \
--security-opt no-new-privileges \
--device /dev/net/tun \
-v {{ hermes_ai_proxy_dir }}:/root/.config/mihomo:ro \
{{ hermes_ai_proxy_image }}
ExecStop=/usr/bin/docker stop {{ hermes_ai_proxy_name }}
[Install]
WantedBy=multi-user.target
register: hermes_ai_proxy_unit
- name: Reload systemd when transparent proxy service changes
ansible.builtin.systemd:
daemon_reload: true
when: hermes_ai_proxy_unit.changed
- name: Enable Hermes AI transparent proxy
ansible.builtin.systemd:
name: hermes-ai-tun-proxy
enabled: true
state: "{{ 'restarted' if hermes_ai_proxy_config.changed or hermes_ai_proxy_unit.changed else 'started' }}"
- name: Check Hermes AI transparent proxy service
ansible.builtin.command: systemctl is-active hermes-ai-tun-proxy
register: hermes_ai_proxy_status
changed_when: false
failed_when: hermes_ai_proxy_status.stdout != 'active'
- name: Check Hermes AI external HTTPS access
ansible.builtin.command: curl --fail --silent --show-error --max-time 20 https://api.ipify.org
changed_when: false
no_log: true
- name: Document Hermes AI runtime layout
ansible.builtin.copy:
dest: "{{ hermes_ai_root }}/README.md"
owner: root
group: root
mode: "0644"
content: |
# Hermes AI runtime
Deploy the Hermes application here. Keep runtime state and the real
`.env` file in /srv/hermes-ai. Do not commit Telegram or LLM tokens.
Docker is installed with fuse-overlayfs for this unprivileged LXC.
Hermes is a Telegram bot and needs no published Docker ports. Do not
use `ports:` or `-p` without adding an explicit firewall policy:
Docker port publishing can bypass UFW.
The hermes-ai-tun-proxy service routes external traffic through
Mihomo at 192.168.1.27:7890. LAN traffic remains direct.
+305
View File
@@ -0,0 +1,305 @@
---
- name: Create memoir-bot LXC on mini-pc
hosts: mini-pc
gather_facts: false
vars:
memoir_bot_vmid: 142
memoir_bot_hostname: memoir-bot
memoir_bot_ip: 192.168.1.26/24
memoir_bot_gateway: 192.168.1.1
memoir_bot_bridge: vmbr0
memoir_bot_rootfs: local-lvm:16
memoir_bot_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
memoir_bot_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
handlers:
- name: restart memoir-bot lxc
ansible.builtin.shell: "pct stop {{ memoir_bot_vmid }} || true; pct start {{ memoir_bot_vmid }}"
changed_when: true
tasks:
- name: Check if memoir-bot LXC exists
ansible.builtin.command: "pct config {{ memoir_bot_vmid }}"
register: memoir_bot_pct_config
changed_when: false
failed_when: false
- name: Install memoir-bot LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/memoir-bot-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', memoir_bot_pubkey_file) }}\n"
when: memoir_bot_pct_config.rc != 0
- name: Create memoir-bot LXC
ansible.builtin.command: >-
pct create {{ memoir_bot_vmid }} {{ memoir_bot_ostemplate }}
--hostname {{ memoir_bot_hostname }}
--rootfs {{ memoir_bot_rootfs }}
--cores 1
--memory 512
--swap 512
--net0 name=eth0,bridge={{ memoir_bot_bridge }},gw={{ memoir_bot_gateway }},ip={{ memoir_bot_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=60
--cmode shell
--ssh-public-keys /tmp/memoir-bot-lxc.pub
when: memoir_bot_pct_config.rc != 0
- name: Start memoir-bot LXC
ansible.builtin.command: "pct start {{ memoir_bot_vmid }}"
register: memoir_bot_pct_start
changed_when: memoir_bot_pct_start.rc == 0
failed_when: memoir_bot_pct_start.rc not in [0, 255]
- name: Allow FUSE device in memoir-bot LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ memoir_bot_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart memoir-bot lxc
- name: Bind mount FUSE device in memoir-bot LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ memoir_bot_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart memoir-bot lxc
- name: Apply pending LXC config changes
ansible.builtin.meta: flush_handlers
- name: Wait for memoir-bot SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: memoir-bot
vars:
ansible_become: false
- name: Configure Docker and memoir-bot inside LXC
hosts: memoir-bot
gather_facts: true
vars:
ansible_become: false
memoir_bot_source_dir: /home/ada/Documents/Projects/Other/memoir_bot/
memoir_bot_app_dir: /opt/memoir-bot/app
memoir_bot_state_dir: /srv/memoir-bot
memoir_bot_env_file: /srv/memoir-bot/.env
memoir_bot_env_source: /home/ada/Documents/Projects/Other/memoir_bot/.env
memoir_bot_ssh_private_key_file: ~/.ssh/id_ed25519
memoir_bot_ssh_public_key_file: ~/.ssh/id_ed25519.pub
memoir_bot_vault_repo: git@github.com:ada-dmitry/SecondBrain.git
memoir_bot_vault_dir: /srv/memoir-bot/vault
memoir_bot_image: memoir-bot:local
memoir_bot_container_name: memoir-bot
tasks:
- name: Install Docker and deploy dependencies
ansible.builtin.apt:
name:
- docker.io
- fuse-overlayfs
- git
- openssh-client
- rsync
- ca-certificates
- curl
state: present
update_cache: true
- name: Ensure Docker config directory exists
ansible.builtin.file:
path: /etc/docker
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: docker_daemon_config
- name: Ensure Docker service is enabled and running
ansible.builtin.systemd:
name: docker
state: "{{ 'restarted' if docker_daemon_config.changed else 'started' }}"
enabled: true
- name: Ensure memoir-bot directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ memoir_bot_app_dir }}"
- "{{ memoir_bot_state_dir }}"
- "{{ memoir_bot_state_dir }}/ssh"
- name: Copy memoir-bot env example
ansible.builtin.copy:
dest: "{{ memoir_bot_state_dir }}/.env.example"
owner: root
group: root
mode: "0640"
content: |
BOT_TOKEN=123456:telegram-token
ALLOWED_USER_IDS=123456789
OBSIDIAN_VAULT_PATH=/srv/obsidian/vault
DAILY_NOTES_DIR=03 Journal
NOTE_PATH_FORMAT=%Y/%m/%d.%m.%y.md
MESSAGE_TIME_FORMAT=%H:%M
TELEGRAM_PROXY=
REMINDER_MIN_HOURS=2
REMINDER_MAX_HOURS=3
REMINDER_START_HOUR=7
REMINDER_END_HOUR=23
REMINDER_TIMEZONE=Europe/Moscow
- name: Copy memoir-bot env file when present locally
ansible.builtin.copy:
src: "{{ memoir_bot_env_source }}"
dest: "{{ memoir_bot_env_file }}"
owner: root
group: root
mode: "0600"
when: lookup('ansible.builtin.fileglob', memoir_bot_env_source) | length > 0
no_log: true
- name: Install GitHub SSH private key for memoir-bot
ansible.builtin.copy:
src: "{{ memoir_bot_ssh_private_key_file }}"
dest: "{{ memoir_bot_state_dir }}/ssh/id_ed25519"
owner: root
group: root
mode: "0600"
no_log: true
- name: Install GitHub SSH public key for memoir-bot when present locally
ansible.builtin.copy:
src: "{{ memoir_bot_ssh_public_key_file }}"
dest: "{{ memoir_bot_state_dir }}/ssh/id_ed25519.pub"
owner: root
group: root
mode: "0644"
when: lookup('ansible.builtin.fileglob', memoir_bot_ssh_public_key_file) | length > 0
- name: Scan GitHub SSH host key
ansible.builtin.command: ssh-keyscan github.com
register: memoir_bot_github_host_key
changed_when: false
- name: Trust GitHub SSH host key for memoir-bot
ansible.builtin.known_hosts:
path: "{{ memoir_bot_state_dir }}/ssh/known_hosts"
name: github.com
key: "{{ memoir_bot_github_host_key.stdout }}"
state: present
- name: Clone SecondBrain vault
ansible.builtin.git:
repo: "{{ memoir_bot_vault_repo }}"
dest: "{{ memoir_bot_vault_dir }}"
key_file: "{{ memoir_bot_state_dir }}/ssh/id_ed25519"
accept_hostkey: true
update: true
version: main
register: memoir_bot_vault_checkout
- name: Configure memoir-bot vault Git author name
ansible.builtin.command: git config user.name memoir-bot
args:
chdir: "{{ memoir_bot_vault_dir }}"
changed_when: false
- name: Configure memoir-bot vault Git author email
ansible.builtin.command: git config user.email memoir-bot@homelab.local
args:
chdir: "{{ memoir_bot_vault_dir }}"
changed_when: false
- name: Sync memoir-bot source code
ansible.posix.synchronize:
src: "{{ memoir_bot_source_dir }}"
dest: "{{ memoir_bot_app_dir }}/"
delete: true
rsync_opts:
- "--exclude=.env"
- "--exclude=.git"
- "--exclude=.venv"
- "--exclude=__pycache__"
- "--exclude=*.pyc"
register: memoir_bot_source_sync
- name: Check if memoir-bot image exists
ansible.builtin.command: "docker image inspect {{ memoir_bot_image }}"
register: memoir_bot_image_inspect
changed_when: false
failed_when: false
- name: Build memoir-bot image
ansible.builtin.command: "docker build -t {{ memoir_bot_image }} {{ memoir_bot_app_dir }}"
when: memoir_bot_source_sync.changed or memoir_bot_image_inspect.rc != 0
register: memoir_bot_image_build
changed_when: memoir_bot_image_build.rc == 0
- name: Install memoir-bot systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/memoir-bot.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Memoir Telegram bot container
After=docker.service
Requires=docker.service
ConditionPathExists={{ memoir_bot_env_file }}
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f {{ memoir_bot_container_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ memoir_bot_container_name }} \
--env-file {{ memoir_bot_env_file }} \
-v {{ memoir_bot_state_dir }}/vault:/srv/obsidian/vault \
-v {{ memoir_bot_state_dir }}/ssh:/root/.ssh:ro \
{{ memoir_bot_image }}
ExecStop=/usr/bin/docker stop {{ memoir_bot_container_name }}
[Install]
WantedBy=multi-user.target
register: memoir_bot_unit
- name: Reload systemd when memoir-bot unit changes
ansible.builtin.systemd:
daemon_reload: true
when: memoir_bot_unit.changed
- name: Check memoir-bot env file
ansible.builtin.stat:
path: "{{ memoir_bot_env_file }}"
register: memoir_bot_env
- name: Enable memoir-bot service
ansible.builtin.systemd:
name: memoir-bot
enabled: true
- name: Start memoir-bot when env file exists
ansible.builtin.systemd:
name: memoir-bot
state: "{{ 'restarted' if memoir_bot_source_sync.changed or memoir_bot_image_build.changed or memoir_bot_unit.changed or memoir_bot_vault_checkout.changed else 'started' }}"
when: memoir_bot_env.stat.exists
+332
View File
@@ -0,0 +1,332 @@
---
- name: Create mihomo LXC on mini-pc
hosts: mini-pc
gather_facts: false
vars:
mihomo_vmid: 143
mihomo_hostname: mihomo
mihomo_ip: 192.168.1.27/24
mihomo_gateway: 192.168.1.1
mihomo_bridge: vmbr0
mihomo_rootfs: local-lvm:8
mihomo_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
mihomo_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
handlers:
- name: restart mihomo lxc
ansible.builtin.shell: "pct stop {{ mihomo_vmid }} || true; pct start {{ mihomo_vmid }}"
changed_when: true
tasks:
- name: Check if mihomo LXC exists
ansible.builtin.command: "pct config {{ mihomo_vmid }}"
register: mihomo_pct_config
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID 143
ansible.builtin.assert:
that:
- mihomo_pct_config.rc != 0 or mihomo_existing_hostname == mihomo_hostname
fail_msg: VMID 143 already exists and is not the Mihomo container.
vars:
mihomo_existing_hostname: >-
{{ mihomo_pct_config.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Install mihomo LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/mihomo-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', mihomo_pubkey_file) }}\n"
when: mihomo_pct_config.rc != 0
- name: Create mihomo LXC
ansible.builtin.command: >-
pct create {{ mihomo_vmid }} {{ mihomo_ostemplate }}
--hostname {{ mihomo_hostname }}
--rootfs {{ mihomo_rootfs }}
--cores 1
--memory 512
--swap 512
--net0 name=eth0,bridge={{ mihomo_bridge }},gw={{ mihomo_gateway }},ip={{ mihomo_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=70
--cmode shell
--ssh-public-keys /tmp/mihomo-lxc.pub
when: mihomo_pct_config.rc != 0
- name: Start mihomo LXC
ansible.builtin.command: "pct start {{ mihomo_vmid }}"
register: mihomo_pct_start
changed_when: mihomo_pct_start.rc == 0
failed_when: mihomo_pct_start.rc not in [0, 255]
- name: Allow FUSE device in mihomo LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart mihomo lxc
- name: Bind mount FUSE device in mihomo LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart mihomo lxc
- name: Allow TUN device in mihomo LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:200 rwm"
state: present
notify: restart mihomo lxc
- name: Bind mount TUN device in mihomo LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
state: present
notify: restart mihomo lxc
- name: Apply pending LXC config changes
ansible.builtin.meta: flush_handlers
- name: Wait for mihomo SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: mihomo
vars:
ansible_become: false
- name: Prepare mihomo runtime host
hosts: mihomo
gather_facts: true
vars:
ansible_become: false
mihomo_config_dir: /opt/mihomo/config
mihomo_ui_dir: /opt/mihomo/ui
mihomo_image: metacubex/mihomo:v1.19.29@sha256:5e7bcc5e7a866afcc8b007ef827c9ba773f2f34b6d7311b6d39ed1751f37cfd5
mihomo_ui_image: ghcr.io/metacubex/metacubexd:v1.270.6@sha256:156d55be885d4ba6254d840bd781b715c20c00afee6e6c24c76be4cfe5eb89d4
mihomo_container_name: mihomo
mihomo_ui_container_name: mihomo-ui
tasks:
- name: Install Docker and mihomo runtime packages
ansible.builtin.apt:
name:
- docker.io
- fuse-overlayfs
- ca-certificates
- curl
- git
state: present
update_cache: true
- name: Ensure Docker config directory exists
ansible.builtin.file:
path: /etc/docker
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: docker_daemon_config
- name: Ensure Docker service is enabled and running
ansible.builtin.systemd:
name: docker
state: "{{ 'restarted' if docker_daemon_config.changed else 'started' }}"
enabled: true
- name: Ensure mihomo directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- /opt/mihomo
- "{{ mihomo_config_dir }}"
- "{{ mihomo_ui_dir }}"
- name: Check if the configured Mihomo image is present
ansible.builtin.command: "docker image inspect {{ mihomo_image }}"
register: mihomo_image_inspect
changed_when: false
failed_when: false
- name: Pull the configured Mihomo image
ansible.builtin.command: "docker pull {{ mihomo_image }}"
when: mihomo_image_inspect.rc != 0
register: mihomo_image_pull
changed_when: true
- name: Check if the configured Mihomo UI image is present
ansible.builtin.command: "docker image inspect {{ mihomo_ui_image }}"
register: mihomo_ui_image_inspect
changed_when: false
failed_when: false
- name: Pull the configured Mihomo UI image
ansible.builtin.command: "docker pull {{ mihomo_ui_image }}"
when: mihomo_ui_image_inspect.rc != 0
register: mihomo_ui_image_pull
changed_when: true
- name: Install default mihomo config if missing
ansible.builtin.copy:
dest: "{{ mihomo_config_dir }}/config.yaml"
owner: root
group: root
mode: "0640"
force: false
content: |
mixed-port: 7890
socks-port: 7891
allow-lan: true
bind-address: 0.0.0.0
mode: rule
log-level: info
external-controller: 0.0.0.0:9090
dns:
enable: true
listen: 0.0.0.0:1053
enhanced-mode: fake-ip
nameserver:
- 1.1.1.1
- 8.8.8.8
proxies: []
proxy-groups:
- name: PROXY
type: select
proxies:
- DIRECT
rules:
- MATCH,DIRECT
register: mihomo_config
- name: Install mihomo systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/mihomo.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Mihomo proxy container
After=docker.service
Requires=docker.service
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f {{ mihomo_container_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ mihomo_container_name }} \
--pull never \
--cap-add NET_ADMIN \
--device /dev/net/tun \
-p 7890:7890 \
-p 7891:7891 \
-p 9090:9090 \
-v {{ mihomo_config_dir }}:/root/.config/mihomo \
{{ mihomo_image }}
ExecStop=/usr/bin/docker stop {{ mihomo_container_name }}
[Install]
WantedBy=multi-user.target
register: mihomo_unit
- name: Install mihomo UI systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/mihomo-ui.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Mihomo MetaCubeXD web UI container
After=docker.service mihomo.service
Requires=docker.service
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f {{ mihomo_ui_container_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ mihomo_ui_container_name }} \
--pull never \
-p 8080:80 \
{{ mihomo_ui_image }}
ExecStop=/usr/bin/docker stop {{ mihomo_ui_container_name }}
[Install]
WantedBy=multi-user.target
register: mihomo_ui_unit
- name: Reload systemd when mihomo units change
ansible.builtin.systemd:
daemon_reload: true
when: mihomo_unit.changed or mihomo_ui_unit.changed
- name: Enable and start mihomo
ansible.builtin.systemd:
name: mihomo
enabled: true
state: "{{ 'restarted' if mihomo_config.changed or mihomo_unit.changed or mihomo_image_pull is changed else 'started' }}"
- name: Enable and start mihomo UI
ansible.builtin.systemd:
name: mihomo-ui
enabled: true
state: "{{ 'restarted' if mihomo_ui_unit.changed or mihomo_ui_image_pull is changed else 'started' }}"
- name: Wait for Mihomo proxy TCP ports
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: started
timeout: 120
loop:
- 7890
- 7891
- 9090
- name: Check Mihomo UI endpoint
ansible.builtin.uri:
url: http://127.0.0.1:8080/
status_code: 200
register: mihomo_ui_health
retries: 24
delay: 5
until: mihomo_ui_health.status == 200
- name: Check Mihomo controller /version endpoint
ansible.builtin.uri:
url: http://127.0.0.1:9090/version
status_code: 200
register: mihomo_controller_health
retries: 24
delay: 5
until: mihomo_controller_health.status == 200
+42
View File
@@ -0,0 +1,42 @@
---
- name: Create monitoring LXC on cloud-pc
hosts: localhost
connection: local
become: false
gather_facts: false
vars:
ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_lxc_vmid: 146
pve_lxc_node: cloud-pc
pve_lxc_hostname: monitoring
pve_lxc_ip: 192.168.1.30/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_disk: data:24
pve_lxc_cores: 2
pve_lxc_memory: 4096
pve_lxc_swap: 512
pve_lxc_startup: order=80
pve_lxc_features:
- nesting=1
pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
roles:
- role: pve_lxc
- name: Enable Docker keyctl feature for monitoring LXC
hosts: cloud-pc
gather_facts: false
tasks:
- name: Read monitoring LXC configuration
ansible.builtin.command: pct config 146
register: monitoring_lxc_config
changed_when: false
- name: Enable keyctl for Docker in monitoring LXC
ansible.builtin.command: pct set 146 --features nesting=1,keyctl=1
when: "'keyctl=1' not in monitoring_lxc_config.stdout"
register: monitoring_lxc_keyctl
- name: Restart monitoring LXC after feature change
ansible.builtin.command: pct reboot 146
when: monitoring_lxc_keyctl.changed
@@ -1,4 +1,4 @@
- name: Create wg-mini LXC on mini-pc
- name: Create ovpn-mini LXC on mini-pc
hosts: localhost
connection: local
gather_facts: false
@@ -7,19 +7,19 @@
vars:
pve_lxc_vmid: 132
pve_lxc_node: mini-pc
pve_lxc_hostname: wg-mini
pve_lxc_hostname: ovpn-mini
pve_lxc_ip: 192.168.1.23/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_storage: local-lvm
pve_lxc_disk: local-lvm:8
pve_lxc_ostemplate: "{{ lookup('env', 'PVE_LXC_OSTEMPLATE') | default('local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst', true) }}"
- name: Allow TUN device in wg-mini LXC config
- name: Allow TUN device in ovpn-mini LXC config
hosts: mini-pc
gather_facts: false
become: true
handlers:
- name: restart wg-mini lxc
- name: restart ovpn-mini lxc
ansible.builtin.shell: pct stop 132 || true; pct start 132
changed_when: true
tasks:
@@ -28,11 +28,11 @@
path: /etc/pve/lxc/132.conf
line: "lxc.cgroup2.devices.allow: c 10:200 rwm"
state: present
notify: restart wg-mini lxc
notify: restart ovpn-mini lxc
- name: Bind mount /dev/net/tun
ansible.builtin.lineinfile:
path: /etc/pve/lxc/132.conf
line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
state: present
notify: restart wg-mini lxc
notify: restart ovpn-mini lxc
+54 -3
View File
@@ -1,3 +1,27 @@
---
- name: Guard Vaultwarden VMID before API updates
hosts: mini-pc
gather_facts: false
tasks:
- name: Read existing VMID 140 configuration
ansible.builtin.command: pct config 140
register: vaultwarden_existing_vmid
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID 140
ansible.builtin.assert:
that:
- vaultwarden_existing_vmid.rc != 0 or vaultwarden_existing_hostname == 'vaultwarden'
fail_msg: VMID 140 already exists and is not the Vaultwarden container.
vars:
vaultwarden_existing_hostname: >-
{{ vaultwarden_existing_vmid.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Create Vaultwarden LXC on mini-pc
hosts: mini-pc
gather_facts: false
@@ -84,7 +108,7 @@
vars:
ansible_become: false
vaultwarden_data_dir: /opt/vaultwarden/data
vaultwarden_image: vaultwarden/server:latest
vaultwarden_image: vaultwarden/server:1.37.1@sha256:e9efdf001bf0d68c21f2cbfb8e1d9b5961a7ca9c85e0a7e58bf51a13b997d744
vaultwarden_container_name: vaultwarden
vaultwarden_http_port: 80
tasks:
@@ -133,6 +157,18 @@
group: root
mode: "0750"
- name: Check if the configured Vaultwarden image is present
ansible.builtin.command: "docker image inspect {{ vaultwarden_image }}"
register: vaultwarden_image_inspect
changed_when: false
failed_when: false
- name: Pull the configured Vaultwarden image
ansible.builtin.command: "docker pull {{ vaultwarden_image }}"
when: vaultwarden_image_inspect.rc != 0
register: vaultwarden_image_pull
changed_when: true
- name: Install Vaultwarden systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/vaultwarden.service
@@ -151,7 +187,7 @@
ExecStartPre=-/usr/bin/docker rm -f {{ vaultwarden_container_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ vaultwarden_container_name }} \
--pull always \
--pull never \
-p {{ vaultwarden_http_port }}:80 \
-v {{ vaultwarden_data_dir }}:/data \
-e WEBSOCKET_ENABLED=true \
@@ -170,5 +206,20 @@
- name: Enable and start Vaultwarden
ansible.builtin.systemd:
name: vaultwarden
state: started
state: "{{ 'restarted' if vaultwarden_unit.changed or vaultwarden_image_pull.changed else 'started' }}"
enabled: true
- name: Wait for Vaultwarden HTTP endpoint
ansible.builtin.uri:
url: "http://127.0.0.1:{{ vaultwarden_http_port }}/"
status_code: 200
register: vaultwarden_health
retries: 24
delay: 5
until: vaultwarden_health.status == 200
- name: Verify Docker storage driver
ansible.builtin.command: docker info --format '{{ "{{" }}.Driver{{ "}}" }}'
register: vaultwarden_docker_driver
changed_when: false
failed_when: vaultwarden_docker_driver.stdout != 'fuse-overlayfs'
@@ -0,0 +1,88 @@
---
- name: Configure Grimmory reverse proxy on ru-vps
hosts: ru-vps
gather_facts: false
vars:
caddy_dir: /opt/services/ru-vps/caddy
caddyfile_path: /opt/services/ru-vps/caddy/Caddyfile
grimmory_domain: books.ada-dev.ru
grimmory_upstream: 192.168.1.34:6060
tasks:
- name: Ensure Caddy service directory exists
ansible.builtin.file:
path: "{{ caddy_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Grimmory Caddy site
ansible.builtin.blockinfile:
path: "{{ caddyfile_path }}"
create: true
owner: root
group: root
mode: "0644"
marker: "# {mark} ANSIBLE MANAGED GRIMMORY SITE"
block: |
{{ grimmory_domain }} {
@grimmory_opds_atom path /api/v1/opds /api/v1/opds/libraries /api/v1/opds/shelves /api/v1/opds/magic-shelves /api/v1/opds/authors /api/v1/opds/series /api/v1/opds/catalog /api/v1/opds/recent /api/v1/opds/surprise
handle @grimmory_opds_atom {
reverse_proxy {{ grimmory_upstream }} {
header_up Accept "application/atom+xml"
header_up Accept-Encoding identity
transport http {
compression off
}
}
}
@grimmory_opds_search path /api/v1/opds/search.opds
handle @grimmory_opds_search {
reverse_proxy {{ grimmory_upstream }} {
header_up Accept "application/opensearchdescription+xml"
header_up Accept-Encoding identity
transport http {
compression off
}
}
}
@grimmory_device_api path /api/koreader /api/koreader/* /api/v1/opds /api/v1/opds/*
handle @grimmory_device_api {
reverse_proxy {{ grimmory_upstream }} {
header_up Accept-Encoding identity
transport http {
compression off
}
}
}
handle {
reverse_proxy {{ grimmory_upstream }}
}
}
register: grimmory_caddy_site
- name: Validate host Caddy config
ansible.builtin.command: caddy validate --config {{ caddyfile_path }}
changed_when: false
- name: Check Caddy container mounted config
ansible.builtin.command: docker exec caddy grep -F '@grimmory_device_api path /api/koreader' /etc/caddy/Caddyfile
register: grimmory_container_caddyfile
changed_when: false
failed_when: false
- name: Restart Caddy when config changed or bind mount is stale
ansible.builtin.command: docker restart caddy
when: grimmory_caddy_site.changed or grimmory_container_caddyfile.rc != 0
- name: Validate Caddy container config after restart
ansible.builtin.command: docker exec caddy caddy validate --config /etc/caddy/Caddyfile
changed_when: false
- name: Check Grimmory upstream from ru-vps
ansible.builtin.uri:
url: http://{{ grimmory_upstream }}/api/v1/healthcheck
status_code: 200
+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.
+6
View File
@@ -0,0 +1,6 @@
---
- name: Freeze Prometheus monitoring and configure Uptime Kuma
hosts: monitoring_server
gather_facts: false
roles:
- role: uptime_kuma
+52
View File
@@ -0,0 +1,52 @@
---
- name: Install user SSH public key on managed hosts
hosts: servers
gather_facts: false
vars:
homelab_user_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
homelab_user_pubkey: "{{ lookup('file', homelab_user_pubkey_file) }}"
tasks:
- name: Install user SSH key for connection user
ansible.posix.authorized_key:
user: "{{ ansible_user }}"
key: "{{ homelab_user_pubkey }}"
state: present
- name: Install user SSH key for root
ansible.posix.authorized_key:
user: root
key: "{{ homelab_user_pubkey }}"
state: present
become: true
when: ansible_user != 'root'
- name: Bootstrap user SSH public key into legacy LXC containers via Proxmox
hosts: pve_nodes
gather_facts: false
vars:
homelab_user_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
homelab_user_pubkey: "{{ lookup('file', homelab_user_pubkey_file) }}"
legacy_lxc_key_targets:
- node: cloud-pc
vmid: 120
name: pbs
tasks:
- name: Install user SSH key for root inside legacy LXC
ansible.builtin.shell: |
pct exec {{ item.vmid }} -- sh -c '
key=$(printf "%s" {{ homelab_user_pubkey | b64encode | quote }} | base64 -d)
mkdir -p /root/.ssh
chmod 700 /root/.ssh
touch /root/.ssh/authorized_keys
if grep -qxF "$key" /root/.ssh/authorized_keys; then
echo present
else
printf "%s\n" "$key" >> /root/.ssh/authorized_keys
echo added
fi
chmod 600 /root/.ssh/authorized_keys
'
loop: "{{ legacy_lxc_key_targets }}"
when: item.node == inventory_hostname
register: legacy_lxc_key_install
changed_when: "'added' in legacy_lxc_key_install.stdout"
+98
View File
@@ -0,0 +1,98 @@
---
- name: Create and verify Vaultwarden backup before update
hosts: vaultwarden
gather_facts: false
tasks:
- name: Create a fresh Vaultwarden offsite backup
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-restic-offsite-vaultwarden.service
changed_when: true
- name: Run Vaultwarden offsite backup audit
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-vaultwarden.service
changed_when: true
- name: Create and verify Vaultwarden PBS backup before update
hosts: mini-pc
gather_facts: false
tasks:
- name: Read existing VMID 140 configuration
ansible.builtin.command: pct config 140
register: vaultwarden_existing_vmid
changed_when: false
- name: Refuse to modify a foreign VMID 140
ansible.builtin.assert:
that:
- vaultwarden_existing_vmid.rc == 0
- vaultwarden_existing_hostname == 'vaultwarden'
fail_msg: VMID 140 already exists and is not the Vaultwarden container.
vars:
vaultwarden_existing_hostname: >-
{{ vaultwarden_existing_vmid.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Check for active Proxmox backup before Vaultwarden PBS backup
ansible.builtin.command: pgrep -x vzdump
register: vaultwarden_vzdump_preflight
changed_when: false
failed_when: false
- name: Require no active Proxmox backup before Vaultwarden PBS backup
ansible.builtin.assert:
that:
- vaultwarden_vzdump_preflight.rc != 0
fail_msg: >-
A Proxmox backup is already running on mini-pc.
Retry after the existing backup completes.
- name: Create a fresh Vaultwarden PBS backup
ansible.builtin.command:
argv:
- vzdump
- "140"
- --storage
- pbs
- --mode
- snapshot
- --prune-backups
- keep-all=1
- --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged
- name: Run current PBS backup audit on mini-pc
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-pbs.service
changed_when: true
- import_playbook: pve-vaultwarden.yml
- name: Verify Vaultwarden public endpoint after update
hosts: ru-vps
gather_facts: false
tasks:
- name: Check Vaultwarden HTTPS endpoint
ansible.builtin.uri:
url: https://pass.ada-dev.ru/
status_code: 200
return_content: false
register: vaultwarden_public_health
retries: 24
delay: 5
until: vaultwarden_public_health.status == 200
+2
View File
@@ -4,3 +4,5 @@ collections:
version: ">=2.0.0"
- name: community.proxmox
version: ">=2.0.0"
- name: community.general
version: ">=10.0.0"
@@ -0,0 +1,31 @@
---
backup_audit_log_file: /var/log/homelab-backup-audit.log
backup_audit_timer_oncalendar: "*-*-* 06:00:00"
backup_audit_timer_randomized_delay: 10m
backup_audit_pbs_vmids:
- vmid: 132
max_age_hours: 48
- vmid: 140
max_age_hours: 48
- vmid: 141
max_age_hours: 48
- vmid: 142
max_age_hours: 48
- vmid: 143
max_age_hours: 48
- vmid: 144
max_age_hours: 48
- vmid: 145
max_age_hours: 48
- vmid: 146
max_age_hours: 48
- vmid: 147
max_age_hours: 48
- vmid: 149
max_age_hours: 48
- vmid: 150
max_age_hours: 48
backup_audit_restic_profiles: []
backup_audit_metrics_dir: /var/lib/node_exporter/textfile_collector
+146
View File
@@ -0,0 +1,146 @@
---
- name: Install audit dependencies
ansible.builtin.apt:
name:
- jq
- sqlite3
state: present
update_cache: true
- name: Ensure audit directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- /etc/homelab-backup-audit
- /var/lib/homelab-backup-audit
- "{{ backup_audit_metrics_dir }}"
- name: Install PBS audit script
ansible.builtin.template:
src: audit-pbs.sh.j2
dest: /usr/local/sbin/homelab-backup-audit-pbs
owner: root
group: root
mode: "0755"
when: backup_audit_type | default('') == 'pbs'
- name: Install restic audit script
ansible.builtin.template:
src: audit-restic.sh.j2
dest: /usr/local/sbin/homelab-backup-audit-restic
owner: root
group: root
mode: "0755"
when: backup_audit_type | default('') == 'restic'
- name: Install restic audit env files
ansible.builtin.copy:
dest: "/etc/homelab-backup-audit/{{ item.name }}.env"
owner: root
group: root
mode: "0600"
content: |
HOMELAB_AUDIT_PROFILE={{ item.name }}
HOMELAB_AUDIT_MAX_AGE_HOURS={{ item.max_age_hours | default(36) }}
HOMELAB_AUDIT_SQLITE_NAME={{ item.sqlite_name | default('') }}
HOMELAB_AUDIT_EXPECTED_NAME={{ item.expected_name | default('') }}
loop: "{{ backup_audit_restic_profiles }}"
when: backup_audit_type | default('') == 'restic'
no_log: true
- name: Install PBS audit systemd service
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-backup-audit-pbs.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=HomeLab PBS backup audit
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/homelab-backup-audit-pbs
when: backup_audit_type | default('') == 'pbs'
- name: Install restic audit systemd services
ansible.builtin.copy:
dest: "/etc/systemd/system/homelab-backup-audit-{{ item.name }}.service"
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=HomeLab restic offsite backup audit ({{ item.name }})
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/flock -w 1800 /var/lock/homelab-restic-{{ item.name }}.lock /usr/local/sbin/homelab-backup-audit-restic {{ item.name }}
loop: "{{ backup_audit_restic_profiles }}"
when: backup_audit_type | default('') == 'restic'
- name: Install PBS audit systemd timer
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-backup-audit-pbs.timer
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Run HomeLab PBS backup audit
[Timer]
OnCalendar={{ backup_audit_timer_oncalendar }}
Persistent=true
RandomizedDelaySec={{ backup_audit_timer_randomized_delay }}
[Install]
WantedBy=timers.target
when: backup_audit_type | default('') == 'pbs'
- name: Install restic audit systemd timers
ansible.builtin.copy:
dest: "/etc/systemd/system/homelab-backup-audit-{{ item.name }}.timer"
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Run HomeLab restic backup audit ({{ item.name }})
[Timer]
OnCalendar={{ backup_audit_timer_oncalendar }}
Persistent=true
RandomizedDelaySec={{ backup_audit_timer_randomized_delay }}
[Install]
WantedBy=timers.target
loop: "{{ backup_audit_restic_profiles }}"
when: backup_audit_type | default('') == 'restic'
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Enable PBS audit timer
ansible.builtin.systemd:
name: homelab-backup-audit-pbs.timer
enabled: true
state: started
when: backup_audit_type | default('') == 'pbs'
- name: Enable restic audit timers
ansible.builtin.systemd:
name: "homelab-backup-audit-{{ item.name }}.timer"
enabled: true
state: started
loop: "{{ backup_audit_restic_profiles }}"
when: backup_audit_type | default('') == 'restic'
@@ -0,0 +1,70 @@
#!/bin/sh
# Managed by Ansible: HomeLab PBS backup audit (L0 freshness)
set -eu
LOG_FILE="{{ backup_audit_log_file }}"
METRICS_DIR="{{ backup_audit_metrics_dir }}"
METRICS_FILE="$METRICS_DIR/homelab_backup_audit_pbs.prom"
METRICS_TMP=$(mktemp "$METRICS_FILE.XXXXXX")
STATUS=OK
NOW_EPOCH=$(date +%s)
trap 'rm -f "$METRICS_TMP"' EXIT
publish_metrics() {
chmod 0644 "$METRICS_TMP"
mv "$METRICS_TMP" "$METRICS_FILE"
}
ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
log() { echo "[$(ts)] [pbs] $*" | tee -a "$LOG_FILE"; }
get_latest_snapshot() {
pvesm list pbs --vmid "$1" 2>/dev/null \
| tail -n +2 \
| awk '{print $1}' \
| sort -t/ -k4 \
| tail -1
}
{% for entry in backup_audit_pbs_vmids %}
audit_vmid_{{ entry.vmid }}() {
vmid={{ entry.vmid }}
max_age={{ entry.max_age_hours }}
latest=$(get_latest_snapshot "$vmid")
if [ -z "$latest" ]; then
log "FAIL L0: vmid $vmid — no snapshots in PBS"
printf 'homelab_backup_audit_snapshot_age_hours{profile="pbs",vmid="%s"} -1\n' "$vmid" >> "$METRICS_TMP"
printf 'homelab_backup_audit_snapshot_success{profile="pbs",vmid="%s"} 0\n' "$vmid" >> "$METRICS_TMP"
STATUS=FAIL
return
fi
ts_str=$(printf '%s' "$latest" | sed -n 's#.*/\([0-9T:Z-]*\)$#\1#p')
snap_epoch=$(date -d "$ts_str" +%s 2>/dev/null || echo 0)
age_hours=$(( (NOW_EPOCH - snap_epoch) / 3600 ))
if [ "$age_hours" -gt "$max_age" ]; then
log "FAIL L0: vmid $vmid — latest snapshot age ${age_hours}h > ${max_age}h (snapshot: $ts_str)"
printf 'homelab_backup_audit_snapshot_success{profile="pbs",vmid="%s"} 0\n' "$vmid" >> "$METRICS_TMP"
STATUS=FAIL
else
log "OK L0: vmid $vmid — latest snapshot age ${age_hours}h"
printf 'homelab_backup_audit_snapshot_success{profile="pbs",vmid="%s"} 1\n' "$vmid" >> "$METRICS_TMP"
fi
printf 'homelab_backup_audit_snapshot_age_hours{profile="pbs",vmid="%s"} %s\n' "$vmid" "$age_hours" >> "$METRICS_TMP"
}
audit_vmid_{{ entry.vmid }}
{% endfor %}
if [ "$STATUS" = "OK" ]; then
log "AUDIT PASSED"
printf 'homelab_backup_audit_success{profile="pbs"} 1\n' >> "$METRICS_TMP"
printf 'homelab_backup_audit_timestamp_seconds{profile="pbs"} %s\n' "$NOW_EPOCH" >> "$METRICS_TMP"
publish_metrics
exit 0
else
log "AUDIT FAILED"
printf 'homelab_backup_audit_success{profile="pbs"} 0\n' >> "$METRICS_TMP"
printf 'homelab_backup_audit_timestamp_seconds{profile="pbs"} %s\n' "$NOW_EPOCH" >> "$METRICS_TMP"
publish_metrics
exit 1
fi
@@ -0,0 +1,167 @@
#!/bin/sh
# Managed by Ansible: HomeLab restic offsite backup audit (L0+L1+L2)
set -eu
if [ "$#" -ne 1 ]; then
echo "usage: $0 <profile>" >&2
exit 64
fi
PROFILE="$1"
case "$PROFILE" in
''|*[!A-Za-z0-9_-]*)
echo "invalid profile name" >&2
exit 64
;;
esac
RESTIC_ENV="/etc/homelab-restic/${PROFILE}.env"
AUDIT_ENV="/etc/homelab-backup-audit/${PROFILE}.env"
LOG_FILE="{{ backup_audit_log_file }}"
TMP="/var/lib/homelab-backup-audit/${PROFILE}"
METRICS_DIR="{{ backup_audit_metrics_dir }}"
METRICS_FILE="$METRICS_DIR/homelab_backup_audit_${PROFILE}.prom"
METRICS_TMP=$(mktemp "$METRICS_FILE.XXXXXX")
STATUS=OK
trap 'rm -f "$METRICS_TMP"' EXIT
publish_metrics() {
chmod 0644 "$METRICS_TMP"
mv "$METRICS_TMP" "$METRICS_FILE"
}
for f in "$RESTIC_ENV" "$AUDIT_ENV"; do
if [ ! -f "$f" ]; then
echo "[$(date '+%Y-%m-%dT%H:%M:%S%z')] [${PROFILE}] FAIL: missing env file $f" | tee -a "$LOG_FILE"
printf 'homelab_backup_audit_success{profile="%s"} 0\n' "$PROFILE" > "$METRICS_TMP"
printf 'homelab_backup_audit_timestamp_seconds{profile="%s"} %s\n' "$PROFILE" "$(date +%s)" >> "$METRICS_TMP"
publish_metrics
exit 66
fi
done
set -a
. "$RESTIC_ENV"
. "$AUDIT_ENV"
set +a
export RESTIC_REPOSITORY RESTIC_PASSWORD_FILE RCLONE_CONFIG
ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
log() { echo "[$(ts)] [${PROFILE}] $*" | tee -a "$LOG_FILE"; }
# ── L0: freshness ──────────────────────────────────────────────────────────
latest_time=$(restic snapshots --latest 1 --json 2>/dev/null | jq -r '.[0].time // empty')
if [ -z "$latest_time" ]; then
log "FAIL L0: no snapshots found in repository"
printf 'homelab_backup_audit_success{profile="%s"} 0\n' "$PROFILE" > "$METRICS_TMP"
printf 'homelab_backup_audit_timestamp_seconds{profile="%s"} %s\n' "$PROFILE" "$(date +%s)" >> "$METRICS_TMP"
publish_metrics
exit 1
fi
now_epoch=$(date +%s)
snap_epoch=$(date -d "$latest_time" +%s 2>/dev/null || echo 0)
age_hours=$(( (now_epoch - snap_epoch) / 3600 ))
max_age="${HOMELAB_AUDIT_MAX_AGE_HOURS:-36}"
if [ "$age_hours" -gt "$max_age" ]; then
log "FAIL L0: latest snapshot age ${age_hours}h > ${max_age}h (snapshot: $latest_time)"
STATUS=FAIL
else
log "OK L0: latest snapshot age ${age_hours}h"
fi
printf 'homelab_backup_audit_snapshot_age_hours{profile="%s"} %s\n' "$PROFILE" "$age_hours" >> "$METRICS_TMP"
# ── L1: repository integrity ───────────────────────────────────────────────
if restic check 2>&1 | tee -a "$LOG_FILE"; then
log "OK L1: restic check passed"
printf 'homelab_backup_audit_level_success{profile="%s",level="l1"} 1\n' "$PROFILE" >> "$METRICS_TMP"
else
log "FAIL L1: restic check failed"
printf 'homelab_backup_audit_level_success{profile="%s",level="l1"} 0\n' "$PROFILE" >> "$METRICS_TMP"
STATUS=FAIL
fi
# ── L2: SQLite restore + integrity ─────────────────────────────────────────
if [ -n "${HOMELAB_AUDIT_SQLITE_NAME:-}" ]; then
case "$TMP" in
/var/lib/homelab-backup-audit/[A-Za-z0-9_-]*) ;;
*)
log "FAIL L2: unsafe temporary path"
exit 64
;;
esac
rm -rf "$TMP"
mkdir -p "$TMP"
if restic restore latest --target "$TMP" --include "**/${HOMELAB_AUDIT_SQLITE_NAME}" 2>&1 | tee -a "$LOG_FILE"; then
db=$(find "$TMP" -name "$HOMELAB_AUDIT_SQLITE_NAME" -type f | head -1)
if [ -z "$db" ] || [ ! -f "$db" ]; then
log "FAIL L2: $HOMELAB_AUDIT_SQLITE_NAME not found in restored data"
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 0\n' "$PROFILE" >> "$METRICS_TMP"
STATUS=FAIL
else
check=$(sqlite3 "$db" "PRAGMA integrity_check;" 2>&1)
if [ "$check" = "ok" ]; then
log "OK L2: SQLite integrity_check ok ($db)"
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 1\n' "$PROFILE" >> "$METRICS_TMP"
else
log "FAIL L2: SQLite integrity_check: $check"
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 0\n' "$PROFILE" >> "$METRICS_TMP"
STATUS=FAIL
fi
fi
else
log "FAIL L2: restic restore failed for $HOMELAB_AUDIT_SQLITE_NAME"
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 0\n' "$PROFILE" >> "$METRICS_TMP"
STATUS=FAIL
fi
rm -rf "$TMP"
fi
# ── L2: expected file restore ───────────────────────────────────────────────
if [ -n "${HOMELAB_AUDIT_EXPECTED_NAME:-}" ]; then
case "$TMP" in
/var/lib/homelab-backup-audit/[A-Za-z0-9_-]*) ;;
*)
log "FAIL L2: unsafe temporary path"
exit 64
;;
esac
rm -rf "$TMP"
mkdir -p "$TMP"
if restic restore latest --target "$TMP" --include "**/${HOMELAB_AUDIT_EXPECTED_NAME}" 2>&1 | tee -a "$LOG_FILE"; then
expected=$(find "$TMP" -name "$HOMELAB_AUDIT_EXPECTED_NAME" -type f | head -1)
if [ -n "$expected" ] && [ -s "$expected" ]; then
log "OK L2: restored non-empty expected file ($expected)"
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 1\n' "$PROFILE" >> "$METRICS_TMP"
else
log "FAIL L2: $HOMELAB_AUDIT_EXPECTED_NAME not found or empty"
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 0\n' "$PROFILE" >> "$METRICS_TMP"
STATUS=FAIL
fi
else
log "FAIL L2: restic restore failed for $HOMELAB_AUDIT_EXPECTED_NAME"
printf 'homelab_backup_audit_level_success{profile="%s",level="l2"} 0\n' "$PROFILE" >> "$METRICS_TMP"
STATUS=FAIL
fi
rm -rf "$TMP"
fi
if [ "$STATUS" = "OK" ]; then
log "AUDIT PASSED"
printf 'homelab_backup_audit_success{profile="%s"} 1\n' "$PROFILE" >> "$METRICS_TMP"
printf 'homelab_backup_audit_timestamp_seconds{profile="%s"} %s\n' "$PROFILE" "$(date +%s)" >> "$METRICS_TMP"
publish_metrics
exit 0
else
log "AUDIT FAILED"
printf 'homelab_backup_audit_success{profile="%s"} 0\n' "$PROFILE" >> "$METRICS_TMP"
printf 'homelab_backup_audit_timestamp_seconds{profile="%s"} %s\n' "$PROFILE" "$(date +%s)" >> "$METRICS_TMP"
publish_metrics
exit 1
fi
@@ -30,6 +30,23 @@ alias gs='git status --short --branch'
alias gd='git diff'
alias 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'
@@ -0,0 +1,11 @@
---
emergency_reverse_user: homelab-rescue
emergency_reverse_port: 22010
emergency_reverse_bind_address: 127.0.0.1
emergency_reverse_tunnel_user: emergency-tunnel
emergency_control_user: emergency-control
emergency_control_key_path: /etc/emergency-access/control_ed25519
emergency_tunnel_state_dir: /var/lib/emergency-reverse-ssh
emergency_reverse_key_path: /var/lib/emergency-reverse-ssh/reverse_ed25519
emergency_access_dir: /etc/emergency-access
emergency_tunnel_ttl: 60m
@@ -0,0 +1,8 @@
---
- name: Reload systemd
ansible.builtin.systemd_service:
daemon_reload: true
- name: Validate and reload SSH
ansible.builtin.shell: sshd -t && systemctl reload ssh
changed_when: true
@@ -0,0 +1,134 @@
---
- name: Validate emergency access client inputs
ansible.builtin.assert:
that:
- emergency_vps_host_key is match('^ssh-(ed25519|rsa|ecdsa-[^ ]+) [A-Za-z0-9+/=]+( [A-Za-z0-9@._:-]+)?$')
- emergency_bot_control_public_key | length > 0
fail_msg: Set EMERGENCY_VPS_HOST_KEY and provision the emergency-bot control key first.
no_log: true
- name: Install reverse SSH client
ansible.builtin.apt:
name:
- openssh-client
- sudo
state: present
update_cache: true
- name: Create emergency access service accounts
ansible.builtin.user:
name: "{{ item }}"
system: true
# SSHD must start the forced command under a valid shell; authorized_keys
# still prevents this account from receiving an arbitrary command or PTY.
shell: "{{ '/bin/sh' if item == emergency_control_user else '/usr/sbin/nologin' }}"
create_home: true
loop:
- "{{ emergency_reverse_tunnel_user }}"
- "{{ emergency_control_user }}"
- name: Create emergency access configuration directory
ansible.builtin.file:
path: "{{ emergency_access_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Create reverse SSH state directory
ansible.builtin.file:
path: "{{ emergency_tunnel_state_dir }}"
state: directory
owner: "{{ emergency_reverse_tunnel_user }}"
group: "{{ emergency_reverse_tunnel_user }}"
mode: "0700"
- name: Generate dedicated reverse SSH key
ansible.builtin.command:
cmd: "ssh-keygen -q -t ed25519 -N '' -f {{ emergency_reverse_key_path }}"
creates: "{{ emergency_reverse_key_path }}"
become_user: "{{ emergency_reverse_tunnel_user }}"
no_log: true
- name: Read reverse SSH public key
ansible.builtin.slurp:
src: "{{ emergency_reverse_key_path }}.pub"
register: emergency_reverse_key
no_log: true
- name: Store reverse SSH public key for VPS configuration
ansible.builtin.set_fact:
emergency_reverse_public_key: "{{ emergency_reverse_key.content | b64decode | trim }}"
no_log: true
- name: Pin ru-vps SSH host key for the tunnel user
ansible.builtin.copy:
dest: "{{ emergency_tunnel_state_dir }}/known_hosts"
content: "ru-vps-emergency {{ emergency_vps_host_key }}\n"
owner: "{{ emergency_reverse_tunnel_user }}"
group: "{{ emergency_reverse_tunnel_user }}"
mode: "0600"
no_log: true
- name: Install reverse SSH systemd unit
ansible.builtin.template:
src: emergency-reverse-ssh.service.j2
dest: /etc/systemd/system/emergency-reverse-ssh.service
owner: root
group: root
mode: "0644"
notify: Reload systemd
- name: Install emergency tunnel expiry unit
ansible.builtin.template:
src: emergency-reverse-ssh-expire.service.j2
dest: /etc/systemd/system/emergency-reverse-ssh-expire.service
owner: root
group: root
mode: "0644"
notify: Reload systemd
- name: Install emergency tunnel expiry timer
ansible.builtin.template:
src: emergency-reverse-ssh.timer.j2
dest: /etc/systemd/system/emergency-reverse-ssh.timer
owner: root
group: root
mode: "0644"
notify: Reload systemd
- name: Install restricted emergency control helper
ansible.builtin.template:
src: emergency-control.j2
dest: /usr/local/libexec/emergency-control
owner: root
group: root
mode: "0755"
- name: Install emergency control sudo policy
ansible.builtin.template:
src: emergency-control.sudoers.j2
dest: /etc/sudoers.d/emergency-control
owner: root
group: root
mode: "0440"
validate: visudo -cf %s
- name: Authorize emergency-bot control key with forced command
ansible.posix.authorized_key:
user: "{{ emergency_control_user }}"
key: "command=\"/usr/local/libexec/emergency-control\",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty {{ emergency_bot_control_public_key }}"
state: present
exclusive: true
no_log: true
- name: Reload systemd before managing emergency units
ansible.builtin.meta: flush_handlers
- name: Keep emergency tunnel disabled at boot
ansible.builtin.systemd_service:
name: "{{ item }}"
enabled: false
loop:
- emergency-reverse-ssh.service
- emergency-reverse-ssh.timer
@@ -0,0 +1,24 @@
---
- name: Validate emergency access endpoint input
ansible.builtin.assert:
that:
- emergency_reverse_public_key | length > 0
fail_msg: Configure the mini-pc reverse SSH client before ru-vps.
no_log: true
- name: Create restricted reverse SSH endpoint user
ansible.builtin.user:
name: "{{ emergency_reverse_user }}"
system: true
# Remote forwarding is accepted before a session exists. A valid shell is
# required by SSHD; the forced command below rejects every session.
shell: /bin/sh
create_home: true
- name: Authorize only the dedicated reverse SSH key
ansible.posix.authorized_key:
user: "{{ emergency_reverse_user }}"
key: "command=\"/usr/bin/false\",restrict,port-forwarding,permitlisten=\"{{ emergency_reverse_bind_address }}:{{ emergency_reverse_port }}\" {{ emergency_reverse_public_key }}"
state: present
exclusive: true
no_log: true
@@ -0,0 +1,27 @@
#!/bin/sh
set -eu
case "${SSH_ORIGINAL_COMMAND:-}" in
start)
/usr/bin/sudo /usr/bin/systemctl start emergency-reverse-ssh.service
/usr/bin/sudo /usr/bin/systemctl restart emergency-reverse-ssh.timer
/usr/bin/systemctl is-active --quiet emergency-reverse-ssh.service
printf 'started; expires in {{ emergency_tunnel_ttl }}\nConnect:\nssh -i ~/.ssh/id_ed25519_homelab_ansible -o IdentitiesOnly=yes -J vps -p 22010 ansible@127.0.0.1\n'
;;
stop)
/usr/bin/sudo /usr/bin/systemctl stop emergency-reverse-ssh.timer
/usr/bin/sudo /usr/bin/systemctl stop emergency-reverse-ssh.service
printf 'stopped\n'
;;
status)
if /usr/bin/systemctl is-active --quiet emergency-reverse-ssh.service; then
/usr/bin/systemctl show --property=ActiveState --property=ActiveEnterTimestamp --value emergency-reverse-ssh.service
else
printf 'stopped\n'
fi
;;
*)
printf 'unsupported command\n' >&2
exit 64
;;
esac
@@ -0,0 +1 @@
{{ emergency_control_user }} ALL=(root) NOPASSWD: /usr/bin/systemctl start emergency-reverse-ssh.service, /usr/bin/systemctl restart emergency-reverse-ssh.timer, /usr/bin/systemctl stop emergency-reverse-ssh.timer, /usr/bin/systemctl stop emergency-reverse-ssh.service
@@ -0,0 +1,6 @@
[Unit]
Description=Close expired HomeLab reverse SSH rescue tunnel
[Service]
Type=oneshot
ExecStart=/usr/bin/systemctl stop emergency-reverse-ssh.service
@@ -0,0 +1,19 @@
[Unit]
Description=Temporary HomeLab reverse SSH rescue tunnel
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ emergency_reverse_tunnel_user }}
ExecStart=/usr/bin/ssh -N -i {{ emergency_reverse_key_path }} -o BatchMode=yes -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o StrictHostKeyChecking=yes -o UserKnownHostsFile={{ emergency_tunnel_state_dir }}/known_hosts -o HostKeyAlias=ru-vps-emergency -p 3422 -R {{ emergency_reverse_bind_address }}:{{ emergency_reverse_port }}:127.0.0.1:22 {{ emergency_reverse_user }}@157.22.231.198
Restart=on-failure
RestartSec=10
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths={{ emergency_tunnel_state_dir }}
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,10 @@
[Unit]
Description=Expire HomeLab reverse SSH rescue tunnel after {{ emergency_tunnel_ttl }}
[Timer]
OnActiveSec={{ emergency_tunnel_ttl }}
AccuracySec=1s
Unit=emergency-reverse-ssh-expire.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,8 @@
---
emergency_bot_user: emergency-bot
emergency_bot_state_dir: /var/lib/emergency-bot
emergency_bot_config_dir: /etc/emergency-bot
emergency_bot_control_key_path: /var/lib/emergency-bot/control_ed25519
emergency_bot_mini_pc_host: 192.168.1.10
emergency_bot_mini_pc_user: emergency-control
emergency_telegram_proxy: http://192.168.1.27:7890
@@ -0,0 +1,9 @@
---
- name: Reload systemd
ansible.builtin.systemd_service:
daemon_reload: true
- name: Restart emergency bot
ansible.builtin.systemd_service:
name: emergency-bot.service
state: restarted
@@ -0,0 +1,48 @@
---
- name: Create emergency bot user
ansible.builtin.user:
name: "{{ emergency_bot_user }}"
system: true
shell: /usr/sbin/nologin
create_home: false
- name: Create emergency bot state directory
ansible.builtin.file:
path: "{{ emergency_bot_state_dir }}"
state: directory
owner: "{{ emergency_bot_user }}"
group: "{{ emergency_bot_user }}"
mode: "0700"
- name: Generate emergency bot control key
ansible.builtin.command:
cmd: "ssh-keygen -q -t ed25519 -N '' -f {{ emergency_bot_control_key_path }}"
creates: "{{ emergency_bot_control_key_path }}"
become: true
become_user: "{{ emergency_bot_user }}"
no_log: true
- name: Set emergency bot control key ownership
ansible.builtin.file:
path: "{{ item.path }}"
state: file
owner: "{{ emergency_bot_user }}"
group: "{{ emergency_bot_user }}"
mode: "{{ item.mode }}"
loop:
- path: "{{ emergency_bot_control_key_path }}"
mode: "0600"
- path: "{{ emergency_bot_control_key_path }}.pub"
mode: "0644"
no_log: true
- name: Read emergency bot control public key
ansible.builtin.slurp:
src: "{{ emergency_bot_control_key_path }}.pub"
register: emergency_bot_control_key
no_log: true
- name: Store emergency bot control public key for mini-pc configuration
ansible.builtin.set_fact:
emergency_bot_control_public_key: "{{ emergency_bot_control_key.content | b64decode | trim }}"
no_log: true
@@ -0,0 +1,77 @@
---
- name: Bootstrap emergency bot control identity
ansible.builtin.import_tasks: bootstrap.yml
- name: Validate emergency bot inputs
ansible.builtin.assert:
that:
- emergency_bot_token is match('^[0-9]+:[A-Za-z0-9_-]+$')
- emergency_bot_allowed_user_ids is match('^[0-9]+(,[0-9]+)*$')
- emergency_mini_pc_host_key is match('^ssh-(ed25519|rsa|ecdsa-[^ ]+) [A-Za-z0-9+/=]+( [A-Za-z0-9@._:-]+)?$')
fail_msg: Set EMERGENCY_BOT_TOKEN, EMERGENCY_ALLOWED_USER_IDS and EMERGENCY_MINI_PC_HOST_KEY.
no_log: true
- name: Install emergency bot dependencies
ansible.builtin.apt:
name:
- ca-certificates
- openssh-client
- python3
state: present
update_cache: true
- name: Create emergency bot configuration directory
ansible.builtin.file:
path: "{{ emergency_bot_config_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Pin mini-pc SSH host key
ansible.builtin.copy:
dest: "{{ emergency_bot_state_dir }}/known_hosts"
content: "mini-pc-emergency {{ emergency_mini_pc_host_key }}\n"
owner: "{{ emergency_bot_user }}"
group: "{{ emergency_bot_user }}"
mode: "0600"
no_log: true
- name: Install emergency bot runtime
ansible.builtin.template:
src: emergency_bot.py.j2
dest: /usr/local/libexec/emergency-bot
owner: root
group: root
mode: "0755"
notify: Restart emergency bot
- name: Install emergency bot environment
ansible.builtin.template:
src: emergency-bot.env.j2
dest: "{{ emergency_bot_config_dir }}/bot.env"
owner: root
group: root
mode: "0600"
no_log: true
notify: Restart emergency bot
- name: Install emergency bot systemd unit
ansible.builtin.template:
src: emergency-bot.service.j2
dest: /etc/systemd/system/emergency-bot.service
owner: root
group: root
mode: "0644"
notify:
- Reload systemd
- Restart emergency bot
- name: Enable and start emergency bot
ansible.builtin.meta: flush_handlers
- name: Enable and start emergency bot
ansible.builtin.systemd_service:
name: emergency-bot.service
enabled: true
state: started
@@ -0,0 +1,7 @@
EMERGENCY_BOT_TOKEN={{ emergency_bot_token }}
EMERGENCY_ALLOWED_USER_IDS={{ emergency_bot_allowed_user_ids }}
EMERGENCY_MINI_PC_HOST={{ emergency_bot_mini_pc_host }}
EMERGENCY_MINI_PC_USER={{ emergency_bot_mini_pc_user }}
EMERGENCY_CONTROL_KEY={{ emergency_bot_control_key_path }}
EMERGENCY_KNOWN_HOSTS={{ emergency_bot_state_dir }}/known_hosts
EMERGENCY_TELEGRAM_PROXY={{ emergency_telegram_proxy }}
@@ -0,0 +1,20 @@
[Unit]
Description=HomeLab emergency access Telegram bot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ emergency_bot_user }}
EnvironmentFile={{ emergency_bot_config_dir }}/bot.env
ExecStart=/usr/local/libexec/emergency-bot
Restart=always
RestartSec=10
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths={{ emergency_bot_state_dir }}
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import time
import urllib.parse
import urllib.request
API = "https://api.telegram.org/bot" + os.environ["EMERGENCY_BOT_TOKEN"]
ALLOWED_USERS = {item.strip() for item in os.environ["EMERGENCY_ALLOWED_USER_IDS"].split(",") if item.strip()}
STATE_FILE = "{{ emergency_bot_state_dir }}/updates.json"
BUTTONS = {
"inline_keyboard": [[
{"text": "Enable SSH", "callback_data": "start"},
{"text": "Status", "callback_data": "status"},
{"text": "Stop", "callback_data": "stop"},
]]
}
HTTP = urllib.request.build_opener(
urllib.request.ProxyHandler({"https": os.environ["EMERGENCY_TELEGRAM_PROXY"]})
)
def api(method, payload=None, timeout=35):
if payload is not None:
payload = {
key: json.dumps(value) if isinstance(value, (dict, list)) else value
for key, value in payload.items()
}
data = None if payload is None else urllib.parse.urlencode(payload).encode()
with HTTP.open(API + "/" + method, data=data, timeout=timeout) as response:
result = json.load(response)
if not result.get("ok"):
raise RuntimeError(result.get("description", "Telegram API request failed"))
return result["result"]
def control(command):
args = [
"/usr/bin/ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
"-o", "HostKeyAlias=mini-pc-emergency", "-o", "UserKnownHostsFile=" + os.environ["EMERGENCY_KNOWN_HOSTS"],
"-i", os.environ["EMERGENCY_CONTROL_KEY"],
os.environ["EMERGENCY_MINI_PC_USER"] + "@" + os.environ["EMERGENCY_MINI_PC_HOST"], command,
]
result = subprocess.run(args, capture_output=True, text=True, timeout=25, check=False)
output = (result.stdout or result.stderr).strip()
if result.returncode:
return "Control command failed: " + (output or "unknown error")[:1000]
return output or "ok"
def reply(chat_id, text):
payload = {"chat_id": chat_id, "text": text[:3500], "reply_markup": BUTTONS}
if text.startswith("started; expires in") and "\nConnect:\n" in text:
status, command = text.split("\nConnect:\n", 1)
payload["text"] = status + "\nConnect:\n`" + command + "`"
payload["parse_mode"] = "Markdown"
api("sendMessage", payload)
def allowed(chat, sender):
return chat.get("type") == "private" and str(sender.get("id")) in ALLOWED_USERS
def handle_command(chat_id, command):
if command == "help":
reply(chat_id, "Choose an action or use /emergency ssh, /emergency status, /emergency stop")
elif command:
print("accepted emergency command: " + command, file=sys.stderr, flush=True)
reply(chat_id, control(command))
def load_offset():
try:
with open(STATE_FILE, encoding="utf-8") as state_file:
return int(json.load(state_file).get("offset", 0))
except (FileNotFoundError, ValueError, json.JSONDecodeError):
return 0
def save_offset(offset):
temporary = STATE_FILE + ".tmp"
with open(temporary, "w", encoding="utf-8") as state_file:
json.dump({"offset": offset}, state_file)
os.replace(temporary, STATE_FILE)
def command_for(text):
parts = text.strip().split()
if not parts:
return None
command_name = parts[0].split("@", 1)[0]
if command_name == "/start":
return "help"
if command_name != "/emergency":
return None
if len(parts) != 2:
return "help"
return {"ssh": "start", "stop": "stop", "status": "status"}.get(parts[1], "help")
def run():
offset = load_offset()
while True:
try:
for update in api("getUpdates", {"offset": offset, "timeout": 30}, timeout=40):
offset = update["update_id"] + 1
save_offset(offset)
callback = update.get("callback_query")
if callback:
callback_message = callback.get("message", {})
chat = callback_message.get("chat", {})
sender = callback.get("from", {})
command = callback.get("data")
if not allowed(chat, sender) or command not in {"start", "status", "stop"}:
print("ignored callback: chat_type=" + str(chat.get("type")) + " sender=" + str(sender.get("id")), file=sys.stderr, flush=True)
api("answerCallbackQuery", {"callback_query_id": callback["id"], "text": "Not authorized", "show_alert": True})
else:
api("answerCallbackQuery", {"callback_query_id": callback["id"]})
handle_command(chat["id"], command)
continue
message = update.get("message", {})
chat = message.get("chat", {})
sender = message.get("from", {})
text = message.get("text", "")
if not allowed(chat, sender):
print("ignored update: chat_type=" + str(chat.get("type")) + " sender=" + str(sender.get("id")), file=sys.stderr, flush=True)
continue
command = command_for(text)
handle_command(chat["id"], command)
except Exception as error:
print("emergency-bot error: " + str(error), file=sys.stderr, flush=True)
time.sleep(10)
if __name__ == "__main__":
run()
+28
View File
@@ -0,0 +1,28 @@
---
gyro_user: gyro
gyro_group: gyro
gyro_home: /home/gyro
gyro_app_dir: /opt/gyro/app
gyro_config_dir: /etc/gyro
gyro_cache_dir: /var/cache/gyro
gyro_uv_venv: /opt/uv
gyro_uv_version: 0.12.5
gyro_python_min_version: "3.13"
gyro_timezone: Europe/Moscow
gyro_repo_url: ""
gyro_repo_version: main
gyro_git_known_hosts_name: ""
gyro_git_host_key: ""
gyro_deploy_enabled: false
gyro_tinvest_token: ""
gyro_tinvest_account_id: ""
gyro_telegram_bot_token: ""
gyro_telegram_user_id: ""
gyro_telegram_proxy: ""
gyro_dry_run_override: "true"
gyro_secrets_configured: false
gyro_timer_enabled: false
gyro_timer_on_calendar: "Mon..Fri *-*-* 11:00:00 Europe/Moscow"
@@ -0,0 +1,37 @@
#!/usr/bin/python3
import os
import socket
import sys
import urllib.parse
import urllib.request
def main() -> int:
token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
user_id = os.environ.get("TELEGRAM_USER_ID", "").strip()
if not token or not user_id:
print("Gyro failure notification skipped: Telegram credentials are absent")
return 1
proxy = os.environ.get("TELEGRAM_PROXY", "").strip()
handlers = [urllib.request.ProxyHandler({"http": proxy, "https": proxy})] if proxy else []
opener = urllib.request.build_opener(*handlers)
failed_unit = sys.argv[1] if len(sys.argv) > 1 else "gyro.service"
message = f"Gyro job failed on {socket.gethostname()}: {failed_unit}. Check journalctl -u gyro.service."
body = urllib.parse.urlencode({"chat_id": user_id, "text": message}).encode()
request = urllib.request.Request(
f"https://api.telegram.org/bot{token}/sendMessage",
data=body,
method="POST",
)
try:
with opener.open(request, timeout=20) as response:
return 0 if response.status == 200 else 1
except Exception as exc:
print(f"Gyro failure notification failed: {type(exc).__name__}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -0,0 +1,4 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
+365
View File
@@ -0,0 +1,365 @@
---
- name: Install Gyro runtime packages
ansible.builtin.apt:
name:
- ca-certificates
- git
- openssh-client
- python3
- python3-packaging
- python3-venv
- sudo
- ufw
state: present
update_cache: true
- name: Configure Gyro timezone
community.general.timezone:
name: "{{ gyro_timezone }}"
- name: Create Gyro service group
ansible.builtin.group:
name: "{{ gyro_group }}"
system: true
- name: Create Gyro service user
ansible.builtin.user:
name: "{{ gyro_user }}"
group: "{{ gyro_group }}"
home: "{{ gyro_home }}"
shell: /bin/bash
system: true
create_home: true
- name: Create Gyro directories
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
group: "{{ item.group }}"
mode: "{{ item.mode }}"
loop:
- { path: "{{ gyro_app_dir }}", owner: "{{ gyro_user }}", group: "{{ gyro_group }}", mode: "0750" }
- { path: "{{ gyro_config_dir }}", owner: root, group: root, mode: "0700" }
- { path: "{{ gyro_cache_dir }}", owner: "{{ gyro_user }}", group: "{{ gyro_group }}", mode: "0700" }
- { path: "{{ gyro_home }}/.ssh", owner: "{{ gyro_user }}", group: "{{ gyro_group }}", mode: "0700" }
- name: Read system Python version
ansible.builtin.command: python3 -c "import platform; print(platform.python_version())"
register: gyro_python_version
changed_when: false
- name: Require Python 3.13 or newer
ansible.builtin.assert:
that:
- gyro_python_version.stdout is version(gyro_python_min_version, '>=')
fail_msg: "Gyro requires Python {{ gyro_python_min_version }} or newer; found {{ gyro_python_version.stdout }}."
- name: Create isolated uv installation environment
ansible.builtin.command:
argv:
- python3
- -m
- venv
- "{{ gyro_uv_venv }}"
creates: "{{ gyro_uv_venv }}/bin/pip"
- name: Install pinned uv version
ansible.builtin.pip:
name: "uv=={{ gyro_uv_version }}"
executable: "{{ gyro_uv_venv }}/bin/pip"
- name: Link uv into the system path
ansible.builtin.file:
src: "{{ gyro_uv_venv }}/bin/uv"
dest: /usr/local/bin/uv
state: link
- name: Generate Git deploy key on the container
ansible.builtin.command:
argv:
- ssh-keygen
- -q
- -t
- ed25519
- -N
- ""
- -C
- gyro@homelab
- -f
- "{{ gyro_home }}/.ssh/id_ed25519_gitea"
creates: "{{ gyro_home }}/.ssh/id_ed25519_gitea"
become: true
become_user: "{{ gyro_user }}"
vars:
ansible_become: true
- name: Secure Git deploy key ownership
ansible.builtin.file:
path: "{{ item.path }}"
owner: "{{ gyro_user }}"
group: "{{ gyro_group }}"
mode: "{{ item.mode }}"
loop:
- { path: "{{ gyro_home }}/.ssh/id_ed25519_gitea", mode: "0600" }
- { path: "{{ gyro_home }}/.ssh/id_ed25519_gitea.pub", mode: "0644" }
- name: Read Git deploy public key
ansible.builtin.slurp:
src: "{{ gyro_home }}/.ssh/id_ed25519_gitea.pub"
register: gyro_deploy_public_key
- name: Show Git deploy public key
ansible.builtin.debug:
msg: "{{ gyro_deploy_public_key.content | b64decode | trim }}"
- name: Allow SSH from the HomeLab LAN
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ homelab_lan_cidr }}"
- name: Allow SSH from the OpenVPN network
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ openvpn_network_cidr }}"
- name: Remove obsolete outbound Gitea SSH allowance
community.general.ufw:
rule: allow
delete: true
direction: out
dest: 192.168.1.25
port: "2222"
proto: tcp
- name: Allow outbound Telegram proxy access
community.general.ufw:
rule: allow
direction: out
dest: 192.168.1.27
port: "7890"
proto: tcp
- name: Deny other outbound HomeLab LAN access
community.general.ufw:
rule: deny
direction: out
dest: "{{ homelab_lan_cidr }}"
- name: Enable restrictive Gyro firewall
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Mask mount units already provided by unprivileged LXC
ansible.builtin.systemd:
name: "{{ item }}"
enabled: false
masked: true
state: stopped
loop:
- dev-mqueue.mount
- run-lock.mount
- tmp.mount
- name: Clear stale failures from masked LXC mount units
ansible.builtin.command: >-
systemctl reset-failed dev-mqueue.mount run-lock.mount tmp.mount
changed_when: false
- name: Create locked placeholder environment file
ansible.builtin.copy:
dest: "{{ gyro_config_dir }}/gyro.env"
owner: root
group: root
mode: "0600"
force: false
content: |
# Managed by Ansible after gyro_secrets_configured is enabled.
DRY_RUN_OVERRIDE=true
- name: Validate deployment inputs
ansible.builtin.assert:
that:
- gyro_repo_url | length > 0
- gyro_git_known_hosts_name | length > 0
- gyro_git_host_key | length > 0
fail_msg: Set the repository URL and verified SSH host key before enabling deployment.
when: gyro_deploy_enabled | bool
- name: Remove obsolete Git SSH alias
ansible.builtin.file:
path: "{{ gyro_home }}/.ssh/config"
state: absent
when: gyro_deploy_enabled | bool
- name: Remove obsolete Gitea known host
ansible.builtin.known_hosts:
path: "{{ gyro_home }}/.ssh/known_hosts"
name: "[192.168.1.25]:2222"
state: absent
when: gyro_deploy_enabled | bool
- name: Pin verified Git host key
ansible.builtin.known_hosts:
path: "{{ gyro_home }}/.ssh/known_hosts"
name: "{{ gyro_git_known_hosts_name }}"
key: "{{ gyro_git_host_key }}"
when: gyro_deploy_enabled | bool
- name: Secure Git known hosts file
ansible.builtin.file:
path: "{{ gyro_home }}/.ssh/known_hosts"
owner: "{{ gyro_user }}"
group: "{{ gyro_group }}"
mode: "0600"
when: gyro_deploy_enabled | bool
- name: Ensure Gyro checkout belongs to the service user
ansible.builtin.file:
path: "{{ gyro_app_dir }}"
owner: "{{ gyro_user }}"
group: "{{ gyro_group }}"
recurse: true
when: gyro_deploy_enabled | bool
- name: Clone Gyro from Git remote
ansible.builtin.git:
repo: "{{ gyro_repo_url }}"
dest: "{{ gyro_app_dir }}"
version: "{{ gyro_repo_version }}"
key_file: "{{ gyro_home }}/.ssh/id_ed25519_gitea"
accept_hostkey: false
ssh_opts: >-
-o UserKnownHostsFile={{ gyro_home }}/.ssh/known_hosts
-o StrictHostKeyChecking=yes
-o IdentitiesOnly=yes
update: true
become: true
become_user: "{{ gyro_user }}"
vars:
ansible_become: true
when: gyro_deploy_enabled | bool
- name: Synchronize locked Gyro dependencies
ansible.builtin.command:
argv:
- /usr/local/bin/uv
- sync
- --frozen
args:
chdir: "{{ gyro_app_dir }}"
environment:
UV_CACHE_DIR: "{{ gyro_cache_dir }}/uv"
become: true
become_user: "{{ gyro_user }}"
vars:
ansible_become: true
register: gyro_uv_sync
changed_when: "'Installed' in gyro_uv_sync.stderr or 'Uninstalled' in gyro_uv_sync.stderr"
when: gyro_deploy_enabled | bool
- name: Verify bundled T-Invest CA file
ansible.builtin.stat:
path: "{{ gyro_app_dir }}/config/certs/russian_ca.pem"
register: gyro_ca_bundle
when: gyro_deploy_enabled | bool
- name: Require complete Gyro checkout
ansible.builtin.assert:
that:
- gyro_ca_bundle.stat.exists
- gyro_ca_bundle.stat.isreg | default(false)
fail_msg: The Git checkout does not contain config/certs/russian_ca.pem.
when: gyro_deploy_enabled | bool
- name: Run Gyro unit tests
ansible.builtin.command:
argv:
- /usr/local/bin/uv
- run
- --frozen
- --no-sync
- python
- -m
- unittest
- discover
- -s
- tests
args:
chdir: "{{ gyro_app_dir }}"
environment:
UV_CACHE_DIR: "{{ gyro_cache_dir }}/uv"
become: true
become_user: "{{ gyro_user }}"
vars:
ansible_become: true
changed_when: false
when: gyro_deploy_enabled | bool
- name: Validate Gyro Vault secrets
ansible.builtin.assert:
that:
- gyro_tinvest_token | length > 0
- gyro_tinvest_account_id | length > 0
- gyro_telegram_bot_token | length > 0
- (gyro_telegram_user_id | string | length) > 0
- gyro_dry_run_override in ['true', 'false']
fail_msg: Populate and encrypt inventory/host_vars/gyro/vault.yml before enabling secrets.
no_log: true
when: gyro_secrets_configured | bool
- name: Install Gyro environment file from Vault
ansible.builtin.template:
src: gyro.env.j2
dest: "{{ gyro_config_dir }}/gyro.env"
owner: root
group: root
mode: "0600"
no_log: true
when: gyro_secrets_configured | bool
- name: Install Gyro failure notifier
ansible.builtin.copy:
src: gyro-failure-notify.py
dest: /usr/local/libexec/gyro-failure-notify
owner: root
group: root
mode: "0755"
- name: Install Gyro systemd units
ansible.builtin.template:
src: "{{ item.src }}"
dest: "/etc/systemd/system/{{ item.dest }}"
owner: root
group: root
mode: "0644"
loop:
- { src: gyro.service.j2, dest: gyro.service }
- { src: gyro.timer.j2, dest: gyro.timer }
- { src: gyro-failure@.service.j2, dest: "gyro-failure@.service" }
notify: Reload systemd
- name: Apply systemd unit changes
ansible.builtin.meta: flush_handlers
- name: Enable Gyro timer only after deployment and secret setup
ansible.builtin.systemd:
name: gyro.timer
enabled: "{{ gyro_timer_ready }}"
state: "{{ 'started' if gyro_timer_ready else 'stopped' }}"
vars:
gyro_timer_ready: "{{ gyro_timer_enabled | bool and gyro_deploy_enabled | bool and gyro_secrets_configured | bool }}"
- name: Verify Gyro unit definitions
ansible.builtin.command: >-
systemd-analyze verify
/etc/systemd/system/gyro.service
/etc/systemd/system/gyro.timer
/etc/systemd/system/gyro-failure@.service
changed_when: false
@@ -0,0 +1,15 @@
[Unit]
Description=Notify Telegram about failed Gyro unit %i
[Service]
Type=oneshot
User={{ gyro_user }}
Group={{ gyro_group }}
EnvironmentFile={{ gyro_config_dir }}/gyro.env
ExecStart=/usr/local/libexec/gyro-failure-notify %i
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
+6
View File
@@ -0,0 +1,6 @@
TINVEST_TOKEN={{ gyro_tinvest_token | string | to_json }}
TINVEST_ACCOUNT_ID={{ gyro_tinvest_account_id | string | to_json }}
TELEGRAM_BOT_TOKEN={{ gyro_telegram_bot_token | string | to_json }}
TELEGRAM_USER_ID={{ gyro_telegram_user_id | string | to_json }}
TELEGRAM_PROXY={{ gyro_telegram_proxy | string | to_json }}
DRY_RUN_OVERRIDE={{ gyro_dry_run_override | string | to_json }}
@@ -0,0 +1,31 @@
[Unit]
Description=Gyro investment allocator
Wants=network-online.target
After=network-online.target
OnFailure=gyro-failure@%n.service
[Service]
Type=oneshot
User={{ gyro_user }}
Group={{ gyro_group }}
WorkingDirectory={{ gyro_app_dir }}
EnvironmentFile={{ gyro_config_dir }}/gyro.env
Environment=UV_CACHE_DIR={{ gyro_cache_dir }}/uv
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStart=/usr/local/bin/uv run --frozen --no-sync python main.py
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectClock=true
ProtectControlGroups=true
ProtectHome=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectSystem=strict
ReadWritePaths={{ gyro_cache_dir }}
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
@@ -0,0 +1,10 @@
[Unit]
Description=Run Gyro investment allocator on weekdays
[Timer]
OnCalendar={{ gyro_timer_on_calendar }}
Persistent=true
Unit=gyro.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,3 @@
---
monitoring_blackbox_listen_address: 127.0.0.1:9115
monitoring_pushgateway_url: http://192.168.1.30:9091
@@ -0,0 +1,9 @@
---
- name: reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: restart blackbox exporter
ansible.builtin.systemd:
name: prometheus-blackbox-exporter
state: restarted
@@ -0,0 +1,96 @@
---
- name: Install Blackbox Exporter
ansible.builtin.apt:
name:
- curl
- prometheus-blackbox-exporter
state: present
update_cache: true
- name: Install external probe configuration
ansible.builtin.template:
src: blackbox.yml.j2
dest: /etc/prometheus/blackbox.yml
owner: root
group: root
mode: "0644"
notify: restart blackbox exporter
- name: Create Blackbox Exporter systemd override directory
ansible.builtin.file:
path: /etc/systemd/system/prometheus-blackbox-exporter.service.d
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Blackbox Exporter listen address
ansible.builtin.copy:
dest: /etc/systemd/system/prometheus-blackbox-exporter.service.d/override.conf
owner: root
group: root
mode: "0644"
content: |
[Service]
ExecStart=
ExecStart=/usr/bin/prometheus-blackbox-exporter --config.file=/etc/prometheus/blackbox.yml --web.listen-address={{ monitoring_blackbox_listen_address }}
notify:
- reload systemd
- restart blackbox exporter
- name: Install ru-vps metrics push script
ansible.builtin.template:
src: push-metrics.sh.j2
dest: /usr/local/sbin/homelab-monitoring-push
owner: root
group: root
mode: "0755"
- name: Install ru-vps metrics push service
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-monitoring-push.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Push ru-vps monitoring metrics through OpenVPN
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/homelab-monitoring-push
notify: reload systemd
- name: Install ru-vps metrics push timer
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-monitoring-push.timer
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Run ru-vps monitoring metric push every minute
[Timer]
OnBootSec=2m
OnUnitActiveSec=1m
Persistent=true
[Install]
WantedBy=timers.target
notify: reload systemd
- name: Ensure Blackbox Exporter is enabled and running
ansible.builtin.systemd:
name: prometheus-blackbox-exporter
enabled: true
state: started
- name: Enable ru-vps metrics push timer
ansible.builtin.systemd:
name: homelab-monitoring-push.timer
daemon_reload: true
enabled: true
state: started
@@ -0,0 +1,13 @@
modules:
http_2xx:
prober: http
timeout: 10s
http:
preferred_ip_protocol: ip4
valid_status_codes: [200, 301, 302]
tcp_connect:
prober: tcp
timeout: 10s
icmp:
prober: icmp
timeout: 10s
@@ -0,0 +1,37 @@
#!/bin/sh
# Managed by Ansible. Push local ru-vps metrics through the OpenVPN route.
set -eu
PUSHGATEWAY_URL="{{ monitoring_pushgateway_url }}"
NOW=$(date +%s)
NODE_METRICS=$(mktemp)
trap 'rm -f "$NODE_METRICS"' EXIT
push_stdin() {
path="$1"
curl --fail --silent --show-error --data-binary @- "$PUSHGATEWAY_URL/metrics/$path"
}
curl --fail --silent --show-error http://127.0.0.1:9100/metrics > "$NODE_METRICS"
push_stdin "job/node/instance/ru-vps" < "$NODE_METRICS"
probe() {
name="$1"
module="$2"
target="$3"
payload=$(curl --fail --silent --show-error --get \
--data-urlencode "module=$module" \
--data-urlencode "target=$target" \
http://127.0.0.1:9115/probe || printf 'probe_success 0\n')
payload=$(printf '%s\nhomelab_vps_probe_timestamp_seconds %s\n' "$payload" "$NOW")
printf '%s\n' "$payload" | push_stdin "job/blackbox-vps/probe_name/$name"
}
probe vaultwarden http_2xx https://pass.ada-dev.ru
probe gitea http_2xx https://git.ada-dev.ru
probe grimmory http_2xx https://books.ada-dev.ru/api/v1/healthcheck
probe ovpn_gateway tcp_connect 10.78.0.2:22
probe cloud_pve tcp_connect 192.168.1.5:8006
probe mini_pve tcp_connect 192.168.1.10:8006
probe pbs tcp_connect 192.168.1.20:8007
@@ -0,0 +1,6 @@
---
monitoring_exporter_install_node: true
monitoring_exporter_install_smart: false
monitoring_exporter_textfile_dir: /var/lib/node_exporter/textfile_collector
monitoring_exporter_node_listen_address: 0.0.0.0:9100
monitoring_exporter_smartctl_timer: "*:0/15"
@@ -0,0 +1,9 @@
---
- name: reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: restart node exporter
ansible.builtin.systemd:
name: prometheus-node-exporter
state: restarted
@@ -0,0 +1,121 @@
---
- name: Install Node Exporter
ansible.builtin.apt:
name: prometheus-node-exporter
state: present
when: monitoring_exporter_install_node
- name: Create Node Exporter textfile directory
ansible.builtin.file:
path: "{{ monitoring_exporter_textfile_dir }}"
state: directory
owner: root
group: root
mode: "0755"
when: monitoring_exporter_install_node
- name: Create Node Exporter systemd override directory
ansible.builtin.file:
path: /etc/systemd/system/prometheus-node-exporter.service.d
state: directory
owner: root
group: root
mode: "0755"
when: monitoring_exporter_install_node
- name: Configure Node Exporter collectors
ansible.builtin.copy:
dest: /etc/systemd/system/prometheus-node-exporter.service.d/override.conf
owner: root
group: root
mode: "0644"
content: |
[Service]
ExecStart=
ExecStart=/usr/bin/prometheus-node-exporter --web.listen-address={{ monitoring_exporter_node_listen_address }} --collector.systemd --collector.textfile.directory={{ monitoring_exporter_textfile_dir }}
when: monitoring_exporter_install_node
notify:
- reload systemd
- restart node exporter
- name: Ensure Node Exporter is enabled and running
ansible.builtin.systemd:
name: prometheus-node-exporter
enabled: true
state: started
when: monitoring_exporter_install_node
- name: Install SMART Exporter dependencies
ansible.builtin.apt:
name:
- jq
- smartmontools
state: present
when: monitoring_exporter_install_smart
- name: Remove obsolete SMART Exporter container unit
ansible.builtin.systemd:
name: homelab-smartctl-exporter
enabled: false
state: stopped
failed_when: false
when: monitoring_exporter_install_smart
- name: Install SMART metric collection script
ansible.builtin.template:
src: smartctl-metrics.sh.j2
dest: /usr/local/sbin/homelab-smartctl-metrics
owner: root
group: root
mode: "0755"
when: monitoring_exporter_install_smart
- name: Install SMART metric collection service
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-smartctl-metrics.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Publish SMART metrics for Node Exporter
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/homelab-smartctl-metrics
when: monitoring_exporter_install_smart
notify: reload systemd
- name: Install SMART metric collection timer
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-smartctl-metrics.timer
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Collect SMART metrics every 15 minutes
[Timer]
OnBootSec=2m
OnUnitActiveSec=15m
Persistent=true
[Install]
WantedBy=multi-user.target
when: monitoring_exporter_install_smart
notify: reload systemd
- name: Enable SMART metric collection timer
ansible.builtin.systemd:
name: homelab-smartctl-metrics.timer
daemon_reload: true
enabled: true
state: started
when: monitoring_exporter_install_smart
- name: Run initial SMART metric collection
ansible.builtin.systemd:
name: homelab-smartctl-metrics.service
state: started
when: monitoring_exporter_install_smart
@@ -0,0 +1,30 @@
#!/bin/sh
# Managed by Ansible. Export SMART health to Node Exporter's textfile collector.
set -eu
METRICS_DIR="{{ monitoring_exporter_textfile_dir }}"
METRICS_FILE="$METRICS_DIR/homelab_smartctl.prom"
METRICS_TMP=$(mktemp "$METRICS_FILE.XXXXXX")
trap 'rm -f "$METRICS_TMP"' EXIT
printf '# HELP homelab_smart_device_healthy SMART overall health (1 healthy, 0 failed)\n' >> "$METRICS_TMP"
printf '# TYPE homelab_smart_device_healthy gauge\n' >> "$METRICS_TMP"
lsblk -dn -o NAME,TYPE | while read -r name type; do
[ "$type" = "disk" ] || continue
device="/dev/$name"
json=$(smartctl -a -j "$device" 2>/dev/null || true)
[ -n "$json" ] || continue
healthy=$(printf '%s' "$json" | jq -r 'if .smart_status.passed == true then 1 else 0 end')
temperature=$(printf '%s' "$json" | jq -r '.temperature.current // .nvme_smart_health_information_log.temperature // empty')
nvme_warning=$(printf '%s' "$json" | jq -r '.nvme_smart_health_information_log.critical_warning // empty')
printf 'homelab_smart_device_healthy{device="%s"} %s\n' "$name" "$healthy" >> "$METRICS_TMP"
[ -z "$temperature" ] || printf 'homelab_smart_temperature_celsius{device="%s"} %s\n' "$name" "$temperature" >> "$METRICS_TMP"
[ -z "$nvme_warning" ] || printf 'homelab_smart_nvme_critical_warning{device="%s"} %s\n' "$name" "$nvme_warning" >> "$METRICS_TMP"
done
chmod 0644 "$METRICS_TMP"
mv "$METRICS_TMP" "$METRICS_FILE"
@@ -0,0 +1,14 @@
---
monitoring_root: /opt/monitoring
monitoring_prometheus_retention: 30d
monitoring_prometheus_retention_size: 8GB
monitoring_grafana_admin_user: admin
monitoring_grafana_admin_password: "{{ lookup('env', 'MONITORING_GRAFANA_ADMIN_PASSWORD') }}"
monitoring_telegram_bot_token: "{{ lookup('env', 'MONITORING_TELEGRAM_BOT_TOKEN') }}"
monitoring_telegram_chat_id: "{{ lookup('env', 'MONITORING_TELEGRAM_CHAT_ID') }}"
monitoring_pve_api_user: "{{ lookup('env', 'MONITORING_PVE_API_USER') }}"
monitoring_pve_api_token_id: "{{ lookup('env', 'MONITORING_PVE_API_TOKEN_ID') }}"
monitoring_pve_api_token_secret: "{{ lookup('env', 'MONITORING_PVE_API_TOKEN_SECRET') }}"
monitoring_lan_cidr: 192.168.1.0/24
monitoring_openvpn_gateway_ip: 192.168.1.23
monitoring_docker_cidr: 172.16.0.0/12
@@ -0,0 +1,26 @@
{
"title": "Backups",
"uid": "homelab-backups",
"schemaVersion": 39,
"version": 1,
"panels": [
{
"type": "table",
"title": "Audit Result",
"gridPos": {"h": 10, "w": 12, "x": 0, "y": 0},
"targets": [{"expr": "homelab_backup_audit_success", "format": "table", "instant": true, "refId": "A"}]
},
{
"type": "timeseries",
"title": "Backup Age (Hours)",
"gridPos": {"h": 10, "w": 12, "x": 12, "y": 0},
"targets": [{"expr": "homelab_backup_audit_snapshot_age_hours", "legendFormat": "{{profile}} {{vmid}}", "refId": "A"}]
},
{
"type": "timeseries",
"title": "Audit Levels",
"gridPos": {"h": 10, "w": 24, "x": 0, "y": 10},
"targets": [{"expr": "homelab_backup_audit_level_success", "legendFormat": "{{profile}} {{level}}", "refId": "A"}]
}
]
}
@@ -0,0 +1,34 @@
{
"title": "HomeLab Overview",
"uid": "homelab-overview",
"schemaVersion": 39,
"version": 1,
"panels": [
{
"type": "stat",
"title": "Active Alerts",
"gridPos": {"h": 8, "w": 6, "x": 0, "y": 0},
"targets": [{"expr": "count(ALERTS{alertstate=\"firing\"})", "refId": "A"}],
"options": {"reduceOptions": {"calcs": ["lastNotNull"]}}
},
{
"type": "stat",
"title": "Successful Probes",
"gridPos": {"h": 8, "w": 6, "x": 6, "y": 0},
"targets": [{"expr": "sum(probe_success)", "refId": "A"}],
"options": {"reduceOptions": {"calcs": ["lastNotNull"]}}
},
{
"type": "timeseries",
"title": "Filesystem Free Space",
"gridPos": {"h": 10, "w": 12, "x": 0, "y": 8},
"targets": [{"expr": "node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay\"}", "legendFormat": "{{instance}} {{mountpoint}}", "refId": "A"}]
},
{
"type": "timeseries",
"title": "Backup Audit Success",
"gridPos": {"h": 10, "w": 12, "x": 12, "y": 8},
"targets": [{"expr": "homelab_backup_audit_success", "legendFormat": "{{profile}} {{instance}}", "refId": "A"}]
}
]
}
@@ -0,0 +1,26 @@
{
"title": "Proxmox and Storage",
"uid": "proxmox-storage",
"schemaVersion": 39,
"version": 1,
"panels": [
{
"type": "timeseries",
"title": "Node CPU Usage",
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 0},
"targets": [{"expr": "1 - avg by(instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))", "legendFormat": "{{instance}}", "refId": "A"}]
},
{
"type": "timeseries",
"title": "Node Memory Available",
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 0},
"targets": [{"expr": "node_memory_MemAvailable_bytes", "legendFormat": "{{instance}}", "refId": "A"}]
},
{
"type": "timeseries",
"title": "Disk Read and Write",
"gridPos": {"h": 9, "w": 24, "x": 0, "y": 9},
"targets": [{"expr": "rate(node_disk_read_bytes_total[5m])", "legendFormat": "read {{instance}} {{device}}", "refId": "A"}, {"expr": "rate(node_disk_written_bytes_total[5m])", "legendFormat": "write {{instance}} {{device}}", "refId": "B"}]
}
]
}
@@ -0,0 +1,26 @@
{
"title": "Services and Network",
"uid": "services-network",
"schemaVersion": 39,
"version": 1,
"panels": [
{
"type": "table",
"title": "Probe State",
"gridPos": {"h": 10, "w": 12, "x": 0, "y": 0},
"targets": [{"expr": "probe_success", "format": "table", "instant": true, "refId": "A"}]
},
{
"type": "timeseries",
"title": "Probe Duration",
"gridPos": {"h": 10, "w": 12, "x": 12, "y": 0},
"targets": [{"expr": "probe_duration_seconds", "legendFormat": "{{job}} {{instance}}", "refId": "A"}]
},
{
"type": "timeseries",
"title": "TLS Certificate Remaining",
"gridPos": {"h": 10, "w": 24, "x": 0, "y": 10},
"targets": [{"expr": "(probe_ssl_earliest_cert_expiry - time()) / 86400", "legendFormat": "{{instance}}", "refId": "A"}]
}
]
}
@@ -0,0 +1,11 @@
apiVersion: 1
providers:
- name: HomeLab
orgId: 1
folder: HomeLab
type: file
disableDeletion: true
editable: false
options:
path: /etc/grafana/dashboards
@@ -0,0 +1,10 @@
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
@@ -0,0 +1,9 @@
---
- name: reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: restart monitoring stack
ansible.builtin.systemd:
name: homelab-monitoring
state: restarted
@@ -0,0 +1,233 @@
---
- name: Validate monitoring secrets
ansible.builtin.assert:
that:
- monitoring_telegram_bot_token | length > 0
- monitoring_telegram_bot_token != 'replace-me'
- monitoring_telegram_chat_id | length > 0
- monitoring_telegram_chat_id != 'replace-me'
- monitoring_pve_api_user | length > 0
- monitoring_pve_api_token_id | length > 0
- monitoring_pve_api_token_secret | length > 0
- monitoring_pve_api_token_secret != 'replace-me'
- monitoring_grafana_admin_password | length > 0
- monitoring_grafana_admin_password != 'change-me'
fail_msg: Load monitoring secrets from ignored ansible/.env or Ansible Vault before running this playbook.
no_log: true
- name: Install monitoring runtime packages
ansible.builtin.apt:
name:
- ca-certificates
- docker.io
- docker-compose
- ufw
state: present
update_cache: true
- name: Ensure Docker is enabled and running
ansible.builtin.systemd:
name: docker
enabled: true
state: started
- name: Allow SSH and Grafana access from the LAN
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
src: "{{ monitoring_lan_cidr }}"
loop:
- "22"
- "3000"
- name: Allow Pushgateway only from the OpenVPN gateway
community.general.ufw:
rule: allow
port: "9091"
proto: tcp
src: "{{ monitoring_openvpn_gateway_ip }}"
- name: Allow local Prometheus container to scrape Node Exporter
community.general.ufw:
rule: allow
port: "9100"
proto: tcp
src: "{{ monitoring_docker_cidr }}"
- name: Enable monitoring LXC firewall
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Create monitoring directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- "{{ monitoring_root }}"
- "{{ monitoring_root }}/prometheus"
- "{{ monitoring_root }}/alertmanager"
- "{{ monitoring_root }}/grafana"
- "{{ monitoring_root }}/grafana/provisioning"
- "{{ monitoring_root }}/grafana/provisioning/dashboards"
- "{{ monitoring_root }}/grafana/provisioning/datasources"
- "{{ monitoring_root }}/grafana/dashboards"
- "{{ monitoring_root }}/pve-exporter"
- name: Grant Prometheus access to its data directory
ansible.builtin.file:
path: "{{ monitoring_root }}/prometheus/data"
state: directory
owner: "65534"
group: "65534"
mode: "0755"
- name: Install monitoring compose definition
ansible.builtin.template:
src: compose.yml.j2
dest: "{{ monitoring_root }}/compose.yml"
owner: root
group: root
mode: "0600"
no_log: true
notify: restart monitoring stack
- name: Install Prometheus configuration
ansible.builtin.template:
src: prometheus.yml.j2
dest: "{{ monitoring_root }}/prometheus/prometheus.yml"
owner: root
group: root
mode: "0644"
notify: restart monitoring stack
- name: Install Prometheus alert rules
ansible.builtin.template:
src: alerts.yml.j2
dest: "{{ monitoring_root }}/prometheus/alerts.yml"
owner: root
group: root
mode: "0644"
notify: restart monitoring stack
- name: Install central Blackbox configuration
ansible.builtin.template:
src: blackbox.yml.j2
dest: "{{ monitoring_root }}/blackbox.yml"
owner: root
group: root
mode: "0644"
notify: restart monitoring stack
- name: Install Alertmanager configuration
ansible.builtin.template:
src: alertmanager.yml.j2
dest: "{{ monitoring_root }}/alertmanager/alertmanager.yml"
owner: root
group: root
mode: "0640"
no_log: true
notify: restart monitoring stack
- name: Install PVE exporter configuration
ansible.builtin.template:
src: pve.yml.j2
dest: "{{ monitoring_root }}/pve-exporter/pve.yml"
owner: root
group: root
mode: "0600"
no_log: true
notify: restart monitoring stack
- name: Provision Grafana datasource
ansible.builtin.copy:
src: grafana-datasource.yml
dest: "{{ monitoring_root }}/grafana/provisioning/datasources/prometheus.yml"
owner: root
group: root
mode: "0644"
notify: restart monitoring stack
- name: Provision Grafana dashboard provider
ansible.builtin.copy:
src: grafana-dashboard-provider.yml
dest: "{{ monitoring_root }}/grafana/provisioning/dashboards/homelab.yml"
owner: root
group: root
mode: "0644"
notify: restart monitoring stack
- name: Install Grafana dashboards
ansible.builtin.copy:
src: "dashboards/{{ item }}"
dest: "{{ monitoring_root }}/grafana/dashboards/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- homelab-overview.json
- proxmox-storage.json
- services-network.json
- backups.json
notify: restart monitoring stack
- name: Validate Docker Compose configuration
ansible.builtin.command: >-
docker-compose -f {{ monitoring_root }}/compose.yml config --quiet
changed_when: false
no_log: true
- name: Validate Prometheus configuration
ansible.builtin.command: >-
docker run --rm --network none
-v {{ monitoring_root }}/prometheus:/etc/prometheus:ro
--entrypoint promtool prom/prometheus:v3.2.1
check config /etc/prometheus/prometheus.yml
changed_when: false
- name: Validate Alertmanager configuration
ansible.builtin.command: >-
docker run --rm --network none
-v {{ monitoring_root }}/alertmanager:/etc/alertmanager:ro
--user 0:0
--entrypoint amtool prom/alertmanager:v0.28.0
check-config /etc/alertmanager/alertmanager.yml
changed_when: false
no_log: true
- name: Install monitoring systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-monitoring.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=HomeLab monitoring stack
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory={{ monitoring_root }}
ExecStart=/usr/bin/docker-compose -f {{ monitoring_root }}/compose.yml up -d --remove-orphans
ExecStop=/usr/bin/docker-compose -f {{ monitoring_root }}/compose.yml down
[Install]
WantedBy=multi-user.target
notify:
- reload systemd
- restart monitoring stack
- name: Enable monitoring stack
ansible.builtin.systemd:
name: homelab-monitoring
daemon_reload: true
enabled: true
state: started
@@ -0,0 +1,40 @@
global:
resolve_timeout: 5m
route:
receiver: telegram-warning
group_by: [alertname, instance]
group_wait: 30s
group_interval: 10m
repeat_interval: 6h
routes:
- matchers:
- severity="critical"
receiver: telegram-critical
repeat_interval: 2h
inhibit_rules:
- source_matchers:
- alertname="ExporterDown"
target_matchers:
- alertname="LanServiceUnavailable"
equal: [instance]
receivers:
- name: telegram-warning
telegram_configs:
- bot_token: "{{ monitoring_telegram_bot_token }}"
chat_id: {{ monitoring_telegram_chat_id }}
send_resolved: true
message: |-
[{{ "{{" }} .Status | toUpper {{ "}}" }}] {{ "{{" }} .CommonLabels.alertname {{ "}}" }}
{{ "{{" }} .CommonAnnotations.summary {{ "}}" }}
- name: telegram-critical
telegram_configs:
- bot_token: "{{ monitoring_telegram_bot_token }}"
chat_id: {{ monitoring_telegram_chat_id }}
send_resolved: true
message: |-
[{{ "{{" }} .Status | toUpper {{ "}}" }}] CRITICAL: {{ "{{" }} .CommonLabels.alertname {{ "}}" }}
{{ "{{" }} .CommonAnnotations.summary {{ "}}" }}
@@ -0,0 +1,134 @@
groups:
- name: homelab-availability
rules:
- alert: ExporterDown
expr: up{job=~"node|smartctl|pve"} == 0
for: 10m
labels:
severity: warning
annotations:
summary: "Exporter unavailable: {{ '{{' }} $labels.job {{ '}}' }} {{ '{{' }} $labels.instance {{ '}}' }}"
- alert: CriticalServiceUnavailable
expr: probe_success{job="blackbox-vps"} == 0
for: 5m
labels:
severity: critical
annotations:
summary: "External or VPN check failed: {{ '{{' }} $labels.probe_name {{ '}}' }}"
- alert: RuVpsMonitoringStale
expr: time() - homelab_vps_probe_timestamp_seconds > 5 * 60
for: 5m
labels:
severity: critical
annotations:
summary: "ru-vps has not pushed monitoring metrics through OpenVPN"
- alert: RuVpsMonitoringMissing
expr: absent(homelab_vps_probe_timestamp_seconds{job="blackbox-vps"})
for: 10m
labels:
severity: critical
annotations:
summary: "ru-vps has no monitoring metrics in Pushgateway"
- alert: LanServiceUnavailable
expr: probe_success{job=~"blackbox-lan-http|blackbox-lan-tcp"} == 0
for: 10m
labels:
severity: warning
annotations:
summary: "LAN service unavailable: {{ '{{' }} $labels.instance {{ '}}' }}"
- alert: TLSCertificateExpiringSoon
expr: (probe_ssl_earliest_cert_expiry - time()) < 14 * 24 * 3600
for: 1h
labels:
severity: warning
annotations:
summary: "TLS certificate expires within 14 days: {{ '{{' }} $labels.instance {{ '}}' }}"
- name: homelab-capacity
rules:
- alert: FilesystemAlmostFull
expr: (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) < 0.15
for: 15m
labels:
severity: warning
annotations:
summary: "Filesystem is more than 85% full: {{ '{{' }} $labels.instance {{ '}}' }} {{ '{{' }} $labels.mountpoint {{ '}}' }}"
- alert: FilesystemWillFillSoon
expr: predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 7 * 24 * 3600) < 0
for: 30m
labels:
severity: warning
annotations:
summary: "Filesystem may fill within seven days: {{ '{{' }} $labels.instance {{ '}}' }} {{ '{{' }} $labels.mountpoint {{ '}}' }}"
- alert: HostMemoryPressure
expr: (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) < 0.1
for: 20m
labels:
severity: warning
annotations:
summary: "Memory pressure on {{ '{{' }} $labels.instance {{ '}}' }}"
- alert: SMARTDeviceFailed
expr: homelab_smart_device_healthy == 0
for: 5m
labels:
severity: critical
annotations:
summary: "SMART health check failed: {{ '{{' }} $labels.instance {{ '}}' }} {{ '{{' }} $labels.device {{ '}}' }}"
- alert: NVMECriticalWarning
expr: homelab_smart_nvme_critical_warning > 0
for: 5m
labels:
severity: critical
annotations:
summary: "NVMe critical warning: {{ '{{' }} $labels.instance {{ '}}' }} {{ '{{' }} $labels.device {{ '}}' }}"
- name: homelab-backups
rules:
- alert: BackupAuditFailed
expr: homelab_backup_audit_success == 0
for: 5m
labels:
severity: critical
annotations:
summary: "Backup audit failed: {{ '{{' }} $labels.profile {{ '}}' }} on {{ '{{' }} $labels.instance {{ '}}' }}"
- alert: BackupAuditStale
expr: time() - homelab_backup_audit_timestamp_seconds > 48 * 3600
for: 15m
labels:
severity: critical
annotations:
summary: "Backup audit result is stale: {{ '{{' }} $labels.profile {{ '}}' }} on {{ '{{' }} $labels.instance {{ '}}' }}"
- alert: BackupAuditMissing
expr: absent(homelab_backup_audit_timestamp_seconds{profile="pbs"}) or absent(homelab_backup_audit_timestamp_seconds{profile="gitea"}) or absent(homelab_backup_audit_timestamp_seconds{profile="vaultwarden"}) or absent(homelab_backup_audit_timestamp_seconds{profile="grimmory"})
for: 12h
labels:
severity: critical
annotations:
summary: "Expected backup audit metric has not been published"
- alert: ResticBackupTooOld
expr: homelab_backup_audit_snapshot_age_hours{profile=~"gitea|vaultwarden|grimmory"} > 36
for: 15m
labels:
severity: critical
annotations:
summary: "Restic backup is older than 36 hours: {{ '{{' }} $labels.profile {{ '}}' }}"
- alert: PBSBackupTooOld
expr: homelab_backup_audit_snapshot_age_hours{profile="pbs"} > 48
for: 15m
labels:
severity: critical
annotations:
summary: "PBS backup is older than 48 hours: VMID {{ '{{' }} $labels.vmid {{ '}}' }}"
@@ -0,0 +1,10 @@
modules:
http_2xx:
prober: http
timeout: 10s
http:
preferred_ip_protocol: ip4
valid_status_codes: [200, 301, 302]
tcp_connect:
prober: tcp
timeout: 10s
@@ -0,0 +1,63 @@
services:
prometheus:
image: prom/prometheus:v3.2.1
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time={{ monitoring_prometheus_retention }}
- --storage.tsdb.retention.size={{ monitoring_prometheus_retention_size }}
- --web.enable-lifecycle
volumes:
- ./prometheus:/etc/prometheus:ro
- ./prometheus/data:/prometheus
restart: unless-stopped
alertmanager:
image: prom/alertmanager:v0.28.0
user: "0:0"
command:
- --config.file=/etc/alertmanager/alertmanager.yml
- --storage.path=/alertmanager
volumes:
- ./alertmanager:/etc/alertmanager:ro
restart: unless-stopped
grafana:
image: grafana/grafana:11.5.1
environment:
GF_SECURITY_ADMIN_USER: {{ monitoring_grafana_admin_user | to_json }}
GF_SECURITY_ADMIN_PASSWORD: {{ monitoring_grafana_admin_password | to_json }}
GF_USERS_ALLOW_SIGN_UP: "false"
ports:
- "192.168.1.30:3000:3000"
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/etc/grafana/dashboards:ro
- grafana-data:/var/lib/grafana
restart: unless-stopped
blackbox-exporter:
image: prom/blackbox-exporter:v0.25.0
command:
- --config.file=/config/blackbox.yml
volumes:
- ./blackbox.yml:/config/blackbox.yml:ro
restart: unless-stopped
pve-exporter:
image: prompve/prometheus-pve-exporter:3.5.5
user: "0:0"
command:
- --config.file=/etc/pve-exporter/pve.yml
volumes:
- ./pve-exporter/pve.yml:/etc/pve-exporter/pve.yml:ro
restart: unless-stopped
pushgateway:
image: prom/pushgateway:v1.11.0
ports:
- "192.168.1.30:9091:9091"
restart: unless-stopped
volumes:
grafana-data:
@@ -0,0 +1,94 @@
global:
scrape_interval: 30s
evaluation_interval: 30s
alerting:
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
rule_files:
- /etc/prometheus/alerts.yml
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
- job_name: node
static_configs:
- targets:
- "192.168.1.5:9100"
- "192.168.1.10:9100"
- "192.168.1.20:9100"
- "192.168.1.23:9100"
- "192.168.1.24:9100"
- "192.168.1.25:9100"
- "192.168.1.26:9100"
- "192.168.1.27:9100"
- "192.168.1.28:9100"
- "192.168.1.30:9100"
- "192.168.1.34:9100"
- job_name: pve
metrics_path: /pve
params:
module: [default]
static_configs:
- targets:
- "192.168.1.5"
- "192.168.1.10"
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: pve-exporter:9221
- job_name: blackbox-lan-http
metrics_path: /probe
params:
module: [http_2xx]
static_configs:
- targets:
- "http://192.168.1.24"
- "http://192.168.1.25:3000"
- "http://192.168.1.27:8080"
- "http://192.168.1.28"
- "http://192.168.1.34:6060/api/v1/healthcheck"
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
- target_label: probe_location
replacement: lan
- job_name: blackbox-lan-tcp
metrics_path: /probe
params:
module: [tcp_connect]
static_configs:
- targets:
- "192.168.1.5:8006"
- "192.168.1.10:8006"
- "192.168.1.20:8007"
- "192.168.1.25:2222"
- "192.168.1.27:9090"
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
- target_label: probe_location
replacement: lan
- job_name: pushgateway
honor_labels: true
static_configs:
- targets: ["pushgateway:9091"]
@@ -0,0 +1,5 @@
default:
user: "{{ monitoring_pve_api_user }}"
token_name: "{{ monitoring_pve_api_token_id }}"
token_value: "{{ monitoring_pve_api_token_secret }}"
verify_ssl: false
@@ -7,6 +7,9 @@ openvpn_config_dir: /etc/openvpn/homelab
openvpn_config_path: /etc/openvpn/homelab/homelab.conf
openvpn_static_key_path: /etc/openvpn/homelab/static.key
openvpn_service_name: homelab-openvpn
openvpn_public_interface: "{{ ansible_facts['default_ipv4']['interface'] }}"
openvpn_lan_interface: eth0
openvpn_forwarded_services: []
openvpn_home_routes:
- network: 192.168.1.5
netmask: 255.255.255.255
+6 -6
View File
@@ -41,13 +41,13 @@
no_log: true
notify: restart openvpn
- name: Enable IPv4 forwarding on gateway
- name: Enable IPv4 forwarding for routed traffic
ansible.posix.sysctl:
name: net.ipv4.ip_forward
value: "1"
state: present
reload: true
when: openvpn_role == 'gateway'
when: openvpn_role == 'gateway' or openvpn_forwarded_services | length > 0
- name: Allow OpenVPN TCP in UFW on server
ansible.builtin.command: "ufw allow {{ openvpn_listen_port }}/tcp"
@@ -65,7 +65,7 @@
mode: "0600"
notify: restart openvpn
- name: Render OpenVPN gateway up script
- name: Render OpenVPN up script
ansible.builtin.template:
src: up.sh.j2
dest: "{{ openvpn_config_dir }}/up.sh"
@@ -73,9 +73,9 @@
group: root
mode: "0700"
notify: restart openvpn
when: openvpn_role == 'gateway'
when: openvpn_role == 'gateway' or openvpn_forwarded_services | length > 0
- name: Render OpenVPN gateway down script
- name: Render OpenVPN down script
ansible.builtin.template:
src: down.sh.j2
dest: "{{ openvpn_config_dir }}/down.sh"
@@ -83,7 +83,7 @@
group: root
mode: "0700"
notify: restart openvpn
when: openvpn_role == 'gateway'
when: openvpn_role == 'gateway' or openvpn_forwarded_services | length > 0
- name: Render OpenVPN systemd service
ansible.builtin.template:
@@ -1,2 +1,15 @@
#!/bin/sh
iptables -t nat -D POSTROUTING -s {{ openvpn_network_cidr }} -d {{ homelab_lan_cidr }} -o eth0 -j MASQUERADE 2>/dev/null || true
{% if openvpn_role == 'gateway' %}
iptables -t nat -D POSTROUTING -s {{ openvpn_network_cidr }} -d {{ homelab_lan_cidr }} -o {{ openvpn_lan_interface }} -j MASQUERADE 2>/dev/null || true
{% for service in openvpn_forwarded_services %}
iptables -D FORWARD -i {{ openvpn_interface }} -o {{ openvpn_lan_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT 2>/dev/null || true
iptables -D FORWARD -i {{ openvpn_lan_interface }} -o {{ openvpn_interface }} -p {{ service.protocol }} -s {{ service.target_host }} --sport {{ service.target_port }} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true
{% endfor %}
{% else %}
{% for service in openvpn_forwarded_services %}
iptables -t nat -D PREROUTING -i {{ openvpn_public_interface }} -p {{ service.protocol }} --dport {{ service.public_port }} -j DNAT --to-destination {{ service.target_host }}:{{ service.target_port }} 2>/dev/null || true
iptables -t nat -D POSTROUTING -o {{ openvpn_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -j SNAT --to-source {{ openvpn_local_ip }} 2>/dev/null || true
iptables -D FORWARD -i {{ openvpn_public_interface }} -o {{ openvpn_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT 2>/dev/null || true
iptables -D FORWARD -i {{ openvpn_interface }} -o {{ openvpn_public_interface }} -p {{ service.protocol }} -s {{ service.target_host }} --sport {{ service.target_port }} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true
{% endfor %}
{% endif %}
@@ -13,6 +13,14 @@ port {{ openvpn_listen_port }}
{% for route in openvpn_home_routes %}
route {{ route.network }} {{ route.netmask }}
{% endfor %}
{% for service in openvpn_forwarded_services %}
route {{ service.target_host }} 255.255.255.255
{% endfor %}
{% if openvpn_forwarded_services | length > 0 %}
script-security 2
up /etc/openvpn/homelab/up.sh
down /etc/openvpn/homelab/down.sh
{% endif %}
{% endif %}
{% if openvpn_role == 'gateway' %}
proto {{ openvpn_proto_client }}
@@ -1,3 +1,23 @@
#!/bin/sh
iptables -t nat -C POSTROUTING -s {{ openvpn_network_cidr }} -d {{ homelab_lan_cidr }} -o eth0 -j MASQUERADE 2>/dev/null || \
iptables -t nat -A POSTROUTING -s {{ openvpn_network_cidr }} -d {{ homelab_lan_cidr }} -o eth0 -j MASQUERADE
sysctl -w net.ipv4.ip_forward=1 >/dev/null
{% if openvpn_role == 'gateway' %}
iptables -t nat -C POSTROUTING -s {{ openvpn_network_cidr }} -d {{ homelab_lan_cidr }} -o {{ openvpn_lan_interface }} -j MASQUERADE 2>/dev/null || \
iptables -t nat -A POSTROUTING -s {{ openvpn_network_cidr }} -d {{ homelab_lan_cidr }} -o {{ openvpn_lan_interface }} -j MASQUERADE
{% for service in openvpn_forwarded_services %}
iptables -C FORWARD -i {{ openvpn_interface }} -o {{ openvpn_lan_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT 2>/dev/null || \
iptables -I FORWARD 1 -i {{ openvpn_interface }} -o {{ openvpn_lan_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT
iptables -C FORWARD -i {{ openvpn_lan_interface }} -o {{ openvpn_interface }} -p {{ service.protocol }} -s {{ service.target_host }} --sport {{ service.target_port }} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || \
iptables -I FORWARD 1 -i {{ openvpn_lan_interface }} -o {{ openvpn_interface }} -p {{ service.protocol }} -s {{ service.target_host }} --sport {{ service.target_port }} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
{% endfor %}
{% else %}
{% for service in openvpn_forwarded_services %}
iptables -t nat -C PREROUTING -i {{ openvpn_public_interface }} -p {{ service.protocol }} --dport {{ service.public_port }} -j DNAT --to-destination {{ service.target_host }}:{{ service.target_port }} 2>/dev/null || \
iptables -t nat -A PREROUTING -i {{ openvpn_public_interface }} -p {{ service.protocol }} --dport {{ service.public_port }} -j DNAT --to-destination {{ service.target_host }}:{{ service.target_port }}
iptables -t nat -C POSTROUTING -o {{ openvpn_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -j SNAT --to-source {{ openvpn_local_ip }} 2>/dev/null || \
iptables -t nat -A POSTROUTING -o {{ openvpn_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -j SNAT --to-source {{ openvpn_local_ip }}
iptables -C FORWARD -i {{ openvpn_public_interface }} -o {{ openvpn_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT 2>/dev/null || \
iptables -I FORWARD 1 -i {{ openvpn_public_interface }} -o {{ openvpn_interface }} -p {{ service.protocol }} -d {{ service.target_host }} --dport {{ service.target_port }} -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT
iptables -C FORWARD -i {{ openvpn_interface }} -o {{ openvpn_public_interface }} -p {{ service.protocol }} -s {{ service.target_host }} --sport {{ service.target_port }} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || \
iptables -I FORWARD 1 -i {{ openvpn_interface }} -o {{ openvpn_public_interface }} -p {{ service.protocol }} -s {{ service.target_host }} --sport {{ service.target_port }} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
{% endfor %}
{% endif %}
+3 -1
View File
@@ -1,7 +1,7 @@
---
pve_lxc_vmid: 132
pve_lxc_node: mini-pc
pve_lxc_hostname: wg-mini
pve_lxc_hostname: ovpn-mini
pve_lxc_ip: 192.168.1.23/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_bridge: vmbr0
@@ -13,8 +13,10 @@ pve_lxc_swap: 128
pve_lxc_onboot: true
pve_lxc_startup: order=30
pve_lxc_unprivileged: true
pve_lxc_update: true
pve_lxc_features:
- nesting=1
pve_lxc_nameserver: 1.1.1.1
pve_lxc_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
pve_lxc_cmode: shell
pve_lxc_api_port: "{{ lookup('env', 'PROXMOX_PORT') | default('8006', true) | int }}"
+5 -1
View File
@@ -11,6 +11,7 @@
- name: Create or update LXC container
community.proxmox.proxmox:
api_host: "{{ lookup('env', 'PROXMOX_HOST') }}"
api_port: "{{ pve_lxc_api_port }}"
api_user: "{{ lookup('env', 'PROXMOX_USER') }}"
api_token_id: "{{ lookup('env', 'PROXMOX_TOKEN_ID') }}"
api_token_secret: "{{ lookup('env', 'PROXMOX_TOKEN_SECRET') }}"
@@ -32,15 +33,18 @@
unprivileged: "{{ pve_lxc_unprivileged }}"
onboot: "{{ pve_lxc_onboot }}"
startup: "{{ pve_lxc_startup }}"
update: true
update: "{{ pve_lxc_update }}"
state: present
no_log: true
- name: Start LXC container
community.proxmox.proxmox:
api_host: "{{ lookup('env', 'PROXMOX_HOST') }}"
api_port: "{{ pve_lxc_api_port }}"
api_user: "{{ lookup('env', 'PROXMOX_USER') }}"
api_token_id: "{{ lookup('env', 'PROXMOX_TOKEN_ID') }}"
api_token_secret: "{{ lookup('env', 'PROXMOX_TOKEN_SECRET') }}"
validate_certs: "{{ lookup('env', 'PROXMOX_VALIDATE_CERTS') | default('false', true) | bool }}"
vmid: "{{ pve_lxc_vmid }}"
state: started
no_log: true
@@ -0,0 +1,9 @@
---
uptime_kuma_root: /opt/uptime-kuma
uptime_kuma_image: louislam/uptime-kuma:1.23.16@sha256:431fee3be822b04861cf0e35daf4beef6b7cb37391c5f26c3ad6e12ce280fe18
uptime_kuma_port: 3001
uptime_kuma_lan_cidr: 192.168.1.0/24
uptime_kuma_listen_address: "{{ expected_lan_ip }}"
uptime_kuma_openvpn_gateway_ip: 192.168.1.23
uptime_kuma_http_proxy: http://192.168.1.27:7890
uptime_kuma_no_proxy: "localhost,127.0.0.1,::1,ada-dev.ru,.ada-dev.ru,192.168.1.5,192.168.1.10,192.168.1.20,192.168.1.23,192.168.1.24,192.168.1.25,192.168.1.26,192.168.1.27,192.168.1.28,192.168.1.29,192.168.1.30,192.168.1.31,192.168.1.32,192.168.1.34,192.168.1.100"
@@ -0,0 +1,5 @@
---
- name: restart uptime kuma
ansible.builtin.systemd:
name: uptime-kuma
state: restarted
+159
View File
@@ -0,0 +1,159 @@
---
- name: Install Uptime Kuma runtime packages
ansible.builtin.apt:
name:
- ca-certificates
- docker.io
- docker-compose
- ufw
state: present
update_cache: true
- name: Ensure Docker is enabled and running
ansible.builtin.systemd:
name: docker
enabled: true
state: started
- name: Allow SSH and Uptime Kuma access from the LAN
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
src: "{{ uptime_kuma_lan_cidr }}"
loop:
- "22"
- "{{ uptime_kuma_port }}"
- name: Allow SSH from the OpenVPN gateway
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ uptime_kuma_openvpn_gateway_ip }}"
- name: Enable Uptime Kuma LXC firewall
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Create Uptime Kuma directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ uptime_kuma_root }}"
- "{{ uptime_kuma_root }}/data"
- name: Check if the configured Uptime Kuma image is present
ansible.builtin.command: "docker image inspect {{ uptime_kuma_image }}"
register: uptime_kuma_image_inspect
changed_when: false
failed_when: false
- name: Pull the configured Uptime Kuma image
ansible.builtin.command: "docker pull {{ uptime_kuma_image }}"
when: uptime_kuma_image_inspect.rc != 0
register: uptime_kuma_image_pull
changed_when: true
- name: Install Uptime Kuma compose definition
ansible.builtin.copy:
dest: "{{ uptime_kuma_root }}/compose.yml"
owner: root
group: root
mode: "0640"
content: |
services:
uptime-kuma:
image: {{ uptime_kuma_image }}
container_name: uptime-kuma
restart: unless-stopped
network_mode: host
environment:
HTTP_PROXY: {{ uptime_kuma_http_proxy }}
HTTPS_PROXY: {{ uptime_kuma_http_proxy }}
http_proxy: {{ uptime_kuma_http_proxy }}
https_proxy: {{ uptime_kuma_http_proxy }}
NO_PROXY: {{ uptime_kuma_no_proxy }}
no_proxy: {{ uptime_kuma_no_proxy }}
volumes:
- {{ uptime_kuma_root }}/data:/app/data
notify: restart uptime kuma
- name: Validate Uptime Kuma Compose configuration
ansible.builtin.command: >-
docker-compose -f {{ uptime_kuma_root }}/compose.yml config --quiet
changed_when: false
- name: Install Uptime Kuma systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/uptime-kuma.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Uptime Kuma
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory={{ uptime_kuma_root }}
ExecStart=/usr/bin/docker-compose -f {{ uptime_kuma_root }}/compose.yml up -d --remove-orphans
ExecStop=/usr/bin/docker-compose -f {{ uptime_kuma_root }}/compose.yml down
[Install]
WantedBy=multi-user.target
register: uptime_kuma_unit
notify: restart uptime kuma
- name: Reload systemd when the Uptime Kuma unit changes
ansible.builtin.systemd:
daemon_reload: true
when: uptime_kuma_unit.changed
- name: Enable and start Uptime Kuma
ansible.builtin.systemd:
name: uptime-kuma
enabled: true
state: "{{ 'restarted' if uptime_kuma_unit.changed or uptime_kuma_image_pull is changed else 'started' }}"
- name: Apply pending Uptime Kuma configuration changes
ansible.builtin.meta: flush_handlers
- name: Check Uptime Kuma web interface
ansible.builtin.uri:
url: "http://{{ uptime_kuma_listen_address }}:{{ uptime_kuma_port }}/"
status_code: 200
register: uptime_kuma_health
retries: 24
delay: 5
until: uptime_kuma_health.status == 200
- name: Freeze the legacy Prometheus monitoring stack after Uptime Kuma is healthy
ansible.builtin.systemd:
name: homelab-monitoring
enabled: false
state: stopped
- name: Remove legacy monitoring firewall rules
community.general.ufw:
rule: allow
port: "{{ item.port }}"
proto: tcp
src: "{{ item.src }}"
delete: true
loop:
- port: "3000"
src: "{{ uptime_kuma_lan_cidr }}"
- port: "9091"
src: "{{ uptime_kuma_openvpn_gateway_ip }}"
- port: "9100"
src: "172.16.0.0/12"
+219
View File
@@ -0,0 +1,219 @@
---
- name: Install offsite backup packages
ansible.builtin.apt:
name:
- restic
- rclone
- sqlite3
- ca-certificates
state: present
update_cache: true
- name: Ensure rclone config directory exists
ansible.builtin.file:
path: /root/.config/rclone
state: directory
owner: root
group: root
mode: "0700"
- name: Install rclone config
ansible.builtin.copy:
src: "{{ offsite_rclone_config_local }}"
dest: /root/.config/rclone/rclone.conf
owner: root
group: root
mode: "0600"
no_log: true
- name: Ensure restic config directory exists
ansible.builtin.file:
path: /etc/restic
state: directory
owner: root
group: root
mode: "0700"
- name: Install restic repository password
ansible.builtin.copy:
src: "{{ offsite_restic_password_local }}"
dest: "/etc/restic/{{ offsite_profile }}.password"
owner: root
group: root
mode: "0600"
no_log: true
- name: Ensure offsite backup directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0700"
loop:
- /etc/homelab-restic
- /var/lib/homelab-restic
- /var/log/homelab-restic
- name: Install restic exclude file
ansible.builtin.copy:
dest: "/etc/homelab-restic/{{ offsite_profile }}.exclude"
owner: root
group: root
mode: "0644"
content: |
{% for exclude in offsite_excludes %}
{{ exclude }}
{% endfor %}
- name: Install restic environment file
ansible.builtin.copy:
dest: "/etc/homelab-restic/{{ offsite_profile }}.env"
owner: root
group: root
mode: "0600"
content: |
RESTIC_REPOSITORY={{ offsite_repository }}
RESTIC_PASSWORD_FILE=/etc/restic/{{ offsite_profile }}.password
RCLONE_CONFIG=/root/.config/rclone/rclone.conf
HOMELAB_RESTIC_PROFILE={{ offsite_profile }}
HOMELAB_RESTIC_SOURCE={{ offsite_source_path }}
HOMELAB_RESTIC_SQLITE_DB={{ offsite_sqlite_db | default('') }}
HOMELAB_RESTIC_MARIADB_CONTAINER={{ offsite_mariadb_container | default('') }}
HOMELAB_RESTIC_MARIADB_DATABASE={{ offsite_mariadb_database | default('') }}
HOMELAB_RESTIC_MARIADB_USER={{ offsite_mariadb_user | default('') }}
HOMELAB_RESTIC_MARIADB_ENV={{ offsite_mariadb_env | default('') }}
HOMELAB_RESTIC_MARIADB_DUMP_NAME={{ offsite_mariadb_dump_name | default('') }}
HOMELAB_RESTIC_EXCLUDE_FILE=/etc/homelab-restic/{{ offsite_profile }}.exclude
HOMELAB_RESTIC_STAGING=/var/lib/homelab-restic/{{ offsite_profile }}
HOMELAB_RESTIC_TAGS={{ offsite_backup_tag }}
HOMELAB_RESTIC_KEEP_DAILY=14
HOMELAB_RESTIC_KEEP_WEEKLY=8
HOMELAB_RESTIC_KEEP_MONTHLY=6
- name: Install restic offsite backup script
ansible.builtin.copy:
dest: /usr/local/sbin/homelab-restic-offsite-backup
owner: root
group: root
mode: "0755"
content: |
#!/bin/sh
set -eu
if [ "$#" -ne 1 ]; then
echo "usage: $0 <profile>" >&2
exit 64
fi
env_file="/etc/homelab-restic/$1.env"
if [ ! -f "$env_file" ]; then
echo "missing env file: $env_file" >&2
exit 66
fi
set -a
. "$env_file"
set +a
export RESTIC_REPOSITORY RESTIC_PASSWORD_FILE RCLONE_CONFIG
mkdir -p "$HOMELAB_RESTIC_STAGING"
rm -rf "$HOMELAB_RESTIC_STAGING"/*
if [ -n "${HOMELAB_RESTIC_SQLITE_DB:-}" ] && [ -f "$HOMELAB_RESTIC_SQLITE_DB" ]; then
sqlite_backup="$HOMELAB_RESTIC_STAGING/$(basename "$HOMELAB_RESTIC_SQLITE_DB")"
sqlite3 -cmd 'PRAGMA busy_timeout=30000;' "$HOMELAB_RESTIC_SQLITE_DB" ".backup '$sqlite_backup'"
sqlite3 -cmd 'PRAGMA busy_timeout=30000;' "$sqlite_backup" "PRAGMA integrity_check;" | grep -qx ok
fi
if [ -n "${HOMELAB_RESTIC_MARIADB_CONTAINER:-}" ]; then
if [ ! -f "$HOMELAB_RESTIC_MARIADB_ENV" ]; then
echo "missing MariaDB env file: $HOMELAB_RESTIC_MARIADB_ENV" >&2
exit 66
fi
set -a
. "$HOMELAB_RESTIC_MARIADB_ENV"
set +a
dump_tmp="$HOMELAB_RESTIC_STAGING/${HOMELAB_RESTIC_MARIADB_DUMP_NAME}.tmp"
dump_final="$HOMELAB_RESTIC_STAGING/$HOMELAB_RESTIC_MARIADB_DUMP_NAME"
docker exec -e MYSQL_PWD="$DB_PASSWORD" "$HOMELAB_RESTIC_MARIADB_CONTAINER" \
mariadb-dump --user="$HOMELAB_RESTIC_MARIADB_USER" --single-transaction --routines --events \
--databases "$HOMELAB_RESTIC_MARIADB_DATABASE" > "$dump_tmp"
test -s "$dump_tmp"
mv "$dump_tmp" "$dump_final"
{
printf '%s\n' 'grimmory_image=grimmory/grimmory:v3.2.4@sha256:dfa7afdfcf25d649fd664497a62385dd00cd9678c37546e182c172e41c8e80cb'
printf '%s\n' 'mariadb_image=lscr.io/linuxserver/mariadb:11.4.8@sha256:91de7f701bc7fc3a424b81beafca7a7c6c4c5b7c8be6afd2ae148698695c0b0c'
printf 'created_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$HOMELAB_RESTIC_STAGING/manifest.txt"
fi
restic snapshots >/dev/null 2>&1 || restic init
tag_args=""
old_ifs="$IFS"
IFS=,
for tag in $HOMELAB_RESTIC_TAGS; do
tag_args="$tag_args --tag $tag"
done
IFS="$old_ifs"
# shellcheck disable=SC2086
restic backup "$HOMELAB_RESTIC_SOURCE" "$HOMELAB_RESTIC_STAGING" \
--exclude-file "$HOMELAB_RESTIC_EXCLUDE_FILE" \
--one-file-system \
$tag_args
restic forget --prune \
--keep-daily "$HOMELAB_RESTIC_KEEP_DAILY" \
--keep-weekly "$HOMELAB_RESTIC_KEEP_WEEKLY" \
--keep-monthly "$HOMELAB_RESTIC_KEEP_MONTHLY"
if [ -n "${HOMELAB_RESTIC_MARIADB_CONTAINER:-}" ]; then
rm -rf "$HOMELAB_RESTIC_STAGING"/*
fi
- name: Install restic offsite systemd service
ansible.builtin.copy:
dest: "/etc/systemd/system/homelab-restic-offsite-{{ offsite_profile }}.service"
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=HomeLab restic offsite backup ({{ offsite_profile }})
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/flock -w 1800 /var/lock/homelab-restic-{{ offsite_profile }}.lock /usr/local/sbin/homelab-restic-offsite-backup {{ offsite_profile }}
- name: Install restic offsite systemd timer
ansible.builtin.copy:
dest: "/etc/systemd/system/homelab-restic-offsite-{{ offsite_profile }}.timer"
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Run HomeLab restic offsite backup ({{ offsite_profile }})
[Timer]
OnCalendar={{ offsite_timer_oncalendar }}
Persistent=true
RandomizedDelaySec=15m
[Install]
WantedBy=timers.target
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Enable restic offsite timer
ansible.builtin.systemd:
name: "homelab-restic-offsite-{{ offsite_profile }}.timer"
enabled: true
state: started