diff --git a/modules/kubernetes/ingress_factory/main.tf b/modules/kubernetes/ingress_factory/main.tf index 58e98618..9ea84d69 100644 --- a/modules/kubernetes/ingress_factory/main.tf +++ b/modules/kubernetes/ingress_factory/main.tf @@ -97,6 +97,21 @@ variable "dns_type" { } } +# Uptime Kuma external monitor: when true, annotate the ingress so the +# external-monitor-sync CronJob creates a `[External] ` monitor pointing +# at https://. Null means "follow dns_type" — enabled when proxied. +variable "external_monitor" { + type = bool + default = null + description = "Enable Uptime Kuma external monitor. null = auto (enabled when dns_type == 'proxied')." +} + +variable "external_monitor_name" { + type = string + default = null + description = "Override the monitor label. Defaults to the ingress hostname label (e.g. 'dawarich' for dawarich.viktorbarzin.me)." +} + # Cloudflare config defaults — override via variables if these change. # Source of truth: config.tfvars (cloudflare_zone_id, cloudflare_tunnel_id, public_ip, public_ipv6) variable "cloudflare_zone_id" { @@ -133,31 +148,40 @@ locals { effective_host = var.full_host != null ? var.full_host : "${var.host != null ? var.host : var.name}.${var.root_domain}" effective_anti_ai = var.anti_ai_scraping != null ? var.anti_ai_scraping : !var.protected + # External monitor enabled by default when the ingress has a public DNS + # record (either CF-proxied or direct A/AAAA). Explicit bool overrides. + effective_external_monitor = var.external_monitor != null ? var.external_monitor : (var.dns_type != "none") + + external_monitor_annotations = local.effective_external_monitor ? merge( + { "uptime.viktorbarzin.me/external-monitor" = "true" }, + var.external_monitor_name != null ? { "uptime.viktorbarzin.me/external-monitor-name" = var.external_monitor_name } : {}, + ) : {} + ns_to_group = { - monitoring = "Infrastructure" - prometheus = "Infrastructure" - technitium = "Infrastructure" - traefik = "Infrastructure" - metallb-system = "Infrastructure" - kyverno = "Infrastructure" - authentik = "Identity & Security" - crowdsec = "Identity & Security" - woodpecker = "Development & CI" - forgejo = "Development & CI" - immich = "Media & Entertainment" - frigate = "Smart Home" - home-assistant = "Smart Home" - ollama = "AI & Data" - dbaas = "Infrastructure" - servarr = "Media & Entertainment" - navidrome = "Media & Entertainment" - nextcloud = "Productivity" - n8n = "Automation" - changedetection = "Automation" - finance = "Finance & Personal" - homepage = "Core Platform" - reverse-proxy = "Smart Home" - mailserver = "Infrastructure" + monitoring = "Infrastructure" + prometheus = "Infrastructure" + technitium = "Infrastructure" + traefik = "Infrastructure" + metallb-system = "Infrastructure" + kyverno = "Infrastructure" + authentik = "Identity & Security" + crowdsec = "Identity & Security" + woodpecker = "Development & CI" + forgejo = "Development & CI" + immich = "Media & Entertainment" + frigate = "Smart Home" + home-assistant = "Smart Home" + ollama = "AI & Data" + dbaas = "Infrastructure" + servarr = "Media & Entertainment" + navidrome = "Media & Entertainment" + nextcloud = "Productivity" + n8n = "Automation" + changedetection = "Automation" + finance = "Finance & Personal" + homepage = "Core Platform" + reverse-proxy = "Smart Home" + mailserver = "Infrastructure" } homepage_group = coalesce( @@ -222,8 +246,9 @@ resource "kubernetes_ingress_v1" "proxied-ingress" { var.custom_content_security_policy != null ? "${var.namespace}-custom-csp-${var.name}@kubernetescrd" : null, ], var.extra_middlewares))) "traefik.ingress.kubernetes.io/router.entrypoints" = "websecure" - }, local.homepage_defaults, var.extra_annotations, - var.dns_type != "none" ? { "cloudflare.viktorbarzin.me/dns-type" = var.dns_type } : {} + }, local.homepage_defaults, var.extra_annotations, + var.dns_type != "none" ? { "cloudflare.viktorbarzin.me/dns-type" = var.dns_type } : {}, + local.external_monitor_annotations, ) } diff --git a/stacks/actualbudget/factory/main.tf b/stacks/actualbudget/factory/main.tf index a28a1c82..ad719aa5 100644 --- a/stacks/actualbudget/factory/main.tf +++ b/stacks/actualbudget/factory/main.tf @@ -13,24 +13,31 @@ variable "budget_encryption_password" { default = null # If not passed, we won't run banksync ;known after initial installation sensitive = true } +# Plan-time toggles — these MUST be known at plan time. The secret values +# (budget_encryption_password, sync_id) are read from ESO-managed K8s Secrets +# and are unknown at plan time on first apply, so we cannot base `count` on +# them directly. Callers pass these booleans as hardcoded plan-time constants +# that reflect whether the corresponding credentials are expected to exist. +variable "enable_http_api" { + type = bool + default = false + description = "Deploy the actual-http-api sidecar. Must be true for the cronjob to run." +} +variable "enable_bank_sync" { + type = bool + default = false + description = "Deploy the daily bank-sync CronJob. Requires enable_http_api=true." +} variable "nfs_server" { type = string } variable "homepage_annotations" { type = map(string) default = {} } -module "nfs_data" { - source = "../../../modules/kubernetes/nfs_volume" - name = "actualbudget-${var.name}-data" - namespace = "actualbudget" - nfs_server = var.nfs_server - nfs_path = "/mnt/main/actualbudget/${var.name}" -} - -resource "kubernetes_persistent_volume_claim" "data_proxmox" { +resource "kubernetes_persistent_volume_claim" "data_encrypted" { wait_until_bound = false metadata { - name = "actualbudget-${var.name}-data-proxmox" + name = "actualbudget-${var.name}-data-encrypted" namespace = "actualbudget" annotations = { "resize.topolvm.io/threshold" = "80%" @@ -40,7 +47,7 @@ resource "kubernetes_persistent_volume_claim" "data_proxmox" { } spec { access_modes = ["ReadWriteOnce"] - storage_class_name = "proxmox-lvm" + storage_class_name = "proxmox-lvm-encrypted" resources { requests = { storage = "1Gi" @@ -103,7 +110,7 @@ resource "kubernetes_deployment" "actualbudget" { volume { name = "data" persistent_volume_claim { - claim_name = kubernetes_persistent_volume_claim.data_proxmox.metadata[0].name + claim_name = kubernetes_persistent_volume_claim.data_encrypted.metadata[0].name } } } @@ -149,7 +156,7 @@ resource "random_string" "api-key" { } resource "kubernetes_deployment" "actualbudget-http-api" { - count = var.budget_encryption_password != null ? 1 : 0 + count = var.enable_http_api ? 1 : 0 metadata { name = "actualbudget-http-api-${var.name}" namespace = "actualbudget" @@ -232,7 +239,7 @@ resource "kubernetes_service" "actualbudget-http-api" { } resource "kubernetes_cron_job_v1" "bank-sync" { - count = var.sync_id != null && var.budget_encryption_password != null ? 1 : 0 + count = var.enable_bank_sync ? 1 : 0 metadata { name = "bank-sync-${var.name}" namespace = "actualbudget" diff --git a/stacks/actualbudget/main.tf b/stacks/actualbudget/main.tf index 552f52a5..4260c9e9 100644 --- a/stacks/actualbudget/main.tf +++ b/stacks/actualbudget/main.tf @@ -77,6 +77,8 @@ module "viktor" { nfs_server = var.nfs_server depends_on = [kubernetes_namespace.actualbudget] tier = local.tiers.edge + enable_http_api = true + enable_bank_sync = true budget_encryption_password = lookup(local.credentials["viktor"], "password", null) sync_id = lookup(local.credentials["viktor"], "sync_id", null) homepage_annotations = { @@ -98,6 +100,8 @@ module "anca" { nfs_server = var.nfs_server depends_on = [kubernetes_namespace.actualbudget] tier = local.tiers.edge + enable_http_api = true + enable_bank_sync = true budget_encryption_password = lookup(local.credentials["anca"], "password", null) sync_id = lookup(local.credentials["anca"], "sync_id", null) homepage_annotations = { @@ -119,6 +123,8 @@ module "emo" { nfs_server = var.nfs_server depends_on = [kubernetes_namespace.actualbudget] tier = local.tiers.edge + enable_http_api = true + enable_bank_sync = true budget_encryption_password = lookup(local.credentials["emo"], "password", null) sync_id = lookup(local.credentials["emo"], "sync_id", null) homepage_annotations = { diff --git a/stacks/reverse-proxy/modules/reverse_proxy/factory/main.tf b/stacks/reverse-proxy/modules/reverse_proxy/factory/main.tf index 066f385e..271d8b4b 100644 --- a/stacks/reverse-proxy/modules/reverse_proxy/factory/main.tf +++ b/stacks/reverse-proxy/modules/reverse_proxy/factory/main.tf @@ -66,6 +66,21 @@ variable "dns_type" { error_message = "dns_type must be 'proxied', 'non-proxied', or 'none'." } } + +# Uptime Kuma external monitor: when true, annotate the ingress so the +# external-monitor-sync CronJob creates a `[External] ` monitor pointing +# at https://. Null means "follow dns_type" — enabled when proxied. +variable "external_monitor" { + type = bool + default = null + description = "Enable Uptime Kuma external monitor. null = auto (enabled when dns_type == 'proxied')." +} + +variable "external_monitor_name" { + type = string + default = null + description = "Override the monitor label. Defaults to the ingress hostname label." +} variable "cloudflare_zone_id" { type = string default = "fd2c5dd4efe8fe38958944e74d0ced6d" @@ -106,6 +121,16 @@ resource "kubernetes_service" "proxied-service" { } } +locals { + # External monitor defaults: on when proxied, off otherwise. Explicit bool overrides. + effective_external_monitor = var.external_monitor != null ? var.external_monitor : (var.dns_type == "proxied") + + external_monitor_annotations = local.effective_external_monitor ? merge( + { "uptime.viktorbarzin.me/external-monitor" = "true" }, + var.external_monitor_name != null ? { "uptime.viktorbarzin.me/external-monitor-name" = var.external_monitor_name } : {}, + ) : {} +} + resource "kubernetes_ingress_v1" "proxied-ingress" { metadata { name = var.name @@ -125,8 +150,9 @@ resource "kubernetes_ingress_v1" "proxied-ingress" { "traefik.ingress.kubernetes.io/router.entrypoints" = "websecure" "traefik.ingress.kubernetes.io/service.serversscheme" = var.backend_protocol == "HTTPS" ? "https" : null "traefik.ingress.kubernetes.io/service.serverstransport" = var.backend_protocol == "HTTPS" ? "traefik-insecure-skip-verify@kubernetescrd" : null - }, var.extra_annotations, - var.dns_type != "none" ? { "cloudflare.viktorbarzin.me/dns-type" = var.dns_type } : {} + }, var.extra_annotations, + var.dns_type != "none" ? { "cloudflare.viktorbarzin.me/dns-type" = var.dns_type } : {}, + local.external_monitor_annotations, ) } diff --git a/stacks/uptime-kuma/modules/uptime-kuma/main.tf b/stacks/uptime-kuma/modules/uptime-kuma/main.tf index fa6f157c..9c23bf60 100644 --- a/stacks/uptime-kuma/modules/uptime-kuma/main.tf +++ b/stacks/uptime-kuma/modules/uptime-kuma/main.tf @@ -252,9 +252,50 @@ module "ingress" { # ============================================================================= # External Monitor Sync -# Ensures Uptime Kuma has external HTTPS monitors for all Cloudflare-proxied services. -# Reads targets from a Terraform-generated ConfigMap, creates/deletes monitors to match. +# Ensures Uptime Kuma has external HTTPS monitors for every ingress annotated +# with `uptime.viktorbarzin.me/external-monitor=true`. Falls back to a +# Terraform-generated ConfigMap when API discovery is unavailable. +# +# Discovery modes (the script tries them in order): +# 1. K8s API — list ingresses cluster-wide, filter by annotation +# 2. ConfigMap fallback — read /config/targets.json (legacy list from +# cloudflare_proxied_names) # ============================================================================= + +resource "kubernetes_service_account_v1" "external_monitor_sync" { + metadata { + name = "external-monitor-sync" + namespace = kubernetes_namespace.uptime-kuma.metadata[0].name + } +} + +resource "kubernetes_cluster_role_v1" "external_monitor_sync" { + metadata { + name = "external-monitor-sync" + } + rule { + api_groups = ["networking.k8s.io"] + resources = ["ingresses"] + verbs = ["list", "get"] + } +} + +resource "kubernetes_cluster_role_binding_v1" "external_monitor_sync" { + metadata { + name = "external-monitor-sync" + } + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "ClusterRole" + name = kubernetes_cluster_role_v1.external_monitor_sync.metadata[0].name + } + subject { + kind = "ServiceAccount" + name = kubernetes_service_account_v1.external_monitor_sync.metadata[0].name + namespace = kubernetes_namespace.uptime-kuma.metadata[0].name + } +} + resource "kubernetes_config_map_v1" "external_monitor_targets" { metadata { name = "external-monitor-targets" @@ -283,24 +324,79 @@ resource "kubernetes_cron_job_v1" "external_monitor_sync" { template { metadata {} spec { + service_account_name = kubernetes_service_account_v1.external_monitor_sync.metadata[0].name container { name = "sync" image = "docker.io/library/python:3.12-alpine" command = ["/bin/sh", "-c", <<-EOT pip install --quiet --disable-pip-version-check uptime-kuma-api python3 << 'PYEOF' -import os, json, time +import os, json, ssl, time, urllib.request, urllib.error from uptime_kuma_api import UptimeKumaApi, MonitorType UPTIME_KUMA_URL = "http://uptime-kuma.uptime-kuma.svc.cluster.local" UPTIME_KUMA_PASS = os.environ["UPTIME_KUMA_PASSWORD"] -TARGETS_FILE = "/config/targets.json" +FALLBACK_FILE = "/config/targets.json" PREFIX = "[External] " +ANNOTATION_ENABLE = "uptime.viktorbarzin.me/external-monitor" +ANNOTATION_NAME = "uptime.viktorbarzin.me/external-monitor-name" +SA_DIR = "/var/run/secrets/kubernetes.io/serviceaccount" +API_SERVER = f"https://{os.environ.get('KUBERNETES_SERVICE_HOST', 'kubernetes.default.svc.cluster.local')}:{os.environ.get('KUBERNETES_SERVICE_PORT', '443')}" -with open(TARGETS_FILE) as f: - targets = json.load(f) -print(f"Loaded {len(targets)} external monitor targets") +def load_from_api(): + """List ingresses via in-cluster API, filter by annotation, derive targets.""" + with open(f"{SA_DIR}/token") as f: + token = f.read().strip() + ctx = ssl.create_default_context(cafile=f"{SA_DIR}/ca.crt") + url = f"{API_SERVER}/apis/networking.k8s.io/v1/ingresses" + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + with urllib.request.urlopen(req, context=ctx, timeout=30) as resp: + body = json.loads(resp.read()) + + targets = [] + for ing in body.get("items", []): + anns = (ing.get("metadata") or {}).get("annotations") or {} + if anns.get(ANNOTATION_ENABLE, "").lower() != "true": + continue + tls = (ing.get("spec") or {}).get("tls") or [] + host = None + if tls and tls[0].get("hosts"): + host = tls[0]["hosts"][0] + else: + rules = (ing.get("spec") or {}).get("rules") or [] + if rules: + host = rules[0].get("host") + if not host: + ns = ing["metadata"]["namespace"] + nm = ing["metadata"]["name"] + print(f"WARN: ingress {ns}/{nm} annotated but has no host; skipping") + continue + label = anns.get(ANNOTATION_NAME) or host.split(".")[0] + targets.append({"name": label, "url": f"https://{host}"}) + return targets + + +def load_from_configmap(): + """Legacy fallback: read the ConfigMap list.""" + with open(FALLBACK_FILE) as f: + raw = json.load(f) + return [{"name": t["name"], "url": t["url"]} for t in raw] + + +try: + targets = load_from_api() + source = "k8s-api" + if not targets: + print("WARN: k8s-api returned 0 targets; falling back to ConfigMap") + targets = load_from_configmap() + source = "configmap" +except (urllib.error.URLError, OSError, KeyError, ValueError) as e: + print(f"WARN: k8s-api discovery failed ({e!r}); falling back to ConfigMap") + targets = load_from_configmap() + source = "configmap" + +print(f"Loaded {len(targets)} external monitor targets (source={source})") api = UptimeKumaApi(UPTIME_KUMA_URL, timeout=120, wait_events=0.2) api.login("admin", UPTIME_KUMA_PASS)