[reverse-proxy] Fix gw.viktorbarzin.me — point at 192.168.1.1 via EndpointSlice

The TP-Link gateway was wired via ExternalName `gw.viktorbarzin.lan`, but
Technitium has no record for that name (the router isn't a DHCP client and
Kea DDNS never registers it), so the ingress backend returned NXDOMAIN and
the `[External] gw` Uptime Kuma monitor was permanently failing.

Factory now accepts `backend_ip` as an alternative to `external_name`: it
creates a selector-less ClusterIP Service + manual EndpointSlice pointing
at the given IP, bypassing cluster DNS entirely. Used for gw (192.168.1.1);
the old ExternalName path is retained for every other service.

Also add a direct `port` monitor for the router in uptime-kuma's
internal_monitors list so we can tell a Cloudflare/tunnel outage apart
from the router itself being down. Extended the internal-monitor-sync
script to handle non-DB monitor types (hostname + port fields).
This commit is contained in:
Viktor Barzin 2026-04-19 15:07:24 +00:00
parent 4b39fbb717
commit a86a97deb7
3 changed files with 112 additions and 25 deletions

View file

@ -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")

View file

@ -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"

View file

@ -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")