Compare commits

...
Author SHA1 Message Date
claude-bot 27e9ab1987 feat(hermes): ha-updates holds UniFi and SMLIGHT firmware
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Owner decision: firmware for integrations unifi (switches, APs, UDM) and
smlight (SLZB coordinators) is reported, never installed by the job.
2026-10-20 20:34:34 +02:00
claude-bot 4c5e77301c feat(hermes): ha-updates cron job for Home Assistant updates
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Hermes checks Home Assistant update entities every 4 hours and applies HACS
and device-firmware updates on its own, only inside the 02:00-05:00
Europe/Berlin window (README change rule 1). Owner decision: auto-apply for
these two classes; HA core stays with Flux image automation.

- skill ha-updates (SKILL.md + stdlib scripts/ha.py): list pending updates
  with their integration, health snapshots (core state, safe mode, component
  count, check_config, unavailable entities, masked ERROR log signatures),
  snapshot comparison, install-and-wait, restart-and-wait.
- Per run: vet each update against release notes and the homeassistant repo
  (safe / risky / held). Outside the window report new findings or answer
  [SILENT]. Inside: firmware one device at a time, then HACS, check_config,
  one restart, before/after comparison, roll back HACS items by version on an
  attributable regression. risky updates are never applied.
- Cron job ha-updates created or updated in place by the init container
  cron-ha-updates (state from git, not only on the PVC). Hermes cron runs in
  UTC; 0 1,5,9,13,17,21 puts one run at 02:00 CET / 03:00 CEST.
  Delivery: matrix (home room); failures follow it.
- ExternalSecret hermes-homeassistant: OpenBao homelab/apps/hermes/homeassistant,
  property token, mounted at /var/run/secrets/homeassistant.
- SOUL.md: Home Assistant access, scoped to this skill and the window.

Tested: ha.py against a fake HA REST API (list, snapshot, firmware install
with background progress, unsupported install refused, HACS install,
restart with downtime, regression detection, version rollback, 401). The
config_entry template renders with jinja2. Init script run twice against a
scratch HERMES_HOME with Hermes 0.22.2: one job, edited in place.
kubectl kustomize, yaml-citations and server dry-run pass.

Before merge:
1. Create a Home Assistant admin user for Hermes, a long-lived access token
   for it, and put it at OpenBao homelab/apps/hermes/homeassistant, property
   token. Without it the ExternalSecret stays unready and the Flux wait fails.
2. Give hermes-bot read access to konairius/homeassistant (today: none);
   the skill vets updates against that repo.

