diff --git a/stacks/reverse-proxy/modules/reverse_proxy/factory/main.tf b/stacks/reverse-proxy/modules/reverse_proxy/factory/main.tf index 6f432225..010c897e 100644 --- a/stacks/reverse-proxy/modules/reverse_proxy/factory/main.tf +++ b/stacks/reverse-proxy/modules/reverse_proxy/factory/main.tf @@ -14,7 +14,16 @@ variable "name" {} variable "namespace" { default = "reverse-proxy" } -variable "external_name" {} +variable "external_name" { + type = string + default = null + description = "DNS name for ExternalName Service. Mutually exclusive with backend_ip." +} +variable "backend_ip" { + type = string + default = null + description = "IP address backend. When set, creates a selector-less Service + EndpointSlice pointing at this IP. Mutually exclusive with external_name — use for hosts that aren't in Technitium (e.g. upstream gateways)." +} variable "port" { default = "80" } @@ -95,7 +104,14 @@ variable "public_ipv6" { } +locals { + use_backend_ip = var.backend_ip != null + port_name = var.backend_protocol == "HTTPS" ? "https-${var.name}" : "${var.name}-web" +} + +# ExternalName flavor — used when the backend is addressable by DNS. resource "kubernetes_service" "proxied-service" { + count = local.use_backend_ip ? 0 : 1 metadata { name = var.name namespace = var.namespace @@ -109,7 +125,7 @@ resource "kubernetes_service" "proxied-service" { external_name = var.external_name port { - name = var.backend_protocol == "HTTPS" ? "https-${var.name}" : "${var.name}-web" + name = local.port_name port = var.port protocol = "TCP" target_port = var.port @@ -117,6 +133,58 @@ resource "kubernetes_service" "proxied-service" { } } +# IP-backend flavor — selector-less Service + manually-managed EndpointSlice. +# Used for upstreams that have no DNS entry in Technitium (e.g. 192.168.1.1). +resource "kubernetes_service" "ip-backend-service" { + count = local.use_backend_ip ? 1 : 0 + metadata { + name = var.name + namespace = var.namespace + labels = { + "app" = var.name + } + } + + spec { + type = "ClusterIP" + port { + name = local.port_name + port = var.port + protocol = "TCP" + target_port = var.port + } + } +} + +resource "kubernetes_manifest" "ip_backend_endpointslice" { + count = local.use_backend_ip ? 1 : 0 + manifest = { + apiVersion = "discovery.k8s.io/v1" + kind = "EndpointSlice" + metadata = { + name = var.name + namespace = var.namespace + labels = { + "kubernetes.io/service-name" = var.name + "app" = var.name + } + } + addressType = "IPv4" + ports = [{ + name = local.port_name + port = tonumber(var.port) + protocol = "TCP" + }] + endpoints = [{ + addresses = [var.backend_ip] + conditions = { + ready = true + } + }] + } + depends_on = [kubernetes_service.ip-backend-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") diff --git a/stacks/reverse-proxy/modules/reverse_proxy/main.tf b/stacks/reverse-proxy/modules/reverse_proxy/main.tf index 9b6acadf..a731cf63 100644 --- a/stacks/reverse-proxy/modules/reverse_proxy/main.tf +++ b/stacks/reverse-proxy/modules/reverse_proxy/main.tf @@ -112,13 +112,11 @@ module "idrac" { depends_on = [kubernetes_namespace.reverse-proxy] } -# Can either listen on https or http; can't do both :/ -# TODO: Not working yet module "tp-link-gateway" { source = "./factory" dns_type = "proxied" name = "gw" - external_name = "gw.viktorbarzin.lan" + backend_ip = "192.168.1.1" port = 443 tls_secret_name = var.tls_secret_name backend_protocol = "HTTPS" diff --git a/stacks/uptime-kuma/modules/uptime-kuma/main.tf b/stacks/uptime-kuma/modules/uptime-kuma/main.tf index 5d034599..3572c2ce 100644 --- a/stacks/uptime-kuma/modules/uptime-kuma/main.tf +++ b/stacks/uptime-kuma/modules/uptime-kuma/main.tf @@ -552,6 +552,8 @@ locals { type = "mysql" database_connection_string = "mysql://uptimekuma@mysql.dbaas.svc.cluster.local:3306" database_password_vault_key = "uptimekuma_db_password" + hostname = null + port = null interval = 60 retry_interval = 60 max_retries = 2 @@ -565,6 +567,23 @@ locals { type = "redis" database_connection_string = "redis://redis-master.redis.svc.cluster.local:6379" database_password_vault_key = null + hostname = null + port = null + interval = 60 + retry_interval = 30 + max_retries = 3 + }, + { + # TP-Link home router upstream of pfSense. Complements the + # `[External] gw` HTTPS monitor: this one checks the router + # directly on 443, so we can tell a Cloudflare/tunnel outage + # apart from the router itself being unreachable. + name = "TP-Link Gateway (192.168.1.1)" + type = "port" + database_connection_string = null + database_password_vault_key = null + hostname = "192.168.1.1" + port = 443 interval = 60 retry_interval = 30 max_retries = 3 @@ -599,6 +618,8 @@ resource "kubernetes_config_map_v1" "internal_monitor_targets" { name = m.name type = m.type database_connection_string = m.database_connection_string + hostname = m.hostname + port = m.port password_env = m.database_password_vault_key != null ? "DB_PASSWORD_${upper(replace(m.name, "/[^A-Za-z0-9]/", "_"))}" : null interval = m.interval retry_interval = m.retry_interval @@ -648,41 +669,41 @@ existing = {m["name"]: m for m in api.get_monitors()} for t in targets: name = t["name"] + mtype = MonitorType(t["type"]) # MYSQL uses `databaseConnectionString` + `radiusPassword` (UK v2 re-uses # radiusPassword for mysql auth — backwards compat). Redis has auth - # disabled on the cluster, so password_env is null. + # disabled on the cluster, so password_env is null. PORT monitors use + # hostname + port directly. desired = { - "type": MonitorType(t["type"]), + "type": mtype, "name": name, - "databaseConnectionString": t["database_connection_string"], "interval": t["interval"], "retryInterval": t["retry_interval"], "maxretries": t["max_retries"], } - if t.get("password_env"): - desired["radiusPassword"] = os.environ[t["password_env"]] + if mtype == MonitorType.PORT: + desired["hostname"] = t["hostname"] + desired["port"] = t["port"] + else: + desired["databaseConnectionString"] = t["database_connection_string"] + if t.get("password_env"): + desired["radiusPassword"] = os.environ[t["password_env"]] if name not in existing: print(f"Creating monitor: {name}") api.add_monitor(**desired) continue m = existing[name] - drifted = ( - m.get("databaseConnectionString") != desired["databaseConnectionString"] - or m.get("interval") != desired["interval"] - or m.get("retryInterval") != desired["retryInterval"] - or m.get("maxretries") != desired["maxretries"] - or ("radiusPassword" in desired and m.get("radiusPassword") != desired["radiusPassword"]) - ) + drift_fields = ["interval", "retryInterval", "maxretries"] + if mtype == MonitorType.PORT: + drift_fields += ["hostname", "port"] + else: + drift_fields += ["databaseConnectionString"] + if "radiusPassword" in desired: + drift_fields += ["radiusPassword"] + drifted = any(m.get(f) != desired.get(f) for f in drift_fields) if drifted: print(f"Updating monitor {name} (id={m['id']})") - edit_kwargs = { - "databaseConnectionString": desired["databaseConnectionString"], - "interval": desired["interval"], - "retryInterval": desired["retryInterval"], - "maxretries": desired["maxretries"], - } - if "radiusPassword" in desired: - edit_kwargs["radiusPassword"] = desired["radiusPassword"] + edit_kwargs = {f: desired[f] for f in drift_fields if f in desired} api.edit_monitor(m["id"], **edit_kwargs) else: print(f"Monitor {name} (id={m['id']}) already in desired state")