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:
@@ -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
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user