Gaps: no live HA test (no token yet). Rollback exists only for entities
that support picking a version (HACS); firmware cannot be rolled back.
2026-10-20 20:25:05 +02:00
security-bot 16e555331b chore(image-automation): bump ghcr.io/immich-app/immich-machine-learning:v3.3.4
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-20 10:43:30 +00:00
k.renner b0f4db7dfc Merge pull request 'fix(matrix): serve turn.matrix.renner.uno from the TCP router, not HTTPS' (#1397) from fix/turn-tcp-router into main
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: #1397
2026-10-20 10:39:54 +00:00
k.renner 6a0992d0cd Merge pull request 'fix(crowdsec): sign the LAPI/agent certs with a CA that outlives them' (#1396) from fix/crowdsec-own-ca into main
Reviewed-on: #1396
2026-10-20 10:39:32 +00:00
security-bot 0d08002137 chore(image-automation): bump ghcr.io/immich-app/immich-server:v3.3.4
Updates managed by image-automation-controller.
2026-10-20 10:39:08 +00:00
hermes-bot f3b8e93c42 fix(matrix): serve turn.matrix.renner.uno from the TCP router, not HTTPS
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
TURN/TLS never reached LiveKit. A TLS connection to
turn.matrix.renner.uno without ALPN, as TURN clients open it, got
LiveKit's HTTP "OK" from the HTTP router; a STUN Binding request on it got
no answer.

Traefik v3.7 (pkg/server/router/tcp/router.go, ServeTCP) matches HTTPS
routers before TCP-TLS routers: an HTTPS router with a specific Host takes
precedence over an IngressRouteTCP with the same HostSNI. The matrix-rtc
Ingress created exactly that HTTPS router for the turn host.

The turn host moves to its own Ingress bound to the `web` entrypoint only,
so netbird-service-sync still publishes it (it reads Ingress hosts) but no
HTTPS router claims the SNI on websecure. Its certificate becomes a
standalone Certificate (livekit-turn-tls), since the Ingress no longer
carries a tls section.
2026-10-20 10:20:02 +00:00
hermes-bot 766a790ee1 fix(crowdsec): sign the LAPI/agent certs with a CA that outlives them
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The LAPI has been in CrashLoopBackOff since the restart #1394 triggered
(10:08Z). Its own watcher login fails:
  tls: failed to verify certificate: x509: certificate has expired or is
  not yet valid: current time 2026-10-20T10:13:44Z is after
  2026-10-20T06:24:37Z
Rolling back to the previous ReplicaSet fails the same way, so any LAPI
restart since this morning would have hit it.

Cause: the chart's CA Certificate sets no duration, so cert-manager's 90d
default applies, the same lifetime as the leaves (tls.certManager.duration
90d, renewBefore 30d). All of them renew together. At 2026-09-20 06:24:37
the leaves were re-signed by the CA issued 2026-07-22 06:24:37 while the
CA itself rotated to a new key. That earlier CA expired today at 06:24:37,
so every leaf now chains to an expired CA. The LAPI pod running since
2026-10-01 kept working with the certs already loaded, until the restart.
The same skew is what made Traefik reject the LAPI cert (500) since
2026-09-20.

Fix, no chart fork: our own CA in tls-ca.yaml (self-signed Issuer, 10y CA
Certificate renewed a year before expiry, CA Issuer), handed to the chart
via tls.certManager.issuerRef. The chart then stops rendering its own
CA/issuers. cert-manager re-issues every leaf because the Secret's issuer
annotation no longer matches the Certificate's issuerRef
(SecretIssuerAnnotationsMismatch). Leaves renew every 60d under a CA that
lives 10y, so the leaves and the CA no longer renew together.

Reloader restarts LAPI (crowdsec-lapi-tls, crowdsec-agent-tls) and agents
(crowdsec-agent-tls) when the certs change; both read them at start only.
The ServersTransport already trusts crowdsec-lapi-tls's ca.crt (#1394), so
it follows the new CA.
2026-10-20 10:15:31 +00:00
k.renner bca807c2c1 Merge pull request 'fix(crowdsec): trust the CA that signed the LAPI cert, not the current root' (#1394) from fix/crowdsec-lapi-transport-ca into main
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: #1394
2026-10-20 10:06:46 +00:00
k.renner a4d60ad689 Merge pull request 'fix(matrix): disable service links for LiveKit and lk-jwt-service' (#1395) from fix/livekit-service-links into main
Reviewed-on: #1395
2026-10-20 10:06:08 +00:00
hermes-bot f96cef137d fix(matrix): disable service links for LiveKit and lk-jwt-service
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
LiveKit crashlooped after #1393:
  could not parse "tcp://10.107.186.157:7880" as uint value from
  environment variable "LIVEKIT_PORT" for flag port

The Service named livekit makes the kubelet inject Docker-style link
variables (LIVEKIT_PORT, LIVEKIT_SERVICE_HOST, ...) into every pod in the
namespace. LiveKit reads LIVEKIT_* as its own config; lk-jwt-service reads
LIVEKIT_* too. enableServiceLinks: false on both pods removes them.
2026-10-20 10:04:28 +00:00
hermes-bot 34a8f3d2f3 fix(crowdsec): trust the CA that signed the LAPI cert, not the current root
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
crowdsec.renner.uno has answered 500 on every path since 2026-09-20.
Traefik logs, every request:
  tls: failed to verify certificate: x509: certificate signed by unknown
  authority (possibly because of "x509: ECDSA verification failure" while
  trying to verify candidate authority certificate "crowdsec-ca")

Cause: the chart's CA (crowdsec-ca, no duration set, so cert-manager's 90d
default) and its leaves (tls.certManager.duration 90d, renewBefore 30d) were
created together and so renew at the same instant. cert-manager logs show
crowdsec-ca, crowdsec-lapi and crowdsec-agent all triggered "Renewing" at
2026-09-20T06:24:37Z. The CA came back with a new ECDSA key, while the
leaves issued in the same second were signed by the previous one. The
ServersTransport trusted crowdsec-root-secret, so it holds the new CA and
rejects the LAPI cert. Same subject, different key, hence "ECDSA
verification failure".

Fix: point the ServersTransport at the ca.crt of crowdsec-lapi-tls. For a
CA issuer, cert-manager stores the signing CA there, so it matches the
served cert whichever key the root holds. Traefik reads ca.crt from a
rootCAs secret (Traefik v3.7 docs).

Reloader also restarts the LAPI when crowdsec-lapi-tls changes. The LAPI
reads its cert at start; without a restart a renewal would update ca.crt
while the old leaf is still served, and the mismatch would come back.

Impact while broken: the edge firewall bouncers on proxy-1/proxy-2 got no
decisions from this endpoint.
2026-10-20 10:04:18 +00:00
security-bot 783bb70a0e chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-20-0933
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-20 09:56:44 +00:00
k.renner de5ca66466 Merge pull request 'feat(matrix): Element Call (audio and video) via LiveKit + lk-jwt-service' (#1393) from feat/matrix-element-call into main
Reviewed-on: #1393
2026-10-20 09:56:13 +00:00
hermes-bot e71bfdeff2 feat(matrix): Element Call (audio and video) via LiveKit + lk-jwt-service
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Element X reported MISSING_MATRIX_RTC_TRANSPORT: matrix.renner.uno had no
MatrixRTC backend (no SFU, /rtc/transports 404, msc4143 off).

Adds the backend from the Element Call self-hosting guide and the
lk-jwt-service README:

- LiveKit SFU (rtc.yaml), signalling at wss://rtc.matrix.renner.uno.
  Media: ICE straight to the pod IPs (UDP 7882 mux, TCP 7881), which the
  LAN reaches through the BGP-routed pod CIDR, dual-stack. Clients that
  cannot reach the pod (mobile data, NetBird-only) use the embedded TURN
  server over TLS on turn.matrix.renner.uno:443. That rides the existing
  public path: NetBird proxy TLS passthrough -> Traefik, where an
  IngressRouteTCP terminates TLS (turn.external_tls) and hands plain TCP to
  LiveKit. The proxy only does TLS on :443, so TURN/TLS is the one transport
  that works from the internet without opening router ports.
- lk-jwt-service as a Synapse appservice (MSC4502 membership look-ups,
  MSC4512 proxy of /rtc/livekit to it), plus its legacy client endpoints
  under rtc.matrix.renner.uno/livekit/jwt: Element Call 0.28 / Element Web
  1.12.32 still use livekit_service_url.
- The legacy endpoints verify OpenID tokens at
  <server>/_matrix/federation/v1/openid/userinfo, found through
  /.well-known/matrix/server (fallback :8448). Synapse therefore serves the
  server well-known and the `openid` listener resource, and the Ingress
  routes exactly those two paths. No federation resource is served and
  federation_domain_whitelist stays empty; federation remains off.
- Synapse: msc4143/4222/4502/4512, matrix_rtc transport (url +
  livekit_service_url), rtc_foci in the client well-known for older
  embedded Element Call builds, delayed events (24h) and the guide's rate
  limits for key sharing and membership heartbeats.
- Secrets: one Password generator (LiveKit API secret, appservice tokens),
  generated once in-cluster like the former matrix-bridge tokens; Synapse,
  LiveKit and lk-jwt-service read the same Secret, so nothing can drift and
  nothing needs seeding in OpenBao.
- CNPs: world -> LiveKit 7881/tcp + 7882/udp and LiveKit -> world UDP
  (ICE checks/media); lk-jwt-service -> Traefik 8443 (matrix.renner.uno and
  rtc.matrix.renner.uno resolve to the Traefik VIP in-cluster).
- embedded TURN denies private peers by default; the relay targets the
  SFU's own pod IP, so 10.0.0.0/8 and fc00::/7 are allowed (LiveKit already
  limits embedded-TURN relays to its own media ports).
- Kuma: DNS + reachability for rtc. and turn.matrix.renner.uno.
2026-10-20 09:49:41 +00:00
k.renner 521c41ba49 Merge pull request 'feat(kuma): monitor every public service and the proxies, defined with their apps' (#1392) from feat/kuma-public-coverage into main
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: #1392
2026-10-20 09:32:55 +00:00
hermes-bot 58b59fbe43 feat(kuma): monitor every public service and the proxies, defined with their apps
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Every host the NetBird proxies serve (exactly the Ingresses labelled
dns.renner.uno/public or netbird.renner.uno/public; verified by SNI probe of
proxy-1 and proxy-2) gets a group with three kinds of check:

- DNS: cluster services have no own record, so the check asserts the
  *.renner.uno wildcard answers (CNAME == proxy.renner.uno, Cloudflare
  resolver, IPv6 first). External names (apex, proxies, third-party) assert
  their own AAAA/A record instead.
- HTTP reachability: a URL routed to the backend, redirects not followed,
  any status below 500 counts.
- Response validation: health/API JSON or a page keyword; services fully
  behind the Authentik gate assert the login page.

The proxies get their own group: proxy.renner.uno, and per instance its
DNS plus reachability and a Gatus health check pinned to that proxy (URL
host is the proxy, Host header status.renner.uno; Node derives SNI from the
Host header, so the proxy routes it like a client request).

Layout: monitors live in the kustomization of the app they monitor;
external ones (proxies, apex, third-party) in apps/monitoring. The CRD moves
to infrastructure/ so every app Kustomization can consume it; it carries
prune: disabled, so leaving kuma-sync's inventory does not delete it. The
old kuma-sync/monitors objects go; their Kuma monitors are recreated under
the new owner keys (history since this morning is lost).

App Kustomizations wait on their resources, and KumaMonitor Ready reflects
whether Uptime Kuma on the VPS is reachable. Each consuming Kustomization
therefore treats KumaMonitor as always current, so a Kuma outage cannot
stall Flux.

kuma-sync: dns monitors gain spec.dns.conditions (Kuma's "record"
variable with its string operators, ANDed), mapped both ways so drift is
detected and an emptied list clears Kuma's conditions. Until the new image
rolls out the controller drops the field and the DNS checks only test
resolvability.
2026-10-20 09:24:09 +00:00
k.renner c4d69fab0f Merge pull request 'feat(hermes): bootstrap Matrix cross-signing for @hermes' (#1390) from feat/hermes-matrix-cross-signing into main
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: #1390
2026-10-20 07:02:25 +00:00
k.renner 63012f0dc6 Merge pull request 'feat(home-assistant): render the Matrix bot password into secrets.yaml' (#1391) from feat/ha-matrix-nabu-secret into main
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: #1391
2026-10-20 06:50:41 +00:00
claude-botandClaude Opus 5.5 d16641661c feat(home-assistant): render the Matrix bot password into secrets.yaml
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Home Assistant's Matrix integration logs in as @nabu:matrix.renner.uno
(MAS account, password login verified) and only takes the password from
YAML, so it reaches HA the same way recorder_db_url does: OpenBao
apps/home-assistant/matrix -> ExternalSecret ha-matrix -> init container
render-matrix-password -> /config/secrets.yaml (matrix_nabu_password).
The config that uses it lands in the homeassistant repo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:47:18 +02:00
claude-botandClaude Opus 5.5 1569faef4b feat(hermes): bootstrap Matrix cross-signing for @hermes
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The bot's device HERMESBOT is not cross-signed, so Element shows it as
unverified. With MATRIX_RECOVERY_KEY_OUTPUT_FILE set, Hermes bootstraps
cross-signing on startup and writes the generated recovery key once (0600).
Step 1 of 2: the key then moves to OpenBao apps/hermes/matrix and a follow-up
PR feeds it back as MATRIX_RECOVERY_KEY and drops the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:35:59 +02:00
k.renner 0911575775 Merge pull request 'feat(hermes): chat with Hermes over Matrix' (#1389) from feat/hermes-matrix into main
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: #1389
2026-10-20 06:21:15 +00:00
claude-botandClaude Opus 5.5 79be70b84e feat(hermes): chat with Hermes over Matrix
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
In the dashboard an agent lives only as long as its browser PTY
connection (close_on_disconnect sessions are reaped on disconnect, upstream
#136713). The messaging gateway that already runs in the pod
(`gateway run`) keeps conversations server-side instead, so Hermes joins
our Matrix homeserver as a bot:

- account @hermes:matrix.renner.uno, registered in MAS without a password
  (token-only), compatibility token on fixed device HERMESBOT in OpenBao
  apps/hermes/matrix -> ExternalSecret hermes-matrix (MATRIX_ACCESS_TOKEN).
  A stable device keeps the E2EE store under /opt/data valid.
- MATRIX_HOMESERVER https://matrix.renner.uno (via Traefik, already in the
  hermes egress), MATRIX_ALLOWED_USERS @k.renner only,
  MATRIX_E2EE_MODE=required (mautrix[encryption] and libolm are in the
  image).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:18:42 +02:00
security-bot dd21d961f3 chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.3.3
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-19 17:43:24 +00:00
security-bot 8d36bbebea chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1792419299
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Updates managed by image-automation-controller.
2026-10-19 14:20:55 +00:00
k.renner b0e3999ef2 Merge pull request 'fix(wiki): the service reads the wiki-index BasicAuth users' (#1388) from fix/wiki-basic-auth-in-service into main
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: #1388
2026-10-19 14:14:03 +00:00
88 changed files with 3463 additions and 385 deletions
@@ -52,6 +52,17 @@ type DNSSpec struct {
// +kubebuilder:validation:Maximum=65535
// +kubebuilder:default=53
Port int32 `json:"port,omitempty"`
// Conditions on the answer, all of which must hold for one record. For
// CNAME the record is the first target, without the trailing dot.
Conditions []DNSCondition `json:"conditions,omitempty"`
}
// DNSCondition tests a DNS record with one of Kuma's string operators.
type DNSCondition struct {
// +kubebuilder:validation:Enum=equals;not_equals;contains;not_contains;starts_with;not_starts_with;ends_with;not_ends_with
Operator string `json:"operator"`
// +kubebuilder:validation:MinLength=1
Value string `json:"value"`
}
type PortSpec struct {
@@ -9,9 +9,29 @@ import (
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DNSCondition) DeepCopyInto(out *DNSCondition) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSCondition.
func (in *DNSCondition) DeepCopy() *DNSCondition {
if in == nil {
return nil
}
out := new(DNSCondition)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DNSSpec) DeepCopyInto(out *DNSSpec) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]DNSCondition, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSSpec.
@@ -176,7 +196,7 @@ func (in *KumaMonitorSpec) DeepCopyInto(out *KumaMonitorSpec) {
if in.DNS != nil {
in, out := &in.DNS, &out.DNS
*out = new(DNSSpec)
**out = **in
(*in).DeepCopyInto(*out)
}
if in.Port != nil {
in, out := &in.Port, &out.Port
@@ -51,6 +51,33 @@ spec:
type: string
dns:
properties:
conditions:
description: |-
Conditions on the answer, all of which must hold for one record. For
CNAME the record is the first target, without the trailing dot.
items:
description: DNSCondition tests a DNS record with one of Kuma's
string operators.
properties:
operator:
enum:
- equals
- not_equals
- contains
- not_contains
- starts_with
- not_starts_with
- ends_with
- not_ends_with
type: string
value:
minLength: 1
type: string
required:
- operator
- value
type: object
type: array
hostname:
minLength: 1
type: string
@@ -11,6 +11,9 @@ import (
// defaultMaxRedirects is Kuma's default for a monitor with no explicit value.
const defaultMaxRedirects = 10
// dnsConditionVariable is the only condition variable of Kuma's dns type.
const dnsConditionVariable = "record"
// OwnerKey is the value of the kuma-sync tag for a resource.
func OwnerKey(km *v1.KumaMonitor) string {
return km.Namespace + "/" + km.Name
@@ -60,6 +63,11 @@ func Desired(km *v1.KumaMonitor) kumaclient.Monitor {
m.DNSResolveType = d.ResolveType
m.DNSResolveServer = d.ResolveServer
m.Port = d.Port
for _, c := range d.Conditions {
m.DNSConditions = append(m.DNSConditions, kumaclient.Condition{
Variable: dnsConditionVariable, Operator: c.Operator, Value: c.Value, AndOr: "and",
})
}
}
if p := s.Port; p != nil {
m.Hostname = p.Hostname
@@ -50,6 +50,9 @@ func TestMappingAllTypes(t *testing.T) {
jq.JSONQuery = &v1.JSONQuerySpec{Expression: "a.b", Operator: "contains", Expected: "z"}
dns := base("dns")
dns.DNS = &v1.DNSSpec{Hostname: "h.com", ResolveType: "AAAA", ResolveServer: "9.9.9.9", Port: 5353}
dnsCond := base("dns")
dnsCond.DNS = &v1.DNSSpec{Hostname: "c.com", ResolveType: "CNAME", ResolveServer: "9.9.9.9", Port: 53,
Conditions: []v1.DNSCondition{{Operator: "equals", Value: "t.com"}, {Operator: "not_contains", Value: "x"}}}
port := base("port")
port.Port = &v1.PortSpec{Hostname: "p.com", Port: 443}
paused := base("group")
@@ -61,6 +64,12 @@ func TestMappingAllTypes(t *testing.T) {
wantJQ.JSONPath, wantJQ.JSONPathOperator, wantJQ.ExpectedValue = "a.b", "contains", "z"
wantDNS := common("dns")
wantDNS.Hostname, wantDNS.DNSResolveType, wantDNS.DNSResolveServer, wantDNS.Port = "h.com", "AAAA", "9.9.9.9", 5353
wantDNSCond := common("dns")
wantDNSCond.Hostname, wantDNSCond.DNSResolveType, wantDNSCond.DNSResolveServer, wantDNSCond.Port = "c.com", "CNAME", "9.9.9.9", 53
wantDNSCond.DNSConditions = []kumaclient.Condition{
{Variable: "record", Operator: "equals", Value: "t.com", AndOr: "and"},
{Variable: "record", Operator: "not_contains", Value: "x", AndOr: "and"},
}
wantPort := common("port")
wantPort.Hostname, wantPort.Port = "p.com", 443
wantPaused := common("group")
@@ -76,6 +85,7 @@ func TestMappingAllTypes(t *testing.T) {
{"keyword", kw, wantKW},
{"json-query", jq, wantJQ},
{"dns", dns, wantDNS},
{"dns with conditions", dnsCond, wantDNSCond},
{"port", port, wantPort},
{"paused maps to inactive", paused, wantPaused},
}
@@ -484,6 +484,11 @@ func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
m.Port = int32(v.Port)
m.DNSResolveType = string(v.ResolveType)
m.DNSResolveServer = v.ResolverServer
for _, c := range v.Conditions {
m.DNSConditions = append(m.DNSConditions, Condition{
Variable: c.Variable, Operator: c.Operator, Value: c.Value, AndOr: string(c.AndOr),
})
}
case "port":
var v monitor.TCPPort
if err := base.As(&v); err != nil {
@@ -566,6 +571,12 @@ func toWire(m Monitor, existing *monitor.Base) (monitor.Monitor, error) {
v.Port = int(m.Port)
v.ResolveType = monitor.DNSResolveType(m.DNSResolveType)
v.ResolverServer = m.DNSResolveServer
v.Conditions = nil
for _, c := range m.DNSConditions {
v.Conditions = append(v.Conditions, monitor.Condition{
Variable: c.Variable, Operator: c.Operator, Value: c.Value, AndOr: monitor.ConditionOperator(c.AndOr),
})
}
return v, nil
case "port":
v := &monitor.TCPPort{}
@@ -160,6 +160,7 @@ func TestCreateFieldsIntact(t *testing.T) {
Type: "dns", Name: "dns", Description: "dns d", Active: true,
Interval: 90, RetryInterval: 45, MaxRetries: 1, Parent: &gid, OwnerKey: "default/dns",
Hostname: "example.com", DNSResolveType: "AAAA", DNSResolveServer: "1.1.1.1", Port: 53,
DNSConditions: []Condition{{Variable: "record", Operator: "contains", Value: ":", AndOr: "and"}},
}
port := Monitor{
Type: "port", Name: "port", Active: false,
@@ -64,6 +64,7 @@ func (m Monitor) clone() Monitor {
m.Headers = maps.Clone(m.Headers)
m.AcceptedStatusCodes = slices.Clone(m.AcceptedStatusCodes)
m.NotificationIDs = slices.Clone(m.NotificationIDs)
m.DNSConditions = slices.Clone(m.DNSConditions)
if m.Parent != nil {
p := *m.Parent
m.Parent = &p
@@ -96,6 +97,9 @@ func (m Monitor) Normalized() Monitor {
if len(m.AcceptedStatusCodes) == 0 {
m.AcceptedStatusCodes = nil
}
if len(m.DNSConditions) == 0 {
m.DNSConditions = nil
}
if !isHTTPFamily(m.Type) {
m.Timeout = 0
return m
@@ -112,6 +116,15 @@ func (m Monitor) Normalized() Monitor {
return m
}
// Condition is one flat Kuma monitor condition.
type Condition struct {
Variable string
Operator string
Value string
// AndOr joins the condition to the previous one: "and" or "or".
AndOr string
}
// Monitor is the domain view of a Kuma monitor.
type Monitor struct {
ID int64
@@ -149,6 +162,7 @@ type Monitor struct {
Port int32
DNSResolveType string
DNSResolveServer string
DNSConditions []Condition
// NotificationIDs are the Kuma notifications attached to the monitor.
NotificationIDs []int64
@@ -0,0 +1,78 @@
package kumaclient
import (
"encoding/json"
"reflect"
"testing"
"github.com/breml/go-uptime-kuma-client/monitor"
)
// The wire form must carry DNS conditions to Kuma and read them back unchanged.
func TestDNSConditionsRoundTrip(t *testing.T) {
m := Monitor{
ID: 7, Type: "dns", Name: "d", Active: true, Interval: 60, RetryInterval: 60,
Hostname: "a.example", DNSResolveType: "CNAME", DNSResolveServer: "1.1.1.1", Port: 53,
DNSConditions: []Condition{{Variable: "record", Operator: "equals", Value: "t.example", AndOr: "and"}},
OwnerKey: "ns/d",
}
w, err := toWire(m, nil)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(w)
if err != nil {
t.Fatal(err)
}
var wire struct {
Conditions []map[string]string `json:"conditions"`
}
if err := json.Unmarshal(raw, &wire); err != nil {
t.Fatal(err)
}
wantWire := []map[string]string{{"type": "expression", "variable": "record", "operator": "equals", "value": "t.example", "andOr": "and"}}
if !reflect.DeepEqual(wire.Conditions, wantWire) {
t.Fatalf("wire conditions: got %v want %v", wire.Conditions, wantWire)
}
var base monitor.Base
if err := json.Unmarshal(raw, &base); err != nil {
t.Fatal(err)
}
got, err := toDomain(base, "ns/d")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got.DNSConditions, m.DNSConditions) {
t.Fatalf("read back: got %+v want %+v", got.DNSConditions, m.DNSConditions)
}
}
// Removing every condition must clear them in Kuma, not keep the stored ones.
func TestDNSConditionsClearedOnUpdate(t *testing.T) {
stored := []byte(`{"id":7,"type":"dns","name":"d","hostname":"a.example","dns_resolve_type":"CNAME",` +
`"dns_resolve_server":"1.1.1.1","port":53,"conditions":[{"type":"expression","variable":"record","operator":"equals","value":"x","andOr":"and"}]}`)
var existing monitor.Base
if err := json.Unmarshal(stored, &existing); err != nil {
t.Fatal(err)
}
m := Monitor{ID: 7, Type: "dns", Name: "d", Hostname: "a.example", DNSResolveType: "CNAME", DNSResolveServer: "1.1.1.1", Port: 53}
w, err := toWire(m, &existing)
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(w)
var wire struct {
Conditions []any `json:"conditions"`
}
_ = json.Unmarshal(raw, &wire)
if wire.Conditions == nil || len(wire.Conditions) != 0 {
t.Fatalf("conditions not cleared: %s", raw)
}
}
func TestNormalizedDNSConditions(t *testing.T) {
m := Monitor{Type: "dns", DNSConditions: []Condition{}}.Normalized()
if m.DNSConditions != nil {
t.Errorf("empty conditions must normalize to nil")
}
}
@@ -0,0 +1,96 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: audiobooks
namespace: audiobookshelf
spec:
type: group
displayName: Audiobooks
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: audiobooks-dns
namespace: audiobookshelf
spec:
type: dns
displayName: Audiobookshelf DNS
parentRef:
name: audiobooks
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: audiobooks.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: audiobooks-http
namespace: audiobookshelf
spec:
type: http
displayName: Audiobookshelf HTTP
parentRef:
name: audiobooks
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://audiobooks.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: audiobooks-health
namespace: audiobookshelf
spec:
type: keyword
displayName: Audiobookshelf Health
parentRef:
name: audiobooks
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
url: https://audiobooks.renner.uno/healthcheck
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: OK
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: audiobooks-status
namespace: audiobookshelf
spec:
type: json-query
displayName: Audiobookshelf Server
parentRef:
name: audiobooks
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# initialized-server flag from the unauthenticated status endpoint
url: https://audiobooks.renner.uno/status
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: isInit
operator: "=="
expected: "true"
@@ -4,3 +4,4 @@ resources:
- pvcs.yaml
- deployment.yaml
- ingress.yaml
- kumamonitors.yaml
@@ -0,0 +1,91 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: auth
namespace: authentik
spec:
type: group
displayName: Authentik
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: auth-dns
namespace: authentik
spec:
type: dns
displayName: Authentik DNS
parentRef:
name: auth
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: auth.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: auth-http
namespace: authentik
spec:
type: http
displayName: Authentik HTTP
parentRef:
name: auth
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://auth.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: auth-live
namespace: authentik
spec:
type: http
displayName: Authentik Live
parentRef:
name: auth
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# ungated health endpoint, empty body
url: https://auth.renner.uno/-/health/live/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: auth-ready
namespace: authentik
spec:
type: http
displayName: Authentik Ready
parentRef:
name: auth
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# ungated readiness endpoint (database and cache), empty body
url: https://auth.renner.uno/-/health/ready/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
@@ -17,3 +17,4 @@ resources:
- cluster.yaml
- scheduled-backup.yaml
- hetzner-creds-externalsecret.yaml
- kumamonitors.yaml
+74
View File
@@ -0,0 +1,74 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: status
namespace: apps
spec:
type: group
displayName: Status Page
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: status-dns
namespace: apps
spec:
type: dns
displayName: Gatus DNS
parentRef:
name: status
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: status.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: status-http
namespace: apps
spec:
type: http
displayName: Gatus HTTP
parentRef:
name: status
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://status.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: status-health
namespace: apps
spec:
type: json-query
displayName: Gatus Health
parentRef:
name: status
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
url: https://status.renner.uno/health
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: status
operator: "=="
expected: "UP"
+1
View File
@@ -6,3 +6,4 @@ resources:
- service.yaml
- ingress.yaml
- pvc.yaml
- kumamonitors.yaml
@@ -0,0 +1,73 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: gitea-mcp
namespace: gitea-mcp
spec:
type: group
displayName: Gitea MCP
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: gitea-mcp-dns
namespace: gitea-mcp
spec:
type: dns
displayName: Gitea MCP DNS
parentRef:
name: gitea-mcp
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: gitea-mcp.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: gitea-mcp-http
namespace: gitea-mcp
spec:
type: http
displayName: Gitea MCP HTTP
parentRef:
name: gitea-mcp
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://gitea-mcp.renner.uno/healthz
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: gitea-mcp-auth
namespace: gitea-mcp
spec:
type: keyword
displayName: Gitea MCP Endpoint
parentRef:
name: gitea-mcp
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# MCP endpoint answers and refuses unauthenticated calls
url: https://gitea-mcp.renner.uno/mcp
acceptedStatusCodes: ["401"]
expiryNotification: true
keyword:
keyword: authorization required
@@ -4,3 +4,4 @@ resources:
- deployment.yaml
- service.yaml
- ingress.yaml
- kumamonitors.yaml
+142
View File
@@ -0,0 +1,142 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: gitea
namespace: gitea
spec:
type: group
displayName: Gitea
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: git-dns
namespace: gitea
spec:
type: dns
displayName: git.renner.uno DNS
parentRef:
name: gitea
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: git.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: git-http
namespace: gitea
spec:
type: http
displayName: git.renner.uno HTTP
parentRef:
name: gitea
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://git.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: git-health
namespace: gitea
spec:
type: json-query
displayName: git.renner.uno Health
parentRef:
name: gitea
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# ungated health endpoint (database and cache)
url: https://git.renner.uno/api/healthz
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: status
operator: "=="
expected: "pass"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: gitea-dns
namespace: gitea
spec:
type: dns
displayName: gitea.renner.uno DNS
parentRef:
name: gitea
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: gitea.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: gitea-http
namespace: gitea
spec:
type: http
displayName: gitea.renner.uno HTTP
parentRef:
name: gitea
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://gitea.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: gitea-health
namespace: gitea
spec:
type: json-query
displayName: gitea.renner.uno Health
parentRef:
name: gitea
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# ungated health endpoint (database and cache)
url: https://gitea.renner.uno/api/healthz
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: status
operator: "=="
expected: "pass"
+1
View File
@@ -16,6 +16,7 @@ resources:
- networkpolicy.yaml
# Nightly prune of old CI image builds (REN-1203).
- package-retention.yaml
- kumamonitors.yaml
configMapGenerator:
- name: gitea-package-retention
namespace: gitea
+1
View File
@@ -9,6 +9,7 @@ When you use a pronoun for someone whose pronouns haven't been stated, use they/
- `bao`: OpenBao at `BAO_ADDR`, read-only on your own `homelab/apps/hermes/*`. Log in per command, never store the token: `export VAULT_TOKEN=$(bao write -field=token auth/kubernetes/login role=hermes jwt=@/var/run/secrets/kubernetes.io/serviceaccount/token)` then e.g. `bao kv list -mount=homelab apps/hermes`. Never print secret values.
- Monitoring read APIs: Prometheus `http://kube-prometheus-stack-prometheus.monitoring.svc:9090`, Alertmanager `http://kube-prometheus-stack-alertmanager.monitoring.svc:9093`, Loki `http://loki.monitoring.svc:3100`.
- The homelab wiki through the `homelab-wiki` skill (read, and deposit facts).
- Home Assistant (`https://home.renner.uno`) as your own admin user, only through the `ha-updates` skill: read state and health, install HACS and device-firmware updates and restart HA, inside 02:00-05:00 Europe/Berlin only (owner decision). No other HA changes.
- The web (HTTPS) and LAN hostnames under `renner.uno` through Traefik.
- Gitea (`https://gitea.renner.uno`) as `hermes-bot`: `git` over HTTPS authenticates and sets the commit identity on its own. For the API, send `Authorization: token <contents of /var/run/secrets/gitea/api-token>` (read it inside the command, never print it). `tea` is logged in as `hermes-bot` (login `hermes-bot`, the default). You write only to repos where `hermes-bot` is a collaborator.
+64
View File
@@ -119,6 +119,45 @@ spec:
volumeMounts:
- name: data
mountPath: /opt/data
# Cron job ha-updates (skill ha-updates), created or updated in place on
# every start. Hermes cron runs in UTC: 01:00/05:00/... UTC puts one run
# at 02:00 CET / 03:00 CEST, inside the 02:00-05:00 Berlin window.
- name: cron-ha-updates
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
command:
- sh
- -ec
- |
H=/opt/hermes/.venv/bin/hermes
PROMPT='Run the ha-updates skill: check Home Assistant updates, apply the safe ones only inside the 02:00-05:00 Europe/Berlin window, report per the skill, or answer [SILENT].'
id=$(/opt/hermes/.venv/bin/python -c '
import json
try:
jobs = json.load(open("/opt/data/cron/jobs.json"))["jobs"]
except FileNotFoundError:
jobs = []
print(next((j["id"] for j in jobs if j.get("name") == "ha-updates"), ""))')
set -- --deliver matrix --skill ha-updates --skill homelab-wiki --continuity
if [ -n "$id" ]; then
"$H" cron edit "$id" --schedule '0 1,5,9,13,17,21 * * *' --prompt "$PROMPT" "$@"
else
"$H" cron create --name ha-updates "$@" '0 1,5,9,13,17,21 * * *' "$PROMPT"
fi
env:
- name: HERMES_HOME
value: /opt/data
- name: HOME
value: /opt/data
securityContext:
runAsUser: 10000
runAsGroup: 10000
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /opt/data
# tea login hermes-bot, written from the mounted token into an
# emptyDir at the agent's ~/.config/tea.
- name: tea-login
@@ -171,6 +210,19 @@ spec:
value: "true"
- name: API_SERVER_HOST
value: 0.0.0.0
# Matrix: bot @hermes (MAS account, compatibility token on device
# HERMESBOT in hermes-matrix); E2EE store under /opt/data/platforms/matrix.
- name: MATRIX_HOMESERVER
value: https://matrix.renner.uno
- name: MATRIX_USER_ID
value: "@hermes:matrix.renner.uno"
- name: MATRIX_ALLOWED_USERS
value: "@k.renner:matrix.renner.uno"
- name: MATRIX_E2EE_MODE
value: required
# One-time cross-signing bootstrap writes the generated recovery key here (0600).
- name: MATRIX_RECOVERY_KEY_OUTPUT_FILE
value: /opt/data/platforms/matrix/recovery-key.txt
# bao: log in with the pod's ServiceAccount token, role hermes
# (kubernetes/infrastructure/openbao/policies/hermes.hcl).
- name: BAO_ADDR
@@ -197,6 +249,8 @@ spec:
envFrom:
- secretRef:
name: hermes-api-key
- secretRef:
name: hermes-matrix
ports:
- name: dashboard
containerPort: 9119
@@ -249,6 +303,9 @@ spec:
- name: talos-config
mountPath: /var/run/secrets/talos.dev
readOnly: true
- name: homeassistant-token
mountPath: /var/run/secrets/homeassistant
readOnly: true
volumes:
- name: data
persistentVolumeClaim:
@@ -264,6 +321,10 @@ spec:
path: homelab-wiki/SKILL.md
- key: homelab-wiki-wiki.py
path: homelab-wiki/scripts/wiki.py
- key: ha-updates-SKILL.md
path: ha-updates/SKILL.md
- key: ha-updates-ha.py
path: ha-updates/scripts/ha.py
- name: wiki-key
secret:
secretName: hermes-wiki
@@ -277,6 +338,9 @@ spec:
secret:
secretName: hermes-talos
optional: true
- name: homeassistant-token
secret:
secretName: hermes-homeassistant
---
apiVersion: v1
kind: Service
@@ -0,0 +1,18 @@
# Long-lived access token of Hermes's own Home Assistant admin user (ha-updates skill).
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hermes-homeassistant
namespace: hermes
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: hermes-homeassistant
data:
- secretKey: token
remoteRef:
key: apps/hermes/homeassistant
property: token
@@ -5,6 +5,8 @@ resources:
- api-key.yaml
- wiki-externalsecret.yaml
- gitea-externalsecret.yaml
- matrix-externalsecret.yaml
- homeassistant-externalsecret.yaml
- talos-serviceaccount.yaml
- hermes.yaml
- ingress.yaml
@@ -18,3 +20,5 @@ configMapGenerator:
files:
- homelab-wiki-SKILL.md=skills/homelab-wiki/SKILL.md
- homelab-wiki-wiki.py=skills/homelab-wiki/scripts/wiki.py
- ha-updates-SKILL.md=skills/ha-updates/SKILL.md
- ha-updates-ha.py=skills/ha-updates/scripts/ha.py
@@ -0,0 +1,18 @@
# Access token of @hermes:matrix.renner.uno (MAS compatibility token, device HERMESBOT).
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hermes-matrix
namespace: hermes
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: hermes-matrix
data:
- secretKey: MATRIX_ACCESS_TOKEN
remoteRef:
key: apps/hermes/matrix
property: access_token
@@ -0,0 +1,113 @@
---
name: ha-updates
description: Check Home Assistant update entities (HACS integrations/cards, device firmware), vet each update against release notes and our config, apply the safe ones inside the 02:00-05:00 Europe/Berlin window, verify HA health before/after and roll back regressions. Used by the ha-updates cron job; reports to Matrix.
version: 1.0.0
metadata:
hermes:
tags: [homelab, home-assistant, updates, cron]
category: devops
---
# Home Assistant updates
Owner decision: HACS updates and device firmware that Home Assistant offers as
`update.*` entities are applied automatically, inside 02:00-05:00
Europe/Berlin only (README "Change rules that protect Home Assistant"). Outside
the window this job only checks and reports. Matrix gets a message when
something was applied or a problem was found; otherwise the run is silent.
Out of scope, never install: Home Assistant core (Flux image automation owns
it), anything with `can_install: false`, and updates the owner skipped.
Held by owner decision (report once per version as `held: owner`, never
install): integration `unifi` (switches, APs, the UDM) and integration
`smlight` (SLZB Zigbee coordinators).
Talk to HA only through the client (stdlib, reads the token itself, never print it):
```
python3 /opt/hermes-skills/ha-updates/scripts/ha.py window # in_window, minutes_left
python3 /opt/hermes-skills/ha-updates/scripts/ha.py updates # pending update entities
python3 /opt/hermes-skills/ha-updates/scripts/ha.py snapshot <name> # health -> /opt/data/ha-updates/<name>.json
python3 /opt/hermes-skills/ha-updates/scripts/ha.py compare <before> <after> # exit 1 + problems on regression
python3 /opt/hermes-skills/ha-updates/scripts/ha.py install <entity_id> [--version V] # waits until settled
python3 /opt/hermes-skills/ha-updates/scripts/ha.py restart # restart, wait for RUNNING
```
A snapshot holds: reachable/core state, version, safe mode, loaded component
count, `check_config` result, unavailable entity ids, and masked ERROR/CRITICAL
log signatures of the current HA session.
## Every run
1. `window`, then `updates`. HA unreachable or HTTP 401/403 is a problem:
report it and stop.
2. Vet each pending update once per target version (your previous run's output
is in the prompt; don't redo a verdict it already gave):
- Ask the wiki (`homelab-wiki`) whether another tool owns that fleet's
firmware or that component (e.g. shelly-manager for Shelly). If it does,
verdict `held: owned by <tool>`.
- Read the release notes for every version between `installed` and
`latest` (`release_url`, `release_summary`; for HACS the GitHub releases
of the repository). Look for breaking changes, removed/renamed options or
entities, a minimum HA version above ours (`snapshot` → `version`),
required migrations or re-pairing.
- Check the breaking items against our config: read-only clone of
`https://gitea.renner.uno/konairius/homeassistant` (git authenticates as
hermes-bot). Grep for the integration, its options and entity ids.
- Verdict: `safe`, `risky: <why>` (needs the owner; never applied by this
job), or `held: <why>`.
3. Outside the window: if there is no problem and no pending update that your
previous output did not already report, answer exactly `[SILENT]`.
Otherwise report the new updates with verdicts and say that `safe` ones go
in at the next window.
4. Inside the window: apply (below), then report.
## Applying (inside the window only)
Re-check `window` before every install; start no install with
`minutes_left` < 30 and no HA restart with `minutes_left` < 20. Leftovers wait
for the next window.
1. `snapshot pre`. If HA is not RUNNING, is in safe mode, or `check_config` is
not `valid`, apply nothing and report the problem.
2. Firmware (`safe` only), one device at a time:
`install <entity>`, wait ~2 min, `snapshot fw`, `compare pre fw`. A
regression whose newly unavailable entities or new errors belong to that
device or integration stops further firmware installs for that
integration; report it (firmware cannot be rolled back). A `timeout` on a
battery Zigbee device is not an HA problem: report it, move on.
3. HACS (`safe` only): note each item's `installed` version, `install` each,
then `snapshot pre-restart`. If `check_config` is no longer `valid`, roll
every item back (`install <entity> --version <old>`) and do not restart.
Otherwise `restart`, wait 3 minutes for integrations to settle,
`snapshot post`, `compare pre post`.
4. On a regression after the restart, wait 5 more minutes and compare a fresh
snapshot (startup noise clears). Still regressed: attribute it by logger
(`custom_components.<name>`) and entity ids, roll the responsible item(s)
back with `--version <old>`, `restart`, and compare again. Not attributable:
do not roll back blindly; report with the evidence.
5. Deposit a wiki fact for any verified breakage (which version broke what, how
it was rolled back).
## Report (Matrix)
Terse, no preamble:
```
HA updates <date> <applied in window | check>
Applied: <title> <old> → <new>, ...
Rolled back: <title> <new> → <old> (<reason>)
Problems: <what, evidence>
Waiting for owner: <title> <latest> (<risk>)
Held: <title> (<why>)
```
Leave out empty sections. Nothing applied and no problems: `[SILENT]`.
## Never
- install or restart outside the window, or install HA core;
- apply a `risky` update, or skip (`update.skip`) one without the owner;
- change git or the cluster: this job only drives HA's update entities;
- print the token, or paste secrets from logs into Matrix.
@@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""Home Assistant client for the ha-updates skill (REST API, bearer token).
ha.py window is now inside 02:00-05:00 Europe/Berlin?
ha.py updates [--all] update entities (pending only unless --all)
ha.py snapshot <name> health snapshot -> $HA_STATE_DIR/<name>.json
ha.py compare <before> <after> regressions between two snapshots (exit 1 if any)
ha.py install <entity_id> [--version V] [--timeout S]
install and wait until the entity settles
ha.py restart [--timeout S] restart HA and wait until it is RUNNING again
"""
import argparse
import datetime
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from zoneinfo import ZoneInfo
URL = os.environ.get("HA_URL", "https://home.renner.uno").rstrip("/")
TOKEN_FILE = os.environ.get("HA_TOKEN_FILE", "/var/run/secrets/homeassistant/token")
STATE_DIR = os.environ.get("HA_STATE_DIR", "/opt/data/ha-updates")
BERLIN = ZoneInfo("Europe/Berlin")
WINDOW = (2, 5) # hours, Europe/Berlin, start inclusive, end exclusive
# update entity supported_features bits (homeassistant.components.update.UpdateEntityFeature)
INSTALL, SPECIFIC_VERSION, PROGRESS, BACKUP, RELEASE_NOTES = 1, 2, 4, 8, 16
def token():
try:
return open(TOKEN_FILE).read().strip()
except OSError as e:
sys.exit(f"ha: cannot read token file {TOKEN_FILE}: {e.strerror}")
def call(method, path, body=None, timeout=60, raw=False):
headers = {"Authorization": "Bearer " + token(), "Accept": "application/json"}
data = None
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(URL + path, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
text = resp.read().decode(errors="replace")
except urllib.error.HTTPError as e:
sys.exit(f"ha: {method} {path}: HTTP {e.code}\n{e.read().decode(errors='replace')[:2000]}")
return text if raw else json.loads(text)
def reachable():
"""(True, core_state) when the API answers, (False, reason) otherwise."""
try:
return True, call("GET", "/api/core/state", timeout=10).get("state")
except SystemExit as e:
return False, str(e)
except (urllib.error.URLError, OSError, ValueError) as e:
return False, str(e)
def domains():
"""entity_id -> integration domain for every update entity."""
tpl = (
"[{% for s in states.update %}{% set e = config_entry_id(s.entity_id) %}"
"[{{ s.entity_id | tojson }}, {{ (config_entry_attr(e, 'domain') if e else none) | tojson }}]"
"{% if not loop.last %},{% endif %}{% endfor %}]"
)
return dict(json.loads(call("POST", "/api/template", {"template": tpl}, raw=True)))
def describe(s, dom):
a = s["attributes"]
f = a.get("supported_features") or 0
return {
"entity_id": s["entity_id"],
"title": a.get("title") or a.get("friendly_name"),
"integration": dom.get(s["entity_id"]),
"state": s["state"],
"installed": a.get("installed_version"),
"latest": a.get("latest_version"),
"skipped": a.get("skipped_version"),
"in_progress": a.get("in_progress"),
"can_install": bool(f & INSTALL),
"can_pick_version": bool(f & SPECIFIC_VERSION),
"release_url": a.get("release_url"),
"release_summary": (a.get("release_summary") or "")[:500] or None,
}
def cmd_window(_):
now = datetime.datetime.now(BERLIN)
start = now.replace(hour=WINDOW[0], minute=0, second=0, microsecond=0)
end = now.replace(hour=WINDOW[1], minute=0, second=0, microsecond=0)
inside = start <= now < end
print(json.dumps({
"now_berlin": now.isoformat(timespec="minutes"),
"in_window": inside,
"minutes_left": int((end - now).total_seconds() // 60) if inside else 0,
}))
def cmd_updates(a):
dom = domains()
out = []
for s in call("GET", "/api/states"):
if not s["entity_id"].startswith("update."):
continue
d = describe(s, dom)
if a.all or d["state"] == "on" or d["in_progress"]:
out.append(d)
out.sort(key=lambda d: (d["integration"] or "", d["entity_id"]))
print(json.dumps(out, indent=2, ensure_ascii=False))
LOG_LINE = re.compile(r"^\S+ \S+ (ERROR|CRITICAL) \([^)]*\) \[([^\]]+)\] (.*)$")
def error_signatures(log):
"""Error/critical log lines with volatile parts (numbers, hex, quoted values) masked."""
sigs = set()
for line in log.splitlines():
m = LOG_LINE.match(line)
if m:
msg = re.sub(r"0x[0-9a-f]+|\d+(\.\d+)?|'[^']*'|\"[^\"]*\"", "#", m.group(3))[:300]
sigs.add(f"{m.group(1)} [{m.group(2)}] {msg}")
return sorted(sigs)
def snapshot():
ok, state = reachable()
snap = {"taken": datetime.datetime.now(BERLIN).isoformat(timespec="seconds"), "reachable": ok, "core_state": state}
if not ok:
return snap
cfg = call("GET", "/api/config")
snap["version"] = cfg.get("version")
snap["safe_mode"] = bool(cfg.get("safe_mode") or cfg.get("recovery_mode"))
snap["components"] = len(cfg.get("components") or [])
chk = call("POST", "/api/config/core/check_config", timeout=180)
snap["config_check"] = {"result": chk.get("result"), "errors": chk.get("errors")}
states = call("GET", "/api/states")
snap["unavailable"] = sorted(s["entity_id"] for s in states if s["state"] == "unavailable")
snap["errors"] = error_signatures(call("GET", "/api/error_log", raw=True, timeout=60))
return snap
def cmd_snapshot(a):
os.makedirs(STATE_DIR, exist_ok=True)
snap = snapshot()
with open(os.path.join(STATE_DIR, a.name + ".json"), "w") as f:
json.dump(snap, f, indent=2)
summary = {k: v for k, v in snap.items() if k not in ("unavailable", "errors")}
summary["unavailable_count"] = len(snap.get("unavailable", []))
summary["error_signatures"] = len(snap.get("errors", []))
print(json.dumps(summary, indent=2))
def load(name):
with open(os.path.join(STATE_DIR, name + ".json")) as f:
return json.load(f)
def cmd_compare(a):
b, c = load(a.before), load(a.after)
problems = []
if not c.get("reachable") or c.get("core_state") != "RUNNING":
problems.append(f"HA not running: reachable={c.get('reachable')} core_state={c.get('core_state')}")
if c.get("safe_mode") and not b.get("safe_mode"):
problems.append("HA came up in safe/recovery mode")
if (c.get("config_check") or {}).get("result") != "valid" and (b.get("config_check") or {}).get("result") == "valid":
problems.append(f"config check now invalid: {(c.get('config_check') or {}).get('errors')}")
if b.get("components") and c.get("components", 0) < b["components"]:
problems.append(f"loaded components dropped {b['components']} -> {c.get('components')}")
new_unavail = sorted(set(c.get("unavailable", [])) - set(b.get("unavailable", [])))
recovered = sorted(set(b.get("unavailable", [])) - set(c.get("unavailable", [])))
new_errors = sorted(set(c.get("errors", [])) - set(b.get("errors", [])))
if new_unavail:
problems.append(f"{len(new_unavail)} entities newly unavailable")
if new_errors:
problems.append(f"{len(new_errors)} new error signatures in the HA log")
print(json.dumps({
"problems": problems,
"newly_unavailable": new_unavail,
"recovered": recovered,
"new_errors": new_errors,
"version": [b.get("version"), c.get("version")],
}, indent=2, ensure_ascii=False))
sys.exit(1 if problems else 0)
def entity(entity_id):
return call("GET", "/api/states/" + entity_id)
def cmd_install(a):
before = entity(a.entity_id)
attrs = before["attributes"]
if not (attrs.get("supported_features") or 0) & INSTALL:
sys.exit(f"ha: {a.entity_id} does not support install")
body = {"entity_id": a.entity_id}
if a.version:
body["version"] = a.version
target = a.version or attrs.get("latest_version")
started = time.time()
try:
# The action call returns once the install finished; long OTA runs outlive it.
call("POST", "/api/services/update/install", body, timeout=a.timeout)
except (urllib.error.URLError, OSError) as e:
print(f"ha: install call returned early ({e}); polling the entity", file=sys.stderr)
idle = 0
while True:
s = entity(a.entity_id)["attributes"]
if not s.get("in_progress") and s.get("installed_version") == target:
result = "installed"
break
if time.time() - started > a.timeout:
result = "timeout"
break
idle = 0 if s.get("in_progress") else idle + 1
if idle >= 8:
result = "failed" # idle for 2 minutes and not on the target version
break
time.sleep(15)
print(json.dumps({
"entity_id": a.entity_id,
"result": result,
"from": attrs.get("installed_version"),
"target": target,
"now": s.get("installed_version"),
"seconds": int(time.time() - started),
}))
sys.exit(0 if result == "installed" else 1)
def cmd_restart(a):
try:
call("POST", "/api/services/homeassistant/restart", {}, timeout=30)
except (urllib.error.URLError, OSError):
pass # the connection may drop as HA goes down
started, went_down, state = time.time(), False, None
while time.time() - started < a.timeout:
ok, state = reachable()
if not ok or state != "RUNNING":
went_down = True
elif went_down or time.time() - started > 120:
# RUNNING again (or the restart was too fast to observe)
print(json.dumps({"result": "running", "observed_down": went_down, "seconds": int(time.time() - started)}))
return
time.sleep(5)
print(json.dumps({"result": "timeout", "observed_down": went_down, "last_state": state}))
sys.exit(1)
def main():
p = argparse.ArgumentParser(prog="ha.py")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("window")
u = sub.add_parser("updates")
u.add_argument("--all", action="store_true")
s = sub.add_parser("snapshot")
s.add_argument("name")
c = sub.add_parser("compare")
c.add_argument("before")
c.add_argument("after")
i = sub.add_parser("install")
i.add_argument("entity_id")
i.add_argument("--version")
i.add_argument("--timeout", type=int, default=3600)
r = sub.add_parser("restart")
r.add_argument("--timeout", type=int, default=900)
a = p.parse_args()
{
"window": cmd_window,
"updates": cmd_updates,
"snapshot": cmd_snapshot,
"compare": cmd_compare,
"install": cmd_install,
"restart": cmd_restart,
}[a.cmd](a)
if __name__ == "__main__":
main()
@@ -93,6 +93,39 @@ spec:
memory: 16Mi
limits:
memory: 32Mi
# `matrix_nabu_password` for the Matrix integration's bot @nabu
# (homeassistant repo packages/matrix_assist.yaml), same filter-then-append
# render as recorder_db_url above. Value is hex, nothing to escape.
- name: render-matrix-password
image: busybox:1.38@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
command:
- sh
- -c
- |
set -eu
S=/config/secrets.yaml
T=/config/.secrets.render.tmp
[ -f "$S" ] || : > "$S"
grep -v '^matrix_nabu_password:' "$S" > "$T" || true
printf 'matrix_nabu_password: "%s"\n' "$NABU_PASSWORD" >> "$T"
[ "$(wc -l < "$T")" -ge "$(wc -l < "$S")" ] || {
echo "FATAL: rewrite would shrink secrets.yaml; aborting" >&2; exit 1; }
chmod 600 "$T"
mv "$T" "$S"
echo "matrix_nabu_password rendered"
env:
- name: NABU_PASSWORD
valueFrom:
secretKeyRef: {name: ha-matrix, key: nabu_password}
volumeMounts:
- name: config
mountPath: /config
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 32Mi
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.10.2 # {"$imagepolicy": "flux-system:home-assistant"}
@@ -0,0 +1,96 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: home
namespace: home-assistant
spec:
type: group
displayName: Home Assistant
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: home-dns
namespace: home-assistant
spec:
type: dns
displayName: Home Assistant DNS
parentRef:
name: home
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: home.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: home-http
namespace: home-assistant
spec:
type: http
displayName: Home Assistant HTTP
parentRef:
name: home
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://home.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: home-api
namespace: home-assistant
spec:
type: json-query
displayName: Home Assistant API
parentRef:
name: home
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# unauthenticated login-provider list, served by the HA core backend
url: https://home.renner.uno/auth/providers
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: "providers[type=\"homeassistant\"].name"
operator: "=="
expected: "Home Assistant Local"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: home-web
namespace: home-assistant
spec:
type: keyword
displayName: Home Assistant Web
parentRef:
name: home
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
url: https://home.renner.uno/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: "<title>Home Assistant</title>"
@@ -11,6 +11,7 @@ resources:
- prometheus-token-externalsecret.yaml
- config-sync-token-externalsecret.yaml
- config-sync-gitea-externalsecret.yaml
- matrix-externalsecret.yaml
- mosquitto-bootstrap.yaml
# Former `health` probe credential. The broker no longer reads it (the
# probes use the cert account mosquitto-probe, REN-953); removed with its
@@ -53,6 +54,7 @@ resources:
- mqtt-account-controller.yaml
# Also mounted by shelly-manager (../home-assistant-iot), which runs after this.
- shelly-manager-auth-externalsecret.yaml
- kumamonitors.yaml
configMapGenerator:
# nginx config for the reverse-proxy sidecar. Hash suffix disabled so
# the cm name stays stable; editing proxy.conf does not auto-roll the
@@ -0,0 +1,19 @@
# Password of @nabu:matrix.renner.uno (MAS account), rendered into
# /config/secrets.yaml by the render-matrix-password init container.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: ha-matrix
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: ha-matrix
data:
- secretKey: nabu_password
remoteRef:
key: apps/home-assistant/matrix
property: nabu_password
+74
View File
@@ -0,0 +1,74 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: immich
namespace: immich
spec:
type: group
displayName: Immich
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: immich-dns
namespace: immich
spec:
type: dns
displayName: Immich DNS
parentRef:
name: immich
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: immich.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: immich-http
namespace: immich
spec:
type: http
displayName: Immich HTTP
parentRef:
name: immich
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://immich.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: immich-api
namespace: immich
spec:
type: json-query
displayName: Immich API
parentRef:
name: immich
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
url: https://immich.renner.uno/api/server/ping
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: res
operator: "=="
expected: "pong"
@@ -14,3 +14,4 @@ resources:
- scheduled-backup.yaml
- hetzner-creds-externalsecret.yaml
- pvcs.yaml
- kumamonitors.yaml
+1 -1
View File
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: immich-machine-learning
image: ghcr.io/immich-app/immich-machine-learning:v3.3.3 # {"$imagepolicy": "flux-system:immich-machine-learning"}
image: ghcr.io/immich-app/immich-machine-learning:v3.3.4 # {"$imagepolicy": "flux-system:immich-machine-learning"}
ports:
- name: http
containerPort: 3003
+1 -1
View File
@@ -87,7 +87,7 @@ spec:
mountPath: /ca
containers:
- name: immich-server
image: ghcr.io/immich-app/immich-server:v3.3.3 # {"$imagepolicy": "flux-system:immich-server"}
image: ghcr.io/immich-app/immich-server:v3.3.4 # {"$imagepolicy": "flux-system:immich-server"}
ports:
- name: http
containerPort: 3001
+1 -1
View File
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-19-0857 # {"$imagepolicy": "flux-system:kuma-sync"}
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-20-0933 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
@@ -1,8 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- crd-kumamonitors.yaml
- externalsecret.yaml
- rbac.yaml
- deployment.yaml
- monitors
@@ -1,53 +0,0 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: audiobooks
namespace: kuma-sync
spec:
type: group
displayName: Audiobooks
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: audiobooks-health
namespace: kuma-sync
spec:
type: keyword
displayName: Audiobookshelf Health
parentRef:
name: audiobooks
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
url: https://audiobooks.renner.uno/healthcheck
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: OK
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: audiobooks-status
namespace: kuma-sync
spec:
type: json-query
displayName: Audiobookshelf Server
parentRef:
name: audiobooks
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# initialized-server flag from the unauthenticated status endpoint
url: https://audiobooks.renner.uno/status
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: isInit
operator: ==
expected: "true"
@@ -1,48 +0,0 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: auth
namespace: kuma-sync
spec:
type: group
displayName: Authentik
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: auth-live
namespace: kuma-sync
spec:
type: http
displayName: Authentik Live
parentRef:
name: auth
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# ungated health endpoint, empty body
url: https://auth.renner.uno/-/health/live/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: auth-ready
namespace: kuma-sync
spec:
type: http
displayName: Authentik Ready
parentRef:
name: auth
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# ungated readiness endpoint (database and cache), empty body
url: https://auth.renner.uno/-/health/ready/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
@@ -1,30 +0,0 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: docs
namespace: kuma-sync
spec:
type: group
displayName: Documents
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: docs-gate
namespace: kuma-sync
spec:
type: keyword
displayName: Documents Login
parentRef:
name: docs
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 20
http:
# Paperless is fully behind the Authentik gate; asserts the login page the redirect chain ends on
url: https://docs.renner.uno/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: <title>authentik</title>
@@ -1,53 +0,0 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: home
namespace: kuma-sync
spec:
type: group
displayName: Home Assistant
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: home-api
namespace: kuma-sync
spec:
type: json-query
displayName: Home Assistant API
parentRef:
name: home
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# unauthenticated login-provider list, served by the HA core backend
url: https://home.renner.uno/auth/providers
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: providers[type="homeassistant"].name
operator: ==
expected: Home Assistant Local
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: home-web
namespace: kuma-sync
spec:
type: keyword
displayName: Home Assistant Web
parentRef:
name: home
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
url: https://home.renner.uno/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: <title>Home Assistant</title>
@@ -1,31 +0,0 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: immich
namespace: kuma-sync
spec:
type: group
displayName: Immich
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: immich-api
namespace: kuma-sync
spec:
type: json-query
displayName: Immich API
parentRef:
name: immich
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
url: https://immich.renner.uno/api/server/ping
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: res
operator: ==
expected: pong
@@ -1,10 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- renner-uno.yaml
- home.yaml
- immich.yaml
- auth.yaml
- docs.yaml
- audiobooks.yaml
- third-party.yaml
@@ -1,51 +0,0 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: renner-uno
namespace: kuma-sync
spec:
type: group
displayName: Renner.uno Website
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: renner-uno-www
namespace: kuma-sync
spec:
type: keyword
displayName: www.renner.uno
parentRef:
name: renner-uno
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
url: https://www.renner.uno/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: Familie Renner
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: renner-uno-apex
namespace: kuma-sync
spec:
type: keyword
displayName: renner.uno
parentRef:
name: renner-uno
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# 301 to www, followed
url: https://renner.uno/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: Familie Renner
@@ -1,87 +0,0 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: third-party
namespace: kuma-sync
spec:
type: group
displayName: Third-party
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: mcb-portal
namespace: kuma-sync
spec:
type: http
displayName: MCB Portal
parentRef:
name: third-party
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 15
http:
# root 302s to / to set a session cookie; Kuma keeps no cookies, so do not follow
url: https://portal.multi-circuit-boards.eu/
acceptedStatusCodes: ["200-299", "302"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: mcb-tech
namespace: kuma-sync
spec:
type: http
displayName: MCB Technician Portal
parentRef:
name: third-party
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 15
http:
url: https://tech.multi-circuit-boards.eu/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: mcb-www
namespace: kuma-sync
spec:
type: http
displayName: MCB Website
parentRef:
name: third-party
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 15
http:
# 301 to /index.html, followed
url: https://www.multi-circuit-boards.eu/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: turm1388
namespace: kuma-sync
spec:
type: http
displayName: Wohnen im Turm
parentRef:
name: third-party
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 15
http:
url: https://turm1388.de/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
@@ -0,0 +1,73 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: llm
namespace: ai
spec:
type: group
displayName: LLM
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: llm-dns
namespace: ai
spec:
type: dns
displayName: LLM DNS
parentRef:
name: llm
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: llm.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: llm-http
namespace: ai
spec:
type: http
displayName: LLM HTTP
parentRef:
name: llm
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://llm.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: llm-gate
namespace: ai
spec:
type: keyword
displayName: LLM Login
parentRef:
name: llm
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 20
http:
# fully behind the Authentik gate; asserts the login page the redirect chain ends on
url: https://llm.renner.uno/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: "<title>authentik</title>"
@@ -7,3 +7,4 @@ resources:
- ingress.yaml
- wiki-embed.yaml
- wiki-embed-ingress.yaml
- kumamonitors.yaml
+15
View File
@@ -100,6 +100,21 @@ spec:
name: synapse
port:
number: 8008
# lk-jwt-service resolves the server via this file and verifies OpenID tokens at the path below.
- path: /.well-known/matrix/server
pathType: Exact
backend:
service:
name: synapse
port:
number: 8008
- path: /_matrix/federation/v1/openid/userinfo
pathType: Exact
backend:
service:
name: synapse
port:
number: 8008
---
apiVersion: networking.k8s.io/v1
kind: Ingress
+361
View File
@@ -0,0 +1,361 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: matrix
namespace: matrix
spec:
type: group
displayName: Matrix
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: matrix-dns
namespace: matrix
spec:
type: dns
displayName: Synapse DNS
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: matrix.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: matrix-http
namespace: matrix
spec:
type: http
displayName: Synapse HTTP
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://matrix.renner.uno/_matrix/client/versions
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: matrix-versions
namespace: matrix
spec:
type: json-query
displayName: Synapse Client API
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
url: https://matrix.renner.uno/_matrix/client/versions
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: $count(versions)
operator: ">"
expected: "0"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: matrix-well-known
namespace: matrix
spec:
type: json-query
displayName: Matrix Client Discovery
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
url: https://matrix.renner.uno/.well-known/matrix/client
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: "`m.homeserver`.base_url"
operator: "=="
expected: "https://matrix.renner.uno/"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: matrix-login
namespace: matrix
spec:
type: json-query
displayName: Matrix Login (MAS)
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# login path routed to MAS; asserts SSO is offered
url: https://matrix.renner.uno/_matrix/client/v3/login
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: "\"m.login.sso\" in flows.type"
operator: "=="
expected: "true"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: mas-dns
namespace: matrix
spec:
type: dns
displayName: MAS DNS
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: account.matrix.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: mas-http
namespace: matrix
spec:
type: http
displayName: MAS HTTP
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://account.matrix.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: mas-discovery
namespace: matrix
spec:
type: json-query
displayName: MAS OIDC Discovery
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
url: https://account.matrix.renner.uno/.well-known/openid-configuration
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: issuer
operator: "=="
expected: "https://account.matrix.renner.uno/"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: element-dns
namespace: matrix
spec:
type: dns
displayName: Element DNS
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: element.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: element-http
namespace: matrix
spec:
type: http
displayName: Element HTTP
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://element.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: element-web
namespace: matrix
spec:
type: keyword
displayName: Element Web
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
url: https://element.renner.uno/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: "<title>Element</title>"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: element-config
namespace: matrix
spec:
type: json-query
displayName: Element Config
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
url: https://element.renner.uno/config.json
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: "default_server_config.`m.homeserver`.base_url"
operator: "=="
expected: "https://matrix.renner.uno"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: rtc-dns
namespace: matrix
spec:
type: dns
displayName: MatrixRTC DNS
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: rtc.matrix.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: rtc-sfu
namespace: matrix
spec:
type: keyword
displayName: LiveKit SFU
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# LiveKit's health answer on its signalling port
url: https://rtc.matrix.renner.uno/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: "OK"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: rtc-jwt
namespace: matrix
spec:
type: http
displayName: MatrixRTC Authorization Service
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx (an unauthenticated GET is rejected with 4xx)
url: https://rtc.matrix.renner.uno/livekit/jwt/sfu/get
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: turn-dns
namespace: matrix
spec:
type: dns
displayName: TURN DNS
parentRef:
name: matrix
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: turn.matrix.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
@@ -5,9 +5,11 @@ resources:
- synapse.yaml
- mas.yaml
- element.yaml
- rtc.yaml
- ingress.yaml
# mx-pg CNPG Postgres + offsite Barman backup. The media store and signing
# key are on synapse-data, backed up by Velero (matrix-daily).
- cluster.yaml
- scheduled-backup.yaml
- hetzner-creds-externalsecret.yaml
- kumamonitors.yaml
+415
View File
@@ -0,0 +1,415 @@
# MatrixRTC backend for Element Call: LiveKit SFU + lk-jwt-service.
# Generated once in-cluster; Synapse, LiveKit and lk-jwt-service read the same Secret.
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
name: matrix-rtc
namespace: matrix
spec:
length: 64
digits: 16
symbols: 0
allowRepeat: true
noUpper: false
secretKeys:
- livekit_secret
- as_token
- hs_token
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: matrix-rtc
namespace: matrix
spec:
refreshPolicy: CreatedOnce
target:
name: matrix-rtc
template:
engineVersion: v2
data:
livekit_secret: "{{ .livekit_secret }}"
# LIVEKIT_KEYS format "<key>: <secret>"; the key name is the fixed id matrix-rtc.
livekit_keys: "matrix-rtc: {{ .livekit_secret }}"
# Appservice registration: membership look-ups and the /rtc/livekit proxy only, no event traffic.
registration.yaml: |
id: lk-jwt-service
url: null
as_token: "{{ .as_token }}"
hs_token: "{{ .hs_token }}"
sender_localpart: _lk_jwt_service
rate_limited: false
namespaces:
users:
- exclusive: false
regex: ".*"
aliases: []
rooms: []
io.element.msc4502.scopes: ["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
io.element.msc4512.proxy_prefix: "rtc/livekit"
io.element.msc4512.proxy_url: "http://lk-jwt-service:8080"
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: matrix-rtc
---
apiVersion: v1
kind: ConfigMap
metadata:
name: livekit-config
namespace: matrix
data:
livekit.yaml: |
port: 7880
rtc:
# Host candidates are the pod IPs (dual-stack, BGP-routed to the LAN).
use_external_ip: false
tcp_port: 7881
udp_port: 7882
turn:
# TURN/TLS on turn.matrix.renner.uno:443; Traefik terminates TLS (IngressRouteTCP livekit-turn).
enabled: true
domain: turn.matrix.renner.uno
tls_port: 443
external_tls: true
# The relay targets the SFU's own pod IP; LiveKit limits embedded-TURN relays to its own media ports.
allow_restricted_peer_cidrs:
- 10.0.0.0/8
- fc00::/7
room:
# lk-jwt-service creates rooms for matrix.renner.uno users only.
auto_create: false
webhook:
api_key: matrix-rtc
urls:
- http://lk-jwt-service:8080/sfu_webhook
logging:
level: info
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: livekit
namespace: matrix
annotations:
configmap.reloader.stakater.com/reload: livekit-config
secret.reloader.stakater.com/reload: matrix-rtc
labels:
app.kubernetes.io/name: livekit
app.kubernetes.io/part-of: matrix
spec:
replicas: 1
# One SFU: two pods would split a call's participants.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: livekit
template:
metadata:
labels:
app.kubernetes.io/name: livekit
app.kubernetes.io/part-of: matrix
spec:
automountServiceAccountToken: false
# Service links would inject LIVEKIT_PORT=tcp://... (Service livekit), which LiveKit and lk-jwt-service read as config.
enableServiceLinks: false
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: livekit
image: livekit/livekit-server:v1.13.10@sha256:89ca9ce0791e18d6e03bf6e2c49ceb27e1c754eae8bd50c989386c1a5963791a
args:
- --config
- /config/livekit.yaml
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: LIVEKIT_KEYS
valueFrom:
secretKeyRef:
name: matrix-rtc
key: livekit_keys
ports:
- name: http
containerPort: 7880
- name: ice-tcp
containerPort: 7881
- name: ice-udp
containerPort: 7882
protocol: UDP
- name: turn
containerPort: 443
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 1Gi
readinessProbe:
httpGet:
path: /
port: http
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: http
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 4
volumeMounts:
- name: config
mountPath: /config
readOnly: true
volumes:
- name: config
configMap:
name: livekit-config
---
apiVersion: v1
kind: Service
metadata:
name: livekit
namespace: matrix
spec:
selector:
app.kubernetes.io/name: livekit
ports:
- name: http
port: 7880
targetPort: http
- name: turn
port: 443
targetPort: turn
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: lk-jwt-service
namespace: matrix
annotations:
secret.reloader.stakater.com/reload: matrix-rtc
labels:
app.kubernetes.io/name: lk-jwt-service
app.kubernetes.io/part-of: matrix
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: lk-jwt-service
template:
metadata:
labels:
app.kubernetes.io/name: lk-jwt-service
app.kubernetes.io/part-of: matrix
spec:
automountServiceAccountToken: false
# Service links would inject LIVEKIT_PORT=tcp://... (Service livekit), which LiveKit and lk-jwt-service read as config.
enableServiceLinks: false
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: lk-jwt-service
image: ghcr.io/element-hq/lk-jwt-service:0.9.0@sha256:d210fd778ba38b7e42e6fc64e8a24a7d66ccc3b938fb20d73bd713224af2dfea
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
# Must equal matrix_rtc.transports[].url in synapse.yaml.
- name: LIVEKIT_URL
value: wss://rtc.matrix.renner.uno
- name: LIVEKIT_KEY
value: matrix-rtc
- name: LIVEKIT_SECRET
valueFrom:
secretKeyRef:
name: matrix-rtc
key: livekit_secret
- name: LIVEKIT_FULL_ACCESS_HOMESERVERS
value: matrix.renner.uno
# Federation is off: no remote homeserver publishes on this SFU.
- name: LIVEKIT_REMOTE_CAN_PUBLISH_HOMESERVERS
value: ""
- name: LIVEKIT_AS_REGISTRATION_FILE
value: /registration/registration.yaml
- name: LIVEKIT_HS_SERVER_NAME
value: matrix.renner.uno
- name: LIVEKIT_CS_API_URL_OVERRIDES
value: matrix.renner.uno=http://synapse:8008
- name: LIVEKIT_SANITY_CHECK_INTERVAL_SECONDS
value: "60"
ports:
- name: http
containerPort: 8080
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 256Mi
readinessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 4
volumeMounts:
- name: registration
mountPath: /registration
readOnly: true
volumes:
- name: registration
secret:
secretName: matrix-rtc
items:
- key: registration.yaml
path: registration.yaml
---
apiVersion: v1
kind: Service
metadata:
name: lk-jwt-service
namespace: matrix
spec:
selector:
app.kubernetes.io/name: lk-jwt-service
ports:
- name: http
port: 8080
targetPort: http
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: lk-jwt-strip
namespace: matrix
spec:
stripPrefix:
prefixes:
- /livekit/jwt
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: matrix-rtc
namespace: matrix
labels:
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
traefik.ingress.kubernetes.io/router.middlewares: matrix-lk-jwt-strip@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- rtc.matrix.renner.uno
secretName: rtc-tls
rules:
- host: rtc.matrix.renner.uno
http:
paths:
# Client endpoints of lk-jwt-service (livekit_service_url); the rest of the service stays in-cluster.
- path: /livekit/jwt/sfu/get
pathType: Exact
backend:
service:
name: lk-jwt-service
port:
number: 8080
- path: /livekit/jwt/get_token
pathType: Exact
backend:
service:
name: lk-jwt-service
port:
number: 8080
- path: /livekit/jwt/delegate_delayed_leave
pathType: Exact
backend:
service:
name: lk-jwt-service
port:
number: 8080
- path: /
pathType: Prefix
backend:
service:
name: livekit
port:
number: 7880
---
# Publishes turn.matrix.renner.uno (netbird-service-sync reads Ingress hosts). Bound to `web` only:
# an HTTPS router for the same SNI on websecure would take precedence over IngressRouteTCP livekit-turn.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: livekit-turn
namespace: matrix
labels:
dns.renner.uno/public: "true"
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
ingressClassName: traefik
rules:
- host: turn.matrix.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: livekit
port:
number: 7880
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: livekit-turn
namespace: matrix
spec:
secretName: livekit-turn-tls
dnsNames:
- turn.matrix.renner.uno
issuerRef:
kind: ClusterIssuer
name: letsencrypt-cluster
---
apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
name: livekit-turn
namespace: matrix
spec:
entryPoints:
- websecure
routes:
- match: HostSNI(`turn.matrix.renner.uno`)
services:
- name: livekit
port: 443
tls:
secretName: livekit-turn-tls
+44 -3
View File
@@ -19,8 +19,15 @@ data:
server_name: matrix.renner.uno
public_baseurl: https://matrix.renner.uno/
# Lets Element X and other clients discover the homeserver from the
# server name. No server well-known: there is no federation to point at.
# server name.
serve_client_wellknown: true
# Delegates to :443 so lk-jwt-service finds /_matrix/federation/v1/openid/userinfo (openid resource below).
serve_server_wellknown: true
# Element Call builds that still read the MatrixRTC transport from the well-known.
extra_well_known_client_content:
org.matrix.msc4143.rtc_foci:
- type: livekit
livekit_service_url: https://rtc.matrix.renner.uno/livekit/jwt
pid_file: /tmp/homeserver.pid
report_stats: false
@@ -34,7 +41,8 @@ data:
bind_addresses: ["::"]
x_forwarded: true
resources:
- names: [client]
# openid: only the OpenID userinfo endpoint lk-jwt-service verifies call tokens with; no federation.
- names: [client, openid]
compress: false
# The password comes from PGPASSWORD (libpq reads it), so no credential
@@ -73,6 +81,30 @@ data:
experimental_features:
msc4108_enabled: true
msc4388_mode: open
# MatrixRTC (Element Call): transports endpoint, sync state_after,
# appservice membership look-ups and the /rtc/livekit proxy to lk-jwt-service.
msc4143_enabled: true
msc4222_enabled: true
msc4502_enabled: true
msc4512_enabled: true
# MatrixRTC transport (rtc.yaml). url must equal LIVEKIT_URL of lk-jwt-service.
matrix_rtc:
transports:
- type: livekit
url: wss://rtc.matrix.renner.uno
livekit_service_url: https://rtc.matrix.renner.uno/livekit/jwt
app_service_config_files:
- /appservices/lk-jwt-service/registration.yaml
# Call membership leaves via delayed events (MSC4140).
max_event_delay_duration: 24h
# Element Call self-hosting guide values: E2EE key sharing bursts and 5 s membership heartbeats.
rc_message:
per_second: 0.5
burst_count: 30
rc_delayed_event_mgmt:
per_second: 1
burst_count: 20
# Federation off.
federation_domain_whitelist: []
@@ -115,7 +147,7 @@ metadata:
namespace: matrix
annotations:
configmap.reloader.stakater.com/reload: synapse-config,mas-config
secret.reloader.stakater.com/reload: synapse-secrets
secret.reloader.stakater.com/reload: synapse-secrets,matrix-rtc
labels:
app.kubernetes.io/name: synapse
app.kubernetes.io/part-of: matrix
@@ -263,6 +295,9 @@ spec:
mountPath: /data
- name: tmp
mountPath: /tmp
- name: appservice-lk-jwt
mountPath: /appservices/lk-jwt-service
readOnly: true
volumes:
- name: config
configMap:
@@ -295,6 +330,12 @@ spec:
claimName: synapse-data
- name: tmp
emptyDir: {}
- name: appservice-lk-jwt
secret:
secretName: matrix-rtc
items:
- key: registration.yaml
path: registration.yaml
---
apiVersion: v1
kind: Service
@@ -0,0 +1,436 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: netbird-proxies
namespace: monitoring
spec:
type: group
displayName: NetBird Proxies
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: netbird-proxies-dns
namespace: monitoring
spec:
type: dns
displayName: proxy.renner.uno DNS
parentRef:
name: netbird-proxies
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# target of the *.renner.uno wildcard
hostname: proxy.renner.uno
resolveType: AAAA
resolveServer: "2606:4700:4700::1111,1.1.1.1"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: proxy-1
namespace: monitoring
spec:
type: group
displayName: proxy-1.renner.uno
parentRef:
name: netbird-proxies
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: proxy-1-dns
namespace: monitoring
spec:
type: dns
displayName: proxy-1.renner.uno DNS
parentRef:
name: proxy-1
interval: 60
retryInterval: 60
maxRetries: 1
dns:
hostname: proxy-1.renner.uno
resolveType: AAAA
resolveServer: "2606:4700:4700::1111,1.1.1.1"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: proxy-1-http
namespace: monitoring
spec:
type: http
displayName: proxy-1.renner.uno HTTP
parentRef:
name: proxy-1
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# pinned to this proxy; the Host header sets SNI, so the proxy routes status.renner.uno
url: https://proxy-1.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
headers:
Host: status.renner.uno
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: proxy-1-route
namespace: monitoring
spec:
type: json-query
displayName: proxy-1.renner.uno Route
parentRef:
name: proxy-1
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# end to end through this proxy, the mesh and Traefik to Gatus
url: https://proxy-1.renner.uno/health
acceptedStatusCodes: ["200-299"]
headers:
Host: status.renner.uno
expiryNotification: true
jsonQuery:
expression: status
operator: "=="
expected: "UP"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: proxy-2
namespace: monitoring
spec:
type: group
displayName: proxy-2.renner.uno
parentRef:
name: netbird-proxies
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: proxy-2-dns
namespace: monitoring
spec:
type: dns
displayName: proxy-2.renner.uno DNS
parentRef:
name: proxy-2
interval: 60
retryInterval: 60
maxRetries: 1
dns:
hostname: proxy-2.renner.uno
resolveType: AAAA
resolveServer: "2606:4700:4700::1111,1.1.1.1"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: proxy-2-dns-a
namespace: monitoring
spec:
type: dns
displayName: proxy-2.renner.uno DNS (IPv4)
parentRef:
name: proxy-2
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# the fleet's only IPv4 address
hostname: proxy-2.renner.uno
resolveType: A
resolveServer: "2606:4700:4700::1111,1.1.1.1"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: proxy-2-http
namespace: monitoring
spec:
type: http
displayName: proxy-2.renner.uno HTTP
parentRef:
name: proxy-2
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# pinned to this proxy; the Host header sets SNI, so the proxy routes status.renner.uno
url: https://proxy-2.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
headers:
Host: status.renner.uno
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: proxy-2-route
namespace: monitoring
spec:
type: json-query
displayName: proxy-2.renner.uno Route
parentRef:
name: proxy-2
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# end to end through this proxy, the mesh and Traefik to Gatus
url: https://proxy-2.renner.uno/health
acceptedStatusCodes: ["200-299"]
headers:
Host: status.renner.uno
expiryNotification: true
jsonQuery:
expression: status
operator: "=="
expected: "UP"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: renner-uno-apex
namespace: monitoring
spec:
type: group
displayName: renner.uno Apex
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: renner-uno-apex-dns
namespace: monitoring
spec:
type: dns
displayName: renner.uno DNS
parentRef:
name: renner-uno-apex
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# Cloudflare-proxied apex, not the wildcard
hostname: renner.uno
resolveType: AAAA
resolveServer: "2606:4700:4700::1111,1.1.1.1"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: renner-uno-apex-http
namespace: monitoring
spec:
type: http
displayName: renner.uno HTTP
parentRef:
name: renner-uno-apex
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: renner-uno-apex-redirect
namespace: monitoring
spec:
type: keyword
displayName: renner.uno
parentRef:
name: renner-uno-apex
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# 301 to www, followed
url: https://renner.uno/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: Familie Renner
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: third-party
namespace: monitoring
spec:
type: group
displayName: Third-party
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: mcb-portal-dns
namespace: monitoring
spec:
type: dns
displayName: MCB Portal DNS
parentRef:
name: third-party
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# no AAAA record
hostname: portal.multi-circuit-boards.eu
resolveType: A
resolveServer: "2606:4700:4700::1111,1.1.1.1"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: mcb-portal
namespace: monitoring
spec:
type: http
displayName: MCB Portal
parentRef:
name: third-party
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 15
http:
# root 302s to / to set a session cookie; do not follow
url: https://portal.multi-circuit-boards.eu/
acceptedStatusCodes: ["200-299", "302"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: mcb-tech-dns
namespace: monitoring
spec:
type: dns
displayName: MCB Technician Portal DNS
parentRef:
name: third-party
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# no AAAA record
hostname: tech.multi-circuit-boards.eu
resolveType: A
resolveServer: "2606:4700:4700::1111,1.1.1.1"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: mcb-tech
namespace: monitoring
spec:
type: http
displayName: MCB Technician Portal
parentRef:
name: third-party
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 15
http:
url: https://tech.multi-circuit-boards.eu/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: mcb-www-dns
namespace: monitoring
spec:
type: dns
displayName: MCB Website DNS
parentRef:
name: third-party
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# no AAAA record
hostname: www.multi-circuit-boards.eu
resolveType: A
resolveServer: "2606:4700:4700::1111,1.1.1.1"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: mcb-www
namespace: monitoring
spec:
type: http
displayName: MCB Website
parentRef:
name: third-party
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 15
http:
# 301 to /index.html, followed
url: https://www.multi-circuit-boards.eu/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: turm1388-dns
namespace: monitoring
spec:
type: dns
displayName: Wohnen im Turm DNS
parentRef:
name: third-party
interval: 60
retryInterval: 60
maxRetries: 1
dns:
hostname: turm1388.de
resolveType: AAAA
resolveServer: "2606:4700:4700::1111,1.1.1.1"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: turm1388
namespace: monitoring
spec:
type: http
displayName: Wohnen im Turm
parentRef:
name: third-party
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 15
http:
url: https://turm1388.de/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
@@ -17,6 +17,7 @@ resources:
- smartctl-exporter.yaml
- intel-gpu-exporter.yaml
- dashboards/
- kumamonitors.yaml
# prometheusrule-garage.yaml lives in ./monitoring-rules/ and is applied by a
# separate Flux Kustomization that dependsOn `monitoring` — otherwise the
# server-side dry-run of the PrometheusRule fails on a fresh cluster
@@ -0,0 +1,75 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: music
namespace: music-assistant
spec:
type: group
displayName: Music Assistant
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: music-dns
namespace: music-assistant
spec:
type: dns
displayName: Music Assistant DNS
parentRef:
name: music
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: music.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: music-http
namespace: music-assistant
spec:
type: http
displayName: Music Assistant HTTP
parentRef:
name: music
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://music.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: music-info
namespace: music-assistant
spec:
type: json-query
displayName: Music Assistant Server
parentRef:
name: music
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# unauthenticated server info
url: https://music.renner.uno/info
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: $exists(server_version)
operator: "=="
expected: "true"
@@ -6,3 +6,4 @@ resources:
- configmap-net-reconcile.yaml
- deployment.yaml
- ingress.yaml
- kumamonitors.yaml
+149
View File
@@ -0,0 +1,149 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: navidrome
namespace: navidrome
spec:
type: group
displayName: Navidrome
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: navidrome-dns
namespace: navidrome
spec:
type: dns
displayName: Navidrome DNS
parentRef:
name: navidrome
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: navidrome.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: navidrome-http
namespace: navidrome
spec:
type: http
displayName: Navidrome HTTP
parentRef:
name: navidrome
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://navidrome.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: navidrome-subsonic
namespace: navidrome
spec:
type: json-query
displayName: Navidrome Subsonic API
parentRef:
name: navidrome
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# ungated Subsonic path; without credentials it answers with a typed error
url: https://navidrome.renner.uno/rest/ping.view?f=json
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: "`subsonic-response`.type"
operator: "=="
expected: "navidrome"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: lidarr
namespace: navidrome
spec:
type: group
displayName: Lidarr
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: lidarr-dns
namespace: navidrome
spec:
type: dns
displayName: Lidarr DNS
parentRef:
name: lidarr
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: lidarr.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: lidarr-http
namespace: navidrome
spec:
type: http
displayName: Lidarr HTTP
parentRef:
name: lidarr
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://lidarr.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: lidarr-gate
namespace: navidrome
spec:
type: keyword
displayName: Lidarr Login
parentRef:
name: lidarr
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 20
http:
# fully behind the Authentik gate; asserts the login page the redirect chain ends on
url: https://lidarr.renner.uno/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: "<title>authentik</title>"
@@ -6,3 +6,4 @@ resources:
- ingress.yaml
- lidarr.yaml
- beets.yaml
- kumamonitors.yaml
+138
View File
@@ -0,0 +1,138 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: docs
namespace: paperless
spec:
type: group
displayName: Documents
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: docs-dns
namespace: paperless
spec:
type: dns
displayName: docs.renner.uno DNS
parentRef:
name: docs
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: docs.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: docs-http
namespace: paperless
spec:
type: http
displayName: docs.renner.uno HTTP
parentRef:
name: docs
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://docs.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: docs-gate
namespace: paperless
spec:
type: keyword
displayName: Documents Login
parentRef:
name: docs
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 20
http:
# Paperless is fully behind the Authentik gate; asserts the login page the redirect chain ends on
url: https://docs.renner.uno/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: "<title>authentik</title>"
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: paperless-dns
namespace: paperless
spec:
type: dns
displayName: paperless.renner.uno DNS
parentRef:
name: docs
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: paperless.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: paperless-http
namespace: paperless
spec:
type: http
displayName: paperless.renner.uno HTTP
parentRef:
name: docs
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://paperless.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: paperless-gate
namespace: paperless
spec:
type: keyword
displayName: Paperless Login
parentRef:
name: docs
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 20
http:
# behind the Authentik gate; asserts the login page the redirect chain ends on
url: https://paperless.renner.uno/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: "<title>authentik</title>"
@@ -20,6 +20,7 @@ resources:
- scheduled-backup.yaml
- hetzner-creds-externalsecret.yaml
- pvcs.yaml
- kumamonitors.yaml
configMapGenerator:
# vsftpd config for the ftp-scanner deployment. The content-hash name
# suffix is intentionally LEFT ENABLED (unlike home-assistant's nginx
+1 -1
View File
@@ -182,7 +182,7 @@ spec:
drop: ["ALL"]
containers:
- name: paperless
image: ghcr.io/paperless-ngx/paperless-ngx:3.3.2 # {"$imagepolicy": "flux-system:paperless-ngx"}
image: ghcr.io/paperless-ngx/paperless-ngx:3.3.3 # {"$imagepolicy": "flux-system:paperless-ngx"}
ports:
- name: http
containerPort: 8000
+1 -1
View File
@@ -53,7 +53,7 @@ spec:
type: RuntimeDefault
containers:
- name: wiki
image: gitea.renner.uno/konairius/wiki:build.1792404861 # {"$imagepolicy": "flux-system:wiki"}
image: gitea.renner.uno/konairius/wiki:build.1792419299 # {"$imagepolicy": "flux-system:wiki"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -0,0 +1,72 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: renner-uno
namespace: wordpress
spec:
type: group
displayName: Renner.uno Website
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: renner-uno-www-dns
namespace: wordpress
spec:
type: dns
displayName: www.renner.uno DNS
parentRef:
name: renner-uno
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: www.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: renner-uno-www-http
namespace: wordpress
spec:
type: http
displayName: www.renner.uno HTTP
parentRef:
name: renner-uno
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://www.renner.uno/
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: renner-uno-www
namespace: wordpress
spec:
type: keyword
displayName: www.renner.uno
parentRef:
name: renner-uno
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
url: https://www.renner.uno/
acceptedStatusCodes: ["200-299"]
expiryNotification: true
keyword:
keyword: Familie Renner
@@ -6,3 +6,4 @@ resources:
- pvc.yaml
- deployment.yaml
- ingress.yaml
- kumamonitors.yaml
+5
View File
@@ -21,3 +21,8 @@ spec:
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
healthCheckExprs:
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
@@ -26,3 +26,8 @@ spec:
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
healthCheckExprs:
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
@@ -31,3 +31,7 @@ spec:
kind: Cluster
failed: "has(status.phase) && status.phase.startsWith('Failed')"
current: "has(status.phase) && (status.phase == 'Cluster in healthy state' || status.readyInstances > 0)"
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
@@ -17,3 +17,8 @@ spec:
- name: infrastructure
- name: infrastructure-crds
- name: gitea # serves Gitea's API
healthCheckExprs:
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
+5
View File
@@ -25,3 +25,8 @@ spec:
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
healthCheckExprs:
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
@@ -26,6 +26,11 @@ spec:
- name: cnpg-object-stores # the ObjectStores its Cluster names
- name: gitea # mqtt-account-controller's image is in Gitea's registry
- name: influxdb # HA's InfluxDB integration writes there
healthCheckExprs:
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
+4
View File
@@ -30,3 +30,7 @@ spec:
kind: Cluster
failed: "has(status.phase) && status.phase.startsWith('Failed')"
current: "has(status.phase) && (status.phase == 'Cluster in healthy state' || status.readyInstances > 0)"
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
@@ -21,6 +21,11 @@ spec:
dependsOn:
- name: infrastructure
- name: infrastructure-crds
healthCheckExprs:
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
+2 -1
View File
@@ -1,4 +1,5 @@
# kuma-sync controller: KumaMonitor -> Uptime Kuma monitors. Ships no KumaMonitor objects.
# kuma-sync controller: KumaMonitor -> Uptime Kuma monitors. Ships no KumaMonitor objects;
# they live with their app, the CRD in infrastructure/kuma-sync-crds.
# healthCheckExprs: Flux has no status reader for ExternalSecret; fail only on the non-retrying reasons.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
@@ -20,3 +20,8 @@ spec:
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
healthCheckExprs:
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
+4
View File
@@ -27,3 +27,7 @@ spec:
kind: Cluster
failed: "has(status.phase) && status.phase.startsWith('Failed')"
current: "has(status.phase) && (status.phase == 'Cluster in healthy state' || status.readyInstances > 0)"
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
@@ -47,6 +47,11 @@ spec:
dependsOn:
- name: grafana-db
- name: influxdb # its Job writes the grafana-influxdb-token Secret Grafana mounts
healthCheckExprs:
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
@@ -26,3 +26,8 @@ spec:
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
healthCheckExprs:
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
@@ -18,3 +18,8 @@ spec:
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
healthCheckExprs:
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
@@ -31,3 +31,7 @@ spec:
kind: Cluster
failed: "has(status.phase) && status.phase.startsWith('Failed')"
current: "has(status.phase) && (status.phase == 'Cluster in healthy state' || status.readyInstances > 0)"
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
@@ -29,3 +29,7 @@ spec:
kind: MariaDB
failed: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'False' && c.reason == 'Failed')"
current: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')"
# KumaMonitor Ready tracks Uptime Kuma on the VPS, not this apply.
- apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
current: "true"
@@ -93,10 +93,10 @@ spec:
# self-heals with no PVC and no intervention.
#
# cert-manager (in `infrastructure`, which the `crowdsec` Kustomization
# depends on) provisions a fully self-contained self-signed CA (selfSigned
# ClusterIssuer -> CA cert -> CA Issuer -> lapi/agent/bouncer leaf certs);
# empty issuerRef => the chart mints that CA itself, so nothing external is
# required.
# depends on) signs the lapi/agent/bouncer leaf certs with the CA in
# tls-ca.yaml. Not the chart's own CA: that one has cert-manager's 90d
# default, the same lifetime as the leaves, so the leaves chain to a CA
# that expires 30 days after they were issued.
#
# NOTE: enabling this makes the LAPI serve HTTPS-only. The Traefik ingress
# bouncer is currently INERT (detached, see post-crds/traefik-crowdsec-middleware.yaml)
@@ -107,8 +107,9 @@ spec:
caBundle: true
certManager:
enabled: true
# Empty => chart provisions its own self-signed CA (self-contained).
issuerRef: {}
issuerRef:
name: crowdsec-tls-ca-issuer
kind: Issuer
# Chart 0.24.0 bug: the agent/bouncer/appsec Certificate templates emit
# `secretTemplate:` unconditionally while its child blocks are guarded,
# so with nothing set it renders as null and cert-manager's webhook
@@ -160,7 +161,8 @@ spec:
# crowdsec-edge-bouncer-key rotates by hand together with
# ansible/plays/install-netbird-proxy.yml; it still needs to be listed
# here, because the LAPI side of that rotation is this env var.
secret.reloader.stakater.com/reload: crowdsec-bouncer-key,crowdsec-edge-bouncer-key
# crowdsec-lapi-tls, crowdsec-agent-tls: certs are read at start only.
secret.reloader.stakater.com/reload: crowdsec-bouncer-key,crowdsec-edge-bouncer-key,crowdsec-lapi-tls,crowdsec-agent-tls
persistentVolume:
data:
# Kept OFF deliberately. The #337 attempt to set this `true` (persist
@@ -203,6 +205,9 @@ spec:
key: key
agent:
podAnnotations:
# The agent reads its client cert at start only.
secret.reloader.stakater.com/reload: crowdsec-agent-tls
# Same reasoning as lapi.resources above, multiplied by three: this is a
# DaemonSet, so the chart's 500m default reserves half a core on every
# node to run a process that measures 1-2m.
@@ -0,0 +1,75 @@
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: crowdsec
namespace: crowdsec
spec:
type: group
displayName: CrowdSec LAPI
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: crowdsec-dns
namespace: crowdsec
spec:
type: dns
displayName: CrowdSec LAPI DNS
parentRef:
name: crowdsec
interval: 60
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
hostname: crowdsec.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
conditions:
- operator: equals
value: proxy.renner.uno
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: crowdsec-http
namespace: crowdsec
spec:
type: http
displayName: CrowdSec LAPI HTTP
parentRef:
name: crowdsec
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# reachable: any answer from the backend below 5xx, redirects not followed
url: https://crowdsec.renner.uno/health
acceptedStatusCodes: ["200-499"]
maxRedirects: 0
expiryNotification: true
---
apiVersion: uptime.renner.uno/v1alpha1
kind: KumaMonitor
metadata:
name: crowdsec-health
namespace: crowdsec
spec:
type: json-query
displayName: CrowdSec LAPI Health
parentRef:
name: crowdsec
interval: 60
retryInterval: 60
maxRetries: 1
timeout: 10
http:
# unauthenticated LAPI health endpoint the edge bouncers depend on
url: https://crowdsec.renner.uno/health
acceptedStatusCodes: ["200-299"]
expiryNotification: true
jsonQuery:
expression: status
operator: "=="
expected: "up"
@@ -2,6 +2,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- lapi-ingress.yaml
- tls-ca.yaml
- helmrelease.yaml
- crowdsec-bouncer-key-externalsecret.yaml
- crowdsec-edge-bouncer-key-externalsecret.yaml
- kumamonitors.yaml
@@ -0,0 +1,35 @@
# CA for the chart's LAPI/agent/bouncer certs (tls.certManager.issuerRef); outlives them by design.
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: crowdsec-tls-selfsigned
namespace: crowdsec
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: crowdsec-tls-ca
namespace: crowdsec
spec:
isCA: true
commonName: crowdsec-tls-ca
secretName: crowdsec-tls-ca
duration: 87600h
renewBefore: 8760h
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: crowdsec-tls-selfsigned
kind: Issuer
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: crowdsec-tls-ca-issuer
namespace: crowdsec
spec:
ca:
secretName: crowdsec-tls-ca
@@ -3,8 +3,8 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.23.0
kustomize.toolkit.fluxcd.io/prune: disabled
controller-gen.kubebuilder.io/version: v0.23.0
name: kumamonitors.uptime.renner.uno
spec:
group: uptime.renner.uno
@@ -52,6 +52,33 @@ spec:
type: string
dns:
properties:
conditions:
description: |-
Conditions on the answer, all of which must hold for one record. For
CNAME the record is the first target, without the trailing dot.
items:
description: DNSCondition tests a DNS record with one of Kuma's
string operators.
properties:
operator:
enum:
- equals
- not_equals
- contains
- not_contains
- starts_with
- not_starts_with
- ends_with
- not_ends_with
type: string
value:
minLength: 1
type: string
required:
- operator
- value
type: object
type: array
hostname:
minLength: 1
type: string
@@ -0,0 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- crd-kumamonitors.yaml
@@ -25,6 +25,7 @@ resources:
- external-secrets/
- reloader/
- admission-policies/
- kuma-sync-crds/
# The OpenBao ClusterSecretStore lives in post-crds/ (needs ESO's CRDs and
# an initialized OpenBao; see kubernetes/MIGRATION-sops-openbao.md).
# cnpg-barman-plugin/ lives in its own Flux Kustomization
@@ -137,3 +137,53 @@ spec:
- ports:
- port: "8443"
protocol: TCP
---
# LiveKit media: ICE from clients straight to the pod IPs (LAN via the BGP-routed pod CIDR).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: livekit-media
namespace: matrix
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: livekit
ingress:
- fromEntities:
- world
toPorts:
- ports:
- port: "7881"
protocol: TCP
- port: "7882"
protocol: UDP
egress:
# ICE connectivity checks and media to client candidates.
- toEntities:
- world
toPorts:
- ports:
- port: "1"
endPort: 65535
protocol: UDP
---
# lk-jwt-service → matrix.renner.uno (server well-known, OpenID userinfo) and
# rtc.matrix.renner.uno (LiveKit room API); both resolve to the Traefik VIP.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: lk-jwt-service-egress
namespace: matrix
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: lk-jwt-service
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
toPorts:
- ports:
- port: "8443"
protocol: TCP
@@ -1,5 +1,5 @@
# traefik.io CRD consumer for the public LAPI endpoint (crowdsec/lapi-ingress.yaml
# holds the Ingress). The CA it names is the chart's self-signed LAPI CA.
# holds the Ingress). Trusts ca.crt of the LAPI's own cert secret: the CA that signed it.
apiVersion: traefik.io/v1alpha1
kind: ServersTransport
metadata:
@@ -8,4 +8,4 @@ metadata:
spec:
serverName: crowdsec-service.crowdsec.svc.cluster.local
rootCAs:
- secret: crowdsec-root-secret
- secret: crowdsec-lapi-tls