Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52899ecb64 | ||
|
|
712a816f88 | ||
|
|
0653361bc4 | ||
|
|
7b2900e594 | ||
|
|
56bb47e483 | ||
|
|
c1a6a2c6ca | ||
|
|
8173cf5517 | ||
|
|
d50be81ed1 | ||
|
|
357f8d79c2 | ||
|
|
0d8f436466 | ||
|
|
e33b0f584a | ||
|
|
f84df3b188 | ||
|
|
e11ba99991 | ||
|
|
43143847e9 | ||
|
|
1febebc55d | ||
|
|
aade7b5d29 | ||
|
|
02817ef45e | ||
|
|
d376f318c2 | ||
|
|
bd5bc920d8 | ||
|
|
b33c482ad3 | ||
|
|
0f68a1b344 | ||
|
|
bd6e101bfb | ||
|
|
be0f891c73 | ||
|
|
1306d2ad89 | ||
|
|
1f15062b80 | ||
|
|
8e97253d6c | ||
|
|
44149d6363 | ||
|
|
78355b58de | ||
|
|
46129f2d4a | ||
|
|
a4885926e3 | ||
|
|
ca324825a9 | ||
|
|
dd727769f6 | ||
|
|
562b2dcc32 | ||
|
|
ef6111bda3 | ||
|
|
269ccef5d2 | ||
|
|
ae6e013877 | ||
|
|
39533bce25 | ||
|
|
5e80a11f15 | ||
|
|
4a2839b35f | ||
|
|
5b47dc3271 | ||
|
|
7c9fa97423 | ||
|
|
41e756107a | ||
|
|
ef6558fbb5 | ||
|
|
7804dbe169 | ||
|
|
e4883870b8 | ||
|
|
13a18b3a96 | ||
|
|
fe6d232b3a | ||
|
|
d99628053c | ||
|
|
63fc1ce63e | ||
|
|
6c1f34e992 | ||
|
|
9e9fdf44bd | ||
|
|
b85107b95b | ||
|
|
2038cd34ae |
@@ -128,42 +128,6 @@ steps:
|
||||
- printf "Host *\n\tStrictHostKeyChecking no\n" > ~/.ssh/config
|
||||
- echo "$VAULT_PASS" > /tmp/.vault_pass && chmod 600 /tmp/.vault_pass
|
||||
- export ANSIBLE_VAULT_PASSWORD_FILE=/tmp/.vault_pass
|
||||
- |
|
||||
# Ensure Talos image exists on all Proxmox nodes via API.
|
||||
#
|
||||
# jupiter is NOT in this list: it runs Talos on bare metal and is no
|
||||
# longer a Proxmox node, so the storage API returns nothing for it and
|
||||
# the download-wait loop below would burn its full 60x10s before
|
||||
# giving up. saturn and neptune still host k8s-2/k8s-3 as VMs and
|
||||
# still need the nocloud image; bare-metal nodes boot the metal-amd64
|
||||
# image from their own installed disk instead.
|
||||
TALOS_VERSION="v1.12.6"
|
||||
TALOS_SCHEMATIC="88e3752db032a08082972bd0adf16a80849e3870c3f95cf5b669936c5699e5ad"
|
||||
TALOS_URL="https://factory.talos.dev/image/$TALOS_SCHEMATIC/$TALOS_VERSION/nocloud-amd64.raw"
|
||||
TALOS_FILENAME="talos-$TALOS_VERSION-$(echo "$TALOS_SCHEMATIC" | cut -c1-12)-nocloud-amd64.raw"
|
||||
API_TOKEN="$TF_VAR_proxmox_user!$TF_VAR_proxmox_token_id=$TF_VAR_proxmox_token_secret"
|
||||
for node in saturn neptune; do
|
||||
EXISTS=$(curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/content" \
|
||||
-H "Authorization: PVEAPIToken=$API_TOKEN" 2>/dev/null | grep -c "$TALOS_FILENAME" || true)
|
||||
if [ "$EXISTS" -gt 0 ]; then
|
||||
echo "Talos image already exists on $node"
|
||||
continue
|
||||
fi
|
||||
echo "Downloading Talos image to $node..."
|
||||
curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/download-url" \
|
||||
-H "Authorization: PVEAPIToken=$API_TOKEN" \
|
||||
--data-urlencode "url=$TALOS_URL" \
|
||||
--data-urlencode "content=import" \
|
||||
--data-urlencode "filename=$TALOS_FILENAME" \
|
||||
--max-time 600
|
||||
echo "Waiting for download on $node..."
|
||||
for i in $(seq 1 60); do
|
||||
sleep 10
|
||||
EXISTS=$(curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/content" \
|
||||
-H "Authorization: PVEAPIToken=$API_TOKEN" 2>/dev/null | grep -c "$TALOS_FILENAME" || true)
|
||||
if [ "$EXISTS" -gt 0 ]; then echo "Download complete on $node"; break; fi
|
||||
done
|
||||
done
|
||||
- tofu init -upgrade -backend-config="access_key=$AWS_ACCESS_KEY_ID" -backend-config="secret_key=$AWS_SECRET_ACCESS_KEY"
|
||||
- |
|
||||
if [ -n "$$TOFU_STATE_RM" ]; then
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
when:
|
||||
- event: cron
|
||||
evaluate: 'PIPELINE == "update-all"'
|
||||
- event: manual
|
||||
evaluate: 'PIPELINE == "update-all"'
|
||||
|
||||
timeout: 120
|
||||
|
||||
steps:
|
||||
- name: update-all
|
||||
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
|
||||
environment:
|
||||
ANSIBLE_FORCE_COLOR: "true"
|
||||
ANSIBLE_HOST_KEY_CHECKING: "false"
|
||||
ANSIBLE_ROLES_PATH: "../roles:./roles"
|
||||
SSH_PRIVATE_KEY:
|
||||
from_secret: ssh_private_key
|
||||
VAULT_PASS:
|
||||
from_secret: vault_pass
|
||||
PROXMOX_TOKEN_ID:
|
||||
from_secret: proxmox_token_id
|
||||
PROXMOX_TOKEN_SECRET:
|
||||
from_secret: proxmox_token_secret
|
||||
commands:
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
|
||||
- chmod 600 ~/.ssh/id_ed25519
|
||||
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
|
||||
- cd ansible
|
||||
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
|
||||
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
|
||||
- ansible-playbook -i inventory/ plays/update.yml -v
|
||||
@@ -1,22 +0,0 @@
|
||||
plugin: community.proxmox.proxmox
|
||||
user: root@pam
|
||||
# token_id: ansible
|
||||
# token_secret: provided via PROXMOX_TOKEN_SECRET env var
|
||||
url: 'https://jupiter.renner.uno:8006'
|
||||
validate_certs: true
|
||||
want_facts: true
|
||||
want_nodes: false
|
||||
keyed_groups:
|
||||
# proxmox_tags_parsed is an example of a fact only returned when 'want_facts=true'
|
||||
- key: proxmox_tags_parsed
|
||||
separator: "_"
|
||||
prefix: proxmox
|
||||
- key: proxmox_status
|
||||
prefix: status
|
||||
compose:
|
||||
ansible_host: >-
|
||||
(proxmox_lxc_interfaces | default([]) | selectattr('name', 'equalto', 'eth0') | map(attribute='inet') | list | first | default('') | split('/') | first)
|
||||
if proxmox_vmtype == 'lxc' else
|
||||
(proxmox_agent_interfaces | default([]) | selectattr('name', 'equalto', 'eth0') | map(attribute='ip-addresses') | list | first | list | first | default('') | split('/') | first)
|
||||
ansible_user: "'root' if proxmox_vmtype == 'lxc' else 'ladmin'"
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
32656264356562366161643464343065313163663461663038646531306532383532393238396566
|
||||
3831646133656131336266346531323035313234663336380a356536313965383066363135343739
|
||||
32663032373165643931663762333036393430333437366630326562653034636464336166313730
|
||||
6666366634346664330a643063343061623465393436656461303862336431393931643638333735
|
||||
36663561626134333639326635373735623739366366396564653763656661663866623639323233
|
||||
63326130303065663265356136343266613966333230653935343836343461623535363161346439
|
||||
64653130383539363933636437643035306536376439326539666662383064636337363265356135
|
||||
39623537303165356635373666643634383731333236643061343033376466643762373636333834
|
||||
65626435353563623938363566626330356662366666666263363038646334323731393631356531
|
||||
37346230303134326135633033393162333839346664363062633736363861343435623331343762
|
||||
36643266333965323861633836336234333839306330343961653636623439656432613662303865
|
||||
33313438316236653461346264373338393539326438666337346162326163313837626364623461
|
||||
37353135323866326536303138363165396339306237393064366335386339303238373563373064
|
||||
62646430313131373964346466373164306432363361626661653531356261313233616563353465
|
||||
33353661663930333037636637353234663231633339333436393362393363653066376337376364
|
||||
61663035396563643665353162356466623032643938353933393930656162386631376132383461
|
||||
65383265623136646261393863336635323634336136376534323231383930383632306161393136
|
||||
66366563633737613931633233666336363166313930613963396636633761633638323465306332
|
||||
65333764343762643133613036353937386338303336656530366433333031323336366165346435
|
||||
62626533363961636361306664616638396538646530633730373636643131303636333036326534
|
||||
65616361353435346161383366626334386536376630326334353837396161356637356431316364
|
||||
31643636326262383362336535346237666465363165643830613932336362623635366138303336
|
||||
66376362386531313063336266623033653736616430386635656134616461643238373831333062
|
||||
62343964343032366434316365626232656337613832313263353237353336643735353464656262
|
||||
66313765393332393663303562393438376139373565623734653432626134376630343863353061
|
||||
31383530643264643330313136356638646265663831363637363464636634356639313936336437
|
||||
33393961363930616435623864303732633564303937343638373761653761666339373433373432
|
||||
39653333396138383332353465396639346535666536373232653536393163353835613239386530
|
||||
62666536633636363035613063643965623735366333386335363066326238323835663535333162
|
||||
36613366326137303564653463393665666337363664363563643361373766663331333363326533
|
||||
36353265616261613232633833616362363433313562303536326537346662383239343965313964
|
||||
36353534666535313239316166343163343132323134313266653537646136383262626233376332
|
||||
31616166366234653632373732633061373362336137303831353763373166643031313466656434
|
||||
64623362663762316465633564633462363866373762383937386362623462333461666462613336
|
||||
64393633336137663631386562366339646464363433396366303466336561653938386639393563
|
||||
33613936356336346134323830366236326231653465373438623433613634656666386162336331
|
||||
32316637383637336266656637343639613233623739303438323033333935663261373230626661
|
||||
6635353534313662646665613438336264303835633564303166
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
# n8n group variables
|
||||
# Maps vault secrets to role defaults
|
||||
|
||||
n8n_encryption_key: "{{ vault_n8n_encryption_key }}"
|
||||
n8n_postgres_password: "{{ vault_n8n_postgres_password }}"
|
||||
@@ -1,2 +0,0 @@
|
||||
---
|
||||
ansible_user: root
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
# Docker hosts - tagged with 'docker' in Proxmox
|
||||
docker_monitoring_enabled: true
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
# --- NTP (see plays/install-ntp-ha.yml) ---
|
||||
# The PVE hosts hold the 10.2.40.4 VIP via keepalived on the VLAN 40 bridge
|
||||
# sub-interface. This role disappears when these nodes are rebuilt as bare-metal
|
||||
# Talos, which cannot run chrony or keepalived — edge-1 is being brought into
|
||||
# ntp_cluster to take over.
|
||||
ntp_keepalived_enabled: true
|
||||
ntp_keepalived_interface: "vmbr1.40"
|
||||
@@ -1,20 +0,0 @@
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
66356134353363643130323632626530653863306635396432353539623731346138323635336632
|
||||
3536633563333231373333323564373763363936356535630a333135623336613832313833626531
|
||||
34646137633633353266653536363934303939626564353036343033643462373439643139353065
|
||||
6539383436343233300a396339356562363832373962306337663464363961346335656130653235
|
||||
38646563656538373530643531643964633934623465643065626266396130643966343166313937
|
||||
65663965613937323634323436386130653633656261653065306365316135343531653737663138
|
||||
65303339323137653362383766343361643837356531643339613431333238613564343563333761
|
||||
30616530613133633762306533363134383265313031343230313764633238626565623935313134
|
||||
38323435653264643534366366313862646161373434623135366164323236363733653533656233
|
||||
34336233396465626264666637363532646562306237363533616461613764306433366639623562
|
||||
30393234396238353038356636373632326130306431663439316363646530636563666364316139
|
||||
34326333363536343963393833616465303636323166643638623061353964303265393733366234
|
||||
33373435643237636536363364373830353833346235333534626561343465373236643165646664
|
||||
39396233363531393835656463666662616633643566383061323161663061323831356337333337
|
||||
65653336663735633536353263316634373639313238343365353833646666353131656530373361
|
||||
32643133353737366465633635663339666138323035633537653462303532366632626262373532
|
||||
32643132363062623033356134356135346361343234656636663936336338303230616665326630
|
||||
65353231356538633034626332633831396362373733646539313834386535666631613463656639
|
||||
373864663230353339666137653835656630
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
# Loki server - central log aggregation host
|
||||
|
||||
# Enable syslog receiver for remote devices (Synology NAS, etc.)
|
||||
# Synology DSM's "IETF" format actually sends BSD/RFC 3164 format: <pri>MMM DD HH:MM:SS host app: msg
|
||||
# Using raw format to accept messages and parse them with regex
|
||||
alloy_syslog_receiver_enabled: true
|
||||
alloy_syslog_receiver_port: 1514
|
||||
alloy_syslog_receiver_protocol: "udp"
|
||||
alloy_syslog_receiver_format: "raw"
|
||||
alloy_stability_level: "experimental"
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
# Paperless server specific configuration
|
||||
|
||||
# Extra scrape targets for Alloy
|
||||
alloy_extra_scrape_targets:
|
||||
- name: "paperless"
|
||||
address: "127.0.0.1:9199"
|
||||
job: "paperless"
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
# Wiki server configuration
|
||||
|
||||
# Home Assistant documentation generation (disabled - see HA Wiki Documentation project)
|
||||
homeassistant_docs_enabled: false
|
||||
homeassistant_url: "http://10.2.30.11:8123"
|
||||
homeassistant_token: "{{ vault_homeassistant_token }}"
|
||||
@@ -1,31 +1,14 @@
|
||||
# Reached by name, like every other host in this file. These three were the last
|
||||
# entries still addressed by IPv4 literal; authentik, netbird, proxy, nas-1,
|
||||
# grafana and edge-1 have always used their FQDN.
|
||||
# Hosts are reached by name. An address in an inventory is allocator state
|
||||
# standing in for identity, and it pins access to the legacy family -- the names
|
||||
# resolve IPv6-first, which is the primary path on this network. DNS is the
|
||||
# authoritative source for where a host is; this file says which host, not where
|
||||
# it currently sits.
|
||||
#
|
||||
# An address in an inventory is allocator state standing in for identity, and it
|
||||
# pins access to the legacy family -- the names resolve IPv6-first, which is the
|
||||
# primary path on this network. DNS is the authoritative source for where a host
|
||||
# is; this file should say which host, not where it currently sits.
|
||||
#
|
||||
# The short inventory_hostname is kept deliberately: plays key on it
|
||||
# (ntp_servers[inventory_hostname] in install-ntp-ha.yml, node names in opentofu,
|
||||
# the prometheus targets), so only ansible_host changes.
|
||||
[pve_cluster]
|
||||
jupiter ansible_host=jupiter.renner.uno ansible_user=root
|
||||
neptune ansible_host=neptune.renner.uno ansible_user=root
|
||||
saturn ansible_host=saturn.renner.uno ansible_user=root
|
||||
|
||||
[authentik_db]
|
||||
authentik-db-1.srv.renner.uno ansible_user=root
|
||||
authentik-db-2.srv.renner.uno ansible_user=root
|
||||
|
||||
[authentik:children]
|
||||
authentik_db
|
||||
authentik_app
|
||||
|
||||
[authentik_app]
|
||||
authentik-app-1.srv.renner.uno ansible_user=root
|
||||
authentik-app-2.srv.renner.uno ansible_user=root
|
||||
# Gone from this file, and why:
|
||||
# pve_cluster (jupiter/neptune/saturn) run Talos on bare metal; no SSH at all
|
||||
# authentik_db / authentik_app authentik runs in-cluster (CNPG)
|
||||
# grafana runs in-cluster
|
||||
# Their hosts no longer resolve, except the three that resolve to Talos nodes.
|
||||
|
||||
[netbird_server]
|
||||
netbird.renner.uno ansible_user=root
|
||||
@@ -38,9 +21,6 @@ proxy-2.renner.uno ansible_user=root
|
||||
[synology_nas]
|
||||
nas-1.renner.uno ansible_user=ladmin ansible_python_interpreter=/usr/bin/python3
|
||||
|
||||
[grafana]
|
||||
grafana.srv.renner.uno ansible_user=ladmin
|
||||
|
||||
# Every bare-metal edge thin client. Gets the host-generic roles only:
|
||||
# flash-wear handling, the log shipper, the MQTT bridge, the VLAN
|
||||
# sub-interface. Nothing here assumes any peripheral is attached.
|
||||
@@ -71,12 +51,21 @@ zwave_gateway
|
||||
heating_can
|
||||
power_meter
|
||||
|
||||
# NTP servers. Includes edge-1 so the role survives the PVE hosts being
|
||||
# retired for bare-metal Talos, which cannot run chrony or keepalived itself.
|
||||
# See plays/install-ntp-ha.yml for why keepalived is scoped more narrowly
|
||||
# than chrony within this group.
|
||||
# NTP servers. edge-1 was added here so the role would survive the PVE hosts
|
||||
# being retired for bare-metal Talos, which cannot run chrony or keepalived
|
||||
# itself. That retirement has now happened, so edge-1 is the ONLY member.
|
||||
#
|
||||
# THIS IS NO LONGER HIGHLY AVAILABLE. The group was jupiter (MASTER, prio 100),
|
||||
# saturn (90), neptune (80) and edge-1 (BACKUP, 70); the first three are gone.
|
||||
# A single VRRP member takes the address regardless of its configured state, so
|
||||
# fd40::123 is still served — verified answering — but by one thin client with
|
||||
# no failover behind it. install-ntp-ha.yml still carries the four-host
|
||||
# ntp_servers priority map, which now describes hosts that do not exist.
|
||||
#
|
||||
# Restoring redundancy needs a second chrony host that is not a Talos node.
|
||||
# See plays/install-ntp-ha.yml for why keepalived is scoped more narrowly than
|
||||
# chrony within this group.
|
||||
[ntp_cluster:children]
|
||||
pve_cluster
|
||||
edge_host
|
||||
|
||||
# Off-site Debian VM (Synology VMM on nas-2, 10.3.40.0/24) running Claude Code.
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
# Backup Authentik PostgreSQL database for K8s migration
|
||||
# Creates a pg_dump on the primary DB host
|
||||
#
|
||||
# Usage (via CI):
|
||||
# ansible-playbook -i inventory/ plays/backup-authentik-db.yml
|
||||
#
|
||||
# Output: /tmp/authentik-backup.sql on the primary DB host
|
||||
|
||||
- name: Backup Authentik PostgreSQL database
|
||||
hosts: authentik-db-1.srv.renner.uno
|
||||
become: true
|
||||
tasks:
|
||||
- name: Create database backup
|
||||
become_user: postgres
|
||||
command: >
|
||||
pg_dump
|
||||
--clean --if-exists
|
||||
--no-owner --no-privileges
|
||||
--format=custom
|
||||
--file=/tmp/authentik-backup.dump
|
||||
{{ authentik_db_name | default('authentik') }}
|
||||
changed_when: true
|
||||
|
||||
- name: Make backup readable
|
||||
file:
|
||||
path: /tmp/authentik-backup.dump
|
||||
mode: "0644"
|
||||
|
||||
- name: Get backup size
|
||||
stat:
|
||||
path: /tmp/authentik-backup.dump
|
||||
register: backup_stat
|
||||
|
||||
- name: Report backup size
|
||||
debug:
|
||||
msg: "Backup created: {{ (backup_stat.stat.size / 1024 / 1024) | round(1) }} MB"
|
||||
|
||||
- name: Fetch backup to controller
|
||||
fetch:
|
||||
src: /tmp/authentik-backup.dump
|
||||
dest: /tmp/authentik-backup.dump
|
||||
flat: true
|
||||
|
||||
- name: Clean up remote backup
|
||||
file:
|
||||
path: /tmp/authentik-backup.dump
|
||||
state: absent
|
||||
@@ -1,59 +0,0 @@
|
||||
---
|
||||
# Dump Paperless-ngx DB from the legacy VM to a local file on the
|
||||
# controller (CI runner), where a downstream pipeline step consumes it
|
||||
# to produce a Barman seed for the CNPG `bootstrap.recovery` path.
|
||||
# Data + media tarballs are handled separately by `backup-paperless.yml`.
|
||||
|
||||
- name: Dump Paperless DB from legacy VM to CI runner
|
||||
hosts: paperless.srv.renner.uno
|
||||
become: true
|
||||
vars:
|
||||
paperless_db_name: paperless
|
||||
paperless_db_user: paperless
|
||||
tasks:
|
||||
- name: Find running paperless DB container
|
||||
ansible.builtin.shell: >
|
||||
docker ps --filter "label=com.docker.compose.service=db"
|
||||
--format '{{ "{{" }}.Names{{ "}}" }}' | head -1
|
||||
register: paperless_db_container
|
||||
changed_when: false
|
||||
|
||||
- name: Fail if DB container not found
|
||||
ansible.builtin.fail:
|
||||
msg: "Could not find paperless DB container (compose service=db)"
|
||||
when: paperless_db_container.stdout | trim == ""
|
||||
|
||||
- name: Dump DB to /tmp on legacy host
|
||||
ansible.builtin.shell: >
|
||||
docker exec -i {{ paperless_db_container.stdout | trim }}
|
||||
pg_dump --clean --if-exists --no-owner --no-privileges --format=custom
|
||||
-U {{ paperless_db_user }} {{ paperless_db_name }}
|
||||
> /tmp/paperless-backup.dump
|
||||
args:
|
||||
executable: /bin/bash
|
||||
changed_when: true
|
||||
|
||||
- name: Make dump readable
|
||||
ansible.builtin.file:
|
||||
path: /tmp/paperless-backup.dump
|
||||
mode: "0644"
|
||||
|
||||
- name: Report dump size
|
||||
ansible.builtin.stat:
|
||||
path: /tmp/paperless-backup.dump
|
||||
register: dump_stat
|
||||
|
||||
- name: Show dump size
|
||||
ansible.builtin.debug:
|
||||
msg: "Dump created: {{ (dump_stat.stat.size / 1024 / 1024) | round(1) }} MB"
|
||||
|
||||
- name: Fetch dump to controller
|
||||
ansible.builtin.fetch:
|
||||
src: /tmp/paperless-backup.dump
|
||||
dest: /tmp/paperless-backup.dump
|
||||
flat: true
|
||||
|
||||
- name: Remove remote dump
|
||||
ansible.builtin.file:
|
||||
path: /tmp/paperless-backup.dump
|
||||
state: absent
|
||||
@@ -1,95 +0,0 @@
|
||||
---
|
||||
- name: Check for Distribution Upgrades
|
||||
hosts: proxmox_all_qemu:&status_running:!proxmox_noansible
|
||||
become: true
|
||||
gather_facts: true
|
||||
ignore_unreachable: true
|
||||
|
||||
tasks:
|
||||
- name: Check Ubuntu Release Availability
|
||||
ansible.builtin.command: do-release-upgrade -c
|
||||
register: ubuntu_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when:
|
||||
- ansible_distribution is defined
|
||||
- ansible_distribution == 'Ubuntu'
|
||||
|
||||
- name: Determine Upgrade Status
|
||||
ansible.builtin.set_fact:
|
||||
upgrade_needed: >-
|
||||
{%- if ansible_distribution is not defined -%}
|
||||
unknown
|
||||
{%- elif ansible_distribution == 'Ubuntu' -%}
|
||||
{%- if ubuntu_check.stdout is defined and 'New release' in ubuntu_check.stdout -%}
|
||||
true
|
||||
{%- else -%}
|
||||
false
|
||||
{%- endif -%}
|
||||
{%- elif ansible_distribution == 'Debian' -%}
|
||||
{%- if ansible_distribution_major_version | int < 12 -%}
|
||||
true
|
||||
{%- else -%}
|
||||
false
|
||||
{%- endif -%}
|
||||
{%- else -%}
|
||||
false
|
||||
{%- endif -%}
|
||||
current_version_str: "{{ ansible_distribution }} {{ ansible_distribution_version }}"
|
||||
target_version_str: >-
|
||||
{%- if ansible_distribution is not defined -%}
|
||||
Unknown
|
||||
{%- elif ansible_distribution == 'Ubuntu' -%}
|
||||
{%- if ubuntu_check.stdout is defined and 'New release' in ubuntu_check.stdout -%}
|
||||
{{ (ubuntu_check.stdout | regex_findall("New release '([^']+)'") | first) | default('Unknown') }}
|
||||
{%- else -%}
|
||||
-
|
||||
{%- endif -%}
|
||||
{%- elif ansible_distribution == 'Debian' -%}
|
||||
{%- if ansible_distribution_major_version | int < 12 -%}
|
||||
Debian 12 (Bookworm)
|
||||
{%- else -%}
|
||||
-
|
||||
{%- endif -%}
|
||||
{%- else -%}
|
||||
-
|
||||
{%- endif -%}
|
||||
when: ansible_distribution is defined
|
||||
|
||||
- name: Group hosts by status
|
||||
ansible.builtin.group_by:
|
||||
key: "upgrade_{{ upgrade_needed | trim }}"
|
||||
when: upgrade_needed is defined
|
||||
|
||||
- name: Generate Report
|
||||
hosts: localhost
|
||||
gather_facts: false
|
||||
tasks:
|
||||
- name: Display Upgrade Candidates
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ report.splitlines() }}"
|
||||
vars:
|
||||
report: |
|
||||
|
||||
DISTRIBUTION UPGRADE REPORT
|
||||
===========================
|
||||
|
||||
REQUIRES UPGRADE
|
||||
================
|
||||
{{ '%-30s' | format('HOST') }} {{ '%-20s' | format('CURRENT') }} {{ 'TARGET' }}
|
||||
{{ '------------------------------' }} {{ '--------------------' }} {{ '--------------------' }}
|
||||
{% for host in groups['upgrade_true'] | default([]) %}
|
||||
{{ '%-30s' | format(host) }} {{ '%-20s' | format(hostvars[host]['current_version_str']) }} {{ hostvars[host]['target_version_str'] }}
|
||||
{% endfor %}
|
||||
{% if groups['upgrade_true'] is not defined or groups['upgrade_true'] | length == 0 %}
|
||||
(No hosts require upgrade)
|
||||
{% endif %}
|
||||
|
||||
UP TO DATE
|
||||
==========
|
||||
{{ '%-30s' | format('HOST') }} {{ 'CURRENT' }}
|
||||
{{ '------------------------------' }} {{ '--------------------' }}
|
||||
{% for host in groups['upgrade_false'] | default([]) %}
|
||||
{{ '%-30s' | format(host) }} {{ hostvars[host]['current_version_str'] }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
- name: Ensure debian-12 template exists on saturn
|
||||
hosts: saturn
|
||||
become: true
|
||||
tasks:
|
||||
- name: Download debian-12 template
|
||||
command: pveam download local debian-12-standard_12.12-1_amd64.tar.zst
|
||||
args:
|
||||
creates: /var/lib/vz/template/cache/debian-12-standard_12.12-1_amd64.tar.zst
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
- name: Install Custom CA Certificates
|
||||
hosts: all:!proxmox_noansible
|
||||
hosts: all
|
||||
become: true
|
||||
roles:
|
||||
- certificates
|
||||
|
||||
- name: Base System Hardening & Common Configuration
|
||||
hosts: all:!pve:!pbs:!proxmox_noansible:!proxmox_nodes:!pve_cluster
|
||||
hosts: all
|
||||
become: true
|
||||
ignore_unreachable: true
|
||||
gather_facts: true
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
# Configure Grafana to use Authentik for authentication
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. Create OAuth2/OpenID application in Authentik:
|
||||
# - Name: Grafana
|
||||
# - Slug: grafana
|
||||
# - Client type: Confidential
|
||||
# - Redirect URIs: https://dash.renner.uno/login/generic_oauth
|
||||
# - Signing Key: authentik Self-signed Certificate
|
||||
#
|
||||
# 2. Add to vault (ansible/inventory/group_vars/all/vault.yml):
|
||||
# vault_grafana_authentik_client_id: "<client-id-from-authentik>"
|
||||
# vault_grafana_authentik_client_secret: "<client-secret-from-authentik>"
|
||||
#
|
||||
# 3. (Optional) Create groups in Authentik:
|
||||
# - "Grafana Admins" - gets Admin role
|
||||
# - "Grafana Editors" - gets Editor role
|
||||
# - Others get Viewer role
|
||||
|
||||
- name: Configure Grafana Authentik Authentication
|
||||
hosts: grafana.srv.renner.uno
|
||||
become: yes
|
||||
roles:
|
||||
- grafana-authentik
|
||||
|
||||
post_tasks:
|
||||
- name: Display configuration status
|
||||
debug:
|
||||
msg: |
|
||||
Grafana Authentik OAuth configured!
|
||||
|
||||
Users can now login at: {{ grafana_root_url }}
|
||||
|
||||
Role mapping:
|
||||
- Members of "Grafana Admins" -> Admin
|
||||
- Members of "Grafana Editors" -> Editor
|
||||
- All others -> Viewer
|
||||
|
||||
If auto_login is enabled, users will be redirected to Authentik automatically.
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
# Set sync=disabled on the zvols backing the K8s control-plane VM root disks
|
||||
# (k8s-1/2/3, VMIDs 150/151/152) to eliminate ZFS fsync amplification on the
|
||||
# shared local-zfs pool. Trades a small durability window for major fsync
|
||||
# latency reduction on etcd's WAL writes:
|
||||
#
|
||||
# - etcd's WAL fsync returns immediately; ZFS still flushes via txgs
|
||||
# (default ~5s)
|
||||
# - Worst case on hard power loss: up to ~5s of writes lost per CP member
|
||||
# - With 3-member quorum and Velero backups, this is acceptable for homelab
|
||||
# - The whole zvol is sync=disabled (Talos OS, containerd, kubelet
|
||||
# ephemeral, etcd WAL all share it), but etcd is the dominant fsync user;
|
||||
# other writers tolerate small loss windows
|
||||
#
|
||||
# Each PVE host owns exactly one CP zvol (jupiter→150, saturn→151, neptune→152).
|
||||
# Auto-discovery handles whatever the host has — no need to update if a CP
|
||||
# VM ever moves.
|
||||
#
|
||||
# Idempotent: skips zvols that already have sync=disabled.
|
||||
#
|
||||
# Run manually against freshly-created CP zvols so they get the property
|
||||
# before Talos starts writing (previously wired into the now-removed
|
||||
# rebuild-k8s pipeline between `recreate-vms` and `talos-bootstrap`).
|
||||
|
||||
- name: Set sync=disabled on K8s CP VM zvols
|
||||
hosts: pve_cluster
|
||||
become: yes
|
||||
gather_facts: no
|
||||
|
||||
tasks:
|
||||
- name: Discover CP-VM zvols on this host
|
||||
shell: |
|
||||
set -o pipefail
|
||||
zfs list -H -t volume -o name | grep -E "/vm-(150|151|152)-disk-[0-9]+$" || true
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: cp_zvols
|
||||
changed_when: false
|
||||
|
||||
- name: Read current sync property
|
||||
shell: "zfs get -H -o value sync {{ item }}"
|
||||
register: current_sync
|
||||
changed_when: false
|
||||
loop: "{{ cp_zvols.stdout_lines }}"
|
||||
when: cp_zvols.stdout_lines | length > 0
|
||||
|
||||
- name: Set sync=disabled where not already
|
||||
shell: "zfs set sync=disabled {{ item.item }}"
|
||||
when:
|
||||
- cp_zvols.stdout_lines | length > 0
|
||||
- item.stdout | trim != "disabled"
|
||||
loop: "{{ current_sync.results | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
|
||||
- name: Verify sync=disabled
|
||||
shell: "zfs get -H -o value sync {{ item }}"
|
||||
register: verify_sync
|
||||
changed_when: false
|
||||
failed_when: verify_sync.stdout | trim != "disabled"
|
||||
loop: "{{ cp_zvols.stdout_lines }}"
|
||||
when: cp_zvols.stdout_lines | length > 0
|
||||
|
||||
- name: Summary
|
||||
debug:
|
||||
msg: >-
|
||||
{{ inventory_hostname }}: tuned {{ cp_zvols.stdout_lines | length }}
|
||||
zvol(s) [{{ cp_zvols.stdout_lines | join(', ') }}]
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
# Configure Proxmox VE Node Host Networking
|
||||
#
|
||||
# Fixes two defects found by live inspection on 2026-07-19:
|
||||
#
|
||||
# 1. All three nodes had no IPv6 address on any external interface. FRR
|
||||
# enables ipv6 forwarding for the OSPFv3 corosync mesh, and with the
|
||||
# kernel default accept_ra=1 the nodes silently discarded the Router
|
||||
# Advertisements the gateway sends on VLAN 40. Fixed with accept_ra=2.
|
||||
#
|
||||
# 2. saturn's vmbr0 sits at MTU 1500 while its own uplink and both peer
|
||||
# nodes are at 9000, so large frames between saturn's guests and the
|
||||
# other nodes' guests blackhole instead of fragmenting.
|
||||
#
|
||||
# The sysctl fix is non-disruptive and applies on every run. The MTU fix is
|
||||
# staged as a pending PVE network change and only applied when explicitly
|
||||
# requested, because applying it reloads networking on the node and briefly
|
||||
# interrupts the Talos k8s VM and its Ceph OSD.
|
||||
#
|
||||
# Safe (converges IPv6, stages the MTU change without applying it):
|
||||
# ansible-playbook -i inventory/ plays/configure-pve-node-network.yml
|
||||
#
|
||||
# Applying the staged MTU change, one node at a time:
|
||||
# ansible-playbook -i inventory/ plays/configure-pve-node-network.yml \
|
||||
# --limit saturn -e pve_node_network_apply_pending=true
|
||||
#
|
||||
# See wiki: pve-ipv6-accept-ra-forwarding-defect,
|
||||
# pve-saturn-vmbr0-mtu-asymmetry
|
||||
|
||||
- name: Configure PVE node host networking
|
||||
hosts: pve_cluster
|
||||
become: yes
|
||||
serial: 1
|
||||
|
||||
roles:
|
||||
- role: pve-node-network
|
||||
|
||||
post_tasks:
|
||||
- name: Summarise node IPv6 state
|
||||
ansible.builtin.command: ip -6 addr show dev vmbr1.40 scope global
|
||||
register: pve_node_network_v6_summary
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Display node IPv6 state
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
{{ inventory_hostname }}:
|
||||
{{ pve_node_network_v6_summary.stdout_lines
|
||||
| select('search', 'inet6')
|
||||
| map('trim')
|
||||
| list
|
||||
| default(['no global IPv6'], true) }}
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
- name: Setup PostgreSQL HA Cluster
|
||||
hosts: authentik_db
|
||||
become: true
|
||||
vars:
|
||||
# Set this in inventory or pass as extra var.
|
||||
# For initial setup, we define authentik-db-1 as primary.
|
||||
postgres_primary_host: "authentik-db-1.srv.renner.uno"
|
||||
|
||||
roles:
|
||||
- common
|
||||
- postgres-simple-ha
|
||||
|
||||
- name: Setup Authentik Stateless App
|
||||
hosts: authentik_app
|
||||
become: true
|
||||
roles:
|
||||
- common
|
||||
- docker-install
|
||||
- authentik-stateless
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
- name: Install and Configure Gatus
|
||||
hosts: proxmox_gatus
|
||||
become: true
|
||||
roles:
|
||||
- role: common
|
||||
- role: docker-install
|
||||
- role: gatus
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
- name: Install and Configure Grist
|
||||
hosts: grist
|
||||
become: true
|
||||
roles:
|
||||
- role: common
|
||||
- role: docker-install
|
||||
- role: grist
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
- name: Install and Configure ical-proxy
|
||||
hosts: proxmox_ical_proxy
|
||||
become: true
|
||||
roles:
|
||||
- role: common
|
||||
- role: docker-install
|
||||
- role: ical-proxy
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
# Deploy MkDocs wiki from Obsidian vault
|
||||
#
|
||||
# Usage:
|
||||
# ansible-playbook -i inventory plays/install-mkdocs-wiki.yml
|
||||
#
|
||||
- name: Deploy MkDocs Wiki
|
||||
hosts: proxmox_wiki
|
||||
become: true
|
||||
|
||||
pre_tasks:
|
||||
- name: Ensure wiki group exists
|
||||
ansible.builtin.group:
|
||||
name: wiki
|
||||
system: true
|
||||
state: present
|
||||
|
||||
roles:
|
||||
- role: mkdocs-wiki
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
- name: Install and Configure Music Assistant
|
||||
hosts: proxmox_music_assistant
|
||||
become: true
|
||||
roles:
|
||||
- role: common
|
||||
- role: docker-install
|
||||
- role: music-assistant
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
- name: Install and Configure n8n
|
||||
hosts: n8n
|
||||
become: true
|
||||
roles:
|
||||
- role: common
|
||||
- role: docker-install
|
||||
- role: n8n
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
# Install Node Exporter and Chrony Exporter on Proxmox VE hosts
|
||||
# Usage: ansible-playbook -i inventory/ plays/install-node-exporter-pve.yml
|
||||
|
||||
- name: Install Exporters on PVE Cluster
|
||||
hosts: pve_cluster
|
||||
become: yes
|
||||
gather_facts: yes
|
||||
|
||||
roles:
|
||||
- node-exporter
|
||||
- chrony-exporter
|
||||
|
||||
post_tasks:
|
||||
- name: Verify node-exporter is running
|
||||
uri:
|
||||
url: "http://127.0.0.1:9100/metrics"
|
||||
status_code: 200
|
||||
register: node_exporter_health
|
||||
retries: 3
|
||||
delay: 5
|
||||
until: node_exporter_health.status == 200
|
||||
|
||||
- name: Verify chrony-exporter is running
|
||||
uri:
|
||||
url: "http://127.0.0.1:9123/metrics"
|
||||
status_code: 200
|
||||
register: chrony_exporter_health
|
||||
retries: 3
|
||||
delay: 5
|
||||
until: chrony_exporter_health.status == 200
|
||||
|
||||
- name: Verify chrony metrics include stratum
|
||||
uri:
|
||||
url: "http://127.0.0.1:9123/metrics"
|
||||
return_content: yes
|
||||
register: chrony_metrics
|
||||
|
||||
- name: Check chrony exporter output
|
||||
debug:
|
||||
msg: "Chrony stratum metrics available: {{ 'chrony_tracking_stratum' in chrony_metrics.content }}"
|
||||
@@ -3,7 +3,7 @@
|
||||
# Usage: ansible-playbook -i inventory/ plays/install-node-exporter.yml
|
||||
|
||||
- name: Install Node Exporter
|
||||
hosts: all:!pve_cluster
|
||||
hosts: all
|
||||
become: yes
|
||||
gather_facts: yes
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
- name: Install and Configure ownCloud Infinite Scale
|
||||
hosts: proxmox_owncloud
|
||||
become: true
|
||||
roles:
|
||||
- role: common
|
||||
- role: docker-install
|
||||
- role: owncloud
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
# Deploy monitoring agents to Proxmox VE nodes
|
||||
# Usage: ansible-playbook -i inventory/ plays/setup-pve-monitoring.yml
|
||||
|
||||
- name: Deploy Monitoring Agents to Proxmox Nodes
|
||||
hosts: pve_cluster
|
||||
become: yes
|
||||
gather_facts: yes
|
||||
|
||||
vars:
|
||||
# Monitoring endpoints
|
||||
loki_endpoint: "http://loki.srv.renner.uno:3100"
|
||||
prometheus_remote_write_endpoint: "http://prometheus.srv.renner.uno:9090/api/v1/write"
|
||||
|
||||
# PVE-specific labels
|
||||
alloy_extra_labels:
|
||||
environment: "production"
|
||||
datacenter: "homelab"
|
||||
type: "hypervisor"
|
||||
|
||||
roles:
|
||||
- monitoring-agent
|
||||
|
||||
post_tasks:
|
||||
- name: Verify Alloy agent is running
|
||||
systemd:
|
||||
name: alloy
|
||||
state: started
|
||||
register: alloy_status
|
||||
|
||||
- name: Display agent status
|
||||
debug:
|
||||
msg: "Monitoring agent deployed to {{ inventory_hostname }}"
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
- name: Install HashiCorp Vault
|
||||
hosts: vault.srv.renner.uno
|
||||
become: true
|
||||
roles:
|
||||
- common
|
||||
- vault
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
- name: Update Authentik HA
|
||||
hosts: authentik_app
|
||||
become: true
|
||||
serial: 1
|
||||
vars:
|
||||
authentik_path: /home/ladmin
|
||||
|
||||
roles:
|
||||
- authentik-update
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
- name: Update Docker Containers
|
||||
hosts: proxmox_docker
|
||||
become: true
|
||||
gather_facts: false
|
||||
|
||||
vars:
|
||||
# Define the root directory where your docker-compose files live.
|
||||
# You can override this per-host in host_vars if they differ.
|
||||
docker_compose_dir: "/opt"
|
||||
|
||||
roles:
|
||||
- docker-update
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
- name: Update LXC Containers
|
||||
hosts: proxmox_all_lxc:!proxmox_noansible
|
||||
become: true
|
||||
gather_facts: true
|
||||
|
||||
tasks:
|
||||
- name: Update APT Cache
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
when: ansible_pkg_mgr == 'apt'
|
||||
|
||||
- name: Upgrade all packages
|
||||
ansible.builtin.apt:
|
||||
upgrade: dist
|
||||
when: ansible_pkg_mgr == 'apt'
|
||||
register: apt_upgrade
|
||||
|
||||
- name: Check if reboot required
|
||||
ansible.builtin.stat:
|
||||
path: /var/run/reboot-required
|
||||
register: reboot_required_file
|
||||
when: ansible_pkg_mgr == 'apt'
|
||||
|
||||
- name: Reboot if required
|
||||
ansible.builtin.reboot:
|
||||
msg: "Rebooting due to system updates"
|
||||
when:
|
||||
- ansible_pkg_mgr == 'apt'
|
||||
- reboot_required_file.stat.exists
|
||||
- apt_upgrade.changed
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
- name: Proxmox VE Rolling Update
|
||||
hosts: proxmox_nodes # Replace with your inventory group name
|
||||
become: true
|
||||
serial: 1 # CRITICAL: Only update one node at a time
|
||||
any_errors_fatal: true
|
||||
|
||||
roles:
|
||||
- pve-update
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
- name: Update Proxmox VMs (Linux)
|
||||
hosts: proxmox_all_qemu:&status_running:!proxmox_noansible:!proxmox_eol
|
||||
become: true
|
||||
serial: 1
|
||||
gather_facts: false # We gather manually after checking connection
|
||||
|
||||
roles:
|
||||
- vm-update
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
- name: Full Infrastructure Update
|
||||
hosts: localhost
|
||||
gather_facts: false
|
||||
tasks:
|
||||
- name: Banner
|
||||
debug:
|
||||
msg: "Starting Full Infrastructure Update Sequence"
|
||||
|
||||
- import_playbook: update-vm.yml
|
||||
- import_playbook: update-lxc.yml
|
||||
- import_playbook: update-authentik.yml
|
||||
# Re-render docker-compose templates before pulling updates
|
||||
# (immich decommissioned — migrated to in-cluster deployment, see kubernetes/apps/immich/)
|
||||
- import_playbook: install-n8n.yml
|
||||
- import_playbook: install-grist.yml
|
||||
- import_playbook: install-music-assistant.yml
|
||||
- import_playbook: update-docker.yml
|
||||
- import_playbook: update-pve-rolling.yml
|
||||
|
||||
- name: Update Summary
|
||||
hosts: localhost
|
||||
gather_facts: false
|
||||
tasks:
|
||||
- name: Done
|
||||
debug:
|
||||
msg: "Full Infrastructure Update Completed Successfully"
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
authentik_path: /home/ladmin
|
||||
authentik_db_host: "authentik-db.renner.uno"
|
||||
authentik_db_user: "authentik"
|
||||
authentik_db_name: "authentik"
|
||||
authentik_db_password: "changeMeInVault"
|
||||
authentik_secret_key: "changeMeInVault"
|
||||
authentik_tag: "latest" # or "2025.10.x"
|
||||
|
||||
authentik_port_http: 9000
|
||||
authentik_port_https: 9443
|
||||
|
||||
authentik_email_host: "smtp.example.com"
|
||||
authentik_email_port: 587
|
||||
authentik_email_user: "user"
|
||||
authentik_email_password: "password"
|
||||
authentik_email_use_tls: true
|
||||
authentik_email_from: "authentik@example.com"
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
- name: Restart Authentik
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ authentik_path }}"
|
||||
state: restarted
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
- name: Ensure Authentik directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ authentik_path }}"
|
||||
state: directory
|
||||
owner: "{{ ansible_user | default('root') }}"
|
||||
group: "{{ ansible_user | default('root') }}"
|
||||
|
||||
- name: Get latest Authentik release if no tag specified
|
||||
ansible.builtin.uri:
|
||||
url: https://api.github.com/repos/goauthentik/authentik/releases/latest
|
||||
return_content: true
|
||||
register: github_response
|
||||
when: authentik_tag is not defined
|
||||
|
||||
- name: Set target version fact
|
||||
set_fact:
|
||||
authentik_version: "{{ github_response.json.tag_name | regex_replace('^version/', '') if authentik_tag is not defined else authentik_tag }}"
|
||||
|
||||
- name: Get Docker group ID
|
||||
ansible.builtin.shell: "getent group docker | cut -d: -f3"
|
||||
register: docker_gid_result
|
||||
changed_when: false
|
||||
|
||||
- name: Deploy .env configuration
|
||||
ansible.builtin.template:
|
||||
src: .env.j2
|
||||
dest: "{{ authentik_path }}/.env"
|
||||
mode: '0600'
|
||||
notify: Restart Authentik
|
||||
|
||||
- name: Deploy Docker Compose file
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ authentik_path }}/docker-compose.yml"
|
||||
notify: Restart Authentik
|
||||
|
||||
- name: Ensure Authentik is started
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ authentik_path }}"
|
||||
state: present
|
||||
pull: always
|
||||
@@ -1,38 +0,0 @@
|
||||
PG_PASS={{ authentik_db_password }}
|
||||
PG_HOST={{ authentik_db_host }}
|
||||
PG_USER={{ authentik_db_user }}
|
||||
PG_DB={{ authentik_db_name }}
|
||||
AUTHENTIK_SECRET_KEY={{ authentik_secret_key }}
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED={{ authentik_error_reporting_enabled | default('true') }}
|
||||
AUTHENTIK_EMAIL__HOST={{ authentik_email_host }}
|
||||
AUTHENTIK_EMAIL__PORT={{ authentik_email_port }}
|
||||
AUTHENTIK_EMAIL__USERNAME={{ authentik_email_username }}
|
||||
AUTHENTIK_EMAIL__PASSWORD={{ authentik_email_password }}
|
||||
AUTHENTIK_EMAIL__USE_TLS={{ authentik_email_use_tls | lower }}
|
||||
AUTHENTIK_EMAIL__USE_SSL={{ authentik_email_use_ssl | lower }}
|
||||
AUTHENTIK_EMAIL__TIMEOUT={{ authentik_email_timeout }}
|
||||
AUTHENTIK_EMAIL__FROM={{ authentik_email_from }}
|
||||
AUTHENTIK_LOG_LEVEL={{ authentik_log_level | default('info') }}
|
||||
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS={{ authentik_listen_trusted_proxy_cidrs | default('10.0.0.0/8,172.16.0.0/12') }}
|
||||
AUTHENTIK_TAG={{ authentik_tag | default(authentik_version) }}
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED=true
|
||||
AUTHENTIK_EMAIL__HOST={{ authentik_email_host }}
|
||||
AUTHENTIK_EMAIL__PORT={{ authentik_email_port }}
|
||||
AUTHENTIK_EMAIL__USERNAME={{ authentik_email_user }}
|
||||
AUTHENTIK_EMAIL__PASSWORD={{ authentik_email_password }}
|
||||
AUTHENTIK_EMAIL__USE_TLS={{ authentik_email_use_tls }}
|
||||
AUTHENTIK_EMAIL__FROM={{ authentik_email_from }}
|
||||
AUTHENTIK_PORT_HTTP={{ authentik_port_http }}
|
||||
AUTHENTIK_PORT_HTTPS={{ authentik_port_https }}
|
||||
AUTHENTIK_CSRF_TRUSTED_ORIGINS=https://authentik.renner.uno,https://auth.renner.uno,https://authentik-app-1.srv.renner.uno,https://authentik-app-2.srv.renner.uno
|
||||
|
||||
# HA specific config:
|
||||
# Disable Redis (Use Postgres)
|
||||
AUTHENTIK_REDIS__HOST=""
|
||||
# Worker needs to use shared storage for media
|
||||
# Assuming nfs/glusterfs or just volume syncing?
|
||||
# Or maybe just use local volume if media uploads ar rare?
|
||||
# For true stateless, external S3 storage is recommended:
|
||||
# AUTHENTIK_STORAGE__MEDIA__BACKEND="aws"
|
||||
# AUTHENTIK_STORAGE__MEDIA__S3__BUCKET="..."
|
||||
# For now, local volume (will not sync across nodes without NFS).
|
||||
@@ -1,41 +0,0 @@
|
||||
services:
|
||||
server:
|
||||
image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
AUTHENTIK_REDIS__HOST: ""
|
||||
AUTHENTIK_POSTGRESQL__HOST: ${PG_HOST}
|
||||
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER}
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_LISTEN__METRICS: "0.0.0.0:9300"
|
||||
volumes:
|
||||
- ./media:/media
|
||||
- ./custom-templates:/templates
|
||||
- ./certs:/certs
|
||||
ports:
|
||||
- "${AUTHENTIK_PORT_HTTP:-9000}:9000"
|
||||
- "${AUTHENTIK_PORT_HTTPS:-9443}:9443"
|
||||
- "9300:9300"
|
||||
|
||||
worker:
|
||||
image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
user: "1000:{{ docker_gid_result.stdout }}"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
AUTHENTIK_REDIS__HOST: ""
|
||||
AUTHENTIK_POSTGRESQL__HOST: ${PG_HOST}
|
||||
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER}
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./media:/media
|
||||
- ./certs:/certs
|
||||
- ./custom-templates:/templates
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
- name: Get latest Authentik release
|
||||
ansible.builtin.uri:
|
||||
url: https://api.github.com/repos/goauthentik/authentik/releases/latest
|
||||
return_content: true
|
||||
register: github_response
|
||||
|
||||
- name: Set target version
|
||||
ansible.builtin.set_fact:
|
||||
target_version: "{{ github_response.json.tag_name | regex_replace('^version/', '') }}"
|
||||
|
||||
- name: Display target version
|
||||
ansible.builtin.debug:
|
||||
msg: "Updating Authentik to version {{ target_version }}"
|
||||
|
||||
- name: Update AUTHENTIK_TAG in .env
|
||||
ansible.builtin.lineinfile:
|
||||
path: "{{ authentik_path | default('/home/ladmin') }}/.env"
|
||||
regexp: '^AUTHENTIK_TAG='
|
||||
line: "AUTHENTIK_TAG={{ target_version }}"
|
||||
create: true
|
||||
|
||||
- name: Pull new images
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose pull
|
||||
chdir: "{{ authentik_path | default('/home/ladmin') }}"
|
||||
|
||||
- name: Pull Outpost images
|
||||
ansible.builtin.shell: |
|
||||
docker pull ghcr.io/goauthentik/proxy:{{ target_version }}
|
||||
docker pull ghcr.io/goauthentik/ldap:{{ target_version }}
|
||||
docker pull ghcr.io/goauthentik/radius:{{ target_version }}
|
||||
changed_when: true
|
||||
|
||||
- name: Apply updates
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d --force-recreate
|
||||
chdir: "{{ authentik_path | default('/home/ladmin') }}"
|
||||
|
||||
- name: Restart worker to trigger outpost update
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose restart worker
|
||||
chdir: "{{ authentik_path | default('/home/ladmin') }}"
|
||||
|
||||
- name: Prune unused images
|
||||
ansible.builtin.command:
|
||||
cmd: docker image prune -f
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
gatus_version: "v5.35.0"
|
||||
gatus_port: 8080
|
||||
gatus_data_dir: "/opt/gatus"
|
||||
|
||||
# SMTP configuration (override in vault)
|
||||
gatus_smtp_host: ""
|
||||
gatus_smtp_port: 587
|
||||
gatus_smtp_from: ""
|
||||
gatus_smtp_username: ""
|
||||
gatus_smtp_password: ""
|
||||
gatus_alert_email: ""
|
||||
|
||||
# Webhook URL for outage notifications (override in vault or group_vars)
|
||||
gatus_webhook_url: ""
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
- name: Restart Gatus
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ gatus_data_dir }}"
|
||||
state: restarted
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
- name: Install required system packages
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- python3-docker
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Create Gatus data directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ gatus_data_dir }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Copy Gatus configuration files
|
||||
ansible.builtin.template:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ gatus_data_dir }}/{{ item.dest }}"
|
||||
mode: '0644'
|
||||
loop:
|
||||
- { src: 'config.yaml.j2', dest: 'config.yaml' }
|
||||
- { src: 'docker-compose.yml.j2', dest: 'docker-compose.yml' }
|
||||
notify: Restart Gatus
|
||||
|
||||
- name: Start Gatus via Docker Compose
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ gatus_data_dir }}"
|
||||
state: present
|
||||
pull: always
|
||||
@@ -1,282 +0,0 @@
|
||||
storage:
|
||||
type: sqlite
|
||||
path: /data/gatus.db
|
||||
|
||||
ui:
|
||||
title: Homelab Status
|
||||
description: Service health monitoring for renner.uno
|
||||
|
||||
{% if gatus_smtp_host %}
|
||||
alerting:
|
||||
email:
|
||||
host: {{ gatus_smtp_host }}
|
||||
port: {{ gatus_smtp_port }}
|
||||
from: {{ gatus_smtp_from }}
|
||||
username: {{ gatus_smtp_username }}
|
||||
password: {{ gatus_smtp_password }}
|
||||
to: {{ gatus_alert_email }}
|
||||
default-alert:
|
||||
enabled: true
|
||||
failure-threshold: 3
|
||||
success-threshold: 2
|
||||
send-on-resolved: true
|
||||
{% if gatus_webhook_url %}
|
||||
custom:
|
||||
url: {{ gatus_webhook_url }}
|
||||
method: POST
|
||||
body: |
|
||||
{
|
||||
"service": "[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
"description": "[ENDPOINT_NAME] - [RESULT_ERRORS]",
|
||||
"status": "[ALERT_TRIGGERED_OR_RESOLVED]"
|
||||
}
|
||||
default-alert:
|
||||
enabled: true
|
||||
failure-threshold: 3
|
||||
success-threshold: 2
|
||||
send-on-resolved: true
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
endpoints:
|
||||
# Web Services
|
||||
- name: ownCloud
|
||||
group: services
|
||||
url: https://owncloud.renner.uno/ocs/v2.php/cloud/capabilities?format=json
|
||||
interval: 60s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[RESPONSE_TIME] < 2000"
|
||||
- "[BODY].ocs.meta.status == ok"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: ONLYOFFICE
|
||||
group: services
|
||||
url: https://office.renner.uno/healthcheck
|
||||
interval: 60s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY] == true"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: Authentik
|
||||
group: services
|
||||
url: https://auth.renner.uno/-/health/ready/
|
||||
interval: 60s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[RESPONSE_TIME] < 2000"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: Gitea
|
||||
group: services
|
||||
url: https://gitea.renner.uno/api/v1/settings/api
|
||||
interval: 60s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[RESPONSE_TIME] < 2000"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: Immich
|
||||
group: services
|
||||
url: https://immich.renner.uno/api/server/ping
|
||||
interval: 60s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].res == pong"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: Woodpecker CI
|
||||
group: services
|
||||
url: https://ci.renner.uno/healthz
|
||||
interval: 60s
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 204)"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: Grafana
|
||||
group: services
|
||||
url: https://dash.renner.uno/api/health
|
||||
interval: 60s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].database == ok"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: Home Assistant
|
||||
group: services
|
||||
url: https://home.renner.uno
|
||||
interval: 60s
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: Paperless
|
||||
group: services
|
||||
url: https://file.renner.uno
|
||||
interval: 60s
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: Audiobookshelf
|
||||
group: services
|
||||
url: https://audiobooks.renner.uno/healthcheck
|
||||
interval: 60s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: Jellyfin
|
||||
group: services
|
||||
url: https://media.renner.uno/health
|
||||
interval: 60s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: Wiki
|
||||
group: services
|
||||
url: https://wiki.renner.uno
|
||||
interval: 60s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: Music Assistant
|
||||
group: services
|
||||
url: https://music.renner.uno
|
||||
interval: 60s
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: InfluxDB
|
||||
group: services
|
||||
url: https://influx.renner.uno/health
|
||||
interval: 60s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: PBS (pbs.obg.renner.uno)
|
||||
group: infrastructure
|
||||
url: https://pbs.obg.renner.uno:8007
|
||||
interval: 60s
|
||||
client:
|
||||
insecure-skip-verify: true
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
|
||||
- name: PBS 2 (pbs2.renner.uno)
|
||||
group: infrastructure
|
||||
url: https://pbs2.renner.uno:8007
|
||||
interval: 60s
|
||||
client:
|
||||
insecure-skip-verify: true
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: Proxmox VE
|
||||
group: infrastructure
|
||||
url: https://pve.renner.uno
|
||||
interval: 60s
|
||||
client:
|
||||
insecure-skip-verify: true
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
# Infrastructure
|
||||
- name: Proxmox DNS (VIP)
|
||||
group: infrastructure
|
||||
url: 10.2.40.53
|
||||
interval: 30s
|
||||
dns:
|
||||
query-name: k8s-1.srv.renner.uno
|
||||
query-type: A
|
||||
conditions:
|
||||
- "[DNS_RCODE] == NOERROR"
|
||||
- "[RESPONSE_TIME] < 500"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: PostgreSQL HA (VIP)
|
||||
group: infrastructure
|
||||
url: tcp://10.2.40.135:5432
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
@@ -1,17 +0,0 @@
|
||||
services:
|
||||
gatus:
|
||||
container_name: gatus
|
||||
image: twinproduction/gatus:{{ gatus_version }}
|
||||
ports:
|
||||
- "{{ gatus_port }}:8080"
|
||||
volumes:
|
||||
- {{ gatus_data_dir }}/config.yaml:/config/config.yaml:ro
|
||||
- {{ gatus_data_dir }}/data:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
# Grafana Authentik OAuth Configuration
|
||||
|
||||
# Authentik OAuth settings (must be set in vault)
|
||||
# Add to ansible/inventory/group_vars/all/vault.yml:
|
||||
# vault_grafana_authentik_client_id: "<client-id>"
|
||||
# vault_grafana_authentik_client_secret: "<client-secret>"
|
||||
grafana_authentik_client_id: "{{ vault_grafana_authentik_client_id | default('') }}"
|
||||
grafana_authentik_client_secret: "{{ vault_grafana_authentik_client_secret | default('') }}"
|
||||
|
||||
# Authentik URLs
|
||||
authentik_base_url: "https://auth.renner.uno"
|
||||
grafana_authentik_auth_url: "{{ authentik_base_url }}/application/o/authorize/"
|
||||
grafana_authentik_token_url: "{{ authentik_base_url }}/application/o/token/"
|
||||
grafana_authentik_api_url: "{{ authentik_base_url }}/application/o/userinfo/"
|
||||
|
||||
# Grafana settings
|
||||
grafana_root_url: "https://dash.renner.uno"
|
||||
grafana_config_dir: "/etc/grafana"
|
||||
|
||||
# Role mapping from Authentik groups
|
||||
grafana_role_attribute_path: "contains(groups, 'Grafana Admins') && 'Admin' || contains(groups, 'Grafana Editors') && 'Editor' || 'Viewer'"
|
||||
|
||||
# Allow users to sign up automatically
|
||||
grafana_oauth_allow_sign_up: true
|
||||
|
||||
# Auto login (skip Grafana login page, go directly to Authentik)
|
||||
grafana_oauth_auto_login: false
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
- name: Restart grafana
|
||||
systemd:
|
||||
name: grafana-server
|
||||
state: restarted
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
# Configure Grafana to use Authentik for OAuth authentication
|
||||
|
||||
- name: Verify Authentik credentials are configured
|
||||
assert:
|
||||
that:
|
||||
- grafana_authentik_client_id | length > 0
|
||||
- grafana_authentik_client_secret | length > 0
|
||||
fail_msg: |
|
||||
Authentik OAuth credentials not configured!
|
||||
|
||||
Please add to ansible/inventory/group_vars/all/vault.yml:
|
||||
vault_grafana_authentik_client_id: "<client-id-from-authentik>"
|
||||
vault_grafana_authentik_client_secret: "<client-secret-from-authentik>"
|
||||
|
||||
To create the OAuth application in Authentik:
|
||||
1. Go to https://auth.renner.uno/if/admin/
|
||||
2. Applications > Providers > Create
|
||||
3. Select "OAuth2/OpenID Provider"
|
||||
4. Name: Grafana, Slug: grafana
|
||||
5. Client type: Confidential
|
||||
6. Redirect URIs: https://grafana.renner.uno/login/generic_oauth
|
||||
6. Redirect URIs: https://dash.renner.uno/login/generic_oauth
|
||||
7. Copy Client ID and Secret to vault
|
||||
|
||||
- name: Ensure Grafana provisioning directory exists
|
||||
file:
|
||||
path: "{{ grafana_config_dir }}/provisioning"
|
||||
state: directory
|
||||
owner: grafana
|
||||
group: grafana
|
||||
mode: '0755'
|
||||
|
||||
- name: Deploy Authentik OAuth configuration
|
||||
template:
|
||||
src: grafana-auth.ini.j2
|
||||
dest: "{{ grafana_config_dir }}/grafana.ini"
|
||||
owner: root
|
||||
group: grafana
|
||||
mode: '0640'
|
||||
backup: yes
|
||||
notify: Restart grafana
|
||||
|
||||
- name: Ensure Grafana service is running
|
||||
systemd:
|
||||
name: grafana-server
|
||||
state: started
|
||||
enabled: yes
|
||||
@@ -1,26 +0,0 @@
|
||||
# Grafana Authentik OAuth Configuration
|
||||
# Managed by Ansible - Do not edit manually
|
||||
|
||||
[server]
|
||||
root_url = {{ grafana_root_url }}
|
||||
|
||||
[auth]
|
||||
# Disable login form if using auto_login
|
||||
disable_login_form = {{ grafana_oauth_auto_login | lower }}
|
||||
|
||||
[auth.generic_oauth]
|
||||
enabled = true
|
||||
name = Authentik
|
||||
icon = signin
|
||||
allow_sign_up = {{ grafana_oauth_allow_sign_up | lower }}
|
||||
auto_login = {{ grafana_oauth_auto_login | lower }}
|
||||
client_id = {{ grafana_authentik_client_id }}
|
||||
client_secret = {{ grafana_authentik_client_secret }}
|
||||
scopes = openid profile email
|
||||
auth_url = {{ grafana_authentik_auth_url }}
|
||||
token_url = {{ grafana_authentik_token_url }}
|
||||
api_url = {{ grafana_authentik_api_url }}
|
||||
role_attribute_path = {{ grafana_role_attribute_path }}
|
||||
role_attribute_strict = false
|
||||
allow_assign_grafana_admin = true
|
||||
use_pkce = true
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
# Grist configuration
|
||||
grist_version: "latest"
|
||||
grist_port: 8484
|
||||
grist_data_dir: "/opt/grist"
|
||||
|
||||
# Public URL (required for proper link generation)
|
||||
grist_public_url: "https://grist.renner.uno"
|
||||
|
||||
# Session secret (override in vault!)
|
||||
grist_session_secret: "CHANGE_ME_IN_VAULT"
|
||||
|
||||
# Default admin email
|
||||
grist_default_email: "admin@renner.uno"
|
||||
|
||||
# Sandbox flavor: gvisor for security, unsandboxed for compatibility
|
||||
grist_sandbox_flavor: "gvisor"
|
||||
grist_default_locale: "de"
|
||||
|
||||
# OIDC Authentication (Authentik)
|
||||
grist_oidc_enabled: true
|
||||
grist_oidc_issuer: "https://auth.renner.uno/application/o/grist/"
|
||||
grist_oidc_client_id: ""
|
||||
grist_oidc_client_secret: ""
|
||||
grist_oidc_sp_profile_name_attr: "name"
|
||||
grist_oidc_sp_profile_email_attr: "email"
|
||||
grist_oidc_ignore_email_verified: true
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
- name: Restart Grist
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ grist_data_dir }}"
|
||||
state: restarted
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
- name: Install required system packages
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- python3-docker
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Create Grist data directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ grist_data_dir }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Create Grist persist directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ grist_data_dir }}/persist"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Copy Grist configuration files
|
||||
ansible.builtin.template:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ grist_data_dir }}/{{ item.dest }}"
|
||||
mode: '0644'
|
||||
loop:
|
||||
- { src: '.env.j2', dest: '.env' }
|
||||
- { src: 'docker-compose.yml.j2', dest: 'docker-compose.yml' }
|
||||
notify: Restart Grist
|
||||
|
||||
- name: Start Grist via Docker Compose
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ grist_data_dir }}"
|
||||
state: present
|
||||
pull: always
|
||||
@@ -1,29 +0,0 @@
|
||||
# Grist Environment Configuration
|
||||
# Managed by Ansible - Do not edit manually
|
||||
|
||||
# Application URL
|
||||
APP_HOME_URL={{ grist_public_url }}
|
||||
|
||||
# Security
|
||||
GRIST_SESSION_SECRET={{ grist_session_secret }}
|
||||
GRIST_SANDBOX_FLAVOR={{ grist_sandbox_flavor }}
|
||||
|
||||
# Default admin
|
||||
GRIST_DEFAULT_EMAIL={{ grist_default_email }}
|
||||
|
||||
{% if grist_oidc_enabled and grist_oidc_client_id %}
|
||||
# OIDC Authentication (Authentik)
|
||||
GRIST_OIDC_IDP_ENABLED=true
|
||||
GRIST_OIDC_IDP_ISSUER={{ grist_oidc_issuer }}
|
||||
GRIST_OIDC_IDP_CLIENT_ID={{ grist_oidc_client_id }}
|
||||
GRIST_OIDC_IDP_CLIENT_SECRET={{ grist_oidc_client_secret }}
|
||||
GRIST_OIDC_SP_PROFILE_NAME_ATTR={{ grist_oidc_sp_profile_name_attr }}
|
||||
GRIST_OIDC_SP_PROFILE_EMAIL_ATTR={{ grist_oidc_sp_profile_email_attr }}
|
||||
GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED={{ grist_oidc_ignore_email_verified | lower }}
|
||||
{% endif %}
|
||||
|
||||
# Locale
|
||||
GRIST_DEFAULT_LOCALE={{ grist_default_locale }}
|
||||
|
||||
# Telemetry (disabled)
|
||||
GRIST_TELEMETRY_LEVEL=off
|
||||
@@ -1,20 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
grist:
|
||||
container_name: grist
|
||||
image: gristlabs/grist:{{ grist_version }}
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "{{ grist_port }}:8484"
|
||||
volumes:
|
||||
- {{ grist_data_dir }}/persist:/persist
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8484/status"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
- name: Ensure install directory exists
|
||||
file:
|
||||
path: /opt/ical-proxy
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Checkout ical-proxy repository
|
||||
git:
|
||||
repo: 'https://github.com/konairius/ical-proxy.git'
|
||||
dest: /opt/ical-proxy
|
||||
version: main
|
||||
force: yes
|
||||
register: git_checkout
|
||||
|
||||
- name: Deploy ical-proxy with docker compose
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: /opt/ical-proxy
|
||||
state: present
|
||||
build: always
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
# MkDocs Wiki configuration
|
||||
|
||||
# Source directory (Obsidian vault path on control node)
|
||||
mkdocs_source_dir: "{{ lookup('env', 'HOME') }}/Notes/Work & Dev/Homelab"
|
||||
|
||||
# Installation directory on target
|
||||
mkdocs_install_dir: /opt/mkdocs-wiki
|
||||
mkdocs_build_dir: /var/www/wiki
|
||||
|
||||
# Service user
|
||||
mkdocs_user: wiki
|
||||
mkdocs_group: wiki
|
||||
|
||||
# Python virtual environment
|
||||
mkdocs_venv_dir: "{{ mkdocs_install_dir }}/venv"
|
||||
|
||||
# Web server port (for development/testing)
|
||||
mkdocs_serve_port: 8000
|
||||
|
||||
# Build options
|
||||
mkdocs_strict_mode: false
|
||||
|
||||
# Sync schedule (cron)
|
||||
mkdocs_sync_enabled: true
|
||||
mkdocs_sync_schedule: "*/15 * * * *" # Every 15 minutes
|
||||
|
||||
# Home Assistant documentation generation
|
||||
homeassistant_docs_enabled: true
|
||||
homeassistant_url: "http://10.2.30.11:8123"
|
||||
# homeassistant_token: set in vault as vault_homeassistant_token
|
||||
homeassistant_docs_dir: "{{ mkdocs_install_dir }}/docs/Services/Home Assistant Auto"
|
||||
@@ -1,477 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Home Assistant Documentation Generator
|
||||
|
||||
Queries the Home Assistant WebSocket API to get entity, device, and area registries,
|
||||
then generates room-organized Markdown documentation.
|
||||
|
||||
Usage:
|
||||
python generate-ha-docs.py --url http://10.2.30.11:8123 --token <token> --output /path/to/docs
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
try:
|
||||
import requests
|
||||
from websocket import create_connection
|
||||
except ImportError:
|
||||
print("Error: Required libraries missing. Install with: pip install requests websocket-client")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class HomeAssistantDocs:
|
||||
def __init__(self, url: str, token: str):
|
||||
self.url = url.rstrip('/')
|
||||
self.token = token
|
||||
self.headers = {
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
self.msg_id = 1
|
||||
|
||||
# Registries
|
||||
self.areas = {} # area_id -> area_name
|
||||
self.devices = {} # device_id -> device_info
|
||||
self.entities = {} # entity_id -> entity_info
|
||||
self.states = {} # entity_id -> state
|
||||
self.automations = []
|
||||
|
||||
def _ws_call(self, ws, msg_type: str, **kwargs) -> dict:
|
||||
"""Make WebSocket API call."""
|
||||
msg = {"id": self.msg_id, "type": msg_type, **kwargs}
|
||||
self.msg_id += 1
|
||||
ws.send(json.dumps(msg))
|
||||
result = json.loads(ws.recv())
|
||||
if not result.get("success", True):
|
||||
print(f"Warning: WS call {msg_type} failed: {result}")
|
||||
return result.get("result", [])
|
||||
|
||||
def fetch_all_data(self):
|
||||
"""Fetch all registries via WebSocket API."""
|
||||
ws_url = self.url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
|
||||
|
||||
print(f"Connecting to WebSocket at {ws_url}...")
|
||||
ws = create_connection(ws_url)
|
||||
|
||||
# Wait for auth_required
|
||||
auth_req = json.loads(ws.recv())
|
||||
if auth_req.get("type") != "auth_required":
|
||||
raise Exception(f"Unexpected message: {auth_req}")
|
||||
|
||||
# Authenticate
|
||||
ws.send(json.dumps({"type": "auth", "access_token": self.token}))
|
||||
auth_result = json.loads(ws.recv())
|
||||
if auth_result.get("type") != "auth_ok":
|
||||
raise Exception(f"Authentication failed: {auth_result}")
|
||||
|
||||
print("Authenticated. Fetching registries...")
|
||||
|
||||
# Fetch area registry
|
||||
areas = self._ws_call(ws, "config/area_registry/list")
|
||||
for area in areas:
|
||||
self.areas[area["area_id"]] = {
|
||||
"name": area["name"],
|
||||
"aliases": area.get("aliases", []),
|
||||
"floor_id": area.get("floor_id"),
|
||||
"icon": area.get("icon"),
|
||||
}
|
||||
print(f" Found {len(self.areas)} areas")
|
||||
|
||||
# Fetch device registry
|
||||
devices = self._ws_call(ws, "config/device_registry/list")
|
||||
for device in devices:
|
||||
self.devices[device["id"]] = {
|
||||
"name": device.get("name_by_user") or device.get("name") or "Unknown",
|
||||
"manufacturer": device.get("manufacturer"),
|
||||
"model": device.get("model"),
|
||||
"area_id": device.get("area_id"),
|
||||
"via_device_id": device.get("via_device_id"),
|
||||
"disabled_by": device.get("disabled_by"),
|
||||
}
|
||||
print(f" Found {len(self.devices)} devices")
|
||||
|
||||
# Fetch entity registry
|
||||
entities = self._ws_call(ws, "config/entity_registry/list")
|
||||
for entity in entities:
|
||||
self.entities[entity["entity_id"]] = {
|
||||
"name": entity.get("name") or entity.get("original_name"),
|
||||
"device_id": entity.get("device_id"),
|
||||
"area_id": entity.get("area_id"), # Entity can have its own area
|
||||
"platform": entity.get("platform"),
|
||||
"disabled_by": entity.get("disabled_by"),
|
||||
"hidden_by": entity.get("hidden_by"),
|
||||
"entity_category": entity.get("entity_category"),
|
||||
}
|
||||
print(f" Found {len(self.entities)} entities")
|
||||
|
||||
ws.close()
|
||||
|
||||
# Fetch states via REST API (more complete)
|
||||
print("Fetching states...")
|
||||
resp = requests.get(f"{self.url}/api/states", headers=self.headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
for state in resp.json():
|
||||
self.states[state["entity_id"]] = state
|
||||
print(f" Found {len(self.states)} states")
|
||||
|
||||
# Extract automations from states
|
||||
for entity_id, state in self.states.items():
|
||||
if entity_id.startswith("automation."):
|
||||
self.automations.append({
|
||||
"id": entity_id.replace("automation.", ""),
|
||||
"alias": state.get("attributes", {}).get("friendly_name", entity_id),
|
||||
"entity_id": entity_id,
|
||||
"state": state,
|
||||
})
|
||||
print(f" Found {len(self.automations)} automations")
|
||||
|
||||
def get_entity_area(self, entity_id: str) -> str:
|
||||
"""Get the area for an entity (from entity or device)."""
|
||||
entity = self.entities.get(entity_id, {})
|
||||
|
||||
# Entity's own area takes precedence
|
||||
if entity.get("area_id"):
|
||||
return entity["area_id"]
|
||||
|
||||
# Fall back to device's area
|
||||
device_id = entity.get("device_id")
|
||||
if device_id:
|
||||
device = self.devices.get(device_id, {})
|
||||
if device.get("area_id"):
|
||||
return device["area_id"]
|
||||
|
||||
return None
|
||||
|
||||
def get_automation_areas(self, automation: dict) -> set:
|
||||
"""Extract areas referenced by an automation from its name and entity_id patterns."""
|
||||
areas = set()
|
||||
|
||||
# Try to match automation alias to areas
|
||||
alias = automation.get("alias", "").lower()
|
||||
|
||||
# Map area names to area_ids
|
||||
for area_id, area_info in self.areas.items():
|
||||
area_name = area_info["name"].lower()
|
||||
# Check if room name appears in automation name
|
||||
# Handle common patterns like "EG Küche", "Küche", "kuche"
|
||||
normalized_area = area_name.replace("ü", "u").replace("ö", "o").replace("ä", "a")
|
||||
normalized_alias = alias.replace("ü", "u").replace("ö", "o").replace("ä", "a")
|
||||
|
||||
# Match full area name or last part (e.g., "Küche" from "EG Küche")
|
||||
parts = area_name.split()
|
||||
if area_name in alias or normalized_area in normalized_alias:
|
||||
areas.add(area_id)
|
||||
elif len(parts) > 1 and parts[-1] in alias:
|
||||
areas.add(area_id)
|
||||
|
||||
return areas
|
||||
|
||||
def generate_room_doc(self, area_id: str, area_info: dict) -> str:
|
||||
"""Generate documentation for a single room/area."""
|
||||
area_name = area_info["name"]
|
||||
|
||||
# Find all entities in this area
|
||||
entities_in_area = []
|
||||
for entity_id, entity in self.entities.items():
|
||||
if self.get_entity_area(entity_id) == area_id:
|
||||
# Skip disabled, hidden, or diagnostic entities
|
||||
if entity.get("disabled_by") or entity.get("hidden_by"):
|
||||
continue
|
||||
if entity.get("entity_category") in ("diagnostic", "config"):
|
||||
continue
|
||||
entities_in_area.append(entity_id)
|
||||
|
||||
# Find devices in this area
|
||||
devices_in_area = []
|
||||
for device_id, device in self.devices.items():
|
||||
if device.get("area_id") == area_id and not device.get("disabled_by"):
|
||||
devices_in_area.append((device_id, device))
|
||||
|
||||
# Find automations that affect this area
|
||||
automations_for_area = []
|
||||
for auto in self.automations:
|
||||
if area_id in self.get_automation_areas(auto):
|
||||
automations_for_area.append(auto)
|
||||
|
||||
# Build document
|
||||
lines = [
|
||||
f"# {area_name}",
|
||||
"",
|
||||
f"Last Updated: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||
"",
|
||||
]
|
||||
|
||||
# Devices section
|
||||
if devices_in_area:
|
||||
lines.extend([
|
||||
"## Devices",
|
||||
"",
|
||||
"| Device | Manufacturer | Model |",
|
||||
"|--------|--------------|-------|",
|
||||
])
|
||||
for device_id, device in sorted(devices_in_area, key=lambda x: x[1]["name"]):
|
||||
name = device["name"].replace("|", "\\|")
|
||||
manufacturer = (device.get("manufacturer") or "-").replace("|", "\\|")
|
||||
model = (device.get("model") or "-").replace("|", "\\|")
|
||||
lines.append(f"| {name} | {manufacturer} | {model} |")
|
||||
lines.append("")
|
||||
|
||||
# Group entities by domain
|
||||
domain_entities = defaultdict(list)
|
||||
for entity_id in entities_in_area:
|
||||
domain = entity_id.split(".")[0]
|
||||
domain_entities[domain].append(entity_id)
|
||||
|
||||
# Entities by type
|
||||
if domain_entities:
|
||||
lines.extend([
|
||||
"## Entities",
|
||||
"",
|
||||
])
|
||||
|
||||
# Preferred order for domains
|
||||
domain_order = ["light", "switch", "climate", "cover", "fan", "media_player",
|
||||
"sensor", "binary_sensor", "number", "select", "button"]
|
||||
|
||||
for domain in domain_order + sorted(set(domain_entities.keys()) - set(domain_order)):
|
||||
if domain not in domain_entities:
|
||||
continue
|
||||
|
||||
entity_ids = domain_entities[domain]
|
||||
domain_title = domain.replace("_", " ").title()
|
||||
|
||||
lines.extend([
|
||||
f"### {domain_title}",
|
||||
"",
|
||||
"| Entity | State |",
|
||||
"|--------|-------|",
|
||||
])
|
||||
|
||||
for entity_id in sorted(entity_ids):
|
||||
state = self.states.get(entity_id, {})
|
||||
name = state.get("attributes", {}).get("friendly_name", entity_id)
|
||||
state_val = state.get("state", "unknown")
|
||||
|
||||
# Add unit if present
|
||||
unit = state.get("attributes", {}).get("unit_of_measurement", "")
|
||||
if unit:
|
||||
state_val = f"{state_val} {unit}"
|
||||
|
||||
# Truncate long values
|
||||
if len(state_val) > 30:
|
||||
state_val = state_val[:27] + "..."
|
||||
|
||||
name = name.replace("|", "\\|")
|
||||
state_val = state_val.replace("|", "\\|")
|
||||
lines.append(f"| {name} | {state_val} |")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Automations section
|
||||
if automations_for_area:
|
||||
lines.extend([
|
||||
"## Automations",
|
||||
"",
|
||||
"Automations that control devices in this room:",
|
||||
"",
|
||||
"| Automation | Status | Last Triggered |",
|
||||
"|------------|--------|----------------|",
|
||||
])
|
||||
|
||||
for auto in sorted(automations_for_area, key=lambda x: x.get("alias", "")):
|
||||
alias = auto.get("alias", "Unnamed automation")
|
||||
state_obj = auto.get("state", {})
|
||||
is_on = state_obj.get("state", "unknown")
|
||||
last_triggered = state_obj.get("attributes", {}).get("last_triggered", "Never")
|
||||
|
||||
if last_triggered and last_triggered not in ("Never", None):
|
||||
try:
|
||||
dt = datetime.fromisoformat(last_triggered.replace("Z", "+00:00"))
|
||||
last_triggered = dt.strftime("%Y-%m-%d %H:%M")
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
last_triggered = "Never"
|
||||
|
||||
alias = alias.replace("|", "\\|")
|
||||
lines.append(f"| {alias} | {is_on} | {last_triggered} |")
|
||||
|
||||
lines.append("")
|
||||
|
||||
if not devices_in_area and not domain_entities and not automations_for_area:
|
||||
lines.extend([
|
||||
"*No devices, entities, or automations assigned to this area.*",
|
||||
"",
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def generate_index(self) -> str:
|
||||
"""Generate index page with all rooms overview."""
|
||||
lines = [
|
||||
"# Home Assistant - Rooms",
|
||||
"",
|
||||
f"Last Updated: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||
"",
|
||||
"Auto-generated documentation organized by room/area.",
|
||||
"",
|
||||
"## Rooms Overview",
|
||||
"",
|
||||
"| Room | Devices | Entities | Automations |",
|
||||
"|------|---------|----------|-------------|",
|
||||
]
|
||||
|
||||
for area_id in sorted(self.areas.keys(), key=lambda x: self.areas[x]["name"]):
|
||||
area_name = self.areas[area_id]["name"]
|
||||
|
||||
# Count devices
|
||||
device_count = sum(1 for d in self.devices.values()
|
||||
if d.get("area_id") == area_id and not d.get("disabled_by"))
|
||||
|
||||
# Count entities
|
||||
entity_count = sum(1 for e_id in self.entities
|
||||
if self.get_entity_area(e_id) == area_id
|
||||
and not self.entities[e_id].get("disabled_by")
|
||||
and not self.entities[e_id].get("hidden_by")
|
||||
and self.entities[e_id].get("entity_category") not in ("diagnostic", "config"))
|
||||
|
||||
# Count automations
|
||||
auto_count = sum(1 for a in self.automations if area_id in self.get_automation_areas(a))
|
||||
|
||||
# Create wiki link
|
||||
safe_name = area_name.replace(" ", "%20")
|
||||
lines.append(f"| [[{area_name}]] | {device_count} | {entity_count} | {auto_count} |")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Unassigned Entities",
|
||||
"",
|
||||
])
|
||||
|
||||
# Find entities without an area
|
||||
unassigned = []
|
||||
for entity_id in self.entities:
|
||||
if not self.get_entity_area(entity_id):
|
||||
entity = self.entities[entity_id]
|
||||
if entity.get("disabled_by") or entity.get("hidden_by"):
|
||||
continue
|
||||
if entity.get("entity_category") in ("diagnostic", "config"):
|
||||
continue
|
||||
unassigned.append(entity_id)
|
||||
|
||||
if unassigned:
|
||||
lines.append(f"There are {len(unassigned)} entities not assigned to any room.")
|
||||
lines.extend([
|
||||
"",
|
||||
"<details>",
|
||||
"<summary>Show unassigned entities</summary>",
|
||||
"",
|
||||
])
|
||||
for entity_id in sorted(unassigned)[:50]: # Limit
|
||||
state = self.states.get(entity_id, {})
|
||||
name = state.get("attributes", {}).get("friendly_name", entity_id)
|
||||
lines.append(f"- `{entity_id}` - {name}")
|
||||
if len(unassigned) > 50:
|
||||
lines.append(f"- ... and {len(unassigned) - 50} more")
|
||||
lines.extend([
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
])
|
||||
else:
|
||||
lines.append("All entities are assigned to rooms.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def generate_automations_doc(self) -> str:
|
||||
"""Generate all automations overview."""
|
||||
lines = [
|
||||
"# All Automations",
|
||||
"",
|
||||
f"Last Updated: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||
"",
|
||||
f"Total: {len(self.automations)} automations",
|
||||
"",
|
||||
"| Automation | Status | Rooms | Last Triggered |",
|
||||
"|------------|--------|-------|----------------|",
|
||||
]
|
||||
|
||||
for auto in sorted(self.automations, key=lambda x: x.get("alias", "")):
|
||||
alias = auto.get("alias", "Unnamed")
|
||||
state_obj = auto.get("state", {})
|
||||
is_on = state_obj.get("state", "?")
|
||||
last_triggered = state_obj.get("attributes", {}).get("last_triggered", "Never")
|
||||
|
||||
if last_triggered and last_triggered not in ("Never", None):
|
||||
try:
|
||||
dt = datetime.fromisoformat(last_triggered.replace("Z", "+00:00"))
|
||||
last_triggered = dt.strftime("%Y-%m-%d %H:%M")
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
last_triggered = "Never"
|
||||
|
||||
areas = self.get_automation_areas(auto)
|
||||
room_names = ", ".join(self.areas.get(a, {}).get("name", a) for a in areas) or "-"
|
||||
if len(room_names) > 30:
|
||||
room_names = room_names[:27] + "..."
|
||||
|
||||
alias = alias.replace("|", "\\|")
|
||||
room_names = room_names.replace("|", "\\|")
|
||||
lines.append(f"| {alias} | {is_on} | {room_names} | {last_triggered} |")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Generate Home Assistant documentation by room')
|
||||
parser.add_argument('--url', required=True, help='Home Assistant URL')
|
||||
parser.add_argument('--token', required=True, help='Long-lived access token')
|
||||
parser.add_argument('--output', required=True, help='Output directory for markdown files')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Create output directory
|
||||
os.makedirs(args.output, exist_ok=True)
|
||||
|
||||
print(f"Connecting to Home Assistant at {args.url}...")
|
||||
ha = HomeAssistantDocs(args.url, args.token)
|
||||
|
||||
try:
|
||||
ha.fetch_all_data()
|
||||
|
||||
# Generate index
|
||||
index_path = os.path.join(args.output, "Home Assistant Auto.md")
|
||||
with open(index_path, 'w') as f:
|
||||
f.write(ha.generate_index())
|
||||
print(f"Generated {index_path}")
|
||||
|
||||
# Generate room pages
|
||||
for area_id, area_info in ha.areas.items():
|
||||
filename = f"{area_info['name']}.md"
|
||||
filepath = os.path.join(args.output, filename)
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(ha.generate_room_doc(area_id, area_info))
|
||||
print(f"Generated {filepath}")
|
||||
|
||||
# Generate all automations page
|
||||
auto_path = os.path.join(args.output, "All Automations.md")
|
||||
with open(auto_path, 'w') as f:
|
||||
f.write(ha.generate_automations_doc())
|
||||
print(f"Generated {auto_path}")
|
||||
|
||||
print(f"\nDone! Generated documentation for {len(ha.areas)} rooms.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
- name: Rebuild MkDocs
|
||||
ansible.builtin.command:
|
||||
cmd: "{{ mkdocs_venv_dir }}/bin/mkdocs build --site-dir {{ mkdocs_build_dir }}"
|
||||
chdir: "{{ mkdocs_install_dir }}"
|
||||
|
||||
- name: Reload Caddy
|
||||
ansible.builtin.systemd:
|
||||
name: caddy
|
||||
state: reloaded
|
||||
@@ -1,160 +0,0 @@
|
||||
---
|
||||
- name: Install system dependencies
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- python3
|
||||
- python3-pip
|
||||
- python3-venv
|
||||
- rsync
|
||||
- caddy
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Create wiki user
|
||||
ansible.builtin.user:
|
||||
name: "{{ mkdocs_user }}"
|
||||
group: "{{ mkdocs_group }}"
|
||||
system: true
|
||||
shell: /usr/sbin/nologin
|
||||
home: "{{ mkdocs_install_dir }}"
|
||||
create_home: false
|
||||
|
||||
- name: Create directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: "{{ mkdocs_user }}"
|
||||
group: "{{ mkdocs_group }}"
|
||||
mode: "0755"
|
||||
loop:
|
||||
- "{{ mkdocs_install_dir }}"
|
||||
- "{{ mkdocs_install_dir }}/docs"
|
||||
- "{{ mkdocs_build_dir }}"
|
||||
|
||||
- name: Sync documentation from control node
|
||||
ansible.posix.synchronize:
|
||||
src: "{{ mkdocs_source_dir }}/"
|
||||
dest: "{{ mkdocs_install_dir }}/docs/"
|
||||
delete: true
|
||||
use_ssh_args: true
|
||||
rsync_opts:
|
||||
- "--exclude=.obsidian"
|
||||
- "--exclude=.git"
|
||||
- "--exclude=*.sync-conflict-*"
|
||||
- "--exclude=mkdocs.yml"
|
||||
- "--rsync-path=rsync"
|
||||
|
||||
- name: Copy mkdocs.yml to install directory
|
||||
ansible.builtin.copy:
|
||||
src: "{{ mkdocs_source_dir }}/mkdocs.yml"
|
||||
dest: "{{ mkdocs_install_dir }}/mkdocs.yml"
|
||||
owner: "{{ mkdocs_user }}"
|
||||
group: "{{ mkdocs_group }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: Update mkdocs.yml docs_dir
|
||||
ansible.builtin.lineinfile:
|
||||
path: "{{ mkdocs_install_dir }}/mkdocs.yml"
|
||||
regexp: "^docs_dir:"
|
||||
line: "docs_dir: docs"
|
||||
insertafter: "^site_url:"
|
||||
notify: Rebuild MkDocs
|
||||
|
||||
- name: Create Python virtual environment
|
||||
ansible.builtin.pip:
|
||||
name:
|
||||
- pip
|
||||
- setuptools
|
||||
- wheel
|
||||
virtualenv: "{{ mkdocs_venv_dir }}"
|
||||
virtualenv_command: python3 -m venv
|
||||
state: latest
|
||||
|
||||
- name: Install MkDocs and plugins
|
||||
ansible.builtin.pip:
|
||||
name:
|
||||
- mkdocs
|
||||
- mkdocs-material
|
||||
- mkdocs-roamlinks-plugin
|
||||
- mkdocs-static-i18n
|
||||
- requests
|
||||
- websocket-client
|
||||
virtualenv: "{{ mkdocs_venv_dir }}"
|
||||
state: latest
|
||||
|
||||
# Home Assistant documentation generation
|
||||
- name: Create HA docs directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ homeassistant_docs_dir }}"
|
||||
state: directory
|
||||
owner: "{{ mkdocs_user }}"
|
||||
group: "{{ mkdocs_group }}"
|
||||
mode: "0755"
|
||||
when: homeassistant_docs_enabled | default(false)
|
||||
|
||||
- name: Copy HA docs generator script
|
||||
ansible.builtin.copy:
|
||||
src: generate-ha-docs.py
|
||||
dest: "{{ mkdocs_install_dir }}/generate-ha-docs.py"
|
||||
owner: "{{ mkdocs_user }}"
|
||||
group: "{{ mkdocs_group }}"
|
||||
mode: "0755"
|
||||
when: homeassistant_docs_enabled | default(false)
|
||||
|
||||
- name: Generate Home Assistant documentation
|
||||
ansible.builtin.command:
|
||||
cmd: >
|
||||
{{ mkdocs_venv_dir }}/bin/python {{ mkdocs_install_dir }}/generate-ha-docs.py
|
||||
--url "{{ homeassistant_url }}"
|
||||
--token "{{ homeassistant_token }}"
|
||||
--output "{{ homeassistant_docs_dir }}"
|
||||
when:
|
||||
- homeassistant_docs_enabled | default(false)
|
||||
- homeassistant_token is defined
|
||||
changed_when: true
|
||||
ignore_errors: true
|
||||
|
||||
- name: Build MkDocs site
|
||||
ansible.builtin.command:
|
||||
cmd: "{{ mkdocs_venv_dir }}/bin/mkdocs build --site-dir {{ mkdocs_build_dir }}"
|
||||
chdir: "{{ mkdocs_install_dir }}"
|
||||
changed_when: true
|
||||
|
||||
- name: Set ownership on build directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ mkdocs_build_dir }}"
|
||||
state: directory
|
||||
owner: "{{ mkdocs_user }}"
|
||||
group: "{{ mkdocs_group }}"
|
||||
recurse: true
|
||||
|
||||
- name: Configure Caddy for wiki
|
||||
ansible.builtin.template:
|
||||
src: Caddyfile.j2
|
||||
dest: /etc/caddy/Caddyfile
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload Caddy
|
||||
|
||||
- name: Create sync script
|
||||
ansible.builtin.template:
|
||||
src: sync-wiki.sh.j2
|
||||
dest: "{{ mkdocs_install_dir }}/sync-wiki.sh"
|
||||
owner: "{{ mkdocs_user }}"
|
||||
group: "{{ mkdocs_group }}"
|
||||
mode: "0755"
|
||||
|
||||
- name: Configure cron for sync
|
||||
ansible.builtin.cron:
|
||||
name: "Sync and rebuild MkDocs wiki"
|
||||
minute: "*/15"
|
||||
job: "{{ mkdocs_install_dir }}/sync-wiki.sh >> /var/log/wiki-sync.log 2>&1"
|
||||
user: root
|
||||
when: mkdocs_sync_enabled
|
||||
|
||||
- name: Ensure Caddy is running
|
||||
ansible.builtin.systemd:
|
||||
name: caddy
|
||||
state: started
|
||||
enabled: true
|
||||
@@ -1,19 +0,0 @@
|
||||
:80 {
|
||||
root * {{ mkdocs_build_dir }}
|
||||
file_server
|
||||
encode gzip
|
||||
|
||||
# Handle clean URLs
|
||||
try_files {path} {path}/ {path}.html /index.html
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/wiki-access.log
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Sync wiki from source and rebuild
|
||||
# This script is called by cron
|
||||
|
||||
set -e
|
||||
|
||||
LOCK_FILE="/tmp/wiki-sync.lock"
|
||||
SOURCE_HOST="{{ hostvars['localhost']['ansible_hostname'] | default('workstation') }}"
|
||||
SOURCE_DIR="{{ mkdocs_source_dir }}"
|
||||
INSTALL_DIR="{{ mkdocs_install_dir }}"
|
||||
BUILD_DIR="{{ mkdocs_build_dir }}"
|
||||
VENV_DIR="{{ mkdocs_venv_dir }}"
|
||||
|
||||
# Prevent concurrent runs
|
||||
exec 200>"$LOCK_FILE"
|
||||
flock -n 200 || { echo "Another sync is running"; exit 0; }
|
||||
|
||||
echo "[$(date)] Starting wiki sync..."
|
||||
|
||||
# Sync docs (this requires SSH key access from wiki server to source)
|
||||
# For now, we rely on Ansible to do the sync
|
||||
# rsync -avz --delete --exclude='.obsidian' --exclude='.git' \
|
||||
# "${SOURCE_HOST}:${SOURCE_DIR}/" "${INSTALL_DIR}/docs/"
|
||||
|
||||
{% if homeassistant_docs_enabled | default(false) and homeassistant_token is defined %}
|
||||
# Generate Home Assistant documentation
|
||||
echo "[$(date)] Generating Home Assistant documentation..."
|
||||
"${VENV_DIR}/bin/python" "${INSTALL_DIR}/generate-ha-docs.py" \
|
||||
--url "{{ homeassistant_url }}" \
|
||||
--token "{{ homeassistant_token }}" \
|
||||
--output "{{ homeassistant_docs_dir }}" || echo "Warning: HA docs generation failed"
|
||||
{% endif %}
|
||||
|
||||
# Rebuild site
|
||||
cd "${INSTALL_DIR}"
|
||||
"${VENV_DIR}/bin/mkdocs" build --site-dir "${BUILD_DIR}" --quiet
|
||||
|
||||
echo "[$(date)] Wiki sync complete"
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
# Music Assistant configuration
|
||||
music_assistant_version: "latest"
|
||||
music_assistant_data_dir: "/opt/music-assistant"
|
||||
|
||||
# Network ports (host networking required for mDNS/uPnP)
|
||||
music_assistant_web_port: 8095
|
||||
music_assistant_stream_port: 8097
|
||||
music_assistant_slimproto_port: 3483
|
||||
|
||||
# Log level: debug, info, warning, error
|
||||
music_assistant_log_level: "info"
|
||||
|
||||
# Optional: Path to local music library (mount point inside container)
|
||||
# Set to empty string to disable local music provider
|
||||
music_assistant_media_path: ""
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
- name: Restart Music Assistant
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ music_assistant_data_dir }}"
|
||||
state: restarted
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
- name: Install required system packages
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- python3-docker
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Create Music Assistant data directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ music_assistant_data_dir }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Create Music Assistant data subdirectory
|
||||
ansible.builtin.file:
|
||||
path: "{{ music_assistant_data_dir }}/data"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Create media directory if specified
|
||||
ansible.builtin.file:
|
||||
path: "{{ music_assistant_media_path }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
when: music_assistant_media_path | length > 0
|
||||
|
||||
- name: Copy Music Assistant Docker Compose file
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ music_assistant_data_dir }}/docker-compose.yml"
|
||||
mode: '0644'
|
||||
notify: Restart Music Assistant
|
||||
|
||||
- name: Start Music Assistant via Docker Compose
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ music_assistant_data_dir }}"
|
||||
state: present
|
||||
pull: always
|
||||
@@ -1,28 +0,0 @@
|
||||
services:
|
||||
music-assistant:
|
||||
container_name: music-assistant
|
||||
image: ghcr.io/music-assistant/server:{{ music_assistant_version }}
|
||||
# Host networking is REQUIRED for mDNS/uPnP discovery of players
|
||||
network_mode: host
|
||||
environment:
|
||||
- LOG_LEVEL={{ music_assistant_log_level }}
|
||||
volumes:
|
||||
- {{ music_assistant_data_dir }}/data:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
{% if music_assistant_media_path | length > 0 %}
|
||||
- {{ music_assistant_media_path }}:/media:ro
|
||||
{% endif %}
|
||||
# Required capabilities for proper audio streaming
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
- DAC_READ_SEARCH
|
||||
# Security: limit capabilities to what's needed
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:{{ music_assistant_web_port }}/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
# n8n configuration
|
||||
n8n_version: "2.11.0"
|
||||
n8n_port: 5678
|
||||
n8n_data_dir: "/opt/n8n"
|
||||
|
||||
# Public URL (required for webhooks)
|
||||
n8n_public_url: "https://n8n.renner.uno"
|
||||
n8n_protocol: "https"
|
||||
|
||||
# Encryption key for credentials (MUST be set in vault!)
|
||||
n8n_encryption_key: "CHANGE_ME_IN_VAULT"
|
||||
|
||||
# Timezone
|
||||
n8n_timezone: "Europe/Vienna"
|
||||
|
||||
# PostgreSQL configuration
|
||||
n8n_db_type: "postgresdb"
|
||||
n8n_postgres_user: "n8n"
|
||||
n8n_postgres_password: "CHANGE_ME_IN_VAULT"
|
||||
n8n_postgres_db: "n8n"
|
||||
n8n_postgres_version: "16"
|
||||
|
||||
# Execution settings
|
||||
n8n_executions_data_save_on_error: "all"
|
||||
n8n_executions_data_save_on_success: "all"
|
||||
n8n_executions_data_save_manual_executions: true
|
||||
n8n_executions_data_prune: true
|
||||
n8n_executions_data_max_age: 336 # 14 days in hours
|
||||
|
||||
# Node environment
|
||||
n8n_node_env: "production"
|
||||
|
||||
# Default admin (optional, for initial setup)
|
||||
n8n_default_user_email: ""
|
||||
n8n_default_user_password: ""
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
- name: Restart n8n
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ n8n_data_dir }}"
|
||||
state: restarted
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
- name: Install required system packages
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- python3-docker
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Create n8n data directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ n8n_data_dir }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Create n8n subdirectories
|
||||
ansible.builtin.file:
|
||||
path: "{{ n8n_data_dir }}/{{ item.path }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
owner: "{{ item.owner | default('root') }}"
|
||||
group: "{{ item.group | default('root') }}"
|
||||
loop:
|
||||
- { path: 'data', owner: '1000', group: '1000' } # n8n runs as user 'node' (UID 1000)
|
||||
- { path: 'postgres' }
|
||||
|
||||
- name: Copy n8n configuration files
|
||||
ansible.builtin.template:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ n8n_data_dir }}/{{ item.dest }}"
|
||||
mode: '0644'
|
||||
loop:
|
||||
- { src: '.env.j2', dest: '.env' }
|
||||
- { src: 'docker-compose.yml.j2', dest: 'docker-compose.yml' }
|
||||
notify: Restart n8n
|
||||
|
||||
- name: Start n8n via Docker Compose
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ n8n_data_dir }}"
|
||||
state: present
|
||||
pull: always
|
||||
@@ -1,42 +0,0 @@
|
||||
# n8n Environment Configuration
|
||||
# Managed by Ansible - Do not edit manually
|
||||
|
||||
# Node environment
|
||||
NODE_ENV={{ n8n_node_env }}
|
||||
|
||||
# Timezone
|
||||
GENERIC_TIMEZONE={{ n8n_timezone }}
|
||||
TZ={{ n8n_timezone }}
|
||||
|
||||
# Public URL and webhooks
|
||||
N8N_HOST=0.0.0.0
|
||||
N8N_PORT=5678
|
||||
N8N_PROTOCOL={{ n8n_protocol }}
|
||||
WEBHOOK_URL={{ n8n_public_url }}/
|
||||
|
||||
# Encryption key for credentials (critical - do not change after initial setup)
|
||||
N8N_ENCRYPTION_KEY={{ n8n_encryption_key }}
|
||||
|
||||
# Database configuration (PostgreSQL)
|
||||
DB_TYPE={{ n8n_db_type }}
|
||||
DB_POSTGRESDB_HOST=postgres
|
||||
DB_POSTGRESDB_PORT=5432
|
||||
DB_POSTGRESDB_DATABASE={{ n8n_postgres_db }}
|
||||
DB_POSTGRESDB_USER={{ n8n_postgres_user }}
|
||||
DB_POSTGRESDB_PASSWORD={{ n8n_postgres_password }}
|
||||
|
||||
# Execution settings
|
||||
EXECUTIONS_DATA_SAVE_ON_ERROR={{ n8n_executions_data_save_on_error }}
|
||||
EXECUTIONS_DATA_SAVE_ON_SUCCESS={{ n8n_executions_data_save_on_success }}
|
||||
EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS={{ n8n_executions_data_save_manual_executions | lower }}
|
||||
EXECUTIONS_DATA_PRUNE={{ n8n_executions_data_prune | lower }}
|
||||
EXECUTIONS_DATA_MAX_AGE={{ n8n_executions_data_max_age }}
|
||||
|
||||
{% if n8n_default_user_email and n8n_default_user_password %}
|
||||
# Default admin user (only used on first startup)
|
||||
N8N_DEFAULT_USER_EMAIL={{ n8n_default_user_email }}
|
||||
N8N_DEFAULT_USER_PASSWORD={{ n8n_default_user_password }}
|
||||
{% endif %}
|
||||
|
||||
# Telemetry (disabled)
|
||||
N8N_DIAGNOSTICS_ENABLED=false
|
||||
@@ -1,39 +0,0 @@
|
||||
services:
|
||||
postgres:
|
||||
container_name: n8n-postgres
|
||||
image: postgres:{{ n8n_postgres_version }}
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER={{ n8n_postgres_user }}
|
||||
- POSTGRES_PASSWORD={{ n8n_postgres_password }}
|
||||
- POSTGRES_DB={{ n8n_postgres_db }}
|
||||
volumes:
|
||||
- {{ n8n_data_dir }}/postgres:/var/lib/postgresql/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U {{ n8n_postgres_user }} -d {{ n8n_postgres_db }}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
n8n:
|
||||
container_name: n8n
|
||||
image: docker.n8n.io/n8nio/n8n:{{ n8n_version }}
|
||||
restart: always
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "{{ n8n_port }}:5678"
|
||||
volumes:
|
||||
- {{ n8n_data_dir }}/data:/home/node/.n8n
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:5678/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
# ownCloud Infinite Scale (oCIS) configuration
|
||||
owncloud_version: "8.0.1"
|
||||
owncloud_port: 9200
|
||||
owncloud_data_dir: "/opt/owncloud"
|
||||
owncloud_storage_dir: "/mnt/data/owncloud"
|
||||
|
||||
# Public URL
|
||||
owncloud_url: "https://owncloud.renner.uno"
|
||||
|
||||
# Admin user (override password in vault!)
|
||||
owncloud_admin_password: "{{ vault_owncloud_admin_password }}"
|
||||
|
||||
# OIDC Authentication (Authentik)
|
||||
owncloud_oidc_enabled: true
|
||||
owncloud_oidc_issuer: "https://auth.renner.uno/application/o/owncloud/"
|
||||
owncloud_oidc_client_id: "{{ vault_owncloud_oidc_client_id }}"
|
||||
owncloud_oidc_client_secret: "{{ vault_owncloud_oidc_client_secret }}"
|
||||
|
||||
# ONLYOFFICE
|
||||
owncloud_onlyoffice_enabled: true
|
||||
owncloud_onlyoffice_url: "https://office.renner.uno"
|
||||
owncloud_onlyoffice_secret: "{{ vault_owncloud_wopi_secret | default('') }}"
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
- name: Restart ownCloud
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ owncloud_data_dir }}"
|
||||
state: restarted
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
- name: Install required system packages
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- python3-docker
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Create ownCloud data directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ owncloud_data_dir }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Create ownCloud config directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ owncloud_data_dir }}/config"
|
||||
state: directory
|
||||
owner: "1000"
|
||||
group: "1000"
|
||||
mode: '0755'
|
||||
|
||||
- name: Create ownCloud data storage directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ owncloud_storage_dir }}/data"
|
||||
state: directory
|
||||
owner: "1000"
|
||||
group: "1000"
|
||||
mode: '0755'
|
||||
|
||||
- name: Create ONLYOFFICE directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ owncloud_data_dir }}/{{ item }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
loop:
|
||||
- onlyoffice/data
|
||||
- onlyoffice/logs
|
||||
when: owncloud_onlyoffice_enabled | default(false)
|
||||
|
||||
- name: Copy ownCloud configuration files
|
||||
ansible.builtin.template:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ owncloud_data_dir }}/{{ item.dest }}"
|
||||
mode: '0644'
|
||||
loop:
|
||||
- { src: '.env.j2', dest: '.env' }
|
||||
- { src: 'docker-compose.yml.j2', dest: 'docker-compose.yml' }
|
||||
notify: Restart ownCloud
|
||||
|
||||
- name: Create proxy config directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ owncloud_data_dir }}/config/proxy"
|
||||
state: directory
|
||||
owner: "1000"
|
||||
group: "1000"
|
||||
mode: '0755'
|
||||
|
||||
- name: Copy proxy role assignment config
|
||||
ansible.builtin.template:
|
||||
src: proxy.yaml.j2
|
||||
dest: "{{ owncloud_data_dir }}/config/proxy.yaml"
|
||||
owner: "1000"
|
||||
group: "1000"
|
||||
mode: '0644'
|
||||
notify: Restart ownCloud
|
||||
|
||||
- name: Copy role assignment configuration
|
||||
ansible.builtin.template:
|
||||
src: role_assignment.json.j2
|
||||
dest: "{{ owncloud_data_dir }}/config/role_assignment.json"
|
||||
owner: "1000"
|
||||
group: "1000"
|
||||
mode: '0644'
|
||||
notify: Restart ownCloud
|
||||
|
||||
- name: Copy CSP configuration
|
||||
ansible.builtin.template:
|
||||
src: csp.yaml.j2
|
||||
dest: "{{ owncloud_data_dir }}/config/csp.yaml"
|
||||
owner: "1000"
|
||||
group: "1000"
|
||||
mode: '0644'
|
||||
notify: Restart ownCloud
|
||||
|
||||
- name: Start ownCloud via Docker Compose
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ owncloud_data_dir }}"
|
||||
state: present
|
||||
pull: always
|
||||
@@ -1,32 +0,0 @@
|
||||
# ownCloud Infinite Scale Configuration
|
||||
# Managed by Ansible - Do not edit manually
|
||||
|
||||
OCIS_URL={{ owncloud_url }}
|
||||
OCIS_INSECURE=true
|
||||
OCIS_LOG_LEVEL=warn
|
||||
|
||||
# Admin credentials
|
||||
IDM_ADMIN_PASSWORD={{ owncloud_admin_password }}
|
||||
|
||||
# Proxy configuration (behind Caddy reverse proxy)
|
||||
PROXY_HTTP_ADDR=0.0.0.0:9200
|
||||
PROXY_TLS=false
|
||||
NATS_NATS_HOST=0.0.0.0
|
||||
GATEWAY_GRPC_ADDR=0.0.0.0:9142
|
||||
|
||||
{% if owncloud_oidc_enabled and owncloud_oidc_client_id %}
|
||||
# OIDC Authentication (Authentik)
|
||||
PROXY_OIDC_ISSUER={{ owncloud_oidc_issuer }}
|
||||
OCIS_OIDC_ISSUER={{ owncloud_oidc_issuer }}
|
||||
PROXY_OIDC_REWRITE_WELLKNOWN=true
|
||||
WEB_OIDC_CLIENT_ID={{ owncloud_oidc_client_id }}
|
||||
PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD=jwt
|
||||
PROXY_USER_OIDC_CLAIM=preferred_username
|
||||
PROXY_USER_CS3_CLAIM=username
|
||||
PROXY_AUTOPROVISION_ACCOUNTS=true
|
||||
PROXY_ROLE_ASSIGNMENT_DRIVER=oidc
|
||||
PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM=groups
|
||||
WEB_OIDC_SCOPE=openid profile email groups
|
||||
OCIS_EXCLUDE_RUN_SERVICES=idp
|
||||
PROXY_CSP_CONFIG_FILE_LOCATION=/etc/ocis/csp.yaml
|
||||
{% endif %}
|
||||
@@ -1,40 +0,0 @@
|
||||
directives:
|
||||
child-src:
|
||||
- "'self'"
|
||||
connect-src:
|
||||
- "'self'"
|
||||
- "https://auth.renner.uno"
|
||||
- "https://raw.githubusercontent.com"
|
||||
- "https://office.renner.uno"
|
||||
default-src:
|
||||
- "'none'"
|
||||
font-src:
|
||||
- "'self'"
|
||||
- "data:"
|
||||
frame-ancestors:
|
||||
- "'self'"
|
||||
- "https://owncloud.renner.uno"
|
||||
- "https://office.renner.uno"
|
||||
frame-src:
|
||||
- "'self'"
|
||||
- "blob:"
|
||||
- "https://auth.renner.uno"
|
||||
- "https://office.renner.uno"
|
||||
img-src:
|
||||
- "'self'"
|
||||
- "data:"
|
||||
- "blob:"
|
||||
- "https://raw.githubusercontent.com"
|
||||
manifest-src:
|
||||
- "'self'"
|
||||
media-src:
|
||||
- "'self'"
|
||||
object-src:
|
||||
- "'self'"
|
||||
- "blob:"
|
||||
script-src:
|
||||
- "'self'"
|
||||
- "'unsafe-inline'"
|
||||
style-src:
|
||||
- "'self'"
|
||||
- "'unsafe-inline'"
|
||||
@@ -1,72 +0,0 @@
|
||||
services:
|
||||
ocis:
|
||||
container_name: ocis
|
||||
image: owncloud/ocis:{{ owncloud_version }}
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "{{ owncloud_port }}:9200"
|
||||
volumes:
|
||||
- {{ owncloud_data_dir }}/config:/etc/ocis
|
||||
- {{ owncloud_storage_dir }}/data:/var/lib/ocis
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
restart: always
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command: ["-c", "ocis init || true; ocis server"]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9200/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
{% if owncloud_onlyoffice_enabled is defined and owncloud_onlyoffice_enabled %}
|
||||
|
||||
ocis-collaboration:
|
||||
container_name: ocis-collaboration
|
||||
image: owncloud/ocis:{{ owncloud_version }}
|
||||
command: ["collaboration", "server"]
|
||||
environment:
|
||||
COLLABORATION_GRPC_ADDR: 0.0.0.0:9301
|
||||
COLLABORATION_HTTP_ADDR: 0.0.0.0:9300
|
||||
MICRO_REGISTRY: nats-js-kv
|
||||
MICRO_REGISTRY_ADDRESS: ocis:9233
|
||||
COLLABORATION_WOPI_SRC: http://ocis-collaboration:9300
|
||||
COLLABORATION_APP_NAME: OnlyOffice
|
||||
COLLABORATION_APP_PRODUCT: OnlyOffice
|
||||
COLLABORATION_APP_ADDR: {{ owncloud_onlyoffice_url }}
|
||||
COLLABORATION_APP_ICON: {{ owncloud_onlyoffice_url }}/web-apps/apps/documenteditor/main/resources/img/favicon.ico
|
||||
COLLABORATION_APP_INSECURE: "true"
|
||||
COLLABORATION_CS3API_DATAGATEWAY_INSECURE: "true"
|
||||
COLLABORATION_LOG_LEVEL: info
|
||||
OCIS_URL: {{ owncloud_url }}
|
||||
volumes:
|
||||
- {{ owncloud_data_dir }}/config:/etc/ocis
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
restart: always
|
||||
depends_on:
|
||||
ocis:
|
||||
condition: service_healthy
|
||||
onlyoffice:
|
||||
condition: service_healthy
|
||||
|
||||
onlyoffice:
|
||||
container_name: onlyoffice
|
||||
image: onlyoffice/documentserver:latest
|
||||
ports:
|
||||
- "9980:80"
|
||||
environment:
|
||||
- JWT_ENABLED=false
|
||||
- WOPI_ENABLED=true
|
||||
volumes:
|
||||
- {{ owncloud_data_dir }}/onlyoffice/data:/var/www/onlyoffice/Data
|
||||
- {{ owncloud_data_dir }}/onlyoffice/logs:/var/log/onlyoffice
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost/healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
{% endif %}
|
||||
@@ -1,11 +0,0 @@
|
||||
role_assignment:
|
||||
driver: oidc
|
||||
oidc_role_mapper:
|
||||
role_claim: groups
|
||||
role_mapping:
|
||||
- role_name: admin
|
||||
claim_value: ocis-admin
|
||||
- role_name: spaceadmin
|
||||
claim_value: ocis-spaceadmin
|
||||
- role_name: user
|
||||
claim_value: ocis-user
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"role_mapping": [
|
||||
{
|
||||
"role_name": "admin",
|
||||
"claim_value": "ocis-admin"
|
||||
},
|
||||
{
|
||||
"role_name": "spaceadmin",
|
||||
"claim_value": "ocis-spaceadmin"
|
||||
},
|
||||
{
|
||||
"role_name": "user",
|
||||
"claim_value": "ocis-user"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
# PVE Node Network Role Defaults
|
||||
|
||||
# --- IPv6 Router Advertisement acceptance -----------------------------------
|
||||
# The PVE nodes run FRR (OSPFv3 over the Thunderbolt mesh, carrying corosync),
|
||||
# which enables net.ipv6.conf.all.forwarding. The kernel default accept_ra=1
|
||||
# means "accept RAs only while NOT forwarding", so the nodes discard the RAs
|
||||
# the UniFi gateway sends. accept_ra=2 is the value a forwarding host needs in
|
||||
# order to still autoconfigure its own uplink.
|
||||
pve_node_network_accept_ra: 2
|
||||
|
||||
# Interfaces to set accept_ra on explicitly, in addition to all/default.
|
||||
# Dots are translated to slashes for the sysctl key (vmbr1.40 -> vmbr1/40).
|
||||
pve_node_network_ra_interfaces:
|
||||
- vmbr1.40
|
||||
- vmbr0
|
||||
|
||||
# --- vmbr0 MTU --------------------------------------------------------------
|
||||
# jupiter and neptune already carry 9000; saturn's stanza is missing the mtu
|
||||
# line and sits at 1500 while its own uplink is 9000. All three should match,
|
||||
# since they share one L2 domain and mismatched MTUs blackhole large frames
|
||||
# rather than fragmenting them.
|
||||
pve_node_network_bridge: vmbr0
|
||||
pve_node_network_bridge_mtu: 9000
|
||||
|
||||
# Whether to manage the bridge MTU at all.
|
||||
pve_node_network_manage_mtu: yes
|
||||
|
||||
# Applying an MTU change requires a network reload (ifreload -a), which briefly
|
||||
# interrupts guest connectivity on the node — including the Talos k8s VM and its
|
||||
# Ceph OSD. Left false, the change is written to /etc/network/interfaces.new and
|
||||
# shows as "pending" in the PVE UI, to be applied deliberately per node.
|
||||
# Run with -e pve_node_network_apply_pending=true, ideally with --limit <node>.
|
||||
pve_node_network_apply_pending: no
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
# PVE Node Network Handlers
|
||||
|
||||
- name: Reload sysctl
|
||||
ansible.builtin.command: sysctl --system
|
||||
changed_when: true
|
||||
@@ -1,141 +0,0 @@
|
||||
---
|
||||
# PVE Node Network Role
|
||||
#
|
||||
# Two independent concerns, deliberately separated by risk:
|
||||
#
|
||||
# 1. IPv6 RA acceptance — a sysctl drop-in. Non-disruptive, reversible,
|
||||
# applied unconditionally.
|
||||
# 2. vmbr0 MTU convergence — requires a network reload that briefly drops
|
||||
# guest connectivity. Staged as a pending change by default; applied only
|
||||
# when pve_node_network_apply_pending is true.
|
||||
|
||||
- name: Verify this is a Proxmox VE node
|
||||
ansible.builtin.command: pveversion
|
||||
register: pve_node_network_pveversion
|
||||
changed_when: false
|
||||
|
||||
# --- 1. IPv6 Router Advertisement acceptance --------------------------------
|
||||
|
||||
- name: Deploy IPv6 RA sysctl drop-in
|
||||
ansible.builtin.template:
|
||||
src: 70-pve-ipv6-ra.conf.j2
|
||||
dest: /etc/sysctl.d/70-pve-ipv6-ra.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
backup: yes
|
||||
notify: Reload sysctl
|
||||
|
||||
- name: Apply pending sysctl changes before verifying
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: Read back effective accept_ra on the management interface
|
||||
ansible.builtin.command: sysctl -n net.ipv6.conf.vmbr1/40.accept_ra
|
||||
register: pve_node_network_effective_accept_ra
|
||||
changed_when: false
|
||||
|
||||
- name: Fail if accept_ra did not take effect
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- pve_node_network_effective_accept_ra.stdout | int == pve_node_network_accept_ra | int
|
||||
fail_msg: >-
|
||||
accept_ra on vmbr1.40 is
|
||||
{{ pve_node_network_effective_accept_ra.stdout }}, expected
|
||||
{{ pve_node_network_accept_ra }}. The sysctl drop-in did not apply.
|
||||
success_msg: "accept_ra on vmbr1.40 is {{ pve_node_network_accept_ra }}"
|
||||
|
||||
# SLAAC needs a Router Advertisement to arrive before an address appears. The
|
||||
# gateway advertises roughly every 10-15s, so a short wait is enough; this is
|
||||
# reported rather than enforced, since a node may legitimately be on a segment
|
||||
# without RAs.
|
||||
- name: Wait for a global IPv6 address to be autoconfigured
|
||||
ansible.builtin.shell: >-
|
||||
ip -6 addr show dev vmbr1.40 scope global | grep -q 'inet6'
|
||||
register: pve_node_network_slaac_check
|
||||
retries: 6
|
||||
delay: 10
|
||||
until: pve_node_network_slaac_check.rc == 0
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Report IPv6 autoconfiguration result
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
{% if pve_node_network_slaac_check.rc == 0 %}
|
||||
{{ inventory_hostname }}: global IPv6 address acquired on vmbr1.40
|
||||
{% else %}
|
||||
{{ inventory_hostname }}: still no global IPv6 on vmbr1.40 after 60s.
|
||||
Check that Router Advertisements reach this VLAN:
|
||||
tcpdump -nni vmbr1.40 'icmp6 and ip6[40] == 134'
|
||||
{% endif %}
|
||||
|
||||
# --- 2. vmbr0 MTU -----------------------------------------------------------
|
||||
|
||||
- name: Query current bridge configuration
|
||||
ansible.builtin.command: >-
|
||||
pvesh get /nodes/{{ inventory_hostname }}/network/{{ pve_node_network_bridge }}
|
||||
--output-format json
|
||||
register: pve_node_network_bridge_raw
|
||||
changed_when: false
|
||||
when: pve_node_network_manage_mtu | bool
|
||||
|
||||
- name: Parse current bridge configuration
|
||||
ansible.builtin.set_fact:
|
||||
pve_node_network_bridge_cfg: "{{ pve_node_network_bridge_raw.stdout | from_json }}"
|
||||
when: pve_node_network_manage_mtu | bool
|
||||
|
||||
# A PUT on /nodes/<node>/network/<iface> REPLACES the interface definition
|
||||
# rather than merging into it. Passing only --mtu would silently drop
|
||||
# bridge_ports, bridge_vlan_aware and bridge_vids, detaching the uplink and
|
||||
# flattening every VNet. Every existing attribute is therefore passed back.
|
||||
- name: Set bridge MTU
|
||||
ansible.builtin.command: >-
|
||||
pvesh set /nodes/{{ inventory_hostname }}/network/{{ pve_node_network_bridge }}
|
||||
--type bridge
|
||||
--autostart {{ pve_node_network_bridge_cfg.autostart | default(1) }}
|
||||
--bridge_ports "{{ pve_node_network_bridge_cfg.bridge_ports }}"
|
||||
--bridge_stp {{ pve_node_network_bridge_cfg.bridge_stp | default('off') }}
|
||||
--bridge_fd {{ pve_node_network_bridge_cfg.bridge_fd | default(0) }}
|
||||
--bridge_vlan_aware {{ pve_node_network_bridge_cfg.bridge_vlan_aware | default(0) }}
|
||||
--bridge_vids "{{ pve_node_network_bridge_cfg.bridge_vids | default('2-4094') }}"
|
||||
--mtu {{ pve_node_network_bridge_mtu }}
|
||||
when:
|
||||
- pve_node_network_manage_mtu | bool
|
||||
- pve_node_network_bridge_cfg.mtu | default(0) | int != pve_node_network_bridge_mtu | int
|
||||
register: pve_node_network_mtu_staged
|
||||
|
||||
- name: Report staged network change
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
{{ inventory_hostname }}: {{ pve_node_network_bridge }} MTU change to
|
||||
{{ pve_node_network_bridge_mtu }} is staged in
|
||||
/etc/network/interfaces.new and NOT yet live. Apply it deliberately,
|
||||
one node at a time:
|
||||
ansible-playbook -i inventory/ plays/configure-pve-node-network.yml
|
||||
--limit {{ inventory_hostname }} -e pve_node_network_apply_pending=true
|
||||
when:
|
||||
- pve_node_network_mtu_staged is changed
|
||||
- not (pve_node_network_apply_pending | bool)
|
||||
|
||||
- name: Apply pending network configuration
|
||||
ansible.builtin.command: pvesh set /nodes/{{ inventory_hostname }}/network
|
||||
when:
|
||||
- pve_node_network_apply_pending | bool
|
||||
- pve_node_network_mtu_staged is changed
|
||||
|
||||
- name: Verify bridge MTU is live
|
||||
ansible.builtin.command: cat /sys/class/net/{{ pve_node_network_bridge }}/mtu
|
||||
register: pve_node_network_live_mtu
|
||||
changed_when: false
|
||||
when: pve_node_network_apply_pending | bool
|
||||
|
||||
- name: Fail if bridge MTU did not converge
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- pve_node_network_live_mtu.stdout | int == pve_node_network_bridge_mtu | int
|
||||
fail_msg: >-
|
||||
{{ pve_node_network_bridge }} MTU is {{ pve_node_network_live_mtu.stdout }},
|
||||
expected {{ pve_node_network_bridge_mtu }}.
|
||||
success_msg: >-
|
||||
{{ pve_node_network_bridge }} MTU is {{ pve_node_network_bridge_mtu }}
|
||||
when: pve_node_network_apply_pending | bool
|
||||
@@ -1,39 +0,0 @@
|
||||
#
|
||||
# Managed by Ansible (role: pve-node-network) — do not edit by hand.
|
||||
#
|
||||
# Why this file exists:
|
||||
#
|
||||
# /etc/frr/frr.conf enables "ipv6 forwarding" for the OSPFv3 Thunderbolt mesh
|
||||
# that carries corosync. That sets net.ipv6.conf.all.forwarding=1.
|
||||
#
|
||||
# The kernel default is accept_ra=1, which means "accept Router Advertisements
|
||||
# ONLY IF forwarding is disabled". So the moment FRR came up, these nodes began
|
||||
# silently discarding the RAs the UniFi gateway sends on VLAN 40, leaving every
|
||||
# external interface with nothing but a link-local fe80:: address. The only
|
||||
# non-link-local IPv6 on the nodes was the corosync mesh /128, which is static
|
||||
# in frr.conf and never depended on RAs.
|
||||
#
|
||||
# accept_ra=2 means "accept RAs even though I am forwarding" — the correct
|
||||
# setting for a router that still needs to autoconfigure its own uplink.
|
||||
#
|
||||
# Verified 2026-07-19: the gateway does advertise on VLAN 40 (RAs from
|
||||
# fe80::265a:4cff:fea6:fd3c roughly every 10-15s, carrying both the Telekom
|
||||
# GUA prefix and a ULA). The nodes were receiving and discarding them.
|
||||
#
|
||||
# See wiki: pve-ipv6-accept-ra-forwarding-defect
|
||||
#
|
||||
|
||||
# 'default' covers interfaces created after boot; 'all' propagates to the
|
||||
# interfaces that already exist when this is applied.
|
||||
net.ipv6.conf.all.accept_ra = {{ pve_node_network_accept_ra }}
|
||||
net.ipv6.conf.default.accept_ra = {{ pve_node_network_accept_ra }}
|
||||
|
||||
# Autoconf is already on by default, but state it explicitly so the intent
|
||||
# survives someone tightening the defaults later.
|
||||
net.ipv6.conf.all.autoconf = 1
|
||||
net.ipv6.conf.default.autoconf = 1
|
||||
{% for iface in pve_node_network_ra_interfaces %}
|
||||
|
||||
net.ipv6.conf.{{ iface | replace('.', '/') }}.accept_ra = {{ pve_node_network_accept_ra }}
|
||||
net.ipv6.conf.{{ iface | replace('.', '/') }}.autoconf = 1
|
||||
{% endfor %}
|
||||
@@ -1,112 +0,0 @@
|
||||
---
|
||||
# -------------------------------------------------------
|
||||
# 1. Update Packages
|
||||
# -------------------------------------------------------
|
||||
- name: Update apt cache
|
||||
apt:
|
||||
update_cache: yes
|
||||
cache_valid_time: 3600
|
||||
|
||||
- name: Perform dist-upgrade
|
||||
apt:
|
||||
upgrade: dist
|
||||
autoremove: yes
|
||||
purge: yes
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 2. Check if Reboot is Required
|
||||
# -------------------------------------------------------
|
||||
- name: Check if reboot is required (file check)
|
||||
stat:
|
||||
path: /var/run/reboot-required
|
||||
register: reboot_required_file
|
||||
|
||||
- name: Check if a new kernel is installed vs running
|
||||
shell: |
|
||||
if [ $(uname -r) != $(ls -t /boot/vmlinuz-* | head -1 | sed 's|/boot/vmlinuz-||') ]; then echo "reboot_needed"; fi
|
||||
register: kernel_check
|
||||
changed_when: false
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 3. Disable HA for VMs that cannot migrate (USB passthrough)
|
||||
# -------------------------------------------------------
|
||||
- name: Get list of HA resources on this node
|
||||
shell: ha-manager status | grep "{{ inventory_hostname }}" | grep -oP 'vm:\d+'
|
||||
register: ha_resources
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: reboot_required_file.stat.exists or 'reboot_needed' in kernel_check.stdout
|
||||
|
||||
- name: Check which VMs have USB passthrough
|
||||
shell: qm config {{ item | regex_replace('vm:', '') }} | grep -q '^usb' && echo "has_usb"
|
||||
loop: "{{ ha_resources.stdout_lines | default([]) }}"
|
||||
register: usb_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: reboot_required_file.stat.exists or 'reboot_needed' in kernel_check.stdout
|
||||
|
||||
- name: Disable HA for VMs with USB passthrough
|
||||
command: ha-manager set {{ item.item }} --state disabled
|
||||
loop: "{{ usb_check.results | default([]) }}"
|
||||
when:
|
||||
- reboot_required_file.stat.exists or 'reboot_needed' in kernel_check.stdout
|
||||
- item.stdout is defined and 'has_usb' in item.stdout
|
||||
register: disabled_ha_vms
|
||||
|
||||
- name: Stop USB VMs before reboot
|
||||
command: qm shutdown {{ item.item | regex_replace('vm:', '') }} --timeout 60
|
||||
loop: "{{ usb_check.results | default([]) }}"
|
||||
when:
|
||||
- reboot_required_file.stat.exists or 'reboot_needed' in kernel_check.stdout
|
||||
- item.stdout is defined and 'has_usb' in item.stdout
|
||||
failed_when: false
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 4. Reboot and Wait for Connection
|
||||
# -------------------------------------------------------
|
||||
- name: Reboot node
|
||||
reboot:
|
||||
msg: "Rebooting for Proxmox updates via Ansible"
|
||||
post_reboot_delay: 30
|
||||
reboot_timeout: 600
|
||||
when: reboot_required_file.stat.exists or 'reboot_needed' in kernel_check.stdout
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 4. Cluster Stabilization Checks
|
||||
# -------------------------------------------------------
|
||||
- name: Wait for PVE Cluster Service (pmxcfs)
|
||||
command: systemctl is-active pve-cluster
|
||||
register: cluster_service
|
||||
until: cluster_service.rc == 0
|
||||
retries: 30
|
||||
delay: 5
|
||||
when: reboot_required_file.stat.exists or 'reboot_needed' in kernel_check.stdout
|
||||
|
||||
- name: Wait for Quorum (pvecm status)
|
||||
shell: |
|
||||
pvecm status | grep -E "Quorate:\s+Yes"
|
||||
register: quorum_check
|
||||
until: quorum_check.rc == 0
|
||||
retries: 60
|
||||
delay: 5
|
||||
when: reboot_required_file.stat.exists or 'reboot_needed' in kernel_check.stdout
|
||||
|
||||
- name: Wait for HA Manager (LRM) to be Active
|
||||
shell: ha-manager status | grep "{{ inventory_hostname }}" | grep "active"
|
||||
register: ha_check
|
||||
until: ha_check.rc == 0
|
||||
retries: 60
|
||||
delay: 10
|
||||
when: reboot_required_file.stat.exists or 'reboot_needed' in kernel_check.stdout
|
||||
|
||||
- name: Re-enable HA for previously disabled VMs
|
||||
command: ha-manager set {{ item.item }} --state started
|
||||
loop: "{{ usb_check.results | default([]) }}"
|
||||
when:
|
||||
- reboot_required_file.stat.exists or 'reboot_needed' in kernel_check.stdout
|
||||
- item.stdout is defined and 'has_usb' in item.stdout
|
||||
|
||||
- name: Final stabilization pause (allow migrations to settle)
|
||||
pause:
|
||||
seconds: 60
|
||||
when: reboot_required_file.stat.exists or 'reboot_needed' in kernel_check.stdout
|
||||
@@ -8,9 +8,6 @@
|
||||
state: present
|
||||
when:
|
||||
- ansible_os_family is defined and ansible_os_family == 'Debian'
|
||||
- "'proxmox_all_lxc' not in group_names"
|
||||
- "'proxmox_nodes' not in group_names"
|
||||
- "'pve_cluster' not in group_names"
|
||||
|
||||
- name: Install QEMU Guest Agent (RHEL/Rocky VMs)
|
||||
dnf:
|
||||
@@ -18,13 +15,9 @@
|
||||
state: present
|
||||
when:
|
||||
- ansible_os_family is defined and ansible_os_family == 'RedHat'
|
||||
- "'proxmox_all_lxc' not in group_names"
|
||||
- "'proxmox_nodes' not in group_names"
|
||||
- "'pve_cluster' not in group_names"
|
||||
|
||||
- name: Ensure qemu-guest-agent is started and enabled
|
||||
service:
|
||||
name: qemu-guest-agent
|
||||
state: started
|
||||
enabled: yes
|
||||
when: "'proxmox_all_lxc' not in group_names"
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
vault_version: "1.21.4"
|
||||
vault_user: vault
|
||||
vault_group: vault
|
||||
vault_data_dir: /opt/vault/data
|
||||
vault_config_dir: /etc/vault.d
|
||||
vault_listen_address: "0.0.0.0"
|
||||
vault_listen_port: 8200
|
||||
vault_api_addr: "http://vault.srv.renner.uno:8200"
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
- name: Restart Vault
|
||||
systemd:
|
||||
name: vault
|
||||
state: restarted
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
- name: Install dependencies
|
||||
apt:
|
||||
name:
|
||||
- gpg
|
||||
- wget
|
||||
state: present
|
||||
|
||||
- name: Add HashiCorp GPG key
|
||||
shell: |
|
||||
wget -O- https://apt.releases.hashicorp.com/gpg | gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
|
||||
args:
|
||||
creates: /usr/share/keyrings/hashicorp-archive-keyring.gpg
|
||||
|
||||
- name: Add HashiCorp apt repository
|
||||
apt_repository:
|
||||
repo: "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com {{ ansible_distribution_release | default('bookworm') }} main"
|
||||
filename: hashicorp
|
||||
state: present
|
||||
|
||||
- name: Install Vault
|
||||
apt:
|
||||
name: vault
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Create Vault data directory
|
||||
file:
|
||||
path: "{{ vault_data_dir }}"
|
||||
state: directory
|
||||
owner: "{{ vault_user }}"
|
||||
group: "{{ vault_group }}"
|
||||
mode: "0750"
|
||||
|
||||
- name: Configure Vault
|
||||
template:
|
||||
src: vault.hcl.j2
|
||||
dest: "{{ vault_config_dir }}/vault.hcl"
|
||||
owner: "{{ vault_user }}"
|
||||
group: "{{ vault_group }}"
|
||||
mode: "0640"
|
||||
notify: Restart Vault
|
||||
|
||||
- name: Enable and start Vault
|
||||
systemd:
|
||||
name: vault
|
||||
enabled: true
|
||||
state: started
|
||||
@@ -1,15 +0,0 @@
|
||||
ui = true
|
||||
disable_mlock = true
|
||||
|
||||
storage "raft" {
|
||||
path = "{{ vault_data_dir }}"
|
||||
node_id = "vault-1"
|
||||
}
|
||||
|
||||
listener "tcp" {
|
||||
address = "{{ vault_listen_address }}:{{ vault_listen_port }}"
|
||||
tls_disable = true
|
||||
}
|
||||
|
||||
api_addr = "{{ vault_api_addr }}"
|
||||
cluster_addr = "http://127.0.0.1:8201"
|
||||
@@ -1,134 +0,0 @@
|
||||
---
|
||||
# -------------------------------------------------------
|
||||
# 0. Safety Check: Is the VM actually running?
|
||||
# -------------------------------------------------------
|
||||
- name: Check SSH connectivity
|
||||
wait_for_connection:
|
||||
timeout: 5
|
||||
ignore_errors: true
|
||||
register: ssh_check
|
||||
|
||||
- name: End play for stopped/unreachable VMs
|
||||
meta: end_host
|
||||
when: ssh_check is failed
|
||||
|
||||
- name: Gather Facts (now that we know it is online)
|
||||
setup:
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 1. Check for available updates
|
||||
# -------------------------------------------------------
|
||||
- name: Clean apt cache
|
||||
command: apt-get clean
|
||||
changed_when: false
|
||||
when: ansible_os_family == 'Debian'
|
||||
|
||||
- name: Update apt cache
|
||||
apt:
|
||||
update_cache: yes
|
||||
lock_timeout: 300
|
||||
when: ansible_os_family == 'Debian'
|
||||
|
||||
- name: Check for available Debian upgrades
|
||||
command: apt-get --simulate dist-upgrade
|
||||
register: debian_updates
|
||||
changed_when: false
|
||||
when: ansible_os_family == 'Debian'
|
||||
|
||||
- name: Set updates_available fact (Debian)
|
||||
set_fact:
|
||||
updates_available: "{{ '0 upgraded' not in debian_updates.stdout }}"
|
||||
when: ansible_os_family == 'Debian'
|
||||
|
||||
- name: Check for available RHEL upgrades
|
||||
command: dnf check-update
|
||||
register: rhel_updates
|
||||
failed_when: rhel_updates.rc not in [0, 100]
|
||||
changed_when: false
|
||||
when: ansible_os_family == 'RedHat'
|
||||
|
||||
- name: Set updates_available fact (RHEL)
|
||||
set_fact:
|
||||
updates_available: "{{ rhel_updates.rc == 100 }}"
|
||||
when: ansible_os_family == 'RedHat'
|
||||
|
||||
- name: Skip if no updates available
|
||||
meta: end_host
|
||||
when: not updates_available
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 2. Create Pre-Update Backup
|
||||
# -------------------------------------------------------
|
||||
- name: Trigger Backup via API
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
uri:
|
||||
url: "{{ pve_api_url }}/api2/json/nodes/{{ proxmox_node }}/vzdump"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: "PVEAPIToken={{ pve_user }}!{{ pve_token_id }}={{ pve_token_secret }}"
|
||||
body_format: json
|
||||
body:
|
||||
vmid: "{{ proxmox_vmid }}"
|
||||
mode: "snapshot"
|
||||
compress: "zstd"
|
||||
register: backup_trigger
|
||||
changed_when: true
|
||||
|
||||
- name: Wait for Backup to Complete
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
uri:
|
||||
url: "{{ pve_api_url }}/api2/json/nodes/{{ proxmox_node }}/tasks/{{ backup_trigger.json.data }}/status"
|
||||
method: GET
|
||||
headers:
|
||||
Authorization: "PVEAPIToken={{ pve_user }}!{{ pve_token_id }}={{ pve_token_secret }}"
|
||||
register: backup_status
|
||||
until: backup_status.json.data.status == 'stopped'
|
||||
retries: 60
|
||||
delay: 5
|
||||
|
||||
- name: Verify Backup Success
|
||||
fail:
|
||||
msg: "Backup failed: {{ backup_status.json.data.exitstatus }}"
|
||||
when: backup_status.json.data.exitstatus != 'OK'
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 3. Apply Updates
|
||||
# -------------------------------------------------------
|
||||
- name: Perform dist-upgrade (Debian)
|
||||
apt:
|
||||
upgrade: dist
|
||||
autoremove: yes
|
||||
lock_timeout: 300
|
||||
when: ansible_os_family == 'Debian'
|
||||
|
||||
- name: Check if reboot is required (Debian)
|
||||
stat:
|
||||
path: /var/run/reboot-required
|
||||
register: debian_reboot_check
|
||||
when: ansible_os_family == 'Debian'
|
||||
|
||||
- name: Update dnf packages (RHEL)
|
||||
dnf:
|
||||
name: "*"
|
||||
state: latest
|
||||
when: ansible_os_family == 'RedHat'
|
||||
|
||||
- name: Check if reboot is required (RHEL)
|
||||
command: needs-restarting -r
|
||||
register: rhel_reboot_check
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
when: ansible_os_family == 'RedHat'
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 4. Reboot
|
||||
# -------------------------------------------------------
|
||||
- name: Reboot VM
|
||||
reboot:
|
||||
msg: "Rebooting after Ansible updates"
|
||||
post_reboot_delay: 15
|
||||
when:
|
||||
- (ansible_os_family == 'Debian' and debian_reboot_check.stat.exists) or
|
||||
(ansible_os_family == 'RedHat' and rhel_reboot_check.rc == 1)
|
||||
@@ -1,31 +0,0 @@
|
||||
# Proxmox DNS Server Configuration
|
||||
|
||||
# Proxmox API connection
|
||||
proxmox:
|
||||
host: "pve.renner.uno"
|
||||
user: "root@pam"
|
||||
|
||||
# Token-based authentication (recommended)
|
||||
token_name: "dns"
|
||||
token_value: "your-token-secret-here"
|
||||
|
||||
# OR password-based authentication
|
||||
# password: "your-password-here"
|
||||
|
||||
verify_ssl: false
|
||||
|
||||
# DNS server settings
|
||||
dns_host: "0.0.0.0" # Listen on all interfaces
|
||||
dns_port: 53 # Standard DNS port (requires root or CAP_NET_BIND_SERVICE)
|
||||
|
||||
# DNS suffix for VM/LXC hostnames
|
||||
dns_suffix: ".renner.uno"
|
||||
|
||||
# TTL for DNS responses (in seconds)
|
||||
dns_ttl: 300
|
||||
|
||||
# Cache settings
|
||||
cache_ttl: 60 # How often to refresh the cache (in seconds)
|
||||
|
||||
# Upstream DNS server for non-Proxmox queries
|
||||
upstream_dns: "10.2.40.1"
|
||||
@@ -1,15 +0,0 @@
|
||||
module proxmox-dns
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/miekg/dns v1.1.58
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/mod v0.14.0 // indirect
|
||||
golang.org/x/net v0.20.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/tools v0.17.0 // indirect
|
||||
)
|
||||
@@ -1,16 +0,0 @@
|
||||
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
|
||||
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
|
||||
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
|
||||
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
|
||||
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,565 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Proxmox struct {
|
||||
Host string `yaml:"host"`
|
||||
User string `yaml:"user"`
|
||||
TokenName string `yaml:"token_name"`
|
||||
TokenValue string `yaml:"token_value"`
|
||||
VerifySSL bool `yaml:"verify_ssl"`
|
||||
} `yaml:"proxmox"`
|
||||
DNSHost string `yaml:"dns_host"`
|
||||
DNSPort int `yaml:"dns_port"`
|
||||
DNSSuffix string `yaml:"dns_suffix"`
|
||||
DNSTTL int `yaml:"dns_ttl"`
|
||||
CacheTTL int `yaml:"cache_ttl"`
|
||||
UpstreamDNS string `yaml:"upstream_dns"`
|
||||
}
|
||||
|
||||
type DNSRecord struct {
|
||||
IPv4 string
|
||||
IPv6 string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type ProxmoxDNS struct {
|
||||
config Config
|
||||
cache map[string]*DNSRecord
|
||||
cacheMux sync.RWMutex
|
||||
client *http.Client
|
||||
stopChan chan struct{}
|
||||
nodes []string
|
||||
nodesMux sync.RWMutex
|
||||
}
|
||||
|
||||
func NewProxmoxDNS(configPath string) (*ProxmoxDNS, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if config.DNSPort == 0 {
|
||||
config.DNSPort = 53
|
||||
}
|
||||
if config.CacheTTL == 0 {
|
||||
config.CacheTTL = 60
|
||||
}
|
||||
if config.DNSTTL == 0 {
|
||||
config.DNSTTL = 300
|
||||
}
|
||||
if config.UpstreamDNS == "" {
|
||||
config.UpstreamDNS = "10.2.40.1:53"
|
||||
}
|
||||
if !strings.Contains(config.UpstreamDNS, ":") {
|
||||
config.UpstreamDNS += ":53"
|
||||
}
|
||||
|
||||
return &ProxmoxDNS{
|
||||
config: config,
|
||||
cache: make(map[string]*DNSRecord),
|
||||
nodes: []string{},
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
},
|
||||
stopChan: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isValidIPv6ForDNS checks if an IPv6 address is suitable for DNS records
|
||||
func isValidIPv6ForDNS(ip net.IP) bool {
|
||||
if ip == nil || ip.To16() == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Reject loopback
|
||||
if ip.IsLoopback() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Reject link-local (fe80::/10) - THIS IS THE KEY FIX
|
||||
if ip.IsLinkLocalUnicast() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Reject multicast
|
||||
if ip.IsMulticast() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Reject unspecified
|
||||
if ip.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Accept global unicast and ULA (fc00::/7)
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) queryProxmoxAPI(endpoint string) ([]byte, error) {
|
||||
// Remove port from host if present
|
||||
host := p.config.Proxmox.Host
|
||||
if !strings.Contains(host, ":") {
|
||||
host += ":8006"
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://%s/api2/json/%s", host, endpoint)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
auth := fmt.Sprintf("PVEAPIToken=%s!%s=%s",
|
||||
p.config.Proxmox.User,
|
||||
p.config.Proxmox.TokenName,
|
||||
p.config.Proxmox.TokenValue)
|
||||
req.Header.Set("Authorization", auth)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) discoverNodes() error {
|
||||
data, err := p.queryProxmoxAPI("nodes")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to discover nodes: %w", err)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Data []struct {
|
||||
Node string `json:"node"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return fmt.Errorf("failed to parse nodes: %w", err)
|
||||
}
|
||||
|
||||
p.nodesMux.Lock()
|
||||
defer p.nodesMux.Unlock()
|
||||
|
||||
p.nodes = []string{}
|
||||
for _, node := range response.Data {
|
||||
if node.Status == "online" {
|
||||
p.nodes = append(p.nodes, node.Node)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Discovered %d online nodes: %v", len(p.nodes), p.nodes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) refreshCache() {
|
||||
log.Println("Refreshing DNS cache...")
|
||||
|
||||
// Discover nodes first
|
||||
if err := p.discoverNodes(); err != nil {
|
||||
log.Printf("Failed to discover nodes: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
newCache := make(map[string]*DNSRecord)
|
||||
|
||||
p.nodesMux.RLock()
|
||||
nodes := append([]string{}, p.nodes...)
|
||||
p.nodesMux.RUnlock()
|
||||
|
||||
for _, node := range nodes {
|
||||
// Query VMs
|
||||
p.refreshVMs(node, newCache)
|
||||
// Query LXC containers
|
||||
p.refreshLXC(node, newCache)
|
||||
}
|
||||
|
||||
p.cacheMux.Lock()
|
||||
p.cache = newCache
|
||||
p.cacheMux.Unlock()
|
||||
|
||||
log.Printf("Cache refreshed: %d entries", len(newCache))
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) refreshVMs(node string, cache map[string]*DNSRecord) {
|
||||
data, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/qemu", node))
|
||||
if err != nil {
|
||||
log.Printf("Failed to query VMs on %s: %v", node, err)
|
||||
return
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Data []struct {
|
||||
VMID int `json:"vmid"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
log.Printf("Failed to parse VM data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, vm := range response.Data {
|
||||
if vm.Status != "running" || vm.Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Query agent network info
|
||||
agentData, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/qemu/%d/agent/network-get-interfaces", node, vm.VMID))
|
||||
if err != nil {
|
||||
log.Printf("VM %s (ID %d): no guest agent data", vm.Name, vm.VMID)
|
||||
continue
|
||||
}
|
||||
|
||||
var agentResp struct {
|
||||
Data struct {
|
||||
Result []struct {
|
||||
Name string `json:"name"`
|
||||
IPAddresses []struct {
|
||||
IPAddress string `json:"ip-address"`
|
||||
IPAddressType string `json:"ip-address-type"`
|
||||
} `json:"ip-addresses"`
|
||||
} `json:"result"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(agentData, &agentResp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var ipv4, ipv6 string
|
||||
for _, iface := range agentResp.Data.Result {
|
||||
if iface.Name == "lo" {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ipAddr := range iface.IPAddresses {
|
||||
if ipAddr.IPAddressType == "ipv4" && ipv4 == "" {
|
||||
if !strings.HasPrefix(ipAddr.IPAddress, "127.") {
|
||||
ipv4 = ipAddr.IPAddress
|
||||
}
|
||||
} else if ipAddr.IPAddressType == "ipv6" && ipv6 == "" {
|
||||
ip := net.ParseIP(ipAddr.IPAddress)
|
||||
if isValidIPv6ForDNS(ip) {
|
||||
ipv6 = ipAddr.IPAddress
|
||||
log.Printf(" VM %s: accepting IPv6 %s", vm.Name, ipv6)
|
||||
} else if ip != nil {
|
||||
log.Printf(" VM %s: rejecting IPv6 %s (link-local/loopback/multicast)", vm.Name, ipAddr.IPAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop after finding both on first non-lo interface
|
||||
if ipv4 != "" && ipv6 != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ipv4 != "" || ipv6 != "" {
|
||||
hostname := strings.ToLower(vm.Name)
|
||||
// Strip DNS suffix from hostname if present
|
||||
hostname = strings.TrimSuffix(hostname, p.config.DNSSuffix)
|
||||
cache[hostname] = &DNSRecord{
|
||||
IPv4: ipv4,
|
||||
IPv6: ipv6,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
log.Printf("VM: %s -> IPv4=%s IPv6=%s", hostname, ipv4, ipv6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) refreshLXC(node string, cache map[string]*DNSRecord) {
|
||||
data, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/lxc", node))
|
||||
if err != nil {
|
||||
log.Printf("Failed to query LXC on %s: %v", node, err)
|
||||
return
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Data []struct {
|
||||
VMID int `json:"vmid"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
log.Printf("Failed to parse LXC data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, lxc := range response.Data {
|
||||
if lxc.Status != "running" || lxc.Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Query LXC interfaces
|
||||
ifaceData, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/lxc/%d/interfaces", node, lxc.VMID))
|
||||
if err != nil {
|
||||
log.Printf("LXC %s (ID %d): failed to get interfaces", lxc.Name, lxc.VMID)
|
||||
continue
|
||||
}
|
||||
|
||||
var ifaceResp struct {
|
||||
Data []struct {
|
||||
Name string `json:"name"`
|
||||
Inet string `json:"inet"`
|
||||
Inet6 string `json:"inet6"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(ifaceData, &ifaceResp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var ipv4, ipv6 string
|
||||
for _, iface := range ifaceResp.Data {
|
||||
if iface.Name == "lo" {
|
||||
continue
|
||||
}
|
||||
|
||||
if iface.Inet != "" && ipv4 == "" {
|
||||
// Format is "IP/CIDR"
|
||||
parts := strings.Split(iface.Inet, "/")
|
||||
if len(parts) > 0 && !strings.HasPrefix(parts[0], "127.") {
|
||||
ipv4 = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
if iface.Inet6 != "" && ipv6 == "" {
|
||||
parts := strings.Split(iface.Inet6, "/")
|
||||
if len(parts) > 0 {
|
||||
ip := net.ParseIP(parts[0])
|
||||
if isValidIPv6ForDNS(ip) {
|
||||
ipv6 = parts[0]
|
||||
log.Printf(" LXC %s: accepting IPv6 %s", lxc.Name, ipv6)
|
||||
} else if ip != nil {
|
||||
log.Printf(" LXC %s: rejecting IPv6 %s (link-local/loopback/multicast)", lxc.Name, parts[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop after finding both on first non-lo interface
|
||||
if ipv4 != "" && ipv6 != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ipv4 != "" || ipv6 != "" {
|
||||
hostname := strings.ToLower(lxc.Name)
|
||||
// Strip DNS suffix from hostname if present
|
||||
hostname = strings.TrimSuffix(hostname, p.config.DNSSuffix)
|
||||
cache[hostname] = &DNSRecord{
|
||||
IPv4: ipv4,
|
||||
IPv6: ipv6,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
log.Printf("LXC: %s -> IPv4=%s IPv6=%s", hostname, ipv4, ipv6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) handleDNSRequest(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Authoritative = true
|
||||
|
||||
for _, q := range r.Question {
|
||||
name := strings.ToLower(strings.TrimSuffix(q.Name, "."))
|
||||
qtype := dns.TypeToString[q.Qtype]
|
||||
|
||||
// Check if query is for our domain suffix
|
||||
if !strings.HasSuffix(name, strings.TrimPrefix(p.config.DNSSuffix, ".")) {
|
||||
// Forward to upstream
|
||||
log.Printf("Query %s (%s) - forwarding to upstream", name, qtype)
|
||||
p.forwardToUpstream(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract hostname (remove suffix)
|
||||
hostname := strings.TrimSuffix(name, p.config.DNSSuffix)
|
||||
|
||||
p.cacheMux.RLock()
|
||||
record, found := p.cache[hostname]
|
||||
p.cacheMux.RUnlock()
|
||||
|
||||
if !found {
|
||||
// NXDOMAIN
|
||||
m.SetRcode(r, dns.RcodeNameError)
|
||||
log.Printf("Query %s (%s) - NXDOMAIN", name, qtype)
|
||||
w.WriteMsg(m)
|
||||
return
|
||||
}
|
||||
|
||||
switch q.Qtype {
|
||||
case dns.TypeA:
|
||||
if record.IPv4 != "" {
|
||||
rr := &dns.A{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: q.Name,
|
||||
Rrtype: dns.TypeA,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: uint32(p.config.DNSTTL),
|
||||
},
|
||||
A: net.ParseIP(record.IPv4),
|
||||
}
|
||||
m.Answer = append(m.Answer, rr)
|
||||
log.Printf("Query %s (A) -> %s", name, record.IPv4)
|
||||
} else {
|
||||
// NODATA (host exists but no A record)
|
||||
m.Ns = append(m.Ns, p.getSOARecord())
|
||||
log.Printf("Query %s (A) - NODATA", name)
|
||||
}
|
||||
|
||||
case dns.TypeAAAA:
|
||||
if record.IPv6 != "" {
|
||||
// Parse and validate IPv6 again before returning
|
||||
ip := net.ParseIP(record.IPv6)
|
||||
if ip != nil && isValidIPv6ForDNS(ip) {
|
||||
rr := &dns.AAAA{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: q.Name,
|
||||
Rrtype: dns.TypeAAAA,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: uint32(p.config.DNSTTL),
|
||||
},
|
||||
AAAA: ip,
|
||||
}
|
||||
m.Answer = append(m.Answer, rr)
|
||||
log.Printf("Query %s (AAAA) -> %s", name, record.IPv6)
|
||||
} else {
|
||||
// IPv6 is invalid (link-local etc)
|
||||
m.Ns = append(m.Ns, p.getSOARecord())
|
||||
log.Printf("Query %s (AAAA) - NODATA (invalid IPv6 in cache)", name)
|
||||
}
|
||||
} else {
|
||||
// NODATA (host exists but no AAAA record)
|
||||
m.Ns = append(m.Ns, p.getSOARecord())
|
||||
log.Printf("Query %s (AAAA) - NODATA", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteMsg(m)
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) getSOARecord() dns.RR {
|
||||
domain := strings.TrimPrefix(p.config.DNSSuffix, ".")
|
||||
soa := &dns.SOA{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: domain + ".",
|
||||
Rrtype: dns.TypeSOA,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: 300,
|
||||
},
|
||||
Ns: "ns." + domain + ".",
|
||||
Mbox: "admin." + domain + ".",
|
||||
Serial: uint32(time.Now().Unix()),
|
||||
Refresh: 3600,
|
||||
Retry: 600,
|
||||
Expire: 86400,
|
||||
Minttl: 300,
|
||||
}
|
||||
return soa
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) forwardToUpstream(w dns.ResponseWriter, r *dns.Msg) {
|
||||
c := new(dns.Client)
|
||||
c.Timeout = 2 * time.Second
|
||||
|
||||
resp, _, err := c.Exchange(r, p.config.UpstreamDNS)
|
||||
if err != nil {
|
||||
log.Printf("Failed to forward to upstream: %v", err)
|
||||
m := new(dns.Msg)
|
||||
m.SetRcode(r, dns.RcodeServerFailure)
|
||||
w.WriteMsg(m)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteMsg(resp)
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) startCacheRefresher() {
|
||||
ticker := time.NewTicker(time.Duration(p.config.CacheTTL) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Initial refresh
|
||||
p.refreshCache()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
p.refreshCache()
|
||||
case <-p.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) Start() error {
|
||||
// Start cache refresher
|
||||
go p.startCacheRefresher()
|
||||
|
||||
// Start DNS server
|
||||
dns.HandleFunc(".", p.handleDNSRequest)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", p.config.DNSHost, p.config.DNSPort)
|
||||
log.Printf("Starting Proxmox DNS server on %s", addr)
|
||||
log.Printf("Base domain: %s", p.config.DNSSuffix)
|
||||
log.Printf("Upstream DNS: %s", p.config.UpstreamDNS)
|
||||
|
||||
server := &dns.Server{Addr: addr, Net: "udp"}
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("c", "/etc/proxmox-dns/config.yaml", "Path to configuration file")
|
||||
flag.Parse()
|
||||
|
||||
server, err := NewProxmoxDNS(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create DNS server: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Proxmox DNS Server starting...")
|
||||
if err := server.Start(); err != nil {
|
||||
log.Fatalf("Failed to start DNS server: %v", err)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,27 +0,0 @@
|
||||
[Unit]
|
||||
Description=Proxmox DNS Server
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/opt/proxmox-dns
|
||||
ExecStart=/usr/bin/python3 /opt/proxmox-dns/proxmox_dns.py -c /etc/proxmox-dns/config.yaml
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/log
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user