Capture current Ansible control plane state

Commit the accumulated infrastructure work that was living only in the
working tree: monitoring stack, emergency access/bot, gyro allocator,
grimmory, adguard, backup audit and the OpenCode agent definitions.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
This commit is contained in:
Dmitry
2026-08-26 21:39:28 +03:00
co-authored by Claude Opus 5
parent 4bafa7d09e
commit c676be81ec
126 changed files with 10583 additions and 44 deletions
+110
View File
@@ -0,0 +1,110 @@
---
- name: Create and verify current PBS audit before AdGuard update
hosts: mini-pc
gather_facts: false
tasks:
- name: Verify AdGuard VMID ownership before backup
ansible.builtin.command: pct config 144
register: adguard_pct_config
changed_when: false
failed_when: false
- name: Refuse to back up a foreign VMID 144
ansible.builtin.assert:
that:
- adguard_pct_config.rc == 0
- adguard_existing_hostname == 'adguard'
fail_msg: VMID 144 is not the AdGuard container.
vars:
adguard_existing_hostname: >-
{{ adguard_pct_config.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Check for active Proxmox backup before AdGuard PBS backup
ansible.builtin.command: pgrep -x vzdump
register: adguard_vzdump_preflight
changed_when: false
failed_when: false
- name: Require no active Proxmox backup before AdGuard PBS backup
ansible.builtin.assert:
that:
- adguard_vzdump_preflight.rc != 0
fail_msg: >-
A Proxmox backup is already running on mini-pc.
Retry after the existing backup completes.
- name: Create a fresh AdGuard PBS backup
ansible.builtin.command:
argv:
- vzdump
- "144"
- --storage
- pbs
- --mode
- snapshot
- --prune-backups
- keep-all=1
- --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged
- name: Run current PBS audit service
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-pbs.service
changed_when: true
- import_playbook: pve-adguard.yml
- name: Verify AdGuard public services after update
hosts: adguard
gather_facts: false
tasks:
- name: Check AdGuard HTTP root
ansible.builtin.uri:
url: http://127.0.0.1/
status_code: [200, 302]
return_content: false
register: adguard_http_root_check
retries: 24
delay: 5
until: adguard_http_root_check.status in [200, 302]
- name: Check AdGuard DNS over UDP
ansible.builtin.command:
argv:
- dig
- "@127.0.0.1"
- localhost
- A
- +time=2
- +tries=1
- +short
register: adguard_dns_udp_check
changed_when: false
retries: 12
delay: 5
until: adguard_dns_udp_check.rc == 0 and '127.0.0.1' in adguard_dns_udp_check.stdout
- name: Check AdGuard DNS over TCP
ansible.builtin.command:
argv:
- dig
- "@127.0.0.1"
- localhost
- A
- +tcp
- +time=2
- +tries=1
- +short
register: adguard_dns_tcp_check
changed_when: false
retries: 12
delay: 5
until: adguard_dns_tcp_check.rc == 0 and '127.0.0.1' in adguard_dns_tcp_check.stdout
+45
View File
@@ -0,0 +1,45 @@
- name: Configure PBS backup audit on mini-pc
hosts: mini-pc
gather_facts: false
roles:
- role: backup_audit
vars:
backup_audit_type: pbs
- name: Configure restic offsite audit on cloud-pc (Gitea)
hosts: cloud-pc
gather_facts: false
roles:
- role: backup_audit
vars:
backup_audit_type: restic
backup_audit_restic_profiles:
- name: gitea
sqlite_name: gitea.db
max_age_hours: 36
- name: Configure restic offsite audit on vaultwarden
hosts: vaultwarden
gather_facts: false
roles:
- role: backup_audit
vars:
backup_audit_type: restic
backup_audit_restic_profiles:
- name: vaultwarden
sqlite_name: db.sqlite3
max_age_hours: 36
- name: Configure restic offsite audit on Grimmory
hosts: grimmory
gather_facts: false
vars:
ansible_become: false
roles:
- role: backup_audit
vars:
backup_audit_type: restic
backup_audit_restic_profiles:
- name: grimmory
expected_name: grimmory.sql
max_age_hours: 36
@@ -0,0 +1,76 @@
---
- name: Create read-only Proxmox token for monitoring
hosts: mini-pc
gather_facts: false
vars:
monitoring_pve_user: monitoring@pve
monitoring_pve_token_id: prometheus
monitoring_pve_env_file: "{{ playbook_dir }}/../.env"
monitoring_pve_rotate_existing_token: false
tasks:
- name: Read existing Proxmox users
ansible.builtin.command: pveum user list --output-format json
register: monitoring_pve_users_raw
changed_when: false
- name: Create monitoring Proxmox user
ansible.builtin.command: >-
pveum user add {{ monitoring_pve_user }}
--comment 'Read-only Prometheus monitoring user'
when: monitoring_pve_user not in (monitoring_pve_users_raw.stdout | from_json | map(attribute='userid') | list)
- name: Grant PVEAuditor role to monitoring user
ansible.builtin.command: >-
pveum acl modify / -user {{ monitoring_pve_user }} -role PVEAuditor
changed_when: false
- name: Read monitoring user tokens
ansible.builtin.command: >-
pveum user token list {{ monitoring_pve_user }} --output-format json
register: monitoring_pve_tokens_raw
changed_when: false
- name: Refuse to overwrite an existing monitoring token
ansible.builtin.assert:
that:
- monitoring_pve_token_id not in (monitoring_pve_tokens_raw.stdout | from_json | map(attribute='tokenid') | list)
fail_msg: Existing monitoring token secret cannot be recovered safely. Rotate it explicitly before rerunning this playbook.
when: not monitoring_pve_rotate_existing_token | bool
- name: Rotate existing monitoring token explicitly
ansible.builtin.command: >-
pveum user token remove {{ monitoring_pve_user }} {{ monitoring_pve_token_id }}
when:
- monitoring_pve_rotate_existing_token | bool
- monitoring_pve_token_id in (monitoring_pve_tokens_raw.stdout | from_json | map(attribute='tokenid') | list)
- name: Create separated monitoring token
ansible.builtin.command: >-
pveum user token add {{ monitoring_pve_user }} {{ monitoring_pve_token_id }}
--privsep 1 --comment 'Prometheus PVE exporter' --output-format json
register: monitoring_pve_token_created
no_log: true
- name: Grant PVEAuditor role to separated monitoring token
ansible.builtin.command: >-
pveum acl modify / -token {{ monitoring_pve_user }}!{{ monitoring_pve_token_id }} -role PVEAuditor
changed_when: false
- name: Store monitoring token variables locally
ansible.builtin.lineinfile:
path: "{{ monitoring_pve_env_file }}"
regexp: "^export {{ item.name }}="
line: "export {{ item.name }}='{{ item.value }}'"
create: false
loop:
- name: MONITORING_PVE_API_USER
value: "{{ monitoring_pve_user }}"
- name: MONITORING_PVE_API_TOKEN_ID
value: "{{ monitoring_pve_token_id }}"
- name: MONITORING_PVE_API_TOKEN_SECRET
value: "{{ (monitoring_pve_token_created.stdout | from_json).value }}"
delegate_to: localhost
vars:
ansible_connection: local
ansible_become: false
no_log: true
+42
View File
@@ -0,0 +1,42 @@
---
- name: Bootstrap emergency-bot control identity
hosts: emergency-bot
gather_facts: false
tasks:
- name: Create emergency-bot control identity
ansible.builtin.include_role:
name: emergency_bot
tasks_from: bootstrap
- name: Configure mini-pc reverse SSH client and restricted control path
hosts: mini-pc
gather_facts: false
vars:
emergency_vps_host_key: "{{ lookup('env', 'EMERGENCY_VPS_HOST_KEY') }}"
emergency_bot_control_public_key: "{{ hostvars['emergency-bot'].emergency_bot_control_public_key }}"
tasks:
- name: Configure mini-pc emergency access client
ansible.builtin.include_role:
name: emergency_access
tasks_from: client
- name: Configure ru-vps reverse SSH endpoint
hosts: ru-vps
gather_facts: false
vars:
emergency_reverse_public_key: "{{ hostvars['mini-pc'].emergency_reverse_public_key }}"
tasks:
- name: Configure ru-vps emergency access endpoint
ansible.builtin.include_role:
name: emergency_access
tasks_from: endpoint
- name: Configure and enable emergency Telegram bot
hosts: emergency-bot
gather_facts: false
vars:
emergency_bot_token: "{{ lookup('env', 'EMERGENCY_BOT_TOKEN') }}"
emergency_bot_allowed_user_ids: "{{ lookup('env', 'EMERGENCY_ALLOWED_USER_IDS') }}"
emergency_mini_pc_host_key: "{{ lookup('env', 'EMERGENCY_MINI_PC_HOST_KEY') }}"
roles:
- role: emergency_bot
+45
View File
@@ -0,0 +1,45 @@
---
- name: Create and verify Gitea backup before update
hosts: cloud-pc
gather_facts: false
tasks:
- name: Create a fresh Gitea offsite backup
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-restic-offsite-gitea.service
changed_when: true
- name: Run Gitea offsite backup audit
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-gitea.service
changed_when: true
- import_playbook: pve-gitea.yml
- name: Verify Gitea public endpoints after update
hosts: ru-vps
gather_facts: false
tasks:
- name: Check Gitea HTTPS endpoint
ansible.builtin.uri:
url: https://git.ada-dev.ru/
status_code: 200
return_content: false
register: gitea_https_check
retries: 24
delay: 5
until: gitea_https_check.status == 200
- name: Check Gitea SSH port from ru-vps
ansible.builtin.wait_for:
host: 192.168.1.25
port: 2222
state: started
timeout: 30
+106
View File
@@ -0,0 +1,106 @@
---
- name: Create and verify Grimmory backup before update
hosts: grimmory
gather_facts: false
tasks:
- name: Create a fresh Grimmory offsite backup
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-restic-offsite-grimmory.service
changed_when: true
- name: Run Grimmory offsite backup audit
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-grimmory.service
changed_when: true
- name: Create a fresh Grimmory PBS backup
hosts: cloud-pc
gather_facts: false
tasks:
- name: Read Grimmory LXC config
ansible.builtin.command:
argv:
- pct
- config
- "149"
register: grimmory_pct_config
changed_when: false
- name: Assert VMID 149 belongs to Grimmory
ansible.builtin.assert:
that:
- grimmory_pct_hostname_line != ""
- grimmory_pct_hostname == "grimmory"
fail_msg: >-
Refusing to run vzdump 149 because pct config hostname is not grimmory:
{{ grimmory_pct_hostname_line | default('missing hostname line') }}
vars:
grimmory_pct_hostname_line: >-
{{ (grimmory_pct_config.stdout_lines | select('match', '^hostname:\\s+') | list | first | default('')) }}
grimmory_pct_hostname: >-
{{ grimmory_pct_hostname_line | regex_replace('^hostname:\\s*', '') }}
- name: Check for active Proxmox backup before Grimmory PBS backup
ansible.builtin.command: pgrep -x vzdump
register: grimmory_vzdump_preflight
changed_when: false
failed_when: false
- name: Require no active Proxmox backup before Grimmory PBS backup
ansible.builtin.assert:
that:
- grimmory_vzdump_preflight.rc != 0
fail_msg: >-
A Proxmox backup is already running on cloud-pc.
Retry after the existing backup completes.
- name: Create a fresh Grimmory PBS backup
ansible.builtin.command:
argv:
- vzdump
- "149"
- --storage
- pbs
- --mode
- snapshot
- --prune-backups
- keep-all=1
- --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged
- name: Run PBS backup audit on mini-pc
hosts: mini-pc
gather_facts: false
tasks:
- name: Run current PBS backup audit on mini-pc
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-pbs.service
changed_when: true
- import_playbook: pve-grimmory.yml
- name: Verify Grimmory public endpoint after update
hosts: ru-vps
gather_facts: false
tasks:
- name: Check Grimmory public health endpoint
ansible.builtin.uri:
url: https://books.ada-dev.ru/api/v1/healthcheck
status_code: 200
return_content: false
register: grimmory_public_health
retries: 24
delay: 5
until: grimmory_public_health.status == 200
+6
View File
@@ -0,0 +1,6 @@
---
- name: Configure Gyro investment allocator host
hosts: gyro
gather_facts: true
roles:
- role: gyro
+96
View File
@@ -0,0 +1,96 @@
---
- name: Verify Mihomo PBS audit before update
hosts: mini-pc
gather_facts: false
tasks:
- name: Read VMID 143 configuration
ansible.builtin.command: "pct config 143"
register: mihomo_pct_config
changed_when: false
failed_when: false
- name: Refuse to run backup unless VMID 143 is Mihomo
ansible.builtin.assert:
that:
- mihomo_pct_config.rc == 0
- mihomo_update_hostname == 'mihomo'
fail_msg: VMID 143 must be the Mihomo container before backup.
vars:
mihomo_update_hostname: >-
{{ mihomo_pct_config.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Check for active Proxmox backup before Mihomo PBS backup
ansible.builtin.command: pgrep -x vzdump
register: mihomo_vzdump_preflight
changed_when: false
failed_when: false
- name: Require no active Proxmox backup before Mihomo PBS backup
ansible.builtin.assert:
that:
- mihomo_vzdump_preflight.rc != 0
fail_msg: >-
A Proxmox backup is already running on mini-pc.
Retry after the existing backup completes.
- name: Create a fresh Mihomo PBS backup
ansible.builtin.command:
argv:
- vzdump
- "143"
- --storage
- pbs
- --mode
- snapshot
- --prune-backups
- keep-all=1
- --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged
- name: Run current PBS backup audit on mini-pc
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-pbs.service
changed_when: true
- import_playbook: pve-mihomo.yml
- name: Verify Mihomo after update
hosts: mihomo
gather_facts: false
tasks:
- name: Wait for Mihomo proxy TCP ports
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: started
timeout: 120
loop:
- 7890
- 7891
- 9090
- name: Check Mihomo UI endpoint
ansible.builtin.uri:
url: http://127.0.0.1:8080/
status_code: 200
register: mihomo_ui_health
retries: 24
delay: 5
until: mihomo_ui_health.status == 200
- name: Check Mihomo controller /version endpoint
ansible.builtin.uri:
url: http://127.0.0.1:9090/version
status_code: 200
register: mihomo_controller_health
retries: 24
delay: 5
until: mihomo_controller_health.status == 200
+26
View File
@@ -0,0 +1,26 @@
---
- name: Configure Prometheus exporters
hosts: monitoring_exporters
gather_facts: false
roles:
- role: monitoring_exporter
- name: Configure SMART exporters on Proxmox nodes
hosts: monitoring_smart_exporters
gather_facts: false
roles:
- role: monitoring_exporter
monitoring_exporter_install_node: false
monitoring_exporter_install_smart: true
- name: Configure ru-vps external probes
hosts: ru-vps
gather_facts: false
roles:
- role: monitoring_blackbox
- name: Configure central monitoring stack
hosts: monitoring_server
gather_facts: false
roles:
- role: monitoring_server
@@ -0,0 +1,74 @@
- name: Configure Gitea offsite backup to Yandex Disk
hosts: cloud-pc
gather_facts: false
vars:
offsite_profile: gitea
offsite_repository: rclone:yadisk:System/Backups/HomeLab/restic/gitea
offsite_source_path: /opt/data/gitea
offsite_sqlite_db: /opt/data/gitea/gitea/gitea.db
offsite_backup_tag: gitea,cloud-pc,yadisk
offsite_timer_oncalendar: "*-*-* 04:15:00"
offsite_rclone_config_local: ~/.config/rclone/rclone.conf
offsite_restic_password_local: "{{ playbook_dir }}/../generated/restic-offsite-password"
offsite_excludes:
- /opt/data/gitea/gitea/gitea.db
- /opt/data/gitea/gitea/gitea.db-shm
- /opt/data/gitea/gitea/gitea.db-wal
- /opt/data/gitea/gitea/log/**
- /opt/data/gitea/gitea/sessions/**
- /opt/data/gitea/gitea/queues/**
- /opt/data/gitea/gitea/tmp/**
tasks:
- name: Configure restic offsite profile
ansible.builtin.include_tasks: ../tasks/offsite-restic-profile.yml
- name: Configure Vaultwarden offsite backup to Yandex Disk
hosts: vaultwarden
gather_facts: false
vars:
ansible_become: false
offsite_profile: vaultwarden
offsite_repository: rclone:yadisk:System/Backups/HomeLab/restic/vaultwarden
offsite_source_path: /opt/vaultwarden/data
offsite_sqlite_db: /opt/vaultwarden/data/db.sqlite3
offsite_backup_tag: vaultwarden,mini-pc,yadisk
offsite_timer_oncalendar: "*-*-* 04:45:00"
offsite_rclone_config_local: ~/.config/rclone/rclone.conf
offsite_restic_password_local: "{{ playbook_dir }}/../generated/restic-offsite-password"
offsite_excludes:
- /opt/vaultwarden/data/db.sqlite3
- /opt/vaultwarden/data/db.sqlite3-shm
- /opt/vaultwarden/data/db.sqlite3-wal
- /opt/vaultwarden/data/tmp/**
tasks:
- name: Configure restic offsite profile
ansible.builtin.include_tasks: ../tasks/offsite-restic-profile.yml
- name: Configure Grimmory offsite backup to Yandex Disk
hosts: grimmory
gather_facts: false
vars:
ansible_become: false
offsite_profile: grimmory
offsite_repository: rclone:yadisk:System/Backups/HomeLab/restic/grimmory
offsite_source_path: /opt/grimmory
offsite_sqlite_db: ""
offsite_mariadb_container: grimmory-mariadb
offsite_mariadb_database: grimmory
offsite_mariadb_user: grimmory
offsite_mariadb_env: /opt/grimmory/.env
offsite_mariadb_dump_name: grimmory.sql
offsite_backup_tag: grimmory,cloud-pc,yadisk
offsite_timer_oncalendar: "*-*-* 05:15:00"
offsite_rclone_config_local: ~/.config/rclone/rclone.conf
offsite_restic_password_local: "{{ playbook_dir }}/../generated/restic-offsite-password"
offsite_excludes:
- /opt/grimmory/books/**
- /opt/grimmory/bookdrop/**
- /opt/grimmory/mariadb/**
- /opt/grimmory/backup-staging/**
- /opt/grimmory/**/heapdump*.hprof
- /opt/grimmory/**/cache/**
tasks:
- name: Configure restic offsite profile
ansible.builtin.include_tasks: ../tasks/offsite-restic-profile.yml
+12
View File
@@ -10,10 +10,22 @@
ansible.builtin.command: nc -vz -w 5 192.168.1.10 8006
changed_when: false
- name: Check mini-pc SSH through OpenVPN gateway
ansible.builtin.command: nc -vz -w 5 192.168.1.10 22
changed_when: false
- name: Check cloud-pc PVE port through OpenVPN gateway
ansible.builtin.command: nc -vz -w 5 192.168.1.5 8006
changed_when: false
- name: Check cloud-pc SSH through OpenVPN gateway
ansible.builtin.command: nc -vz -w 5 192.168.1.5 22
changed_when: false
- name: Check PBS port through OpenVPN gateway
ansible.builtin.command: nc -vz -w 5 192.168.1.20 8007
changed_when: false
- name: Check PBS SSH through OpenVPN gateway
ansible.builtin.command: nc -vz -w 5 192.168.1.20 22
changed_when: false
+247
View File
@@ -0,0 +1,247 @@
---
- name: Create AdGuard Home LXC on mini-pc
hosts: mini-pc
gather_facts: false
vars:
adguard_vmid: 144
adguard_hostname: adguard
adguard_ip: 192.168.1.28/24
adguard_gateway: 192.168.1.1
adguard_rootfs: local-lvm:8
adguard_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
adguard_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
adguard_image: adguard/adguardhome:v0.107.78@sha256:2c127294fa5f96151d9d3a433fb9d66c17e4d18cf698c2b04372a80e26fdd26f
handlers:
- name: restart adguard lxc
ansible.builtin.shell: "pct stop {{ adguard_vmid }} || true; pct start {{ adguard_vmid }}"
changed_when: true
tasks:
- name: Check if AdGuard LXC exists
ansible.builtin.command: "pct config {{ adguard_vmid }}"
register: adguard_pct_config
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID {{ adguard_vmid }}
ansible.builtin.assert:
that:
- adguard_pct_config.rc != 0 or adguard_existing_hostname == adguard_hostname
fail_msg: VMID {{ adguard_vmid }} already exists and is not the AdGuard container.
vars:
adguard_existing_hostname: >-
{{ adguard_pct_config.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Install AdGuard LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/adguard-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', adguard_pubkey_file) }}\n"
when: adguard_pct_config.rc != 0
- name: Create AdGuard LXC
ansible.builtin.command: >-
pct create {{ adguard_vmid }} {{ adguard_ostemplate }}
--hostname {{ adguard_hostname }}
--rootfs {{ adguard_rootfs }}
--cores 1
--memory 512
--swap 512
--net0 name=eth0,bridge=vmbr0,gw={{ adguard_gateway }},ip={{ adguard_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=40
--cmode shell
--ssh-public-keys /tmp/adguard-lxc.pub
when: adguard_pct_config.rc != 0
- name: Start AdGuard LXC
ansible.builtin.command: "pct start {{ adguard_vmid }}"
register: adguard_pct_start
changed_when: adguard_pct_start.rc == 0
failed_when: adguard_pct_start.rc not in [0, 255]
- name: Allow FUSE device in AdGuard LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ adguard_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart adguard lxc
- name: Bind mount FUSE device in AdGuard LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ adguard_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart adguard lxc
- name: Apply pending LXC config changes
ansible.builtin.meta: flush_handlers
- name: Wait for AdGuard SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: adguard
vars:
ansible_become: false
- name: Configure AdGuard Home inside LXC
hosts: adguard
gather_facts: true
vars:
ansible_become: false
adguard_image: adguard/adguardhome:v0.107.78@sha256:2c127294fa5f96151d9d3a433fb9d66c17e4d18cf698c2b04372a80e26fdd26f
tasks:
- name: Install Docker packages
ansible.builtin.apt:
name:
- docker.io
- fuse-overlayfs
- ca-certificates
- curl
- dnsutils
state: present
update_cache: true
- name: Ensure Docker config directory exists
ansible.builtin.file:
path: /etc/docker
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: adguard_docker_daemon_config
- name: Enable Docker service
ansible.builtin.systemd:
name: docker
state: "{{ 'restarted' if adguard_docker_daemon_config.changed else 'started' }}"
enabled: true
- name: Ensure AdGuard data directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- /opt/adguard/work
- /opt/adguard/conf
- name: Check if the configured AdGuard image is present
ansible.builtin.command: "docker image inspect {{ adguard_image }}"
register: adguard_image_inspect
changed_when: false
failed_when: false
- name: Pull the configured AdGuard image
ansible.builtin.command: "docker pull {{ adguard_image }}"
when: adguard_image_inspect.rc != 0
register: adguard_image_pull
changed_when: true
- name: Install AdGuard Home systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/adguard.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=AdGuard Home container
After=docker.service
Requires=docker.service
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f adguard
ExecStart=/usr/bin/docker run --rm --name adguard --pull never \
-p 53:53/tcp -p 53:53/udp -p 80:80/tcp -p 3000:3000/tcp \
-v /opt/adguard/work:/opt/adguardhome/work \
-v /opt/adguard/conf:/opt/adguardhome/conf \
{{ adguard_image }}
ExecStop=/usr/bin/docker stop adguard
[Install]
WantedBy=multi-user.target
register: adguard_unit
- name: Remove obsolete AdGuard post-start validation unit
ansible.builtin.file:
path: /etc/systemd/system/adguard-post-start.service
state: absent
register: adguard_post_start_unit_removed
- name: Reload systemd when AdGuard units change
ansible.builtin.systemd:
daemon_reload: true
when: adguard_unit.changed or adguard_post_start_unit_removed.changed
- name: Enable and start AdGuard Home
ansible.builtin.systemd:
name: adguard
state: "{{ 'restarted' if adguard_unit.changed or adguard_image_pull.changed else 'started' }}"
enabled: true
- name: Wait for AdGuard HTTP root
ansible.builtin.uri:
url: http://127.0.0.1/
status_code: [200, 302]
return_content: false
register: adguard_http_root
retries: 24
delay: 5
until: adguard_http_root.status in [200, 302]
- name: Verify AdGuard DNS over UDP
ansible.builtin.command:
argv:
- dig
- "@127.0.0.1"
- localhost
- A
- +time=2
- +tries=1
- +short
register: adguard_dns_udp
changed_when: false
retries: 12
delay: 5
until: adguard_dns_udp.rc == 0 and '127.0.0.1' in adguard_dns_udp.stdout
- name: Verify AdGuard DNS over TCP
ansible.builtin.command:
argv:
- dig
- "@127.0.0.1"
- localhost
- A
- +tcp
- +time=2
- +tries=1
- +short
register: adguard_dns_tcp
changed_when: false
retries: 12
delay: 5
until: adguard_dns_tcp.rc == 0 and '127.0.0.1' in adguard_dns_tcp.stdout
+114
View File
@@ -0,0 +1,114 @@
- name: Configure Proxmox backup jobs
hosts: mini-pc
gather_facts: false
vars:
pve_backup_jobs:
- id: homelab-pbs-daily-cloud
comment: Daily PBS backup for cloud-pc service containers
node: cloud-pc
vmid: 141,145,146,147,149
storage: pbs
schedule: "02:10"
mode: snapshot
exclude_path: /var/lib/docker/fuse-overlayfs/*/merged
- id: homelab-pbs-daily-mini
comment: Daily PBS backup for mini-pc service containers
node: mini-pc
vmid: 132,140,142,143,144,150
storage: pbs
schedule: "02:40"
mode: snapshot
exclude_path: /var/lib/docker/fuse-overlayfs/*/merged
- id: homelab-local-weekly-pbs
comment: Weekly local backup for PBS container rootfs/config
node: cloud-pc
vmid: 120
storage: backup
schedule: "Sun 03:30"
mode: snapshot
prune_backups: keep-last=2
tasks:
- name: Get existing Proxmox backup jobs
ansible.builtin.command: pvesh get /cluster/backup --output-format json
register: pve_backup_jobs_existing_raw
changed_when: false
- name: Parse existing Proxmox backup jobs
ansible.builtin.set_fact:
pve_backup_jobs_existing: "{{ pve_backup_jobs_existing_raw.stdout | from_json }}"
- name: Create missing Proxmox backup jobs
ansible.builtin.command: >-
pvesh create /cluster/backup
--id {{ item.id }}
--enabled 1
--node {{ item.node }}
--vmid {{ item.vmid }}
--storage {{ item.storage }}
--schedule {{ item.schedule | quote }}
--mode {{ item.mode }}
{% if item.prune_backups is defined %}
--prune-backups {{ item.prune_backups }} --remove 1
{% else %}
--remove 0
{% endif %}
{% if item.exclude_path is defined %}
--exclude-path {{ item.exclude_path | quote }}
{% endif %}
--notes-template {{ '{{' }}guestname{{ '}}' }}
--comment {{ item.comment | quote }}
loop: "{{ pve_backup_jobs }}"
when: item.id not in (pve_backup_jobs_existing | map(attribute='id') | list)
- name: Update existing Proxmox backup jobs
ansible.builtin.command: >-
pvesh set /cluster/backup/{{ item.id }}
--enabled 1
--node {{ item.node }}
--vmid {{ item.vmid }}
--storage {{ item.storage }}
--schedule {{ item.schedule | quote }}
--mode {{ item.mode }}
{% if item.prune_backups is defined %}
--prune-backups {{ item.prune_backups }} --remove 1
{% else %}
--delete prune-backups --remove 0
{% endif %}
{% if item.exclude_path is defined %}
--exclude-path {{ item.exclude_path | quote }}
{% else %}
--delete exclude-path
{% endif %}
--notes-template {{ '{{' }}guestname{{ '}}' }}
--comment {{ item.comment | quote }}
loop: "{{ pve_backup_jobs }}"
vars:
current_job: >-
{{ pve_backup_jobs_existing | selectattr('id', 'equalto', item.id) | first }}
when:
- item.id in (pve_backup_jobs_existing | map(attribute='id') | list)
- >-
current_job.enabled | int != 1 or
current_job.node != item.node or
current_job.vmid | string != item.vmid | string or
current_job.storage != item.storage or
current_job.schedule != item.schedule or
current_job.mode != item.mode or
current_job.comment != item.comment or
current_job.remove | int != (1 if item.prune_backups is defined else 0) or
current_job['exclude-path'] | default([]) != ([item.exclude_path] if item.exclude_path is defined else []) or
(item.prune_backups is not defined and current_job['prune-backups'] is defined) or
(item.prune_backups is defined and
(current_job['prune-backups'] | default({})).get('keep-last', 0) | int !=
item.prune_backups | regex_replace('^keep-last=', '') | int)
changed_when: true
- name: Show configured Proxmox backup jobs
ansible.builtin.command: pvesh get /cluster/backup --output-format yaml
register: pve_backup_jobs_configured
changed_when: false
- name: Print configured Proxmox backup jobs
ansible.builtin.debug:
var: pve_backup_jobs_configured.stdout_lines
+125
View File
@@ -0,0 +1,125 @@
---
- name: Create Docker test LXC on cloud-pc
hosts: cloud-pc
gather_facts: false
vars:
docker_test_vmid: 145
docker_test_hostname: docker-test
docker_test_ip: 192.168.1.29/24
docker_test_gateway: 192.168.1.1
docker_test_rootfs: data:8
docker_test_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
docker_test_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
handlers:
- name: restart docker test lxc
ansible.builtin.shell: "pct stop {{ docker_test_vmid }} || true; pct start {{ docker_test_vmid }}"
changed_when: true
tasks:
- name: Check if Docker test LXC exists
ansible.builtin.command: "pct config {{ docker_test_vmid }}"
register: docker_test_pct_config
changed_when: false
failed_when: false
- name: Install Docker test LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/docker-test-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', docker_test_pubkey_file) }}\n"
when: docker_test_pct_config.rc != 0
- name: Create Docker test LXC
ansible.builtin.command: >-
pct create {{ docker_test_vmid }} {{ docker_test_ostemplate }}
--hostname {{ docker_test_hostname }}
--rootfs {{ docker_test_rootfs }}
--cores 1
--memory 512
--swap 512
--net0 name=eth0,bridge=vmbr0,gw={{ docker_test_gateway }},ip={{ docker_test_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=50
--cmode shell
--ssh-public-keys /tmp/docker-test-lxc.pub
when: docker_test_pct_config.rc != 0
- name: Start Docker test LXC
ansible.builtin.command: "pct start {{ docker_test_vmid }}"
register: docker_test_pct_start
changed_when: docker_test_pct_start.rc == 0
failed_when: docker_test_pct_start.rc not in [0, 255]
- name: Allow FUSE device in Docker test LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ docker_test_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart docker test lxc
- name: Bind mount FUSE device in Docker test LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ docker_test_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart docker test lxc
- name: Apply pending LXC config changes
ansible.builtin.meta: flush_handlers
- name: Wait for Docker test SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: docker-test
vars:
ansible_become: false
- name: Configure Docker test host
hosts: docker-test
gather_facts: true
vars:
ansible_become: false
tasks:
- name: Install Docker packages
ansible.builtin.apt:
name:
- docker.io
- fuse-overlayfs
- ca-certificates
- curl
state: present
update_cache: true
- name: Ensure Docker config directory exists
ansible.builtin.file:
path: /etc/docker
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: docker_test_daemon_config
- name: Enable Docker service
ansible.builtin.systemd:
name: docker
state: "{{ 'restarted' if docker_test_daemon_config.changed else 'started' }}"
enabled: true
- name: Verify Docker can run a container
ansible.builtin.command: docker run --rm hello-world
changed_when: false
+30
View File
@@ -0,0 +1,30 @@
---
- name: Create emergency-bot LXC on mini-pc
hosts: localhost
connection: local
become: false
gather_facts: false
vars:
ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_lxc_vmid: 148
pve_lxc_node: mini-pc
pve_lxc_hostname: emergency-bot
pve_lxc_ip: 192.168.1.32/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_disk: local-lvm:4
pve_lxc_cores: 1
pve_lxc_memory: 512
pve_lxc_swap: 256
pve_lxc_startup: order=70
pve_lxc_ostemplate: "{{ lookup('env', 'PVE_LXC_OSTEMPLATE') | default('local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst', true) }}"
roles:
- role: pve_lxc
- name: Wait for emergency-bot SSH
hosts: emergency-bot
gather_facts: false
tasks:
- name: Wait for emergency-bot to accept SSH connections
ansible.builtin.wait_for_connection:
timeout: 120
+37 -3
View File
@@ -25,6 +25,19 @@
changed_when: false
failed_when: false
- name: Refuse to modify a foreign Gitea VMID
ansible.builtin.assert:
that:
- gitea_pct_config.rc != 0 or gitea_existing_hostname == 'gitea'
fail_msg: VMID {{ gitea_vmid }} already exists and is not the Gitea container.
vars:
gitea_existing_hostname: >-
{{ gitea_pct_config.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Install Gitea LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/gitea-lxc.pub
@@ -103,7 +116,7 @@
vars:
ansible_become: false
gitea_data_dir: /opt/gitea/data
gitea_image: gitea/gitea:latest
gitea_image: gitea/gitea:1.27.1@sha256:b64126cf5c3f4e5f0f231b510bb13715f6cb8e508188b44de90bdb9a04f3055d
gitea_container_name: gitea
gitea_http_port: 3000
gitea_ssh_port: 2222
@@ -154,6 +167,18 @@
group: "1000"
mode: "0750"
- name: Check if the configured Gitea image is present
ansible.builtin.command: "docker image inspect {{ gitea_image }}"
register: gitea_image_inspect
changed_when: false
failed_when: false
- name: Pull the configured Gitea image
ansible.builtin.command: "docker pull {{ gitea_image }}"
when: gitea_image_inspect.rc != 0
register: gitea_image_pull
changed_when: true
- name: Install Gitea systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/gitea.service
@@ -172,7 +197,7 @@
ExecStartPre=-/usr/bin/docker rm -f {{ gitea_container_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ gitea_container_name }} \
--pull always \
--pull never \
-p {{ gitea_http_port }}:3000 \
-p {{ gitea_ssh_port }}:22 \
-v {{ gitea_data_dir }}:/data \
@@ -193,5 +218,14 @@
- name: Enable and start Gitea
ansible.builtin.systemd:
name: gitea
state: started
state: "{{ 'restarted' if gitea_unit.changed or gitea_image_pull.changed else 'started' }}"
enabled: true
- name: Wait for Gitea HTTP health endpoint
ansible.builtin.uri:
url: "http://127.0.0.1:{{ gitea_http_port }}/api/healthz"
status_code: 200
register: gitea_health
retries: 24
delay: 5
until: gitea_health.status == 200
+431
View File
@@ -0,0 +1,431 @@
---
- name: Guard Grimmory VMID before API updates
hosts: cloud-pc
gather_facts: false
tasks:
- name: Read existing VMID 149 configuration
ansible.builtin.command: pct config 149
register: grimmory_existing_vmid
check_mode: false
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID 149
ansible.builtin.assert:
that:
- grimmory_existing_vmid.rc != 0 or grimmory_existing_hostname == 'grimmory'
fail_msg: VMID 149 already exists and is not the Grimmory container.
vars:
grimmory_existing_hostname: >-
{{ grimmory_existing_vmid.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Create Grimmory LXC on cloud-pc
hosts: localhost
connection: local
become: false
gather_facts: false
vars:
ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_lxc_vmid: 149
pve_lxc_node: cloud-pc
pve_lxc_hostname: grimmory
pve_lxc_ip: 192.168.1.34/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_disk: data:64
pve_lxc_cores: 2
pve_lxc_memory: 4096
pve_lxc_swap: 1024
pve_lxc_startup: order=100
pve_lxc_unprivileged: true
pve_lxc_update: false
pve_lxc_features:
- nesting=1
pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
roles:
- role: pve_lxc
- name: Configure Grimmory LXC devices
hosts: cloud-pc
gather_facts: false
vars:
grimmory_vmid: 149
handlers:
- name: restart Grimmory LXC
ansible.builtin.command: "pct reboot {{ grimmory_vmid }}"
changed_when: true
tasks:
- name: Read Grimmory LXC configuration
ansible.builtin.command: "pct config {{ grimmory_vmid }}"
register: grimmory_lxc_config
changed_when: false
- name: Require expected Grimmory LXC properties
ansible.builtin.assert:
that:
- "'unprivileged: 1' in grimmory_lxc_config.stdout"
- "'rootfs: data:' in grimmory_lxc_config.stdout"
- "'onboot: 1' in grimmory_lxc_config.stdout"
- name: Configure Grimmory LXC network
ansible.builtin.command: >-
pct set {{ grimmory_vmid }}
--net0 name=eth0,bridge=vmbr0,firewall=1,gw=192.168.1.1,ip=192.168.1.34/24
vars:
grimmory_net0: >-
{{ grimmory_lxc_config.stdout_lines
| select('match', '^net0: ')
| first
| default('') }}
when: >-
'name=eth0' not in grimmory_net0 or
'bridge=vmbr0' not in grimmory_net0 or
'firewall=1' not in grimmory_net0 or
'gw=192.168.1.1' not in grimmory_net0 or
'ip=192.168.1.34/24' not in grimmory_net0
notify: restart Grimmory LXC
- name: Enable keyctl for Docker in Grimmory LXC
ansible.builtin.command: "pct set {{ grimmory_vmid }} --features nesting=1,keyctl=1"
when: "'nesting=1' not in grimmory_lxc_config.stdout or 'keyctl=1' not in grimmory_lxc_config.stdout"
notify: restart Grimmory LXC
- name: Allow FUSE device in Grimmory LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ grimmory_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart Grimmory LXC
- name: Bind mount FUSE device in Grimmory LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ grimmory_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart Grimmory LXC
- name: Apply pending Grimmory LXC configuration
ansible.builtin.meta: flush_handlers
- name: Wait for Grimmory SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 180
delegate_to: grimmory
vars:
ansible_become: false
- name: Configure Grimmory runtime
hosts: grimmory
gather_facts: true
vars:
ansible_become: false
grimmory_root: /opt/grimmory
grimmory_image: grimmory/grimmory:v3.2.4@sha256:dfa7afdfcf25d649fd664497a62385dd00cd9678c37546e182c172e41c8e80cb
grimmory_mariadb_image: lscr.io/linuxserver/mariadb:11.4.8@sha256:91de7f701bc7fc3a424b81beafca7a7c6c4c5b7c8be6afd2ae148698695c0b0c
tasks:
- name: Install Grimmory runtime packages
ansible.builtin.apt:
name:
- ca-certificates
- curl
- docker-compose
- docker.io
- fuse-overlayfs
- mariadb-client
- openssl
- prometheus-node-exporter
- ufw
state: present
update_cache: true
- name: Verify FUSE device exists
ansible.builtin.stat:
path: /dev/fuse
register: grimmory_fuse
- name: Require FUSE device
ansible.builtin.assert:
that:
- grimmory_fuse.stat.exists
- grimmory_fuse.stat.ischr
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: grimmory_docker_config
- name: Enable Docker service
ansible.builtin.systemd:
name: docker
enabled: true
state: "{{ 'restarted' if grimmory_docker_config.changed else 'started' }}"
- name: Allow SSH from the HomeLab LAN
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ homelab_lan_cidr }}"
- name: Allow SSH from the OpenVPN network
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ openvpn_network_cidr }}"
- name: Allow Grimmory from the HomeLab LAN
community.general.ufw:
rule: allow
port: "6060"
proto: tcp
src: "{{ homelab_lan_cidr }}"
- name: Allow Grimmory from the OpenVPN network
community.general.ufw:
rule: allow
port: "6060"
proto: tcp
src: "{{ openvpn_network_cidr }}"
- name: Allow Node Exporter from the monitoring LXC
community.general.ufw:
rule: allow
port: "9100"
proto: tcp
src: 192.168.1.30
- name: Enable restrictive Grimmory firewall
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Create Grimmory directories
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
group: "{{ item.group }}"
mode: "{{ item.mode }}"
loop:
- { path: "{{ grimmory_root }}", owner: root, group: root, mode: "0750" }
- { path: "{{ grimmory_root }}/data", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/books", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/bookdrop", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/mariadb", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/backup-staging", owner: root, group: root, mode: "0700" }
- name: Generate Grimmory secrets once
ansible.builtin.shell: |
set -eu
umask 077
if [ -e {{ grimmory_root }}/.env ]; then
exit 0
fi
db_password=$(openssl rand -hex 32)
root_password=$(openssl rand -hex 32)
cat > {{ grimmory_root }}/.env <<EOF
TZ=Europe/Moscow
APP_USER_ID=1000
APP_GROUP_ID=1000
DB_USER_ID=1000
DB_GROUP_ID=1000
DB_PASSWORD=$db_password
MYSQL_ROOT_PASSWORD=$root_password
EOF
printf created
args:
executable: /bin/sh
register: grimmory_secrets
changed_when: grimmory_secrets.stdout == 'created'
no_log: true
- name: Enforce Grimmory secret file permissions
ansible.builtin.file:
path: "{{ grimmory_root }}/.env"
owner: root
group: root
mode: "0600"
- name: Install Grimmory environment example
ansible.builtin.copy:
dest: "{{ grimmory_root }}/.env.example"
owner: root
group: root
mode: "0644"
content: |
TZ=Europe/Moscow
APP_USER_ID=1000
APP_GROUP_ID=1000
DB_USER_ID=1000
DB_GROUP_ID=1000
DB_PASSWORD=replace-with-random-password
MYSQL_ROOT_PASSWORD=replace-with-separate-random-password
- name: Install Grimmory Compose configuration
ansible.builtin.copy:
dest: "{{ grimmory_root }}/compose.yml"
owner: root
group: root
mode: "0644"
content: |
services:
grimmory:
image: {{ grimmory_image }}
container_name: grimmory
environment:
USER_ID: "${APP_USER_ID}"
GROUP_ID: "${APP_GROUP_ID}"
TZ: "${TZ}"
DATABASE_URL: jdbc:mariadb://mariadb:3306/grimmory
DATABASE_USERNAME: grimmory
DATABASE_PASSWORD: "${DB_PASSWORD}"
DISK_TYPE: LOCAL
ALLOWED_ORIGINS: https://books.ada-dev.ru
depends_on:
mariadb:
condition: service_healthy
ports:
- "192.168.1.34:6060:6060"
volumes:
- ./data:/app/data
- ./books:/books
- ./bookdrop:/bookdrop
restart: unless-stopped
mariadb:
image: {{ grimmory_mariadb_image }}
container_name: grimmory-mariadb
environment:
PUID: "${DB_USER_ID}"
PGID: "${DB_GROUP_ID}"
TZ: "${TZ}"
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
MYSQL_DATABASE: grimmory
MYSQL_USER: grimmory
MYSQL_PASSWORD: "${DB_PASSWORD}"
volumes:
- ./mariadb:/config
healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
register: grimmory_compose
- name: Install Docker firewall script
ansible.builtin.copy:
dest: /usr/local/sbin/grimmory-docker-firewall
owner: root
group: root
mode: "0755"
content: |
#!/bin/sh
set -eu
iptables -N GRIMMORY-FILTER 2>/dev/null || true
iptables -F GRIMMORY-FILTER
iptables -A GRIMMORY-FILTER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A GRIMMORY-FILTER -s {{ homelab_lan_cidr }} -p tcp -m conntrack --ctorigdst 192.168.1.34 --ctorigdstport 6060 -j ACCEPT
iptables -A GRIMMORY-FILTER -s {{ openvpn_network_cidr }} -p tcp -m conntrack --ctorigdst 192.168.1.34 --ctorigdstport 6060 -j ACCEPT
iptables -A GRIMMORY-FILTER -p tcp -m conntrack --ctorigdst 192.168.1.34 --ctorigdstport 6060 -j DROP
iptables -A GRIMMORY-FILTER -j RETURN
iptables -C DOCKER-USER -j GRIMMORY-FILTER 2>/dev/null || iptables -I DOCKER-USER 1 -j GRIMMORY-FILTER
register: grimmory_firewall_script
- name: Install Docker firewall service
ansible.builtin.copy:
dest: /etc/systemd/system/grimmory-docker-firewall.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Restrict Grimmory Docker published port
After=docker.service
Requires=docker.service
Before=grimmory.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/grimmory-docker-firewall
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
register: grimmory_firewall_unit
- name: Install Grimmory systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/grimmory.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Grimmory Compose stack
Wants=network-online.target
After=network-online.target docker.service grimmory-docker-firewall.service
Requires=docker.service grimmory-docker-firewall.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory={{ grimmory_root }}
ExecStart=/usr/bin/docker compose -f {{ grimmory_root }}/compose.yml up -d --remove-orphans
ExecStop=/usr/bin/docker compose -f {{ grimmory_root }}/compose.yml down
[Install]
WantedBy=multi-user.target
register: grimmory_unit
- name: Reload systemd for Grimmory units
ansible.builtin.systemd:
daemon_reload: true
when: grimmory_firewall_unit.changed or grimmory_unit.changed
- name: Enable and apply Docker firewall
ansible.builtin.systemd:
name: grimmory-docker-firewall
enabled: true
state: "{{ 'restarted' if grimmory_firewall_script.changed or grimmory_firewall_unit.changed else 'started' }}"
- name: Validate Grimmory Compose configuration
ansible.builtin.command: docker compose -f {{ grimmory_root }}/compose.yml config --quiet
args:
chdir: "{{ grimmory_root }}"
changed_when: false
no_log: true
- name: Enable and start Grimmory
ansible.builtin.systemd:
name: grimmory
enabled: true
state: "{{ 'restarted' if grimmory_compose.changed or grimmory_unit.changed else 'started' }}"
- name: Wait for Grimmory health endpoint
ansible.builtin.uri:
url: http://192.168.1.34:6060/api/v1/healthcheck
status_code: 200
register: grimmory_health
retries: 120
delay: 5
until: grimmory_health.status == 200
- name: Verify Docker storage driver
ansible.builtin.command: docker info --format '{{ "{{" }}.Driver{{ "}}" }}'
register: grimmory_docker_driver
changed_when: false
failed_when: grimmory_docker_driver.stdout != 'fuse-overlayfs'
+189
View File
@@ -0,0 +1,189 @@
---
- name: Guard Gyro VMID before API updates
hosts: mini-pc
gather_facts: false
tasks:
- name: Read existing VMID 150 configuration
ansible.builtin.command: pct config 150
register: gyro_existing_vmid
check_mode: false
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID 150
ansible.builtin.assert:
that:
- gyro_existing_vmid.rc != 0 or gyro_existing_hostname == 'gyro'
fail_msg: VMID 150 already exists and is not the Gyro container.
vars:
gyro_existing_hostname: >-
{{ gyro_existing_vmid.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Create Gyro LXC on mini-pc
hosts: localhost
connection: local
become: false
gather_facts: false
vars:
ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_lxc_vmid: 150
pve_lxc_node: mini-pc
pve_lxc_hostname: gyro
pve_lxc_ip: 192.168.1.35/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_disk: local-lvm:2
pve_lxc_cores: 1
pve_lxc_memory: 512
pve_lxc_swap: 256
pve_lxc_startup: order=80
pve_lxc_unprivileged: true
pve_lxc_update: false
pve_lxc_features: []
pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
roles:
- role: pve_lxc
- name: Configure Gyro LXC isolation
hosts: mini-pc
gather_facts: false
vars:
gyro_vmid: 150
tasks:
- name: Read Gyro LXC configuration
ansible.builtin.command: "pct config {{ gyro_vmid }}"
register: gyro_lxc_config
changed_when: false
- name: Require expected Gyro LXC properties
ansible.builtin.assert:
that:
- "'hostname: gyro' in gyro_lxc_config.stdout"
- "'unprivileged: 1' in gyro_lxc_config.stdout"
- "'rootfs: local-lvm:' in gyro_lxc_config.stdout"
- "'onboot: 1' in gyro_lxc_config.stdout"
- name: Check for an existing Gyro firewall file
ansible.builtin.stat:
path: "/etc/pve/firewall/{{ gyro_vmid }}.fw"
register: gyro_firewall_file
- name: Preserve the existing Gyro firewall file
ansible.builtin.slurp:
src: "/etc/pve/firewall/{{ gyro_vmid }}.fw"
register: gyro_previous_firewall
when: gyro_firewall_file.stat.exists
- name: Apply and verify Gyro firewall
block:
- name: Render Proxmox firewall for Gyro
ansible.builtin.copy:
dest: "/tmp/gyro-{{ gyro_vmid }}.fw"
owner: root
group: root
mode: "0640"
content: |
[OPTIONS]
enable: 1
policy_in: DROP
policy_out: ACCEPT
[RULES]
IN ACCEPT -source {{ homelab_lan_cidr }} -p tcp -dport 22 -log nolog
IN ACCEPT -source {{ openvpn_network_cidr }} -p tcp -dport 22 -log nolog
OUT ACCEPT -dest 192.168.1.27 -p tcp -dport 7890 -log nolog
OUT DROP -dest {{ homelab_lan_cidr }} -log nolog
register: gyro_rendered_firewall
changed_when: false
- name: Compare rendered and active Gyro firewall
ansible.builtin.command: >-
cmp -s /tmp/gyro-{{ gyro_vmid }}.fw /etc/pve/firewall/{{ gyro_vmid }}.fw
register: gyro_firewall_comparison
changed_when: false
failed_when: gyro_firewall_comparison.rc not in [0, 1]
when: gyro_firewall_file.stat.exists
- name: Install Proxmox firewall for Gyro
ansible.builtin.command: >-
cp /tmp/gyro-{{ gyro_vmid }}.fw /etc/pve/firewall/{{ gyro_vmid }}.fw
when: not gyro_firewall_file.stat.exists or gyro_firewall_comparison.rc != 0
changed_when: true
- name: Validate Proxmox firewall configuration
ansible.builtin.command: pve-firewall compile
changed_when: false
- name: Read cluster firewall options
ansible.builtin.command: pvesh get /cluster/firewall/options --output-format json
register: gyro_cluster_firewall_options
changed_when: false
- name: Report staged Proxmox firewall state
ansible.builtin.debug:
msg: >-
{{ 'Gyro Proxmox firewall is active.'
if (gyro_cluster_firewall_options.stdout | from_json).enable | default(0) | int == 1
else 'Gyro Proxmox firewall is staged but inactive because the cluster firewall is disabled; UFW remains the enforced isolation layer.' }}
- name: Verify SSH remains reachable through the firewall
ansible.builtin.wait_for_connection:
timeout: 180
delegate_to: gyro
vars:
ansible_become: false
rescue:
- name: Restore the previous Gyro firewall file
ansible.builtin.copy:
dest: "/tmp/gyro-{{ gyro_vmid }}-previous.fw"
content: "{{ gyro_previous_firewall.content | b64decode }}"
owner: root
group: root
mode: "0640"
when: gyro_firewall_file.stat.exists
- name: Reinstall the previous Gyro firewall file
ansible.builtin.command: >-
cp /tmp/gyro-{{ gyro_vmid }}-previous.fw /etc/pve/firewall/{{ gyro_vmid }}.fw
when: gyro_firewall_file.stat.exists
changed_when: true
- name: Remove the failed new Gyro firewall file
ansible.builtin.file:
path: "/etc/pve/firewall/{{ gyro_vmid }}.fw"
state: absent
when: not gyro_firewall_file.stat.exists
- name: Recompile restored Proxmox firewall configuration
ansible.builtin.command: pve-firewall compile
changed_when: false
- name: Remove temporary Gyro firewall files after rollback
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- "/tmp/gyro-{{ gyro_vmid }}.fw"
- "/tmp/gyro-{{ gyro_vmid }}-previous.fw"
- name: Stop after rolling back the Gyro firewall
ansible.builtin.fail:
msg: Gyro firewall validation or SSH reachability failed; the previous firewall state was restored.
- name: Remove temporary Gyro firewall file
ansible.builtin.file:
path: "/tmp/gyro-{{ gyro_vmid }}.fw"
state: absent
changed_when: false
- name: Wait for Gyro SSH
hosts: gyro
gather_facts: false
tasks:
- name: Wait for Gyro to accept SSH connections
ansible.builtin.wait_for_connection:
timeout: 180
+334
View File
@@ -0,0 +1,334 @@
---
- name: Create Hermes AI LXC on cloud-pc
hosts: cloud-pc
gather_facts: false
vars:
hermes_ai_vmid: 147
hermes_ai_hostname: hermes-ai
hermes_ai_ip: 192.168.1.31/24
hermes_ai_gateway: 192.168.1.1
hermes_ai_rootfs: data:24
hermes_ai_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
hermes_ai_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
tasks:
- name: Check if Hermes AI LXC exists
ansible.builtin.command: "pct config {{ hermes_ai_vmid }}"
register: hermes_ai_pct_config
changed_when: false
failed_when: false
- name: Install Hermes AI LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/hermes-ai-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', hermes_ai_pubkey_file) }}\n"
when: hermes_ai_pct_config.rc != 0
- name: Create Hermes AI LXC
ansible.builtin.command: >-
pct create {{ hermes_ai_vmid }} {{ hermes_ai_ostemplate }}
--hostname {{ hermes_ai_hostname }}
--rootfs {{ hermes_ai_rootfs }}
--cores 2
--memory 4096
--swap 512
--net0 name=eth0,bridge=vmbr0,gw={{ hermes_ai_gateway }},ip={{ hermes_ai_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=90
--cmode shell
--ssh-public-keys /tmp/hermes-ai-lxc.pub
when: hermes_ai_pct_config.rc != 0
- name: Start Hermes AI LXC
ansible.builtin.command: "pct start {{ hermes_ai_vmid }}"
register: hermes_ai_pct_start
changed_when: hermes_ai_pct_start.rc == 0
failed_when: hermes_ai_pct_start.rc not in [0, 255]
- name: Configure Hermes AI runtime
hosts: cloud-pc
gather_facts: false
vars:
hermes_ai_vmid: 147
handlers:
- name: restart Hermes AI LXC
ansible.builtin.command: "pct reboot {{ hermes_ai_vmid }}"
changed_when: true
tasks:
- name: Read Hermes AI LXC configuration
ansible.builtin.command: "pct config {{ hermes_ai_vmid }}"
register: hermes_ai_lxc_config
changed_when: false
- name: Enable keyctl for Docker in Hermes AI LXC
ansible.builtin.command: "pct set {{ hermes_ai_vmid }} --features nesting=1,keyctl=1"
when: "'keyctl=1' not in hermes_ai_lxc_config.stdout"
notify: restart Hermes AI LXC
- name: Allow FUSE device in Hermes AI LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart Hermes AI LXC
- name: Bind mount FUSE device in Hermes AI LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart Hermes AI LXC
- name: Check TUN device on PVE host
ansible.builtin.stat:
path: /dev/net/tun
register: hermes_ai_tun_device
- name: Require TUN device on PVE host
ansible.builtin.assert:
that:
- hermes_ai_tun_device.stat.exists
- hermes_ai_tun_device.stat.ischr
fail_msg: /dev/net/tun must exist on cloud-pc before enabling the Hermes AI transparent proxy.
- name: Allow TUN device in Hermes AI LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:200 rwm"
state: present
notify: restart Hermes AI LXC
- name: Bind mount TUN device in Hermes AI LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ hermes_ai_vmid }}.conf"
line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
state: present
notify: restart Hermes AI LXC
- name: Apply pending Hermes AI LXC configuration
ansible.builtin.meta: flush_handlers
- name: Wait for Hermes AI SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: hermes-ai
vars:
ansible_become: false
- name: Install Hermes AI base runtime
hosts: hermes-ai
gather_facts: false
vars:
ansible_become: false
hermes_ai_root: /opt/hermes-ai
hermes_ai_state: /srv/hermes-ai
hermes_ai_proxy_dir: /opt/hermes-ai/tun-proxy
hermes_ai_proxy_image: metacubex/mihomo@sha256:e6acd921addecfd59a8e2d38203f88356d635b54de6c0673db0e015139989312
hermes_ai_proxy_name: hermes-ai-tun-proxy
tasks:
- name: Install Hermes AI runtime packages
ansible.builtin.apt:
name:
- ca-certificates
- curl
- docker-compose
- docker.io
- fuse-overlayfs
- git
- ufw
state: present
update_cache: true
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: hermes_ai_docker_config
- name: Enable Docker service
ansible.builtin.systemd:
name: docker
enabled: true
state: "{{ 'restarted' if hermes_ai_docker_config.changed else 'started' }}"
- name: Allow SSH only from the HomeLab LAN
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ homelab_lan_cidr }}"
- name: Allow SSH from the OpenVPN network
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ openvpn_network_cidr }}"
- name: Enable restrictive Hermes AI firewall
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Create Hermes AI application directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ hermes_ai_root }}"
- "{{ hermes_ai_state }}"
- "{{ hermes_ai_proxy_dir }}"
- name: Configure Hermes AI transparent proxy
ansible.builtin.copy:
dest: "{{ hermes_ai_proxy_dir }}/config.yaml"
owner: root
group: root
mode: "0640"
content: |
ipv6: false
tun:
enable: true
stack: system
device: hermes-tun
auto-route: true
auto-redirect: true
auto-detect-interface: true
route-exclude-address:
- 192.168.1.27/32
dns-hijack:
- any:53
dns:
enable: true
enhanced-mode: redir-host
nameserver:
- https://cloudflare-dns.com/dns-query
sniffer:
enable: true
force-dns-mapping: true
parse-pure-ip: true
proxies:
- name: mihomo-lan
type: socks5
server: 192.168.1.27
port: 7891
proxy-groups:
- name: PROXY
type: select
proxies:
- mihomo-lan
rules:
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
- IP-CIDR,169.254.0.0/16,DIRECT,no-resolve
- MATCH,PROXY
register: hermes_ai_proxy_config
- name: Wait for Mihomo SOCKS5 upstream
ansible.builtin.wait_for:
host: 192.168.1.27
port: 7891
timeout: 15
- name: Validate Hermes AI transparent proxy config
ansible.builtin.command: >-
docker run --rm --network none
-v {{ hermes_ai_proxy_dir }}:/root/.config/mihomo:ro
{{ hermes_ai_proxy_image }} -t -d /root/.config/mihomo
changed_when: false
- name: Install Hermes AI transparent proxy service
ansible.builtin.copy:
dest: /etc/systemd/system/hermes-ai-tun-proxy.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Hermes AI transparent Mihomo proxy
After=docker.service network-online.target
Requires=docker.service
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f {{ hermes_ai_proxy_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ hermes_ai_proxy_name }} \
--network host \
--cap-drop ALL \
--cap-add NET_ADMIN \
--security-opt no-new-privileges \
--device /dev/net/tun \
-v {{ hermes_ai_proxy_dir }}:/root/.config/mihomo:ro \
{{ hermes_ai_proxy_image }}
ExecStop=/usr/bin/docker stop {{ hermes_ai_proxy_name }}
[Install]
WantedBy=multi-user.target
register: hermes_ai_proxy_unit
- name: Reload systemd when transparent proxy service changes
ansible.builtin.systemd:
daemon_reload: true
when: hermes_ai_proxy_unit.changed
- name: Enable Hermes AI transparent proxy
ansible.builtin.systemd:
name: hermes-ai-tun-proxy
enabled: true
state: "{{ 'restarted' if hermes_ai_proxy_config.changed or hermes_ai_proxy_unit.changed else 'started' }}"
- name: Check Hermes AI transparent proxy service
ansible.builtin.command: systemctl is-active hermes-ai-tun-proxy
register: hermes_ai_proxy_status
changed_when: false
failed_when: hermes_ai_proxy_status.stdout != 'active'
- name: Check Hermes AI external HTTPS access
ansible.builtin.command: curl --fail --silent --show-error --max-time 20 https://api.ipify.org
changed_when: false
no_log: true
- name: Document Hermes AI runtime layout
ansible.builtin.copy:
dest: "{{ hermes_ai_root }}/README.md"
owner: root
group: root
mode: "0644"
content: |
# Hermes AI runtime
Deploy the Hermes application here. Keep runtime state and the real
`.env` file in /srv/hermes-ai. Do not commit Telegram or LLM tokens.
Docker is installed with fuse-overlayfs for this unprivileged LXC.
Hermes is a Telegram bot and needs no published Docker ports. Do not
use `ports:` or `-p` without adding an explicit firewall policy:
Docker port publishing can bypass UFW.
The hermes-ai-tun-proxy service routes external traffic through
Mihomo at 192.168.1.27:7890. LAN traffic remains direct.
+305
View File
@@ -0,0 +1,305 @@
---
- name: Create memoir-bot LXC on mini-pc
hosts: mini-pc
gather_facts: false
vars:
memoir_bot_vmid: 142
memoir_bot_hostname: memoir-bot
memoir_bot_ip: 192.168.1.26/24
memoir_bot_gateway: 192.168.1.1
memoir_bot_bridge: vmbr0
memoir_bot_rootfs: local-lvm:16
memoir_bot_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
memoir_bot_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
handlers:
- name: restart memoir-bot lxc
ansible.builtin.shell: "pct stop {{ memoir_bot_vmid }} || true; pct start {{ memoir_bot_vmid }}"
changed_when: true
tasks:
- name: Check if memoir-bot LXC exists
ansible.builtin.command: "pct config {{ memoir_bot_vmid }}"
register: memoir_bot_pct_config
changed_when: false
failed_when: false
- name: Install memoir-bot LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/memoir-bot-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', memoir_bot_pubkey_file) }}\n"
when: memoir_bot_pct_config.rc != 0
- name: Create memoir-bot LXC
ansible.builtin.command: >-
pct create {{ memoir_bot_vmid }} {{ memoir_bot_ostemplate }}
--hostname {{ memoir_bot_hostname }}
--rootfs {{ memoir_bot_rootfs }}
--cores 1
--memory 512
--swap 512
--net0 name=eth0,bridge={{ memoir_bot_bridge }},gw={{ memoir_bot_gateway }},ip={{ memoir_bot_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=60
--cmode shell
--ssh-public-keys /tmp/memoir-bot-lxc.pub
when: memoir_bot_pct_config.rc != 0
- name: Start memoir-bot LXC
ansible.builtin.command: "pct start {{ memoir_bot_vmid }}"
register: memoir_bot_pct_start
changed_when: memoir_bot_pct_start.rc == 0
failed_when: memoir_bot_pct_start.rc not in [0, 255]
- name: Allow FUSE device in memoir-bot LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ memoir_bot_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart memoir-bot lxc
- name: Bind mount FUSE device in memoir-bot LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ memoir_bot_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart memoir-bot lxc
- name: Apply pending LXC config changes
ansible.builtin.meta: flush_handlers
- name: Wait for memoir-bot SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: memoir-bot
vars:
ansible_become: false
- name: Configure Docker and memoir-bot inside LXC
hosts: memoir-bot
gather_facts: true
vars:
ansible_become: false
memoir_bot_source_dir: /home/ada/Documents/Projects/Other/memoir_bot/
memoir_bot_app_dir: /opt/memoir-bot/app
memoir_bot_state_dir: /srv/memoir-bot
memoir_bot_env_file: /srv/memoir-bot/.env
memoir_bot_env_source: /home/ada/Documents/Projects/Other/memoir_bot/.env
memoir_bot_ssh_private_key_file: ~/.ssh/id_ed25519
memoir_bot_ssh_public_key_file: ~/.ssh/id_ed25519.pub
memoir_bot_vault_repo: git@github.com:ada-dmitry/SecondBrain.git
memoir_bot_vault_dir: /srv/memoir-bot/vault
memoir_bot_image: memoir-bot:local
memoir_bot_container_name: memoir-bot
tasks:
- name: Install Docker and deploy dependencies
ansible.builtin.apt:
name:
- docker.io
- fuse-overlayfs
- git
- openssh-client
- rsync
- ca-certificates
- curl
state: present
update_cache: true
- name: Ensure Docker config directory exists
ansible.builtin.file:
path: /etc/docker
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: docker_daemon_config
- name: Ensure Docker service is enabled and running
ansible.builtin.systemd:
name: docker
state: "{{ 'restarted' if docker_daemon_config.changed else 'started' }}"
enabled: true
- name: Ensure memoir-bot directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ memoir_bot_app_dir }}"
- "{{ memoir_bot_state_dir }}"
- "{{ memoir_bot_state_dir }}/ssh"
- name: Copy memoir-bot env example
ansible.builtin.copy:
dest: "{{ memoir_bot_state_dir }}/.env.example"
owner: root
group: root
mode: "0640"
content: |
BOT_TOKEN=123456:telegram-token
ALLOWED_USER_IDS=123456789
OBSIDIAN_VAULT_PATH=/srv/obsidian/vault
DAILY_NOTES_DIR=03 Journal
NOTE_PATH_FORMAT=%Y/%m/%d.%m.%y.md
MESSAGE_TIME_FORMAT=%H:%M
TELEGRAM_PROXY=
REMINDER_MIN_HOURS=2
REMINDER_MAX_HOURS=3
REMINDER_START_HOUR=7
REMINDER_END_HOUR=23
REMINDER_TIMEZONE=Europe/Moscow
- name: Copy memoir-bot env file when present locally
ansible.builtin.copy:
src: "{{ memoir_bot_env_source }}"
dest: "{{ memoir_bot_env_file }}"
owner: root
group: root
mode: "0600"
when: lookup('ansible.builtin.fileglob', memoir_bot_env_source) | length > 0
no_log: true
- name: Install GitHub SSH private key for memoir-bot
ansible.builtin.copy:
src: "{{ memoir_bot_ssh_private_key_file }}"
dest: "{{ memoir_bot_state_dir }}/ssh/id_ed25519"
owner: root
group: root
mode: "0600"
no_log: true
- name: Install GitHub SSH public key for memoir-bot when present locally
ansible.builtin.copy:
src: "{{ memoir_bot_ssh_public_key_file }}"
dest: "{{ memoir_bot_state_dir }}/ssh/id_ed25519.pub"
owner: root
group: root
mode: "0644"
when: lookup('ansible.builtin.fileglob', memoir_bot_ssh_public_key_file) | length > 0
- name: Scan GitHub SSH host key
ansible.builtin.command: ssh-keyscan github.com
register: memoir_bot_github_host_key
changed_when: false
- name: Trust GitHub SSH host key for memoir-bot
ansible.builtin.known_hosts:
path: "{{ memoir_bot_state_dir }}/ssh/known_hosts"
name: github.com
key: "{{ memoir_bot_github_host_key.stdout }}"
state: present
- name: Clone SecondBrain vault
ansible.builtin.git:
repo: "{{ memoir_bot_vault_repo }}"
dest: "{{ memoir_bot_vault_dir }}"
key_file: "{{ memoir_bot_state_dir }}/ssh/id_ed25519"
accept_hostkey: true
update: true
version: main
register: memoir_bot_vault_checkout
- name: Configure memoir-bot vault Git author name
ansible.builtin.command: git config user.name memoir-bot
args:
chdir: "{{ memoir_bot_vault_dir }}"
changed_when: false
- name: Configure memoir-bot vault Git author email
ansible.builtin.command: git config user.email memoir-bot@homelab.local
args:
chdir: "{{ memoir_bot_vault_dir }}"
changed_when: false
- name: Sync memoir-bot source code
ansible.posix.synchronize:
src: "{{ memoir_bot_source_dir }}"
dest: "{{ memoir_bot_app_dir }}/"
delete: true
rsync_opts:
- "--exclude=.env"
- "--exclude=.git"
- "--exclude=.venv"
- "--exclude=__pycache__"
- "--exclude=*.pyc"
register: memoir_bot_source_sync
- name: Check if memoir-bot image exists
ansible.builtin.command: "docker image inspect {{ memoir_bot_image }}"
register: memoir_bot_image_inspect
changed_when: false
failed_when: false
- name: Build memoir-bot image
ansible.builtin.command: "docker build -t {{ memoir_bot_image }} {{ memoir_bot_app_dir }}"
when: memoir_bot_source_sync.changed or memoir_bot_image_inspect.rc != 0
register: memoir_bot_image_build
changed_when: memoir_bot_image_build.rc == 0
- name: Install memoir-bot systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/memoir-bot.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Memoir Telegram bot container
After=docker.service
Requires=docker.service
ConditionPathExists={{ memoir_bot_env_file }}
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f {{ memoir_bot_container_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ memoir_bot_container_name }} \
--env-file {{ memoir_bot_env_file }} \
-v {{ memoir_bot_state_dir }}/vault:/srv/obsidian/vault \
-v {{ memoir_bot_state_dir }}/ssh:/root/.ssh:ro \
{{ memoir_bot_image }}
ExecStop=/usr/bin/docker stop {{ memoir_bot_container_name }}
[Install]
WantedBy=multi-user.target
register: memoir_bot_unit
- name: Reload systemd when memoir-bot unit changes
ansible.builtin.systemd:
daemon_reload: true
when: memoir_bot_unit.changed
- name: Check memoir-bot env file
ansible.builtin.stat:
path: "{{ memoir_bot_env_file }}"
register: memoir_bot_env
- name: Enable memoir-bot service
ansible.builtin.systemd:
name: memoir-bot
enabled: true
- name: Start memoir-bot when env file exists
ansible.builtin.systemd:
name: memoir-bot
state: "{{ 'restarted' if memoir_bot_source_sync.changed or memoir_bot_image_build.changed or memoir_bot_unit.changed or memoir_bot_vault_checkout.changed else 'started' }}"
when: memoir_bot_env.stat.exists
+332
View File
@@ -0,0 +1,332 @@
---
- name: Create mihomo LXC on mini-pc
hosts: mini-pc
gather_facts: false
vars:
mihomo_vmid: 143
mihomo_hostname: mihomo
mihomo_ip: 192.168.1.27/24
mihomo_gateway: 192.168.1.1
mihomo_bridge: vmbr0
mihomo_rootfs: local-lvm:8
mihomo_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
mihomo_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
handlers:
- name: restart mihomo lxc
ansible.builtin.shell: "pct stop {{ mihomo_vmid }} || true; pct start {{ mihomo_vmid }}"
changed_when: true
tasks:
- name: Check if mihomo LXC exists
ansible.builtin.command: "pct config {{ mihomo_vmid }}"
register: mihomo_pct_config
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID 143
ansible.builtin.assert:
that:
- mihomo_pct_config.rc != 0 or mihomo_existing_hostname == mihomo_hostname
fail_msg: VMID 143 already exists and is not the Mihomo container.
vars:
mihomo_existing_hostname: >-
{{ mihomo_pct_config.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Install mihomo LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/mihomo-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', mihomo_pubkey_file) }}\n"
when: mihomo_pct_config.rc != 0
- name: Create mihomo LXC
ansible.builtin.command: >-
pct create {{ mihomo_vmid }} {{ mihomo_ostemplate }}
--hostname {{ mihomo_hostname }}
--rootfs {{ mihomo_rootfs }}
--cores 1
--memory 512
--swap 512
--net0 name=eth0,bridge={{ mihomo_bridge }},gw={{ mihomo_gateway }},ip={{ mihomo_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=70
--cmode shell
--ssh-public-keys /tmp/mihomo-lxc.pub
when: mihomo_pct_config.rc != 0
- name: Start mihomo LXC
ansible.builtin.command: "pct start {{ mihomo_vmid }}"
register: mihomo_pct_start
changed_when: mihomo_pct_start.rc == 0
failed_when: mihomo_pct_start.rc not in [0, 255]
- name: Allow FUSE device in mihomo LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart mihomo lxc
- name: Bind mount FUSE device in mihomo LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart mihomo lxc
- name: Allow TUN device in mihomo LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:200 rwm"
state: present
notify: restart mihomo lxc
- name: Bind mount TUN device in mihomo LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ mihomo_vmid }}.conf"
line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
state: present
notify: restart mihomo lxc
- name: Apply pending LXC config changes
ansible.builtin.meta: flush_handlers
- name: Wait for mihomo SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: mihomo
vars:
ansible_become: false
- name: Prepare mihomo runtime host
hosts: mihomo
gather_facts: true
vars:
ansible_become: false
mihomo_config_dir: /opt/mihomo/config
mihomo_ui_dir: /opt/mihomo/ui
mihomo_image: metacubex/mihomo:v1.19.29@sha256:5e7bcc5e7a866afcc8b007ef827c9ba773f2f34b6d7311b6d39ed1751f37cfd5
mihomo_ui_image: ghcr.io/metacubex/metacubexd:v1.270.6@sha256:156d55be885d4ba6254d840bd781b715c20c00afee6e6c24c76be4cfe5eb89d4
mihomo_container_name: mihomo
mihomo_ui_container_name: mihomo-ui
tasks:
- name: Install Docker and mihomo runtime packages
ansible.builtin.apt:
name:
- docker.io
- fuse-overlayfs
- ca-certificates
- curl
- git
state: present
update_cache: true
- name: Ensure Docker config directory exists
ansible.builtin.file:
path: /etc/docker
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: docker_daemon_config
- name: Ensure Docker service is enabled and running
ansible.builtin.systemd:
name: docker
state: "{{ 'restarted' if docker_daemon_config.changed else 'started' }}"
enabled: true
- name: Ensure mihomo directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- /opt/mihomo
- "{{ mihomo_config_dir }}"
- "{{ mihomo_ui_dir }}"
- name: Check if the configured Mihomo image is present
ansible.builtin.command: "docker image inspect {{ mihomo_image }}"
register: mihomo_image_inspect
changed_when: false
failed_when: false
- name: Pull the configured Mihomo image
ansible.builtin.command: "docker pull {{ mihomo_image }}"
when: mihomo_image_inspect.rc != 0
register: mihomo_image_pull
changed_when: true
- name: Check if the configured Mihomo UI image is present
ansible.builtin.command: "docker image inspect {{ mihomo_ui_image }}"
register: mihomo_ui_image_inspect
changed_when: false
failed_when: false
- name: Pull the configured Mihomo UI image
ansible.builtin.command: "docker pull {{ mihomo_ui_image }}"
when: mihomo_ui_image_inspect.rc != 0
register: mihomo_ui_image_pull
changed_when: true
- name: Install default mihomo config if missing
ansible.builtin.copy:
dest: "{{ mihomo_config_dir }}/config.yaml"
owner: root
group: root
mode: "0640"
force: false
content: |
mixed-port: 7890
socks-port: 7891
allow-lan: true
bind-address: 0.0.0.0
mode: rule
log-level: info
external-controller: 0.0.0.0:9090
dns:
enable: true
listen: 0.0.0.0:1053
enhanced-mode: fake-ip
nameserver:
- 1.1.1.1
- 8.8.8.8
proxies: []
proxy-groups:
- name: PROXY
type: select
proxies:
- DIRECT
rules:
- MATCH,DIRECT
register: mihomo_config
- name: Install mihomo systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/mihomo.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Mihomo proxy container
After=docker.service
Requires=docker.service
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f {{ mihomo_container_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ mihomo_container_name }} \
--pull never \
--cap-add NET_ADMIN \
--device /dev/net/tun \
-p 7890:7890 \
-p 7891:7891 \
-p 9090:9090 \
-v {{ mihomo_config_dir }}:/root/.config/mihomo \
{{ mihomo_image }}
ExecStop=/usr/bin/docker stop {{ mihomo_container_name }}
[Install]
WantedBy=multi-user.target
register: mihomo_unit
- name: Install mihomo UI systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/mihomo-ui.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Mihomo MetaCubeXD web UI container
After=docker.service mihomo.service
Requires=docker.service
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f {{ mihomo_ui_container_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ mihomo_ui_container_name }} \
--pull never \
-p 8080:80 \
{{ mihomo_ui_image }}
ExecStop=/usr/bin/docker stop {{ mihomo_ui_container_name }}
[Install]
WantedBy=multi-user.target
register: mihomo_ui_unit
- name: Reload systemd when mihomo units change
ansible.builtin.systemd:
daemon_reload: true
when: mihomo_unit.changed or mihomo_ui_unit.changed
- name: Enable and start mihomo
ansible.builtin.systemd:
name: mihomo
enabled: true
state: "{{ 'restarted' if mihomo_config.changed or mihomo_unit.changed or mihomo_image_pull is changed else 'started' }}"
- name: Enable and start mihomo UI
ansible.builtin.systemd:
name: mihomo-ui
enabled: true
state: "{{ 'restarted' if mihomo_ui_unit.changed or mihomo_ui_image_pull is changed else 'started' }}"
- name: Wait for Mihomo proxy TCP ports
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: started
timeout: 120
loop:
- 7890
- 7891
- 9090
- name: Check Mihomo UI endpoint
ansible.builtin.uri:
url: http://127.0.0.1:8080/
status_code: 200
register: mihomo_ui_health
retries: 24
delay: 5
until: mihomo_ui_health.status == 200
- name: Check Mihomo controller /version endpoint
ansible.builtin.uri:
url: http://127.0.0.1:9090/version
status_code: 200
register: mihomo_controller_health
retries: 24
delay: 5
until: mihomo_controller_health.status == 200
+42
View File
@@ -0,0 +1,42 @@
---
- name: Create monitoring LXC on cloud-pc
hosts: localhost
connection: local
become: false
gather_facts: false
vars:
ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_lxc_vmid: 146
pve_lxc_node: cloud-pc
pve_lxc_hostname: monitoring
pve_lxc_ip: 192.168.1.30/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_disk: data:24
pve_lxc_cores: 2
pve_lxc_memory: 4096
pve_lxc_swap: 512
pve_lxc_startup: order=80
pve_lxc_features:
- nesting=1
pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
roles:
- role: pve_lxc
- name: Enable Docker keyctl feature for monitoring LXC
hosts: cloud-pc
gather_facts: false
tasks:
- name: Read monitoring LXC configuration
ansible.builtin.command: pct config 146
register: monitoring_lxc_config
changed_when: false
- name: Enable keyctl for Docker in monitoring LXC
ansible.builtin.command: pct set 146 --features nesting=1,keyctl=1
when: "'keyctl=1' not in monitoring_lxc_config.stdout"
register: monitoring_lxc_keyctl
- name: Restart monitoring LXC after feature change
ansible.builtin.command: pct reboot 146
when: monitoring_lxc_keyctl.changed
@@ -1,4 +1,4 @@
- name: Create wg-mini LXC on mini-pc
- name: Create ovpn-mini LXC on mini-pc
hosts: localhost
connection: local
gather_facts: false
@@ -7,19 +7,19 @@
vars:
pve_lxc_vmid: 132
pve_lxc_node: mini-pc
pve_lxc_hostname: wg-mini
pve_lxc_hostname: ovpn-mini
pve_lxc_ip: 192.168.1.23/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_storage: local-lvm
pve_lxc_disk: local-lvm:8
pve_lxc_ostemplate: "{{ lookup('env', 'PVE_LXC_OSTEMPLATE') | default('local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst', true) }}"
- name: Allow TUN device in wg-mini LXC config
- name: Allow TUN device in ovpn-mini LXC config
hosts: mini-pc
gather_facts: false
become: true
handlers:
- name: restart wg-mini lxc
- name: restart ovpn-mini lxc
ansible.builtin.shell: pct stop 132 || true; pct start 132
changed_when: true
tasks:
@@ -28,11 +28,11 @@
path: /etc/pve/lxc/132.conf
line: "lxc.cgroup2.devices.allow: c 10:200 rwm"
state: present
notify: restart wg-mini lxc
notify: restart ovpn-mini lxc
- name: Bind mount /dev/net/tun
ansible.builtin.lineinfile:
path: /etc/pve/lxc/132.conf
line: "lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file"
state: present
notify: restart wg-mini lxc
notify: restart ovpn-mini lxc
+54 -3
View File
@@ -1,3 +1,27 @@
---
- name: Guard Vaultwarden VMID before API updates
hosts: mini-pc
gather_facts: false
tasks:
- name: Read existing VMID 140 configuration
ansible.builtin.command: pct config 140
register: vaultwarden_existing_vmid
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID 140
ansible.builtin.assert:
that:
- vaultwarden_existing_vmid.rc != 0 or vaultwarden_existing_hostname == 'vaultwarden'
fail_msg: VMID 140 already exists and is not the Vaultwarden container.
vars:
vaultwarden_existing_hostname: >-
{{ vaultwarden_existing_vmid.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Create Vaultwarden LXC on mini-pc
hosts: mini-pc
gather_facts: false
@@ -84,7 +108,7 @@
vars:
ansible_become: false
vaultwarden_data_dir: /opt/vaultwarden/data
vaultwarden_image: vaultwarden/server:latest
vaultwarden_image: vaultwarden/server:1.37.1@sha256:e9efdf001bf0d68c21f2cbfb8e1d9b5961a7ca9c85e0a7e58bf51a13b997d744
vaultwarden_container_name: vaultwarden
vaultwarden_http_port: 80
tasks:
@@ -133,6 +157,18 @@
group: root
mode: "0750"
- name: Check if the configured Vaultwarden image is present
ansible.builtin.command: "docker image inspect {{ vaultwarden_image }}"
register: vaultwarden_image_inspect
changed_when: false
failed_when: false
- name: Pull the configured Vaultwarden image
ansible.builtin.command: "docker pull {{ vaultwarden_image }}"
when: vaultwarden_image_inspect.rc != 0
register: vaultwarden_image_pull
changed_when: true
- name: Install Vaultwarden systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/vaultwarden.service
@@ -151,7 +187,7 @@
ExecStartPre=-/usr/bin/docker rm -f {{ vaultwarden_container_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ vaultwarden_container_name }} \
--pull always \
--pull never \
-p {{ vaultwarden_http_port }}:80 \
-v {{ vaultwarden_data_dir }}:/data \
-e WEBSOCKET_ENABLED=true \
@@ -170,5 +206,20 @@
- name: Enable and start Vaultwarden
ansible.builtin.systemd:
name: vaultwarden
state: started
state: "{{ 'restarted' if vaultwarden_unit.changed or vaultwarden_image_pull.changed else 'started' }}"
enabled: true
- name: Wait for Vaultwarden HTTP endpoint
ansible.builtin.uri:
url: "http://127.0.0.1:{{ vaultwarden_http_port }}/"
status_code: 200
register: vaultwarden_health
retries: 24
delay: 5
until: vaultwarden_health.status == 200
- name: Verify Docker storage driver
ansible.builtin.command: docker info --format '{{ "{{" }}.Driver{{ "}}" }}'
register: vaultwarden_docker_driver
changed_when: false
failed_when: vaultwarden_docker_driver.stdout != 'fuse-overlayfs'
@@ -0,0 +1,88 @@
---
- name: Configure Grimmory reverse proxy on ru-vps
hosts: ru-vps
gather_facts: false
vars:
caddy_dir: /opt/services/ru-vps/caddy
caddyfile_path: /opt/services/ru-vps/caddy/Caddyfile
grimmory_domain: books.ada-dev.ru
grimmory_upstream: 192.168.1.34:6060
tasks:
- name: Ensure Caddy service directory exists
ansible.builtin.file:
path: "{{ caddy_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Grimmory Caddy site
ansible.builtin.blockinfile:
path: "{{ caddyfile_path }}"
create: true
owner: root
group: root
mode: "0644"
marker: "# {mark} ANSIBLE MANAGED GRIMMORY SITE"
block: |
{{ grimmory_domain }} {
@grimmory_opds_atom path /api/v1/opds /api/v1/opds/libraries /api/v1/opds/shelves /api/v1/opds/magic-shelves /api/v1/opds/authors /api/v1/opds/series /api/v1/opds/catalog /api/v1/opds/recent /api/v1/opds/surprise
handle @grimmory_opds_atom {
reverse_proxy {{ grimmory_upstream }} {
header_up Accept "application/atom+xml"
header_up Accept-Encoding identity
transport http {
compression off
}
}
}
@grimmory_opds_search path /api/v1/opds/search.opds
handle @grimmory_opds_search {
reverse_proxy {{ grimmory_upstream }} {
header_up Accept "application/opensearchdescription+xml"
header_up Accept-Encoding identity
transport http {
compression off
}
}
}
@grimmory_device_api path /api/koreader /api/koreader/* /api/v1/opds /api/v1/opds/*
handle @grimmory_device_api {
reverse_proxy {{ grimmory_upstream }} {
header_up Accept-Encoding identity
transport http {
compression off
}
}
}
handle {
reverse_proxy {{ grimmory_upstream }}
}
}
register: grimmory_caddy_site
- name: Validate host Caddy config
ansible.builtin.command: caddy validate --config {{ caddyfile_path }}
changed_when: false
- name: Check Caddy container mounted config
ansible.builtin.command: docker exec caddy grep -F '@grimmory_device_api path /api/koreader' /etc/caddy/Caddyfile
register: grimmory_container_caddyfile
changed_when: false
failed_when: false
- name: Restart Caddy when config changed or bind mount is stale
ansible.builtin.command: docker restart caddy
when: grimmory_caddy_site.changed or grimmory_container_caddyfile.rc != 0
- name: Validate Caddy container config after restart
ansible.builtin.command: docker exec caddy caddy validate --config /etc/caddy/Caddyfile
changed_when: false
- name: Check Grimmory upstream from ru-vps
ansible.builtin.uri:
url: http://{{ grimmory_upstream }}/api/v1/healthcheck
status_code: 200
+280
View File
@@ -0,0 +1,280 @@
---
- name: Harden live Mihomo on ru-vps
hosts: ru-vps
gather_facts: false
vars:
ru_vps_mihomo_harden_confirm: false
mihomo_root: /opt/services/ru-vps/mihomo
mihomo_config_path: "{{ mihomo_root }}/config/config.yaml"
mihomo_compose_path: "{{ mihomo_root }}/docker-compose.yml"
mihomo_backup_dir: /var/backups/ru-vps-mihomo
mihomo_state_dir: /var/lib/ru-vps-mihomo
mihomo_state_path: "{{ mihomo_state_dir }}/auth.json"
mihomo_harden_script_path: /usr/local/sbin/ru-vps-mihomo-harden
mihomo_harden_script_src: "{{ playbook_dir }}/../files/ru-vps-mihomo-harden.py"
mihomo_backup_path: "{{ mihomo_backup_dir }}/config.yaml.{{ lookup('pipe', 'date -u +%Y%m%d%H%M%S') }}"
pre_tasks:
- name: Require explicit Mihomo hardening confirmation
ansible.builtin.assert:
that:
- ru_vps_mihomo_harden_confirm | bool
fail_msg: Run with -e ru_vps_mihomo_harden_confirm=true.
- name: Preflight Mihomo config file exists
ansible.builtin.stat:
path: "{{ mihomo_config_path }}"
register: mihomo_config_stat
- name: Preflight Mihomo compose file exists
ansible.builtin.stat:
path: "{{ mihomo_compose_path }}"
register: mihomo_compose_stat
- name: Preflight Mihomo state file exists
ansible.builtin.stat:
path: "{{ mihomo_state_path }}"
register: mihomo_state_stat
- name: Refuse to run without live Mihomo config and compose files
ansible.builtin.assert:
that:
- mihomo_config_stat.stat.exists
- mihomo_compose_stat.stat.exists
fail_msg: Live Mihomo config or compose file is missing.
- name: Preflight Mihomo container is running
ansible.builtin.command:
argv:
- docker
- inspect
- -f
- '{{ "{{" }}.State.Running{{ "}}" }}'
- mihomo
register: mihomo_container_state
changed_when: false
failed_when: mihomo_container_state.stdout.strip() != 'true'
- name: Preflight PyYAML is available on the target
ansible.builtin.command:
argv:
- python3
- -c
- import yaml
changed_when: false
- name: Preflight current Mihomo config validates
ansible.builtin.command:
argv:
- docker
- compose
- -f
- "{{ mihomo_compose_path }}"
- exec
- -T
- mihomo
- /mihomo
- -t
- -d
- /root/.config/mihomo
changed_when: false
- name: Ensure Mihomo backup directory exists
ansible.builtin.file:
path: "{{ mihomo_backup_dir }}"
state: directory
owner: root
group: root
mode: "0700"
- name: Install root-only Mihomo hardening helper
ansible.builtin.copy:
src: "{{ mihomo_harden_script_src }}"
dest: "{{ mihomo_harden_script_path }}"
owner: root
group: root
mode: "0700"
- name: Create fresh backup of the live Mihomo config after validation
ansible.builtin.copy:
src: "{{ mihomo_config_path }}"
dest: "{{ mihomo_backup_path }}"
remote_src: true
owner: root
group: root
mode: "0600"
tasks:
- block:
- name: Apply Mihomo hardening in place
ansible.builtin.command:
argv:
- "{{ mihomo_harden_script_path }}"
- apply
- --config
- "{{ mihomo_config_path }}"
- --state
- "{{ mihomo_state_path }}"
register: mihomo_harden_apply
changed_when: (mihomo_harden_apply.stdout | from_json).changed
no_log: true
- name: Validate hardened Mihomo config in the running container
ansible.builtin.command:
argv:
- docker
- compose
- -f
- "{{ mihomo_compose_path }}"
- exec
- -T
- mihomo
- /mihomo
- -t
- -d
- /root/.config/mihomo
changed_when: false
- name: Recreate Mihomo container after config hardening
ansible.builtin.command:
argv:
- docker
- compose
- -f
- "{{ mihomo_compose_path }}"
- up
- -d
- --force-recreate
- mihomo
changed_when: true
- name: Wait for hardened Mihomo listeners on loopback
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: started
timeout: 30
loop:
- 7890
- 7891
- name: Run authenticated Mihomo SOCKS probe to Telegram
ansible.builtin.command:
argv:
- "{{ mihomo_harden_script_path }}"
- probe
- --config
- "{{ mihomo_config_path }}"
- --state
- "{{ mihomo_state_path }}"
register: mihomo_harden_probe
changed_when: false
no_log: true
- name: Check anonymous SOCKS access fails
ansible.builtin.command:
argv:
- curl
- --silent
- --show-error
- --connect-timeout
- "5"
- --max-time
- "15"
- --proxy
- socks5h://127.0.0.1:7891
- https://api.telegram.org
- --output
- /dev/null
register: mihomo_anon_probe
changed_when: false
failed_when: false
- name: Remove public Mihomo UFW rules after successful hardening
community.general.ufw:
rule: allow
port: "{{ item.port }}"
proto: "{{ item.proto }}"
delete: true
loop:
- { port: 7890, proto: tcp }
- { port: 7890, proto: udp }
- { port: 7891, proto: tcp }
- { port: 7891, proto: udp }
- name: Verify Mihomo listeners are loopback only
ansible.builtin.command:
argv:
- ss
- -H
- -ltnp
- '( sport = :7890 or sport = :7891 )'
register: mihomo_ss
changed_when: false
- name: Confirm hardened Mihomo is bound to loopback only and anonymous access fails
ansible.builtin.assert:
that:
- mihomo_ss.stdout is search('127\\.0\\.0\\.1:7890')
- mihomo_ss.stdout is search('127\\.0\\.0\\.1:7891')
- mihomo_ss.stdout is not search('0\\.0\\.0\\.0:7890|:::7890|0\\.0\\.0\\.0:7891|:::7891')
- mihomo_anon_probe.rc != 0
fail_msg: Hardened Mihomo must listen on loopback only and reject anonymous SOCKS access.
rescue:
- name: Restore the live Mihomo config from backup
ansible.builtin.copy:
src: "{{ mihomo_backup_path }}"
dest: "{{ mihomo_config_path }}"
remote_src: true
owner: root
group: root
mode: "0640"
- name: Recreate Mihomo container after rollback
ansible.builtin.command:
argv:
- docker
- compose
- -f
- "{{ mihomo_compose_path }}"
- up
- -d
- --force-recreate
- mihomo
changed_when: true
- name: Remove newly created Mihomo credentials state after rollback
ansible.builtin.file:
path: "{{ mihomo_state_path }}"
state: absent
when: not mihomo_state_stat.stat.exists
- name: Wait for restored Mihomo listeners on loopback
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: started
timeout: 30
loop:
- 7890
- 7891
- name: Validate restored Mihomo config in the running container
ansible.builtin.command:
argv:
- docker
- compose
- -f
- "{{ mihomo_compose_path }}"
- exec
- -T
- mihomo
- /mihomo
- -t
- -d
- /root/.config/mihomo
changed_when: false
- name: Fail Mihomo hardening after restoring the backup
ansible.builtin.fail:
msg: Mihomo hardening failed and the live config was restored from backup.
+6
View File
@@ -0,0 +1,6 @@
---
- name: Freeze Prometheus monitoring and configure Uptime Kuma
hosts: monitoring_server
gather_facts: false
roles:
- role: uptime_kuma
+52
View File
@@ -0,0 +1,52 @@
---
- name: Install user SSH public key on managed hosts
hosts: servers
gather_facts: false
vars:
homelab_user_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
homelab_user_pubkey: "{{ lookup('file', homelab_user_pubkey_file) }}"
tasks:
- name: Install user SSH key for connection user
ansible.posix.authorized_key:
user: "{{ ansible_user }}"
key: "{{ homelab_user_pubkey }}"
state: present
- name: Install user SSH key for root
ansible.posix.authorized_key:
user: root
key: "{{ homelab_user_pubkey }}"
state: present
become: true
when: ansible_user != 'root'
- name: Bootstrap user SSH public key into legacy LXC containers via Proxmox
hosts: pve_nodes
gather_facts: false
vars:
homelab_user_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
homelab_user_pubkey: "{{ lookup('file', homelab_user_pubkey_file) }}"
legacy_lxc_key_targets:
- node: cloud-pc
vmid: 120
name: pbs
tasks:
- name: Install user SSH key for root inside legacy LXC
ansible.builtin.shell: |
pct exec {{ item.vmid }} -- sh -c '
key=$(printf "%s" {{ homelab_user_pubkey | b64encode | quote }} | base64 -d)
mkdir -p /root/.ssh
chmod 700 /root/.ssh
touch /root/.ssh/authorized_keys
if grep -qxF "$key" /root/.ssh/authorized_keys; then
echo present
else
printf "%s\n" "$key" >> /root/.ssh/authorized_keys
echo added
fi
chmod 600 /root/.ssh/authorized_keys
'
loop: "{{ legacy_lxc_key_targets }}"
when: item.node == inventory_hostname
register: legacy_lxc_key_install
changed_when: "'added' in legacy_lxc_key_install.stdout"
+98
View File
@@ -0,0 +1,98 @@
---
- name: Create and verify Vaultwarden backup before update
hosts: vaultwarden
gather_facts: false
tasks:
- name: Create a fresh Vaultwarden offsite backup
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-restic-offsite-vaultwarden.service
changed_when: true
- name: Run Vaultwarden offsite backup audit
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-vaultwarden.service
changed_when: true
- name: Create and verify Vaultwarden PBS backup before update
hosts: mini-pc
gather_facts: false
tasks:
- name: Read existing VMID 140 configuration
ansible.builtin.command: pct config 140
register: vaultwarden_existing_vmid
changed_when: false
- name: Refuse to modify a foreign VMID 140
ansible.builtin.assert:
that:
- vaultwarden_existing_vmid.rc == 0
- vaultwarden_existing_hostname == 'vaultwarden'
fail_msg: VMID 140 already exists and is not the Vaultwarden container.
vars:
vaultwarden_existing_hostname: >-
{{ vaultwarden_existing_vmid.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Check for active Proxmox backup before Vaultwarden PBS backup
ansible.builtin.command: pgrep -x vzdump
register: vaultwarden_vzdump_preflight
changed_when: false
failed_when: false
- name: Require no active Proxmox backup before Vaultwarden PBS backup
ansible.builtin.assert:
that:
- vaultwarden_vzdump_preflight.rc != 0
fail_msg: >-
A Proxmox backup is already running on mini-pc.
Retry after the existing backup completes.
- name: Create a fresh Vaultwarden PBS backup
ansible.builtin.command:
argv:
- vzdump
- "140"
- --storage
- pbs
- --mode
- snapshot
- --prune-backups
- keep-all=1
- --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged
- name: Run current PBS backup audit on mini-pc
ansible.builtin.command:
argv:
- systemctl
- start
- --wait
- homelab-backup-audit-pbs.service
changed_when: true
- import_playbook: pve-vaultwarden.yml
- name: Verify Vaultwarden public endpoint after update
hosts: ru-vps
gather_facts: false
tasks:
- name: Check Vaultwarden HTTPS endpoint
ansible.builtin.uri:
url: https://pass.ada-dev.ru/
status_code: 200
return_content: false
register: vaultwarden_public_health
retries: 24
delay: 5
until: vaultwarden_public_health.status == 200