Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5 f8c968c5a7 fix(trmnl): render the panel in German via the schedule's lang field
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
lang was null, so the screenshot session rendered in English — and the
dashboard grew a German weekday array, a German month array and a
fifteen-entry weather-condition dict to translate around it by hand.

The field is documented as "UI language code (HA mode only)" and calls
Home Assistant's own _selectLanguage() on the page before capture;
upstream's tests use lang: 'de'. So localisation is HA's job and the
field was sitting in the schedule template, already written out, set to
null because I copied the shape without asking what it did.

Pairs with homeassistant#220, which deletes both hand-written mappings:
the weather badge points at weather.garten directly and the date is a
device_class: timestamp the frontend formats.

The instance stays language "en". This affects only the render session,
so the panel is German without changing anyone's UI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:16:43 +02:00
k.renner 595e5eb7a3 Merge pull request 'fix(trmnl): disable Terminus session expiry, the other half of the token fix' (#487) from fix-trmnl-session-expiry into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#487
2026-08-12 20:02:38 +00:00
claude-botandClaude Opus 5 7e293d3de7 fix(trmnl): disable Terminus session expiry, the other half of the token fix
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The renderer stopped pushing and the panel preview froze. Every webhook:

  HTTP 401: Unauthorized - This session has expired, please login again

Terminus has TWO independent expiries and the earlier fix only addressed
one. API_ACCESS_TOKEN_PERIOD governs the JWT's own lifetime and is now
10 years. SESSION_EXPIRATION_ENABLED / SESSION_INACTIVITY_LIMIT govern
the SESSION BEHIND that token and were left at their defaults, so a
valid, decade-long token stopped being accepted after about half an
hour of session inactivity.

Worth being precise, because the two look alike and are not: the first
failure was 400 "expired JWT access token", the token itself. This is
401 "This session has expired", the session. Different mechanism,
different error, same symptom — a panel that silently stops updating.

I noticed both expiries when diagnosing the first one, wrote that
Terminus "has two independent expiries", and then fixed one and moved
on. That is the actual mistake here, not the missing variable.

Disabling session expiry is the state the renderer's own code already
assumes ("stock Terminus disables session expiration"), which is false
by default and has to be made true here — the same shape as the token
period. And it is the deciding constraint for an unattended appliance:
a session that expires needs a human with a password to restore it, and
a hallway panel has neither.

Reloader will roll the pod; the ConfigMap annotation added earlier
covers this. Existing tokens must be re-minted afterwards — the current
session is already dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:01:34 +02:00
k.renner 9423381102 Merge pull request 'fix(trmnl): point the renderer at Graphite E-ink Light' (#486) from fix-trmnl-graphite-theme into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#486
2026-08-12 19:36:24 +00:00
claude-botandClaude Opus 5 ae12ce760c fix(trmnl): point the renderer at Graphite E-ink Light
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The renderer's own docs recommend Graphite by name — "Use e-ink theme
(Graphite recommended)", with theme=Graphite in the example — and the
Home Assistant community converges on it independently. The dashboard
now ships graphite-eink-light.yaml (homeassistant repo) in place of the
theme I hand-wrote from a spec sheet.

Pairs with homeassistant#216. The theme name must match the key inside
the vendored file exactly ("Graphite E-ink Light"); a name that does not
resolve fails silently — HA serves the default theme and the panel just
looks wrong rather than erroring.

Note upstream's example also passes `dark`. Not used here: the e-ink
LIGHT variant is the one that gives black-on-white, which is what an
e-paper panel wants. `dark: false` stays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:34:55 +02:00
k.renner 641e8bce84 Merge pull request 'fix(trmnl): make Terminus issue long-lived API tokens' (#485) from fix-trmnl-token-lifetime into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#485
2026-08-12 17:34:47 +00:00
claude-botandClaude Opus 5 463e493ec0 docs(trmnl): cite the source for API_ACCESS_TOKEN_PERIOD
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The setting was treated as an unverified assumption in review, and I
repeated that framing. It was not one.

Upstream's doc/configuration.adoc documents the variable and its
1800-second default in as many words, and this instance corroborated it
empirically: a token minted here was rejected roughly thirty minutes
later, which is the documented default behaving exactly as written.
Documentation plus matching observed behaviour is not an assumption.

The reviewer's uncertainty was reasonable — Terminus's source is not
vendored in this repo, so from the checkout alone the claim is
unsupported. The failing was mine for citing nothing at the point of
use, which is the same gap as the /health probe earlier: the evidence
existed and was not written where a reader could find it. Quotes the
documentation inline so the question is answerable from the manifest.

Note this does not make the post-merge check redundant. Whether Terminus
honours the variable is settled here; whether this ConfigMap edit
actually reached the running process via Reloader is a separate
question, and that is what decoding the new token's exp claim answers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:30:03 +02:00
claude-botandClaude Opus 5 8031a0ad7a docs(openbao): record the trmnl renderer tokens as deliberately unrotated
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review's remaining point: with API_ACCESS_TOKEN_PERIOD at 10 years,
nothing will ever expire the renderer's BYOS token or prompt anyone to
rotate it. Left undocumented, "never rotated" is indistinguishable from
"forgotten".

Records it where it is discoverable — the rotation directory itself,
whose purpose is that unrotated credentials are a visible choice rather
than an omission. Notes why there is no rotator: minting a Terminus JWT
requires the account password, which is precisely the secret this design
declined to hold in-cluster, so a rotator would have to store the thing
the long-lived token exists to avoid. Rotation is therefore manual, tied
to the Terminus account password changing.

Also flags that HA_ACCESS_TOKEN in the same OpenBao path is a different
case: secret-rotator already has an `ha-token` mode (home-assistant.yaml
uses it), so enrolling that one is a real follow-up rather than a
limitation, and doing so would leave only the BYOS pair unrotated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:25:32 +02:00
claude-botandClaude Opus 5 18c8b74baa fix(trmnl): let Reloader roll terminus on ConfigMap changes
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review caught that this PR, as it stood, would not have fixed the
outage it describes.

terminus-config is consumed via envFrom, and env vars are read once at
container start — Kubernetes does not hot-reload them. The ConfigMap is
a plain resource rather than a configMapGenerator, so editing it does
not change the pod template hash and Flux will not roll the pods
either. Stakater Reloader is deployed but acts only on annotated
workloads, and this Deployment carried no annotation.

Net effect would have been a silent no-op: git and the ConfigMap object
would show a 10-year token period while the running app kept issuing
30-minute tokens, and the re-mint step in the PR plan would have
produced another short-lived token that looked correct and expired
anyway. The manifest and reality would disagree with nothing to
indicate it.

Annotates for terminus-config specifically rather than the blanket
reloader.stakater.com/auto: "true", matching the reasoning already
recorded on mosquitto-deployment.yaml — enrolling anything further
stays a visible, reviewable edit to that line.

Verified Reloader is actually running (kube-system/reloader-reloader).

This also removes a manual step rather than documenting one, which is
the point: a fix that needs a human to remember `kubectl rollout
restart` is not deployed, it is a runbook entry waiting to be missed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:20:29 +02:00
claude-botandClaude Opus 5 e84ea02100 fix(trmnl): make Terminus issue long-lived API tokens
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The renderer renders correctly and pushes nothing. Every webhook is
rejected:

  [webhook] Webhook failed: 400 {"error":"expired JWT access token"}
  [webhook] BYOS auth: refresh failed (400) {"error":"expired JWT ..."}

Terminus issues API JWTs with API_ACCESS_TOKEN_PERIOD, default 1800s.
Minting a replacement via POST /api/jwt requires a still-VALID access
token in the Authorization header, so an expired token cannot refresh
itself — the second line above is the refresh attempt being rejected by
the very expiry it was trying to repair. There is no recovery path once
the window closes.

That makes "mint once, store in OpenBao" unworkable for an unattended
appliance, which is what this stack is. The renderer's own code assumes
"stock Terminus disables session expiration and issues effectively
permanent tokens"; that is not true of this deployment. Rather than work
around the assumption in the client, this makes it true: sets
API_ACCESS_TOKEN_PERIOD to 10 years.

The trade is deliberate. A long-lived JWT with full Terminus API rights
now sits in OpenBao. The alternative was putting the Terminus account
PASSWORD into the cluster so an init container could mint a short-lived
token on every pod start — a strictly worse secret to hold, for a
LAN-only service whose token is revocable by rotating the account.

Also corrects the obtained_at comment on the renderer, which justified
stamping "now" by repeating that same false permanence assumption.
Stamping now is safe ONLY because of this setting; against the 30-minute
default it is actively harmful, because the app would believe a stale
token had a fresh window, skip its proactive refresh, and strand itself.
The comment now says so, and says what to do if this setting is reverted.

The period is fixed at mint time, so this does not revive the existing
tokens — they must be re-minted after it deploys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:15:43 +02:00
k.renner fc53334b31 Merge pull request 'fix(trmnl): give Chromium a writable HOME and stamp obtained_at' (#484) from fix-trmnl-renderer-runtime into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#484
2026-08-12 17:02:10 +00:00
claude-botandClaude Opus 5 2dd3e8f0b2 fix(trmnl): bound the renderer's /tmp with a sized emptyDir
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Follow-up to the HOME=/tmp fix in this PR, from review.

Pointing HOME at /tmp was correct but left it on the container's
writable layer, which was harmless while Chromium crashed on launch and
wrote nothing. With the browser actually starting once a minute for the
life of the pod, crashpad state and profile data now accumulate there
with nothing to bound them — resources.limits sets memory only, so
there is no ephemeral-storage ceiling to catch it either.

Backs it with an emptyDir at a 512Mi sizeLimit, matching the /tmp mount
convention already used in pve-exporter-deployment.yaml. The point of
the limit is the failure mode, not the size: unbounded it degrades into
node disk pressure, which surfaces far from this pod and hurts whatever
else lands on that node; bounded it is an eviction of the renderer,
attributable on sight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:53:55 +02:00
claude-botandClaude Opus 5 d824263759 fix(trmnl): give Chromium a writable HOME and stamp obtained_at
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Two runtime faults from the first deploy. Neither is visible statically
— both needed the pod running to find.

1. The browser never launched. Every render died with

     chrome_crashpad_handler: --database is required

   Chromium cannot start without a writable HOME: crashpad builds its
   database path from it. Diagnosed on the pod rather than guessed —
   HOME=/, uid=1000, / is root-owned 0755 and not writable, /tmp is.
   The cause is OUR securityContext, not the image: forcing
   runAsUser 1000 with no matching passwd entry makes HOME fall back
   to "/". Sets HOME=/tmp; crash dumps are throwaway, so they do not
   belong on the PVC.

   Worth being precise about, because review had flagged the dropped
   capabilities as the likely Chromium risk and the comment I added
   said they were fine. The capabilities were fine. Something adjacent
   to that securityContext broke it anyway, and the comment now says
   to check HOME before loosening the capability block.

2. Even once the browser works, nothing would have been pushed. The
   scheduler logged

     BYOS tokens ... expired beyond the refresh window

   and skipped the webhook entirely. It derives token age from
   obtained_at, and the template shipped a literal 0, so
   freshly-minted tokens read as dating from 1970. The init container
   now stamps it with jq's `now * 1000 | floor` at write time.
   Verified the filter produces a 13-digit epoch-ms value. Stock
   Terminus disables session expiry and issues effectively permanent
   tokens, so a write-time stamp is honest enough.

Failure mode of the pair was quiet in the way this stack keeps being
quiet: pod Running and Ready, /health passing, schedule firing once a
minute, zero screens reaching Terminus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:48:38 +02:00
k.renner b0f0f9242b Merge pull request 'feat(trmnl): deploy the headless dashboard renderer' (#483) from feat-trmnl-renderer into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#483
2026-08-12 16:43:16 +00:00
k.renner 0581bc334a Merge pull request 'chore(ansible): retire the monitoring plays for decommissioned hosts' (#479) from retire-dead-monitoring into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#479
2026-08-12 16:30:30 +00:00
security-bot 3f4846bbbc chore(image-automation): bump ghcr.io/music-assistant/server:2.9.13
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-12 14:35:43 +00:00
claude-botandClaude Opus 5 8e5b91e6d6 chore(ansible): retire the monitoring plays for decommissioned hosts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Prometheus and Loki run in Kubernetes -- statefulsets
prometheus-kube-prometheus-stack-prometheus and the Loki release, both up 93
days. The standalone VMs these plays targeted are gone: neither
prometheus.srv.renner.uno nor loki.srv.renner.uno resolves at all, and the
proxmox_logging group install-log-aggregation.yml targets is not in the
inventory.

So these rendered scrape configs, exporter installs and log shippers for
hosts that do not exist. Removed:

  plays/install-prometheus.yml          -> prometheus.srv.renner.uno (gone)
  plays/install-blackbox-exporter.yml   -> prometheus.srv.renner.uno (gone)
  plays/install-loki.yml                -> loki.srv.renner.uno (gone)
  plays/install-log-aggregation.yml     -> proxmox_logging (no such group)
  roles/prometheus-server, snmp-exporter, pve-exporter, unifi-poller,
  roles/blackbox-exporter, loki-server, promtail

Each role was consumed only by the plays above; promtail was referenced by
nothing at all. Verified no dangling references remain.

plays/update.yml imported install-log-aggregation.yml, so deleting it alone
would have broken the fleet update play. That import is removed and
update.yml re-syntax-checked.

This also retires #473, which changed IPv4 literals to FQDNs in
prometheus.yml.j2 and nodes.yml.j2. That was a change to a template nothing
renders. Its PR body described the instance-label consequences for
dashboards and alert rules, which was fiction -- no dashboard reads those
labels. When it would not apply I reported it as blocked on Proxmox API
credentials; the actual reason was that the target host had not existed for
months, and `--list-hosts` returning 0 was the evidence I misread.

Worth checking separately, not assumed here: whether anything scrapes the
PVE node_exporter and chrony_exporter today. install-node-exporter-pve.yml
still installs them on pve_cluster, but the config that pointed a Prometheus
at them was in the templates just deleted. If that was the only reference,
those metrics have been dark since the VM went away -- and the FQDN change
in #473 would have masked the question rather than raising it.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:10:50 +02:00
46 changed files with 170 additions and 2064 deletions
@@ -1,19 +0,0 @@
---
# Deploy Blackbox Exporter to Prometheus server for endpoint probing
# Usage: ansible-playbook -i inventory/ plays/install-blackbox-exporter.yml
- name: Install Blackbox Exporter
hosts: prometheus.srv.renner.uno
become: yes
roles:
- blackbox-exporter
post_tasks:
- name: Verify blackbox_exporter is running
uri:
url: "http://127.0.0.1:9115/metrics"
status_code: 200
register: exporter_health
retries: 3
delay: 5
until: exporter_health.status == 200
-86
View File
@@ -1,86 +0,0 @@
---
# Setup Log Aggregation Infrastructure
# This playbook configures the complete logging stack:
# 1. Loki server for log storage and querying
# 2. Alloy agents on all VMs/CTs for log collection
# 3. Syslog receiver for remote devices (Synology NAS, network devices)
#
# Usage: ansible-playbook -i inventory/ plays/setup-log-aggregation.yml
#
# To limit to specific hosts:
# --limit loki.srv.renner.uno # Only configure Loki server
# --limit proxmox_all_running # Only configure log agents
- name: Setup Loki Log Aggregation Server
hosts: proxmox_logging
become: yes
gather_facts: yes
roles:
- common
- loki-server
post_tasks:
- name: Verify Loki is running
uri:
url: "http://127.0.0.1:3100/ready"
status_code: 200
register: loki_health
retries: 5
delay: 10
until: loki_health.status == 200
- name: Display Loki status
debug:
msg: "Loki is running and healthy at http://{{ inventory_hostname }}:3100"
- name: Deploy Monitoring Agent to Loki Server (with syslog receiver)
hosts: proxmox_logging
become: yes
gather_facts: yes
vars:
loki_endpoint: "http://loki.srv.renner.uno:3100"
prometheus_remote_write_endpoint: "http://prometheus.srv.renner.uno:9090/api/v1/write"
# Syslog receiver settings are defined in host_vars/loki.srv.renner.uno/vars.yml:
# - alloy_syslog_receiver_enabled: true
# - alloy_syslog_receiver_port: 1514
# - alloy_syslog_receiver_protocol: "udp"
# - alloy_syslog_receiver_format: "raw"
# - alloy_stability_level: "experimental"
roles:
- monitoring-agent
post_tasks:
- name: Display syslog receiver info
debug:
msg: |
Syslog receiver is listening on UDP port {{ alloy_syslog_receiver_port | default(1514) }}
Configure remote devices to send syslog to: {{ inventory_hostname }}:{{ alloy_syslog_receiver_port | default(1514) }}
when: alloy_syslog_receiver_enabled | default(false)
- name: Deploy Monitoring Agent to All Running VMs/CTs
hosts: proxmox_all_running:!proxmox_logging:!proxmox_noansible
become: yes
gather_facts: yes
serial: 10 # Deploy to 10 hosts at a time to avoid overwhelming the network
vars:
loki_endpoint: "http://loki.srv.renner.uno:3100"
prometheus_remote_write_endpoint: "http://prometheus.srv.renner.uno:9090/api/v1/write"
roles:
- monitoring-agent
post_tasks:
- name: Verify Alloy is running
systemd:
name: alloy
state: started
register: alloy_service
- name: Display agent status
debug:
msg: "Monitoring agent deployed successfully on {{ inventory_hostname }}"
when: alloy_service is defined
-26
View File
@@ -1,26 +0,0 @@
---
# Install Loki Log Aggregation Server
# Usage: ansible-playbook -i inventory/ plays/install-loki.yml
- name: Install Loki Log Aggregation Server
hosts: loki.srv.renner.uno
become: yes
gather_facts: yes
roles:
- common
- loki-server
post_tasks:
- name: Verify Loki is running
uri:
url: "http://127.0.0.1:3100/ready"
status_code: 200
register: loki_health
retries: 5
delay: 10
until: loki_health.status == 200
- name: Display Loki status
debug:
msg: "Loki is running and healthy at http://{{ inventory_hostname }}:3100"
-43
View File
@@ -1,43 +0,0 @@
---
# Install Prometheus Metrics Server
# Usage: ansible-playbook -i inventory/ plays/install-prometheus.yml
- name: Install Prometheus Metrics Server
hosts: prometheus.srv.renner.uno
become: yes
gather_facts: yes
vars:
# Synology NAS SNMP targets
snmp_synology_targets:
- name: nas-1
target: "10.2.40.50"
- name: nas-2
target: "10.3.40.57"
# SNMPv3 credentials from vault
snmp_use_v3: true
snmp_v3_username: "{{ vault_snmp_v3_username }}"
snmp_v3_auth_password: "{{ vault_snmp_v3_auth_password }}"
snmp_v3_priv_password: "{{ vault_snmp_v3_priv_password }}"
roles:
- common
- prometheus-server
- snmp-exporter
- pve-exporter
- unifi-poller
post_tasks:
- name: Verify Prometheus is running
uri:
url: "http://127.0.0.1:9090/-/ready"
status_code: 200
register: prometheus_health
retries: 5
delay: 10
until: prometheus_health.status == 200
- name: Display Prometheus status
debug:
msg: "Prometheus is running and healthy at http://{{ inventory_hostname }}:9090"
-1
View File
@@ -16,7 +16,6 @@
- import_playbook: install-grist.yml
- import_playbook: install-music-assistant.yml
- import_playbook: update-docker.yml
- import_playbook: install-log-aggregation.yml
- import_playbook: update-pve-rolling.yml
- name: Update Summary
@@ -1,26 +0,0 @@
---
blackbox_exporter_version: "0.25.0"
blackbox_exporter_port: 9115
blackbox_exporter_user: "blackbox_exporter"
blackbox_exporter_group: "blackbox_exporter"
# Endpoints to probe
blackbox_http_targets:
- name: "gitea"
url: "https://gitea.renner.uno"
- name: "immich"
url: "https://immich.renner.uno"
- name: "authentik"
url: "https://auth.renner.uno"
- name: "home-assistant"
url: "https://ha.renner.uno"
- name: "grafana"
url: "https://grafana.renner.uno"
- name: "prometheus"
url: "https://prometheus.renner.uno"
blackbox_dns_targets: []
blackbox_tcp_targets:
- name: "postgres-ha"
target: "10.2.40.135:5432"
@@ -1,6 +0,0 @@
---
- name: Restart blackbox_exporter
systemd:
name: blackbox_exporter
state: restarted
daemon_reload: yes
@@ -1,80 +0,0 @@
---
- name: Create blackbox_exporter group
group:
name: "{{ blackbox_exporter_group }}"
system: yes
state: present
- name: Create blackbox_exporter user
user:
name: "{{ blackbox_exporter_user }}"
group: "{{ blackbox_exporter_group }}"
system: yes
shell: /usr/sbin/nologin
createhome: no
state: present
- name: Create blackbox_exporter directories
file:
path: "{{ item }}"
state: directory
owner: "{{ blackbox_exporter_user }}"
group: "{{ blackbox_exporter_group }}"
mode: '0755'
loop:
- /etc/blackbox_exporter
- /var/lib/blackbox_exporter
- name: Download blackbox_exporter
get_url:
url: "https://github.com/prometheus/blackbox_exporter/releases/download/v{{ blackbox_exporter_version }}/blackbox_exporter-{{ blackbox_exporter_version }}.linux-amd64.tar.gz"
dest: "/tmp/blackbox_exporter-{{ blackbox_exporter_version }}.tar.gz"
mode: '0644'
- name: Extract blackbox_exporter
unarchive:
src: "/tmp/blackbox_exporter-{{ blackbox_exporter_version }}.tar.gz"
dest: /tmp
remote_src: yes
creates: "/tmp/blackbox_exporter-{{ blackbox_exporter_version }}.linux-amd64"
- name: Install blackbox_exporter binary
copy:
src: "/tmp/blackbox_exporter-{{ blackbox_exporter_version }}.linux-amd64/blackbox_exporter"
dest: /usr/local/bin/blackbox_exporter
owner: root
group: root
mode: '0755'
remote_src: yes
notify: Restart blackbox_exporter
- name: Deploy blackbox_exporter configuration
template:
src: blackbox.yml.j2
dest: /etc/blackbox_exporter/blackbox.yml
owner: "{{ blackbox_exporter_user }}"
group: "{{ blackbox_exporter_group }}"
mode: '0644'
notify: Restart blackbox_exporter
- name: Deploy blackbox_exporter systemd service
template:
src: blackbox_exporter.service.j2
dest: /etc/systemd/system/blackbox_exporter.service
mode: '0644'
notify: Restart blackbox_exporter
- name: Enable and start blackbox_exporter
systemd:
name: blackbox_exporter
state: started
enabled: yes
daemon_reload: yes
- name: Clean up downloaded files
file:
path: "{{ item }}"
state: absent
loop:
- "/tmp/blackbox_exporter-{{ blackbox_exporter_version }}.tar.gz"
- "/tmp/blackbox_exporter-{{ blackbox_exporter_version }}.linux-amd64"
@@ -1,60 +0,0 @@
modules:
http_2xx:
prober: http
timeout: 5s
http:
valid_http_versions: ["HTTP/1.1", "HTTP/2.0"]
valid_status_codes: [] # Defaults to 2xx
method: GET
follow_redirects: true
fail_if_ssl: false
fail_if_not_ssl: false
tls_config:
insecure_skip_verify: false
http_post_2xx:
prober: http
timeout: 5s
http:
method: POST
valid_status_codes: []
tcp_connect:
prober: tcp
timeout: 5s
# DNS module for external domain resolution (tests upstream/cache)
dns_external:
prober: dns
timeout: 5s
dns:
query_name: "google.com"
query_type: "A"
valid_rcodes:
- NOERROR
# DNS module for internal domain resolution (tests local resolution)
dns_internal:
prober: dns
timeout: 5s
dns:
query_name: "k8s-1.srv.renner.uno"
query_type: "A"
valid_rcodes:
- NOERROR
# DNS module for common cached domain
dns_cached:
prober: dns
timeout: 5s
dns:
query_name: "cloudflare.com"
query_type: "A"
valid_rcodes:
- NOERROR
icmp:
prober: icmp
timeout: 5s
icmp:
preferred_ip_protocol: "ip4"
@@ -1,22 +0,0 @@
[Unit]
Description=Prometheus Blackbox Exporter
Documentation=https://github.com/prometheus/blackbox_exporter
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User={{ blackbox_exporter_user }}
Group={{ blackbox_exporter_group }}
ExecStart=/usr/local/bin/blackbox_exporter \
--config.file=/etc/blackbox_exporter/blackbox.yml \
--web.listen-address=:{{ blackbox_exporter_port }}
Restart=on-failure
RestartSec=5s
# Allow ICMP probing (requires CAP_NET_RAW)
AmbientCapabilities=CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_RAW
[Install]
WantedBy=multi-user.target
@@ -1,44 +0,0 @@
---
# Loki Server Configuration
# Version and download
loki_version: "3.4.2"
loki_arch: "linux-amd64"
loki_download_url: "https://github.com/grafana/loki/releases/download/v{{ loki_version }}/loki-{{ loki_arch }}.zip"
loki_bin_path: "/usr/local/bin/loki"
# Directories
loki_data_dir: "/var/lib/loki"
loki_config_dir: "/etc/loki"
loki_config_file: "{{ loki_config_dir }}/loki-config.yaml"
# Server configuration
loki_http_listen_port: 3100
loki_grpc_listen_port: 9096
loki_http_listen_address: "0.0.0.0"
# Retention settings (90 days)
loki_retention_period: "2160h" # 90 days
loki_retention_delete_delay: "2h"
# Storage configuration
loki_storage_type: "filesystem"
loki_chunks_directory: "{{ loki_data_dir }}/chunks"
loki_rules_directory: "{{ loki_data_dir }}/rules"
# Index configuration
loki_index_prefix: "loki_index_"
loki_index_period: "24h"
# Limits
loki_ingestion_rate_mb: 10
loki_ingestion_burst_size_mb: 20
loki_max_streams_per_user: 10000
loki_max_entries_limit_per_query: 5000
# Query settings
loki_max_query_length: "720h" # 30 days max query span
# Systemd service
loki_user: "loki"
loki_group: "loki"
@@ -1,10 +0,0 @@
---
- name: Restart loki
systemd:
name: loki
state: restarted
daemon_reload: yes
- name: Reload systemd
systemd:
daemon_reload: yes
-95
View File
@@ -1,95 +0,0 @@
---
# Loki Server Installation and Configuration
- name: Install dependencies
apt:
name:
- unzip
- curl
state: present
update_cache: yes
- name: Create loki user
user:
name: "{{ loki_user }}"
system: yes
shell: /usr/sbin/nologin
create_home: no
- name: Create directories
file:
path: "{{ item }}"
state: directory
owner: "{{ loki_user }}"
group: "{{ loki_group }}"
mode: '0755'
loop:
- "{{ loki_config_dir }}"
- "{{ loki_data_dir }}"
- "{{ loki_chunks_directory }}"
- "{{ loki_rules_directory }}"
- "{{ loki_data_dir }}/index"
- "{{ loki_data_dir }}/index_cache"
- "{{ loki_data_dir }}/compactor"
- name: Download Loki
get_url:
url: "{{ loki_download_url }}"
dest: "/tmp/loki.zip"
mode: '0644'
- name: Unzip Loki
unarchive:
src: "/tmp/loki.zip"
dest: "/tmp"
remote_src: yes
- name: Install Loki binary
copy:
src: "/tmp/loki-{{ loki_arch }}"
dest: "{{ loki_bin_path }}"
mode: '0755'
remote_src: yes
notify: Restart loki
- name: Create Loki configuration
template:
src: loki-config.yaml.j2
dest: "{{ loki_config_file }}"
owner: "{{ loki_user }}"
group: "{{ loki_group }}"
mode: '0644'
notify: Restart loki
- name: Create Loki systemd service
template:
src: loki.service.j2
dest: /etc/systemd/system/loki.service
mode: '0644'
notify:
- Reload systemd
- Restart loki
- name: Enable and start Loki service
systemd:
name: loki
state: started
enabled: yes
daemon_reload: yes
- name: Wait for Loki to be ready
uri:
url: "http://127.0.0.1:{{ loki_http_listen_port }}/ready"
status_code: 200
register: result
until: result.status == 200
retries: 30
delay: 5
- name: Clean up downloaded files
file:
path: "{{ item }}"
state: absent
loop:
- "/tmp/loki.zip"
- "/tmp/loki-{{ loki_arch }}"
@@ -1,72 +0,0 @@
# Loki Configuration
# Generated by Ansible
auth_enabled: false
server:
http_listen_address: {{ loki_http_listen_address }}
http_listen_port: {{ loki_http_listen_port }}
grpc_listen_port: {{ loki_grpc_listen_port }}
log_level: info
common:
instance_addr: 127.0.0.1
path_prefix: {{ loki_data_dir }}
storage:
filesystem:
chunks_directory: {{ loki_chunks_directory }}
rules_directory: {{ loki_rules_directory }}
replication_factor: 1
ring:
kvstore:
store: inmemory
query_range:
results_cache:
cache:
embedded_cache:
enabled: true
max_size_mb: 100
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: {{ loki_index_prefix }}
period: {{ loki_index_period }}
storage_config:
tsdb_shipper:
active_index_directory: {{ loki_data_dir }}/index
cache_location: {{ loki_data_dir }}/index_cache
compactor:
working_directory: {{ loki_data_dir }}/compactor
compaction_interval: 10m
retention_enabled: true
retention_delete_delay: {{ loki_retention_delete_delay }}
retention_delete_worker_count: 150
delete_request_store: filesystem
limits_config:
retention_period: {{ loki_retention_period }}
ingestion_rate_mb: {{ loki_ingestion_rate_mb }}
ingestion_burst_size_mb: {{ loki_ingestion_burst_size_mb }}
max_streams_per_user: {{ loki_max_streams_per_user }}
max_entries_limit_per_query: {{ loki_max_entries_limit_per_query }}
max_query_length: {{ loki_max_query_length }}
reject_old_samples: true
reject_old_samples_max_age: 168h # 7 days
ruler:
alertmanager_url: ""
storage:
type: local
local:
directory: {{ loki_rules_directory }}
analytics:
reporting_enabled: false
@@ -1,25 +0,0 @@
[Unit]
Description=Loki Log Aggregation System
Documentation=https://grafana.com/docs/loki/latest/
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ loki_user }}
Group={{ loki_group }}
ExecStart={{ loki_bin_path }} -config.file={{ loki_config_file }}
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=10s
LimitNOFILE=65536
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths={{ loki_data_dir }}
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
@@ -1,48 +0,0 @@
---
# Prometheus Server Configuration
# Version and download
prometheus_version: "3.2.1"
prometheus_arch: "linux-amd64"
prometheus_download_url: "https://github.com/prometheus/prometheus/releases/download/v{{ prometheus_version }}/prometheus-{{ prometheus_version }}.{{ prometheus_arch }}.tar.gz"
prometheus_bin_path: "/usr/local/bin/prometheus"
prometheus_promtool_path: "/usr/local/bin/promtool"
# Directories
prometheus_data_dir: "/var/lib/prometheus"
prometheus_config_dir: "/etc/prometheus"
prometheus_config_file: "{{ prometheus_config_dir }}/prometheus.yml"
prometheus_rules_dir: "{{ prometheus_config_dir }}/rules"
prometheus_targets_dir: "{{ prometheus_config_dir }}/targets"
# Server configuration
prometheus_listen_address: "0.0.0.0"
prometheus_listen_port: 9090
prometheus_web_external_url: ""
# Retention settings (90 days)
prometheus_retention_time: "90d"
prometheus_retention_size: "30GB"
# Scrape settings
prometheus_scrape_interval: "30s"
prometheus_evaluation_interval: "30s"
prometheus_scrape_timeout: "10s"
# Remote write (for Alloy agents)
prometheus_remote_write_enabled: true
# Systemd service
prometheus_user: "prometheus"
prometheus_group: "prometheus"
# Alert notification endpoints
prometheus_alertmanager_url: ""
# Email alerting (via Grafana)
alerting_email_enabled: true
alerting_email_to: ""
# Home Assistant webhook for alerts
alerting_ha_webhook_enabled: true
alerting_ha_webhook_url: ""
@@ -1,15 +0,0 @@
---
- name: Restart prometheus
systemd:
name: prometheus
state: restarted
daemon_reload: yes
- name: Reload prometheus
systemd:
name: prometheus
state: reloaded
- name: Reload systemd
systemd:
daemon_reload: yes
@@ -1,129 +0,0 @@
---
# Prometheus Server Installation and Configuration
- name: Install dependencies
apt:
name:
- curl
state: present
update_cache: yes
- name: Create prometheus user
user:
name: "{{ prometheus_user }}"
system: yes
shell: /usr/sbin/nologin
create_home: no
- name: Create directories
file:
path: "{{ item }}"
state: directory
owner: "{{ prometheus_user }}"
group: "{{ prometheus_group }}"
mode: '0755'
loop:
- "{{ prometheus_config_dir }}"
- "{{ prometheus_data_dir }}"
- "{{ prometheus_rules_dir }}"
- "{{ prometheus_targets_dir }}"
- name: Download Prometheus
get_url:
url: "{{ prometheus_download_url }}"
dest: "/tmp/prometheus.tar.gz"
mode: '0644'
- name: Extract Prometheus
unarchive:
src: "/tmp/prometheus.tar.gz"
dest: "/tmp"
remote_src: yes
- name: Install Prometheus binaries
copy:
src: "/tmp/prometheus-{{ prometheus_version }}.{{ prometheus_arch }}/{{ item.src }}"
dest: "{{ item.dest }}"
mode: '0755'
remote_src: yes
loop:
- { src: "prometheus", dest: "{{ prometheus_bin_path }}" }
- { src: "promtool", dest: "{{ prometheus_promtool_path }}" }
notify: Restart prometheus
- name: Check for console templates
stat:
path: "/tmp/prometheus-{{ prometheus_version }}.{{ prometheus_arch }}/consoles"
register: consoles_dir
- name: Copy console templates
copy:
src: "/tmp/prometheus-{{ prometheus_version }}.{{ prometheus_arch }}/{{ item }}/"
dest: "{{ prometheus_config_dir }}/{{ item }}/"
remote_src: yes
owner: "{{ prometheus_user }}"
group: "{{ prometheus_group }}"
loop:
- consoles
- console_libraries
when: consoles_dir.stat.exists
- name: Create Prometheus configuration
template:
src: prometheus.yml.j2
dest: "{{ prometheus_config_file }}"
owner: "{{ prometheus_user }}"
group: "{{ prometheus_group }}"
mode: '0644'
validate: "{{ prometheus_promtool_path }} check config %s"
notify: Restart prometheus
- name: Create alert rules
template:
src: alert-rules.yml.j2
dest: "{{ prometheus_rules_dir }}/alert-rules.yml"
owner: "{{ prometheus_user }}"
group: "{{ prometheus_group }}"
mode: '0644'
notify: Reload prometheus
- name: Create node exporter targets file
template:
src: nodes.yml.j2
dest: "{{ prometheus_targets_dir }}/nodes.yml"
owner: "{{ prometheus_user }}"
group: "{{ prometheus_group }}"
mode: '0644'
- name: Create Prometheus systemd service
template:
src: prometheus.service.j2
dest: /etc/systemd/system/prometheus.service
mode: '0644'
notify:
- Reload systemd
- Restart prometheus
- name: Enable and start Prometheus service
systemd:
name: prometheus
state: started
enabled: yes
daemon_reload: yes
- name: Wait for Prometheus to be ready
uri:
url: "http://127.0.0.1:{{ prometheus_listen_port }}/-/ready"
status_code: 200
register: result
until: result.status == 200
retries: 30
delay: 5
- name: Clean up downloaded files
file:
path: "{{ item }}"
state: absent
loop:
- "/tmp/prometheus.tar.gz"
- "/tmp/prometheus-{{ prometheus_version }}.{{ prometheus_arch }}"
@@ -1,100 +0,0 @@
# Prometheus Alert Rules
# Generated by Ansible
groups:
- name: infrastructure
rules:
# Disk usage alerts
- alert: HighDiskUsage
expr: (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) * 100 < 15
for: 5m
labels:
severity: warning
annotations:
summary: "High disk usage on {{ '{{' }} $labels.instance {{ '}}' }}"
description: "Disk usage is above 85% on {{ '{{' }} $labels.mountpoint {{ '}}' }} ({{ '{{' }} $value | printf \"%.1f\" {{ '}}' }}% free)"
- alert: CriticalDiskUsage
expr: (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) * 100 < 5
for: 2m
labels:
severity: critical
annotations:
summary: "Critical disk usage on {{ '{{' }} $labels.instance {{ '}}' }}"
description: "Disk usage is above 95% on {{ '{{' }} $labels.mountpoint {{ '}}' }} ({{ '{{' }} $value | printf \"%.1f\" {{ '}}' }}% free)"
# Memory alerts
- alert: HighMemoryUsage
expr: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100 > 90
for: 5m
labels:
severity: warning
annotations:
summary: "High memory usage on {{ '{{' }} $labels.instance {{ '}}' }}"
description: "Memory usage is above 90% ({{ '{{' }} $value | printf \"%.1f\" {{ '}}' }}%)"
# CPU alerts
- alert: HighCPUUsage
expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85
for: 10m
labels:
severity: warning
annotations:
summary: "High CPU usage on {{ '{{' }} $labels.instance {{ '}}' }}"
description: "CPU usage is above 85% for 10 minutes ({{ '{{' }} $value | printf \"%.1f\" {{ '}}' }}%)"
- name: services
rules:
# Service down alerts
- alert: ServiceDown
expr: up == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Service {{ '{{' }} $labels.job {{ '}}' }} down on {{ '{{' }} $labels.instance {{ '}}' }}"
description: "Service has been unreachable for more than 2 minutes"
# DNS VIP health
- alert: DNSVIPDown
expr: absent(up{job="dns-servers"} == 1) or count(up{job="dns-servers"} == 1) < 2
for: 1m
labels:
severity: critical
annotations:
summary: "DNS cluster degraded"
description: "Less than 2 DNS servers are healthy"
- name: security
rules:
# High auth failure rate (placeholder - needs log-based metrics from Loki)
- alert: HighAuthFailureRate
expr: rate(authentik_login_failed_total[5m]) > 5
for: 2m
labels:
severity: warning
annotations:
summary: "High authentication failure rate"
description: "More than 5 auth failures per minute detected"
- name: loki
rules:
# Loki health
- alert: LokiDown
expr: up{job="loki"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Loki log aggregation is down"
description: "Loki has been unreachable for more than 2 minutes"
# Loki ingestion rate
- alert: LokiHighIngestionRate
expr: rate(loki_distributor_bytes_received_total[5m]) > 10485760
for: 5m
labels:
severity: warning
annotations:
summary: "High log ingestion rate in Loki"
description: "Log ingestion rate is above 10MB/s"
@@ -1,26 +0,0 @@
# Node Exporter targets - auto-generated by Ansible
# Updates require re-running the prometheus playbook
#
# NOTE: Most hosts have Alloy agents that push metrics via remote_write.
# This file only includes hosts that need direct scraping (standalone node_exporter).
# Alloy-managed hosts are excluded to avoid duplicate "down" targets.
# PVE cluster nodes - need direct scraping (no Alloy agent)
- targets:
- 'jupiter.renner.uno:9100'
labels:
job: 'node'
group: 'pve_cluster'
hostname: 'jupiter'
- targets:
- 'neptune.renner.uno:9100'
labels:
job: 'node'
group: 'pve_cluster'
hostname: 'neptune'
- targets:
- 'saturn.renner.uno:9100'
labels:
job: 'node'
group: 'pve_cluster'
hostname: 'saturn'
@@ -1,38 +0,0 @@
[Unit]
Description=Prometheus Monitoring System
Documentation=https://prometheus.io/docs/
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ prometheus_user }}
Group={{ prometheus_group }}
ExecStart={{ prometheus_bin_path }} \
--config.file={{ prometheus_config_file }} \
--storage.tsdb.path={{ prometheus_data_dir }} \
--storage.tsdb.retention.time={{ prometheus_retention_time }} \
--storage.tsdb.retention.size={{ prometheus_retention_size }} \
--web.listen-address={{ prometheus_listen_address }}:{{ prometheus_listen_port }} \
{% if prometheus_remote_write_enabled %}
--web.enable-remote-write-receiver \
{% endif %}
{% if prometheus_web_external_url %}
--web.external-url={{ prometheus_web_external_url }} \
{% endif %}
--web.enable-lifecycle
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=10s
LimitNOFILE=65536
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths={{ prometheus_data_dir }}
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
@@ -1,304 +0,0 @@
# Prometheus Configuration
# Generated by Ansible
global:
scrape_interval: {{ prometheus_scrape_interval }}
evaluation_interval: {{ prometheus_evaluation_interval }}
scrape_timeout: {{ prometheus_scrape_timeout }}
external_labels:
cluster: homelab
datacenter: renner
rule_files:
- "{{ prometheus_rules_dir }}/*.yml"
{% if prometheus_alertmanager_url %}
alerting:
alertmanagers:
- static_configs:
- targets:
- {{ prometheus_alertmanager_url }}
{% endif %}
scrape_configs:
# Prometheus self-monitoring
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:{{ prometheus_listen_port }}']
# Loki metrics
- job_name: 'loki'
static_configs:
- targets: ['loki.srv.renner.uno:3100']
# File-based service discovery for dynamic targets
- job_name: 'alloy-nodes'
file_sd_configs:
- files:
- '{{ prometheus_targets_dir }}/*.yml'
- '{{ prometheus_targets_dir }}/*.json'
refresh_interval: 30s
{% if prometheus_remote_write_enabled %}
# Remote write receiver for Alloy agents
# Agents push metrics to /api/v1/write
{% endif %}
# Static targets for infrastructure components
# Proxmox VE API metrics (via pve-exporter)
- job_name: 'pve'
scrape_interval: 60s
scrape_timeout: 55s
static_configs:
- targets:
- 'jupiter.renner.uno'
- 'neptune.renner.uno'
- 'saturn.renner.uno'
metrics_path: /pve
params:
module: [default]
cluster: ['1']
node: ['1']
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: localhost:9221 # pve-exporter address
- target_label: group
replacement: proxmox
- target_label: type
replacement: hypervisor
# RustFS (S3-compatible storage)
# NOTE: RustFS uses OpenTelemetry push-model via --obs-endpoint, not Prometheus pull.
# Unlike MinIO, it doesn't expose /minio/v2/metrics/cluster endpoint.
# Metrics collection would require setting up RUSTFS_OBS_ENDPOINT to push to an OTEL collector.
# For now, node-level metrics are available via Alloy agents on the RustFS hosts.
# Home Assistant metrics
- job_name: 'homeassistant'
metrics_path: /api/prometheus
bearer_token: '{{ homeassistant_prometheus_token }}'
scheme: http
static_configs:
- targets: ['homeassistant.iot.renner.uno:8123']
labels:
group: 'smarthome'
type: 'homeassistant'
# UniFi Poller metrics
- job_name: 'unifi'
static_configs:
- targets: ['localhost:9130']
labels:
group: 'network'
type: 'unifi'
# Caddy reverse proxy metrics (HA cluster)
- job_name: 'caddy'
static_configs:
- targets:
- 'caddy.dmz.renner.uno:2019'
- 'caddy2.dmz.renner.uno:2019'
labels:
group: 'ingress'
type: 'reverse-proxy'
# PostgreSQL metrics (via postgres_exporter)
- job_name: 'postgresql'
static_configs:
- targets: ['localhost:9187']
labels:
group: 'database'
type: 'postgresql'
# Authentik identity provider metrics
- job_name: 'authentik'
static_configs:
- targets:
- 'authentik-app-1.srv.renner.uno:9300'
- 'authentik-app-2.srv.renner.uno:9300'
labels:
group: 'identity'
type: 'authentik'
# Node Exporter - system metrics from all hosts
- job_name: 'node'
file_sd_configs:
- files:
- '{{ prometheus_targets_dir }}/nodes.yml'
refresh_interval: 60s
# Chrony Exporter - NTP metrics from PVE cluster
# One static_config per host so each carries its own hostname label. The
# previous form listed all three targets together and recovered the hostname
# in relabel_configs by regex-matching __address__ against the node's IP --
# which silently coupled the labels to the addressing. Changing a target
# would have left the regex unmatched and the hostname label simply absent,
# with the scrape still succeeding.
- job_name: 'chrony'
static_configs:
- targets: ['jupiter.renner.uno:9123']
labels:
group: 'ntp'
type: 'time-server'
hostname: 'jupiter'
- targets: ['neptune.renner.uno:9123']
labels:
group: 'ntp'
type: 'time-server'
hostname: 'neptune'
- targets: ['saturn.renner.uno:9123']
labels:
group: 'ntp'
type: 'time-server'
hostname: 'saturn'
# Immich photo management metrics
- job_name: 'immich'
static_configs:
- targets: ['immich.srv.renner.uno:8081']
labels:
group: 'media'
type: 'photo-management'
# Blackbox Exporter - HTTP endpoint probing
- job_name: 'blackbox-http'
metrics_path: /probe
params:
module: [http_2xx]
static_configs:
- targets:
- https://gitea.renner.uno
- https://immich.renner.uno
- https://auth.renner.uno
- https://ha.renner.uno
- https://grafana.renner.uno
- https://prometheus.renner.uno
labels:
group: 'probe'
type: 'http'
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: localhost:9115
# Blackbox Exporter - TCP probing
- job_name: 'blackbox-tcp'
metrics_path: /probe
params:
module: [tcp_connect]
static_configs:
- targets:
- 10.2.40.135:5432 # PostgreSQL HA VIP
labels:
group: 'probe'
type: 'tcp'
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: localhost:9115
# Blackbox Exporter - DNS probing (external domains like google.com)
- job_name: 'blackbox-dns-external'
metrics_path: /probe
params:
module: [dns_external]
static_configs:
- targets:
- 10.2.40.53 # Proxmox DNS VIP
- 10.2.40.1 # UDM Gateway DNS
labels:
group: 'probe'
type: 'dns'
query_type: 'external'
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: localhost:9115
# Blackbox Exporter - DNS probing (internal domains like k8s-1.srv.renner.uno)
- job_name: 'blackbox-dns-internal'
metrics_path: /probe
params:
module: [dns_internal]
static_configs:
- targets:
- 10.2.40.53 # Proxmox DNS VIP
- 10.2.40.1 # UDM Gateway DNS
labels:
group: 'probe'
type: 'dns'
query_type: 'internal'
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: localhost:9115
# Blackbox Exporter - DNS probing (cached domains)
- job_name: 'blackbox-dns-cached'
metrics_path: /probe
params:
module: [dns_cached]
static_configs:
- targets:
- 10.2.40.53 # Proxmox DNS VIP
- 10.2.40.1 # UDM Gateway DNS
labels:
group: 'probe'
type: 'dns'
query_type: 'cached'
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: localhost:9115
{% if snmp_synology_targets is defined and snmp_synology_targets | length > 0 %}
# Synology NAS SNMP metrics
# snmp_exporter v0.27+ requires both synology and ucd_la_table modules
- job_name: 'synology'
scrape_interval: 60s
scrape_timeout: 55s
metrics_path: /snmp
params:
module: [synology, ucd_la_table, ucd_memory, if_mib]
{% if snmp_use_v3 | default(false) %}
auth: [synology_v3]
{% else %}
auth: [synology_v2]
{% endif %}
static_configs:
- targets:
{% for target in snmp_synology_targets %}
- '{{ target.target }}' # {{ target.name }}
{% endfor %}
labels:
group: 'storage'
type: 'nas'
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__address__]
regex: '([^:]+)(:\d+)?'
replacement: '${1}'
target_label: instance
- target_label: __address__
replacement: localhost:9116 # snmp_exporter address
{% endif %}
-36
View File
@@ -1,36 +0,0 @@
---
# Promtail Configuration
# Version and download
promtail_version: "3.4.2"
promtail_arch: "linux-amd64"
promtail_download_url: "https://github.com/grafana/loki/releases/download/v{{ promtail_version }}/promtail-{{ promtail_arch }}.zip"
promtail_bin_path: "/usr/local/bin/promtail"
# Directories
promtail_config_dir: "/etc/promtail"
promtail_config_file: "{{ promtail_config_dir }}/promtail-config.yaml"
promtail_positions_file: "/var/lib/promtail/positions.yaml"
# Server configuration
promtail_http_listen_port: 9080
promtail_http_listen_address: "0.0.0.0"
# Loki server
promtail_loki_url: "http://loki.srv.renner.uno:3100/loki/api/v1/push"
# Log scrape configurations
# Override in playbook or host_vars for specific services
promtail_scrape_configs:
- job_name: system
static_configs:
- targets:
- localhost
labels:
job: syslog
host: "{{ inventory_hostname }}"
__path__: /var/log/syslog
# Systemd service
promtail_user: "promtail"
promtail_group: "promtail"
-6
View File
@@ -1,6 +0,0 @@
---
- name: Restart promtail
systemd:
name: promtail
state: restarted
daemon_reload: yes
-112
View File
@@ -1,112 +0,0 @@
---
- name: Install dependencies
apt:
name:
- unzip
- curl
state: present
update_cache: yes
- name: Create promtail group
group:
name: "{{ promtail_group }}"
system: yes
state: present
- name: Create promtail user
user:
name: "{{ promtail_user }}"
group: "{{ promtail_group }}"
system: yes
shell: /usr/sbin/nologin
home: /var/lib/promtail
create_home: yes
state: present
- name: Add promtail user to systemd-journal group
user:
name: "{{ promtail_user }}"
groups: systemd-journal
append: yes
- name: Create config directory
file:
path: "{{ promtail_config_dir }}"
state: directory
owner: "{{ promtail_user }}"
group: "{{ promtail_group }}"
mode: '0755'
- name: Create positions directory
file:
path: "{{ promtail_positions_file | dirname }}"
state: directory
owner: "{{ promtail_user }}"
group: "{{ promtail_group }}"
mode: '0755'
- name: Check if promtail binary exists
stat:
path: "{{ promtail_bin_path }}"
register: promtail_binary
- name: Get installed promtail version
command: "{{ promtail_bin_path }} --version"
register: installed_version
changed_when: false
failed_when: false
when: promtail_binary.stat.exists
- name: Download promtail
get_url:
url: "{{ promtail_download_url }}"
dest: /tmp/promtail.zip
mode: '0644'
when: not promtail_binary.stat.exists or (installed_version.stdout is defined and promtail_version not in installed_version.stdout)
- name: Extract promtail binary
unarchive:
src: /tmp/promtail.zip
dest: /tmp/
remote_src: yes
when: not promtail_binary.stat.exists or (installed_version.stdout is defined and promtail_version not in installed_version.stdout)
- name: Install promtail binary
copy:
src: "/tmp/promtail-{{ promtail_arch }}"
dest: "{{ promtail_bin_path }}"
remote_src: yes
mode: '0755'
when: not promtail_binary.stat.exists or (installed_version.stdout is defined and promtail_version not in installed_version.stdout)
notify: Restart promtail
- name: Clean up downloaded files
file:
path: "{{ item }}"
state: absent
loop:
- /tmp/promtail.zip
- "/tmp/promtail-{{ promtail_arch }}"
- name: Configure promtail
template:
src: promtail-config.yaml.j2
dest: "{{ promtail_config_file }}"
owner: "{{ promtail_user }}"
group: "{{ promtail_group }}"
mode: '0644'
notify: Restart promtail
- name: Create systemd service
template:
src: promtail.service.j2
dest: /etc/systemd/system/promtail.service
mode: '0644'
notify: Restart promtail
- name: Enable and start promtail
systemd:
name: promtail
state: started
enabled: yes
daemon_reload: yes
@@ -1,79 +0,0 @@
server:
http_listen_port: {{ promtail_http_listen_port }}
http_listen_address: {{ promtail_http_listen_address }}
grpc_listen_port: 0
positions:
filename: {{ promtail_positions_file }}
clients:
- url: {{ promtail_loki_url }}
tenant_id: default
scrape_configs:
{% for config in promtail_scrape_configs %}
- job_name: {{ config.job_name }}
{% if config.journal is defined %}
journal:
max_age: {{ config.journal.max_age | default('12h') }}
labels:
job: {{ config.job_name }}
host: {{ inventory_hostname }}
{% if config.journal.matches is defined %}
matches: {{ config.journal.matches }}
{% endif %}
{% if config.relabel_configs is defined %}
relabel_configs:
{% for relabel in config.relabel_configs %}
- source_labels: {{ relabel.source_labels | to_json }}
target_label: {{ relabel.target_label }}
{% if relabel.regex is defined %}
regex: '{{ relabel.regex }}'
{% endif %}
{% if relabel.replacement is defined %}
replacement: '{{ relabel.replacement }}'
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% if config.static_configs is defined %}
static_configs:
{% for static in config.static_configs %}
- targets:
{% for target in static.targets %}
- {{ target }}
{% endfor %}
labels:
{% for key, value in static.labels.items() %}
{{ key }}: {{ value }}
{% endfor %}
{% endfor %}
{% if config.pipeline_stages is defined %}
pipeline_stages:
{% for stage in config.pipeline_stages %}
{% if stage.multiline is defined %}
- multiline:
firstline: '{{ stage.multiline.firstline }}'
{% if stage.multiline.max_wait_time is defined %}
max_wait_time: {{ stage.multiline.max_wait_time }}
{% endif %}
{% endif %}
{% if stage.regex is defined %}
- regex:
expression: '{{ stage.regex.expression }}'
{% endif %}
{% if stage.labels is defined %}
- labels:
{% for label in stage.labels %}
{{ label }}:
{% endfor %}
{% endif %}
{% if stage.timestamp is defined %}
- timestamp:
source: {{ stage.timestamp.source }}
format: '{{ stage.timestamp.format }}'
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
@@ -1,24 +0,0 @@
[Unit]
Description=Promtail Log Collector
Documentation=https://grafana.com/docs/loki/latest/clients/promtail/
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ promtail_user }}
Group={{ promtail_group }}
ExecStart={{ promtail_bin_path }} -config.file={{ promtail_config_file }}
Restart=always
RestartSec=5
TimeoutStopSec=30
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/promtail
CapabilityBoundingSet=CAP_DAC_READ_SEARCH
[Install]
WantedBy=multi-user.target
@@ -1,19 +0,0 @@
---
# PVE Exporter defaults
# Installation
pve_exporter_version: "3.4.5"
pve_exporter_install_dir: /opt/pve-exporter
pve_exporter_config_dir: /etc/pve-exporter
pve_exporter_venv_path: "{{ pve_exporter_install_dir }}/venv"
# Service
pve_exporter_user: pve-exporter
pve_exporter_group: pve-exporter
pve_exporter_port: 9221
# Proxmox connection (override with vault variables)
pve_exporter_pve_user: "{{ pve_user | default('root@pam') }}"
pve_exporter_pve_token_name: "{{ pve_token_id | default('ansible') }}"
pve_exporter_pve_token_value: "{{ pve_token_secret }}"
pve_exporter_pve_verify_ssl: false
@@ -1,6 +0,0 @@
---
- name: Restart pve-exporter
systemd:
name: pve-exporter
state: restarted
daemon_reload: yes
-78
View File
@@ -1,78 +0,0 @@
---
# PVE Exporter installation and configuration
- name: Install Python dependencies
apt:
name:
- python3
- python3-pip
- python3-venv
state: present
update_cache: yes
- name: Create pve-exporter system user
user:
name: "{{ pve_exporter_user }}"
system: yes
shell: /usr/sbin/nologin
create_home: no
- name: Create installation directory
file:
path: "{{ pve_exporter_install_dir }}"
state: directory
owner: "{{ pve_exporter_user }}"
group: "{{ pve_exporter_group }}"
mode: '0755'
- name: Create configuration directory
file:
path: "{{ pve_exporter_config_dir }}"
state: directory
owner: "{{ pve_exporter_user }}"
group: "{{ pve_exporter_group }}"
mode: '0750'
- name: Create Python virtual environment
command: python3 -m venv {{ pve_exporter_venv_path }}
args:
creates: "{{ pve_exporter_venv_path }}/bin/python"
- name: Set venv ownership
file:
path: "{{ pve_exporter_venv_path }}"
state: directory
owner: "{{ pve_exporter_user }}"
group: "{{ pve_exporter_group }}"
recurse: yes
- name: Install prometheus-pve-exporter
pip:
name: prometheus-pve-exporter
virtualenv: "{{ pve_exporter_venv_path }}"
state: present
- name: Create configuration file
template:
src: pve.yml.j2
dest: "{{ pve_exporter_config_dir }}/pve.yml"
owner: "{{ pve_exporter_user }}"
group: "{{ pve_exporter_group }}"
mode: '0640'
notify: Restart pve-exporter
- name: Create systemd service
template:
src: pve-exporter.service.j2
dest: /etc/systemd/system/pve-exporter.service
owner: root
group: root
mode: '0644'
notify: Restart pve-exporter
- name: Enable and start pve-exporter service
systemd:
name: pve-exporter
state: started
enabled: yes
daemon_reload: yes
@@ -1,22 +0,0 @@
[Unit]
Description=Prometheus PVE Exporter
Documentation=https://github.com/prometheus-pve/prometheus-pve-exporter
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ pve_exporter_user }}
Group={{ pve_exporter_group }}
ExecStart={{ pve_exporter_venv_path }}/bin/pve_exporter \
--config.file {{ pve_exporter_config_dir }}/pve.yml \
--web.listen-address 0.0.0.0:{{ pve_exporter_port }}
Restart=on-failure
RestartSec=10s
# Security hardening
NoNewPrivileges=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
@@ -1,8 +0,0 @@
# PVE Exporter Configuration
# Generated by Ansible
default:
user: "{{ pve_exporter_pve_user }}"
token_name: "{{ pve_exporter_pve_token_name }}"
token_value: "{{ pve_exporter_pve_token_value }}"
verify_ssl: {{ pve_exporter_pve_verify_ssl | lower }}
@@ -1,33 +0,0 @@
---
# SNMP Exporter defaults
snmp_exporter_version: "0.30.1"
snmp_exporter_listen_port: 9116
snmp_exporter_user: "snmp_exporter"
snmp_exporter_group: "snmp_exporter"
snmp_exporter_install_dir: "/opt/snmp_exporter"
snmp_exporter_config_dir: "/etc/snmp_exporter"
# Synology NAS SNMP targets
# Configure in host_vars or group_vars
snmp_synology_targets: []
# Example:
# snmp_synology_targets:
# - name: nas-1
# target: 10.2.40.50
# - name: nas-2
# target: 10.2.40.51
# SNMPv3 authentication (recommended for security)
# These should be set in vault
snmp_v3_username: "prometheus"
snmp_v3_auth_protocol: "SHA" # MD5 or SHA
snmp_v3_auth_password: "" # Set in vault
snmp_v3_priv_protocol: "AES" # DES or AES
snmp_v3_priv_password: "" # Set in vault
snmp_v3_security_level: "authPriv" # noAuthNoPriv, authNoPriv, authPriv
# SNMPv2c fallback (less secure, but simpler)
snmp_v2_community: "public"
snmp_use_v3: true
@@ -1,6 +0,0 @@
---
- name: Restart snmp_exporter
systemd:
name: snmp_exporter
state: restarted
daemon_reload: yes
-114
View File
@@ -1,114 +0,0 @@
---
# SNMP Exporter installation and configuration
- name: Create snmp_exporter group
group:
name: "{{ snmp_exporter_group }}"
system: yes
state: present
- name: Create snmp_exporter user
user:
name: "{{ snmp_exporter_user }}"
group: "{{ snmp_exporter_group }}"
system: yes
shell: /usr/sbin/nologin
home: "{{ snmp_exporter_install_dir }}"
create_home: no
- name: Create directories
file:
path: "{{ item }}"
state: directory
owner: "{{ snmp_exporter_user }}"
group: "{{ snmp_exporter_group }}"
mode: '0755'
loop:
- "{{ snmp_exporter_install_dir }}"
- "{{ snmp_exporter_config_dir }}"
- name: Check if snmp_exporter is already installed
stat:
path: "{{ snmp_exporter_install_dir }}/snmp_exporter"
register: snmp_exporter_binary
- name: Get installed snmp_exporter version
command: "{{ snmp_exporter_install_dir }}/snmp_exporter --version"
register: installed_version
changed_when: false
failed_when: false
when: snmp_exporter_binary.stat.exists
- name: Download snmp_exporter
get_url:
url: "https://github.com/prometheus/snmp_exporter/releases/download/v{{ snmp_exporter_version }}/snmp_exporter-{{ snmp_exporter_version }}.linux-amd64.tar.gz"
dest: "/tmp/snmp_exporter.tar.gz"
mode: '0644'
when: not snmp_exporter_binary.stat.exists or (installed_version.stdout is defined and snmp_exporter_version not in installed_version.stdout)
- name: Extract snmp_exporter
unarchive:
src: "/tmp/snmp_exporter.tar.gz"
dest: "/tmp/"
remote_src: yes
when: not snmp_exporter_binary.stat.exists or (installed_version.stdout is defined and snmp_exporter_version not in installed_version.stdout)
- name: Install snmp_exporter binary
copy:
src: "/tmp/snmp_exporter-{{ snmp_exporter_version }}.linux-amd64/snmp_exporter"
dest: "{{ snmp_exporter_install_dir }}/snmp_exporter"
remote_src: yes
owner: "{{ snmp_exporter_user }}"
group: "{{ snmp_exporter_group }}"
mode: '0755'
when: not snmp_exporter_binary.stat.exists or (installed_version.stdout is defined and snmp_exporter_version not in installed_version.stdout)
notify: Restart snmp_exporter
- name: Install default snmp.yml from release
copy:
src: "/tmp/snmp_exporter-{{ snmp_exporter_version }}.linux-amd64/snmp.yml"
dest: "{{ snmp_exporter_config_dir }}/snmp.yml"
remote_src: yes
owner: "{{ snmp_exporter_user }}"
group: "{{ snmp_exporter_group }}"
mode: '0644'
when: not snmp_exporter_binary.stat.exists or (installed_version.stdout is defined and snmp_exporter_version not in installed_version.stdout)
notify: Restart snmp_exporter
- name: Add synology_v3 auth entry
lineinfile:
path: "{{ snmp_exporter_config_dir }}/snmp.yml"
insertafter: "^auths:"
line: " synology_v3: {version: 3, security_level: {{ snmp_v3_security_level }}, username: {{ snmp_v3_username }}, password: {{ snmp_v3_auth_password }}, auth_protocol: {{ snmp_v3_auth_protocol }}, priv_protocol: {{ snmp_v3_priv_protocol }}, priv_password: {{ snmp_v3_priv_password }}}"
notify: Restart snmp_exporter
- name: Add synology_v2 auth entry
lineinfile:
path: "{{ snmp_exporter_config_dir }}/snmp.yml"
insertafter: "^auths:"
line: " synology_v2: {version: 2, community: {{ snmp_v2_community }}}"
notify: Restart snmp_exporter
- name: Create snmp_exporter systemd service
template:
src: snmp_exporter.service.j2
dest: /etc/systemd/system/snmp_exporter.service
owner: root
group: root
mode: '0644'
notify: Restart snmp_exporter
- name: Enable and start snmp_exporter service
systemd:
name: snmp_exporter
state: started
enabled: yes
daemon_reload: yes
- name: Clean up downloaded files
file:
path: "{{ item }}"
state: absent
loop:
- "/tmp/snmp_exporter.tar.gz"
- "/tmp/snmp_exporter-{{ snmp_exporter_version }}.linux-amd64"
@@ -1,18 +0,0 @@
# SNMPv3 Authentication for Synology NAS
# Appended to default snmp.yml by Ansible
auths:
{% if snmp_use_v3 | default(true) %}
synology_v3:
security_level: {{ snmp_v3_security_level }}
username: {{ snmp_v3_username }}
password: {{ snmp_v3_auth_password }}
auth_protocol: {{ snmp_v3_auth_protocol }}
{% if snmp_v3_security_level == 'authPriv' %}
priv_protocol: {{ snmp_v3_priv_protocol }}
priv_password: {{ snmp_v3_priv_password }}
{% endif %}
{% else %}
synology_v2:
community: {{ snmp_v2_community }}
{% endif %}
@@ -1,26 +0,0 @@
[Unit]
Description=Prometheus SNMP Exporter
Documentation=https://github.com/prometheus/snmp_exporter
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ snmp_exporter_user }}
Group={{ snmp_exporter_group }}
ExecStart={{ snmp_exporter_install_dir }}/snmp_exporter \
--config.file={{ snmp_exporter_config_dir }}/snmp.yml \
--web.listen-address=:{{ snmp_exporter_listen_port }}
Restart=on-failure
RestartSec=5s
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadOnlyPaths=/
[Install]
WantedBy=multi-user.target
@@ -1,34 +0,0 @@
---
# UniFi Poller defaults
# Version to install
unifi_poller_version: "2.11.2"
# Installation paths
unifi_poller_bin_path: /usr/local/bin/unpoller
unifi_poller_config_dir: /etc/unpoller
unifi_poller_config_file: "{{ unifi_poller_config_dir }}/up.conf"
# Service user
unifi_poller_user: unpoller
unifi_poller_group: unpoller
# UniFi Controller settings (override in vars/vault)
unifi_controller_url: "https://192.168.1.1"
unifi_controller_user: ""
unifi_controller_pass: ""
unifi_controller_verify_ssl: false
unifi_controller_site: "default"
# Prometheus exporter settings
unifi_poller_prometheus_port: 9130
unifi_poller_prometheus_path: /metrics
# Polling intervals
unifi_poller_interval: 30s
unifi_poller_save_sites: true
unifi_poller_save_ids: false
unifi_poller_save_events: false
unifi_poller_save_alarms: false
unifi_poller_save_anomalies: false
unifi_poller_save_dpi: true
@@ -1,6 +0,0 @@
---
- name: Restart unpoller
systemd:
name: unpoller
state: restarted
daemon_reload: yes
-88
View File
@@ -1,88 +0,0 @@
---
# UniFi Poller installation and configuration
- name: Create unpoller system user
user:
name: "{{ unifi_poller_user }}"
system: yes
shell: /usr/sbin/nologin
create_home: no
- name: Create configuration directory
file:
path: "{{ unifi_poller_config_dir }}"
state: directory
owner: "{{ unifi_poller_user }}"
group: "{{ unifi_poller_group }}"
mode: '0750'
- name: Check if unpoller is already installed
stat:
path: "{{ unifi_poller_bin_path }}"
register: unpoller_binary
- name: Get installed version
command: "{{ unifi_poller_bin_path }} --version"
register: installed_version
changed_when: false
failed_when: false
when: unpoller_binary.stat.exists
- name: Download UniFi Poller
get_url:
url: "https://github.com/unpoller/unpoller/releases/download/v{{ unifi_poller_version }}/unpoller_{{ unifi_poller_version }}_linux_amd64.tar.gz"
dest: /tmp/unpoller.tar.gz
mode: '0644'
when: not unpoller_binary.stat.exists or (installed_version.stdout is defined and unifi_poller_version not in installed_version.stdout)
- name: Extract UniFi Poller
unarchive:
src: /tmp/unpoller.tar.gz
dest: /tmp/
remote_src: yes
when: not unpoller_binary.stat.exists or (installed_version.stdout is defined and unifi_poller_version not in installed_version.stdout)
- name: Install UniFi Poller binary
copy:
src: /tmp/unpoller
dest: "{{ unifi_poller_bin_path }}"
remote_src: yes
owner: root
group: root
mode: '0755'
when: not unpoller_binary.stat.exists or (installed_version.stdout is defined and unifi_poller_version not in installed_version.stdout)
notify: Restart unpoller
- name: Create configuration file
template:
src: up.conf.j2
dest: "{{ unifi_poller_config_file }}"
owner: "{{ unifi_poller_user }}"
group: "{{ unifi_poller_group }}"
mode: '0640'
notify: Restart unpoller
- name: Create systemd service
template:
src: unpoller.service.j2
dest: /etc/systemd/system/unpoller.service
owner: root
group: root
mode: '0644'
notify: Restart unpoller
- name: Enable and start unpoller service
systemd:
name: unpoller
state: started
enabled: yes
daemon_reload: yes
- name: Clean up downloaded files
file:
path: "{{ item }}"
state: absent
loop:
- /tmp/unpoller.tar.gz
- /tmp/unpoller
changed_when: false
@@ -1,24 +0,0 @@
[Unit]
Description=UniFi Poller - Prometheus Exporter for UniFi Network
Documentation=https://github.com/unpoller/unpoller
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ unifi_poller_user }}
Group={{ unifi_poller_group }}
ExecStart={{ unifi_poller_bin_path }} --config {{ unifi_poller_config_file }}
Restart=on-failure
RestartSec=10s
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadOnlyPaths=/
ReadWritePaths={{ unifi_poller_config_dir }}
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
@@ -1,42 +0,0 @@
# UniFi Poller Configuration
# Generated by Ansible
[poller]
# Turns on debug messages
debug = false
# Turns off messages
quiet = false
[prometheus]
# Whether to export metrics
disable = false
# HTTP listen address for /metrics endpoint
http_listen = "0.0.0.0:{{ unifi_poller_prometheus_port }}"
# Path for metrics endpoint
report_errors = false
[unifi]
# UniFi Controller configurations
[[unifi.controller]]
# URL for the UniFi Controller
url = "{{ unifi_controller_url }}"
# Username
user = "{{ unifi_controller_user }}"
# Password
pass = "{{ unifi_controller_pass }}"
# Sites to poll (leave empty for all sites)
sites = ["{{ unifi_controller_site }}"]
# Verify SSL certificates
verify_ssl = {{ unifi_controller_verify_ssl | lower }}
# Save site information
save_sites = {{ unifi_poller_save_sites | lower }}
# Save device IDs
save_ids = {{ unifi_poller_save_ids | lower }}
# Save events
save_events = {{ unifi_poller_save_events | lower }}
# Save alarms
save_alarms = {{ unifi_poller_save_alarms | lower }}
# Save anomalies
save_anomalies = {{ unifi_poller_save_anomalies | lower }}
# Save DPI data
save_dpi = {{ unifi_poller_save_dpi | lower }}
@@ -52,7 +52,7 @@ spec:
# stdlib only; Flux keeps the tag in lockstep via the shared image
# policy) — no extra dependency.
- name: reconcile-publish-ip
image: ghcr.io/music-assistant/server:2.9.12 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.9.13 # {"$imagepolicy": "flux-system:music-assistant"}
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
volumeMounts:
- name: data
@@ -62,7 +62,7 @@ spec:
readOnly: true
containers:
- name: music-assistant
image: ghcr.io/music-assistant/server:2.9.12 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.9.13 # {"$imagepolicy": "flux-system:music-assistant"}
env:
- name: LOG_LEVEL
value: info
+77 -6
View File
@@ -29,12 +29,27 @@ data:
# The app re-reads schedules.json every 60 s, so edits here take
# effect without a restart once the init container has rewritten it.
#
# lang is "de", not null. It calls Home Assistant's own
# _selectLanguage() for the screenshot session, so the rendered page
# is localised by HA rather than by hand. With it left null the
# dashboard grew a German weekday array, a German month array and a
# fifteen-entry weather-condition dict — reimplementing translations
# HA already ships. The instance itself stays language "en"; this
# only affects the render session.
#
# model_id is 1 — TRMNL X (name "v2", 1872x1404, 16 colours) — and it
# is an INTEGER, not a name. Confirmed against the running server:
# GET /api/screens returns model_id as a number, and upstream's
# example value "og_1bit" matches no model on this instance (the
# 2-bit OG model here is id 11, name "og_plus"). Getting this wrong
# shows up as Terminus rejecting the pushed screen with a 422.
#
# All three auth fields below are placeholders the init container
# replaces — including obtained_at, which must end up a real epoch-MS
# stamp. The 0 here is inert only because it is always overwritten;
# left as 0 the scheduler reads the tokens as ancient and refuses to
# push at all ("expired beyond the refresh window"), with no render
# attempt and no error beyond one startup warning.
schedules.template.json: |
[
{
@@ -53,8 +68,8 @@ data:
"rotate": null,
"zoom": 1,
"wait": null,
"theme": "trmnl-eink",
"lang": null,
"theme": "Graphite E-ink Light",
"lang": "de",
"dark": false,
"invert": false,
"timestamp": false,
@@ -135,9 +150,16 @@ spec:
# proxy that exchanges a Terminus username+password for these
# tokens, but that would mean shipping the Terminus PASSWORD
# into the cluster and doing a network call at every pod start.
# Stock Terminus disables session expiry and issues effectively
# permanent tokens, so they are minted once out of band and kept
# in OpenBao — the password never enters the cluster.
# Tokens are therefore minted once out of band and kept in
# OpenBao — the password never enters the cluster.
#
# That only works because Terminus is configured with
# API_ACCESS_TOKEN_PERIOD = 10 years (see terminus.yaml). It is
# NOT, as an earlier version of this comment claimed, because
# stock Terminus issues permanent tokens — the stock default is
# 1800s, and this deployment demonstrated it by having the
# webhook start failing with "expired JWT access token" about
# thirty minutes after seeding.
- name: render-schedule
image: gitea.renner.uno/gitea_admin/alpine-jq:2026-07-30
command:
@@ -145,10 +167,25 @@ spec:
- -c
- |
set -eu
# obtained_at is stamped at write time, in MILLISECONDS.
# It is not cosmetic: the scheduler derives token age from
# it, and a literal 0 made the app treat freshly-minted
# tokens as ancient and skip the webhook entirely —
# "BYOS tokens ... expired beyond the refresh window"
#
# Stamping it "now" is only safe because Terminus is
# configured with API_ACCESS_TOKEN_PERIOD = 10 years (see
# terminus.yaml). Against the 30-minute default it would be
# actively harmful: the app would believe a stale token had
# 30 fresh minutes left, skip its proactive refresh, and
# then be unable to recover, because refreshing requires a
# still-valid token. If that setting is ever reverted, this
# must become a real mint time carried alongside the tokens.
jq --arg at "$BYOS_ACCESS_TOKEN" \
--arg rt "$BYOS_REFRESH_TOKEN" \
'(.[].webhook_format.byosConfig.auth.access_token) = $at
| (.[].webhook_format.byosConfig.auth.refresh_token) = $rt' \
| (.[].webhook_format.byosConfig.auth.refresh_token) = $rt
| (.[].webhook_format.byosConfig.auth.obtained_at) = (now * 1000 | floor)' \
/template/schedules.template.json > /data/schedules.json
echo "wrote /data/schedules.json ($(wc -c < /data/schedules.json) bytes)"
env:
@@ -208,6 +245,21 @@ spec:
value: "http://home-assistant-bgp.home-assistant.svc.cluster.local:8123"
- name: TZ
value: "Europe/Berlin"
# Chromium will not start without a writable HOME. Running
# as uid 1000 with no matching passwd entry leaves HOME="/",
# which is root-owned 0755, and the browser dies on launch
# with
# chrome_crashpad_handler: --database is required
# because crashpad cannot create its database directory.
#
# Diagnosed on the running pod, not guessed: HOME=/, uid=1000,
# / not writable, /tmp writable. Crash dumps are throwaway, so
# /tmp is the right home for them rather than the PVC.
#
# This is a consequence of OUR non-root securityContext, not
# of the image — so it must move together with runAsUser.
- name: HOME
value: "/tmp"
# Dedicated long-lived token, NOT the operator's personal
# one, so the renderer's access can be revoked on its own.
# It is injected into the headless browser's localStorage,
@@ -220,6 +272,16 @@ spec:
volumeMounts:
- name: data
mountPath: /data
# Bounded scratch backing HOME. Chromium writes crashpad
# state and profile data here on every launch, and it now
# launches once a minute for the life of the pod — on the
# container's writable layer that is unbounded growth
# against node disk, with no ephemeral-storage limit to
# catch it. A sizeLimit converts a slow, cluster-wide
# node-disk-pressure problem into a bounded and clearly
# attributable eviction of this pod.
- name: tmp
mountPath: /tmp
# HTTP probes here, unlike terminus.yaml's TCP-only ones, because
# /health is confirmed rather than assumed: the upstream image's
# own Dockerfile declares
@@ -260,6 +322,12 @@ spec:
# mounted either). Those flags are baked into the app's own
# PUPPETEER_ARGS, not something we pass, so this holds as long as
# the pinned tag does — worth re-checking on any bump.
#
# Confirmed on the running pod: the capability drop is genuinely
# not what broke the browser. The first deploy DID crash it, but
# on a writable-HOME problem caused by runAsUser — see the HOME
# env above. Do not "fix" a future Chromium launch failure by
# loosening this block before checking HOME and /tmp.
securityContext:
allowPrivilegeEscalation: false
capabilities:
@@ -271,6 +339,9 @@ spec:
- name: schedule-template
configMap:
name: trmnl-renderer-schedule
- name: tmp
emptyDir:
sizeLimit: 512Mi
---
apiVersion: v1
kind: Service
+72
View File
@@ -18,6 +18,64 @@ data:
# pool (fd99::/120) and the LAN ULA — without it every IPv6 client,
# including the device and Traefik, is rate-limited as if it were remote.
RACK_ATTACK_ALLOWED_SUBNETS: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1,::1,fc00::/7"
# 10 years, against a default of 1800s (30 minutes).
#
# Documented upstream in doc/configuration.adoc:
# API_ACCESS_TOKEN_PERIOD — "The amount of time, in seconds, the JWT
# access token remains valid before needing to refresh your access
# token." Default: 1,800 seconds (30 minutes).
# Corroborated on this instance: a token minted here was rejected about
# thirty minutes later, i.e. the documented default behaving as written.
#
# This exists for the renderer, which pushes screens to /api/screens with
# a JWT held in OpenBao. At the default period that arrangement cannot
# work unattended, and it fails in a way that has no recovery: minting a
# replacement token via POST /api/jwt requires a still-VALID access token
# in the Authorization header, so once one expires it cannot be refreshed
# from itself. Observed exactly that on first deploy —
# [webhook] Webhook failed: 400 {"error":"expired JWT access token"}
# [webhook] BYOS auth: refresh failed (400) {"error":"expired JWT ..."}
# — the refresh attempt being rejected by the very expiry it was trying
# to repair.
#
# The renderer's own code assumes "stock Terminus disables session
# expiration and issues effectively permanent tokens". That is simply not
# true of this deployment, so the assumption is made true here rather
# than worked around in the client.
#
# The trade is deliberate: a long-lived JWT with full Terminus API rights
# now sits in OpenBao. Accepted because the alternative was putting the
# Terminus account PASSWORD into the cluster so an init container could
# mint a short-lived token on every pod start, which is a strictly worse
# secret to hold. The token is revocable by rotating the account's
# credentials; it grants access only to this LAN-only instance.
#
# NOTE: the period is fixed at mint time. Changing this does not extend
# tokens that already exist — they must be re-minted afterwards.
API_ACCESS_TOKEN_PERIOD: "315360000"
# The OTHER half of the same problem, and the reason the first fix
# did not hold. Terminus has TWO independent expiries: the JWT's own
# lifetime (above) and the SESSION behind it. Raising only the token
# period left the session at its defaults, so pushes kept working for
# a while and then failed with
# HTTP 401 {"error":"This session has expired, please login again"}
# which is a different error, from a different mechanism, than the
# "expired JWT access token" the first fix addressed.
#
# Documented in doc/configuration.adoc:
# SESSION_EXPIRATION_ENABLED — "Determines if your browser session
# or JWT API token is expired or not." Default: true.
# SESSION_INACTIVITY_LIMIT 1800s, SESSION_LIFETIME_LIMIT 86400s.
#
# Disabling it is exactly the state the renderer's own code assumes
# ("stock Terminus disables session expiration and issues ~100-year
# tokens") — an assumption that is false by default and has to be
# made true here, same as the token period.
#
# Same trade as above, and it is the deciding one for an unattended
# appliance: a session that expires needs a human with a password to
# restore it, and this panel has neither.
SESSION_EXPIRATION_ENABLED: "false"
---
apiVersion: apps/v1
kind: Deployment
@@ -26,6 +84,20 @@ metadata:
namespace: trmnl
labels:
app.kubernetes.io/name: terminus
annotations:
# terminus-config is consumed via envFrom, and env vars are read ONCE at
# container start — Kubernetes does not hot-reload them. The ConfigMap is
# a plain resource, not a configMapGenerator, so editing it does not
# change the pod template hash and Flux will not roll the pods either.
# Without this annotation a ConfigMap-only change (such as raising
# API_ACCESS_TOKEN_PERIOD) applies to git and to the ConfigMap object
# while the running app keeps the old value indefinitely — a silent
# no-op, with the manifest and reality disagreeing.
#
# Named per-ConfigMap rather than the blanket `reloader.stakater.com/
# auto: "true"`, matching mosquitto-deployment.yaml: enrolling anything
# further stays a visible, reviewable edit to this line.
configmap.reloader.stakater.com/reload: terminus-config
spec:
replicas: 1
# RWO PVC shared by both containers in this pod, and APP_SETUP runs
@@ -59,3 +59,22 @@ resources:
- review-bot.yaml
- claude-oauth.yaml
- mosquitto-health.yaml
# NOT ROTATED, deliberately — recorded here because the whole point of this
# directory is that unrotated credentials should be a visible choice:
#
# homelab/apps/trmnl/renderer → BYOS_ACCESS_TOKEN / BYOS_REFRESH_TOKEN
# Terminus API JWTs used by the e-ink renderer to push screens. Terminus
# is configured with API_ACCESS_TOKEN_PERIOD = 10 years
# (kubernetes/apps/trmnl/terminus.yaml), so nothing will ever expire
# these or prompt a rotation. That is deliberate: the alternative was
# holding the Terminus account password in-cluster to mint short-lived
# tokens per pod start. There is no rotator because minting requires the
# account password, which is exactly what we declined to store.
# Rotate BY HAND whenever the Terminus account password changes, by
# re-running POST /login and re-seeding both keys.
#
# HA_ACCESS_TOKEN in the same path is a dedicated Home Assistant
# long-lived token. `ha-token` mode in secret-rotator already handles
# this class (see home-assistant.yaml) — enrolling it is a genuine
# follow-up, not a limitation, and would leave only the BYOS pair
# unrotated.