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
@@ -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"