feat: switch to gugagr.xyz with TLS via Let's Encrypt (#39)

Adds Traefik Helm release (kube-system) with ACME HTTP-01 challenge
configured for Let's Encrypt, replacing the k3s-disabled bundled Traefik.

Migrates all hostnames from *.homelab.local to *.gugagr.xyz and upgrades
all ingresses to HTTPS with certresolver=letsencrypt annotations.

Adds var.domain (default homelab.local) to Terraform so the domain is
a single config point for monitoring and Gitea ingresses.

Gateway reads DOMAIN env var at runtime — falls back to homelab.local
so local k3d dev continues to work without changes.

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gonçalo Rodrigues 2026-06-26 21:45:19 +01:00 committed by GitHub
parent 8436295bbc
commit d4ccff518e
11 changed files with 119 additions and 33 deletions

View File

@ -36,6 +36,8 @@ spec:
value: "http://users" value: "http://users"
- name: OTEL_EXPORTER_OTLP_ENDPOINT - name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "jaeger.monitoring.svc:4317" value: "jaeger.monitoring.svc:4317"
- name: DOMAIN
value: "gugagr.xyz"
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /healthz path: /healthz

View File

@ -5,10 +5,15 @@ metadata:
name: gateway-home name: gateway-home
annotations: annotations:
traefik.ingress.kubernetes.io/router.middlewares: auth-forward-auth@kubernetescrd traefik.ingress.kubernetes.io/router.middlewares: auth-forward-auth@kubernetescrd
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
spec: spec:
ingressClassName: traefik ingressClassName: traefik
tls:
- hosts:
- gugagr.xyz
rules: rules:
- host: homelab.local - host: gugagr.xyz
http: http:
paths: paths:
- path: / - path: /

View File

@ -3,10 +3,16 @@ kind: Ingress
metadata: metadata:
namespace: auth namespace: auth
name: gateway name: gateway
annotations:
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
spec: spec:
ingressClassName: traefik ingressClassName: traefik
tls:
- hosts:
- auth.gugagr.xyz
rules: rules:
- host: auth.homelab.local - host: auth.gugagr.xyz
http: http:
paths: paths:
- path: /api/ - path: /api/

View File

@ -39,6 +39,20 @@ var dashboardTmpl = parseTmpl("templates/base.html", "templates/dashboard.html")
var homeTmpl = parseTmpl("templates/base.html", "templates/home.html") var homeTmpl = parseTmpl("templates/base.html", "templates/home.html")
var forbiddenTmpl = parseTmpl("templates/base.html", "templates/forbidden.html") var forbiddenTmpl = parseTmpl("templates/base.html", "templates/forbidden.html")
func domain() string {
if d := os.Getenv("DOMAIN"); d != "" {
return d
}
return "homelab.local"
}
func scheme() string {
if os.Getenv("DOMAIN") != "" {
return "https"
}
return "http"
}
var tracer = otel.Tracer("gateway") var tracer = otel.Tracer("gateway")
type Handler struct{} type Handler struct{}
@ -142,12 +156,13 @@ func (h *Handler) Home(w http.ResponseWriter, r *http.Request) {
_, span := tracer.Start(r.Context(), "Home", trace.WithAttributes(spanAttrs(r)...)) _, span := tracer.Start(r.Context(), "Home", trace.WithAttributes(spanAttrs(r)...))
defer span.End() defer span.End()
s, d := scheme(), domain()
services := []ServiceCard{ services := []ServiceCard{
{Name: "Auth", Description: "Login and account management", URL: "http://auth.homelab.local/dashboard", Icon: "🔑", Delay: 0}, {Name: "Auth", Description: "Login and account management", URL: s + "://auth." + d + "/dashboard", Icon: "🔑", Delay: 0},
{Name: "Finance", Description: "Track your finances", URL: "http://finance.homelab.local", Icon: "💰", Delay: 0.2}, {Name: "Finance", Description: "Track your finances", URL: s + "://finance." + d, Icon: "💰", Delay: 0.2},
{Name: "Test App", Description: "Example Go service", URL: "http://test.homelab.local", Icon: "🧪", Delay: 0.4}, {Name: "Test App", Description: "Example Go service", URL: s + "://example-service." + d, Icon: "🧪", Delay: 0.4},
{Name: "Monitoring", Description: "Use Grafana to monitor services", URL: "http://grafana.homelab.local", Icon: "📊", Delay: 0.6}, {Name: "Monitoring", Description: "Use Grafana to monitor services", URL: s + "://grafana." + d, Icon: "📊", Delay: 0.6},
{Name: "Jaeger", Description: "Trace service requests", URL: "http://jaeger.homelab.local", Icon: "🔍", Delay: 0.8}, {Name: "Jaeger", Description: "Trace service requests", URL: s + "://jaeger." + d, Icon: "🔍", Delay: 0.8},
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@ -300,7 +315,7 @@ func (h *Handler) LoginAPI(w http.ResponseWriter, r *http.Request) {
Name: "auth_token", Name: "auth_token",
Value: token, Value: token,
Path: "/", Path: "/",
Domain: ".homelab.local", Domain: "." + domain(),
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
@ -343,7 +358,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
Name: "auth_token", Name: "auth_token",
Value: token, Value: token,
Path: "/", Path: "/",
Domain: ".homelab.local", Domain: "." + domain(),
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
@ -364,12 +379,12 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
Name: "auth_token", Name: "auth_token",
Value: "", Value: "",
Path: "/", Path: "/",
Domain: ".homelab.local", Domain: "." + domain(),
MaxAge: -1, MaxAge: -1,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
http.Redirect(w, r, "http://homelab.local/", http.StatusSeeOther) http.Redirect(w, r, scheme()+"://"+domain()+"/", http.StatusSeeOther)
slog.InfoContext(ctx, "user logged out") slog.InfoContext(ctx, "user logged out")
} }
@ -384,7 +399,7 @@ func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) {
if redirect == "" { if redirect == "" {
redirect = fmt.Sprintf("http://%s/", host) redirect = fmt.Sprintf("http://%s/", host)
} }
loginURL := fmt.Sprintf("http://auth.homelab.local/login?redirect=%s", url.QueryEscape(redirect)) loginURL := fmt.Sprintf("%s://auth.%s/login?redirect=%s", scheme(), domain(), url.QueryEscape(redirect))
span.SetAttributes(attribute.String("verify.result", "no_cookie")) span.SetAttributes(attribute.String("verify.result", "no_cookie"))
slog.DebugContext(ctx, "verify: no cookie, redirecting", "to", loginURL) slog.DebugContext(ctx, "verify: no cookie, redirecting", "to", loginURL)
http.Redirect(w, r, loginURL, http.StatusFound) http.Redirect(w, r, loginURL, http.StatusFound)
@ -395,7 +410,7 @@ func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
span.SetAttributes(attribute.String("verify.result", "invalid_token")) span.SetAttributes(attribute.String("verify.result", "invalid_token"))
slog.WarnContext(ctx, "verify: invalid token") slog.WarnContext(ctx, "verify: invalid token")
http.Redirect(w, r, "http://auth.homelab.local/login", http.StatusFound) http.Redirect(w, r, scheme()+"://auth."+domain()+"/login", http.StatusFound)
return return
} }
span.SetAttributes( span.SetAttributes(
@ -407,7 +422,7 @@ func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) {
// Check service-specific permission // Check service-specific permission
host := originalHost(r) host := originalHost(r)
if reqPerm, ok := servicePermissions[host]; ok { if reqPerm, ok := servicePermissions()[host]; ok {
span.SetAttributes(attribute.String("verify.target_host", host), attribute.String("verify.required_perm", reqPerm)) span.SetAttributes(attribute.String("verify.target_host", host), attribute.String("verify.required_perm", reqPerm))
if !hasPermission(claims.Permissions, reqPerm) { if !hasPermission(claims.Permissions, reqPerm) {
span.SetAttributes(attribute.String("verify.result", "forbidden")) span.SetAttributes(attribute.String("verify.result", "forbidden"))
@ -541,10 +556,13 @@ func (h *Handler) RegisterProxy(w http.ResponseWriter, r *http.Request) {
w.Write(respBody) w.Write(respBody)
} }
var servicePermissions = map[string]string{ func servicePermissions() map[string]string {
"grafana.homelab.local": "service:grafana:access", d := domain()
"jaeger.homelab.local": "service:jaeger:access", return map[string]string{
"homelab.local": "service:home:access", "grafana." + d: "service:grafana:access",
"jaeger." + d: "service:jaeger:access",
d: "service:home:access",
}
} }
func hasPermission(perms []string, target string) bool { func hasPermission(perms []string, target string) bool {

View File

@ -32,7 +32,7 @@ spec:
- name: OTEL_EXPORTER_OTLP_ENDPOINT - name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "jaeger.monitoring.svc:4317" value: "jaeger.monitoring.svc:4317"
- name: BASE_URL - name: BASE_URL
value: "https://finance.homelab.local" value: "https://finance.gugagr.xyz"
- name: ADMIN_EMAIL - name: ADMIN_EMAIL
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:

View File

@ -3,13 +3,16 @@ kind: Ingress
metadata: metadata:
name: api name: api
namespace: finance namespace: finance
annotations:
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
spec: spec:
ingressClassName: traefik ingressClassName: traefik
tls: tls:
- hosts: - hosts:
- finance.homelab.local - finance.gugagr.xyz
rules: rules:
- host: finance.homelab.local - host: finance.gugagr.xyz
http: http:
paths: paths:
- path: / - path: /

View File

@ -1,12 +1,17 @@
---
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: example-service name: example-service
annotations:
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
spec: spec:
ingressClassName: traefik ingressClassName: traefik
tls:
- hosts:
- example-service.gugagr.xyz
rules: rules:
- host: example-service.homelab.local - host: example-service.gugagr.xyz
http: http:
paths: paths:
- path: / - path: /

View File

@ -13,7 +13,7 @@ resource "kubernetes_secret" "gitea_admin" {
data = { data = {
username = "admin" username = "admin"
password = random_password.gitea_admin[0].result password = random_password.gitea_admin[0].result
email = "admin@homelab.local" email = "admin@${var.domain}"
} }
} }
@ -35,8 +35,8 @@ resource "helm_release" "gitea" {
config = { config = {
APP_NAME = "Homelab Git" APP_NAME = "Homelab Git"
server = { server = {
DOMAIN = "git.homelab.local" DOMAIN = "git.${var.domain}"
ROOT_URL = "http://git.homelab.local" ROOT_URL = "http://git.${var.domain}"
SSH_DOMAIN = "localhost" SSH_DOMAIN = "localhost"
SSH_PORT = 30001 SSH_PORT = 30001
} }
@ -57,7 +57,7 @@ resource "helm_release" "gitea" {
enabled = true enabled = true
className = "traefik" className = "traefik"
hosts = [{ hosts = [{
host = "git.homelab.local" host = "git.${var.domain}"
paths = [{ path = "/", pathType = "Prefix" }] paths = [{ path = "/", pathType = "Prefix" }]
}] }]
} }
@ -112,7 +112,7 @@ resource "terraform_data" "gitea_runner_registration" {
command = <<-EOT command = <<-EOT
set -e set -e
echo "Waiting for Gitea to be ready..." echo "Waiting for Gitea to be ready..."
until curl -sf "http://git.homelab.local/api/v1/version" > /dev/null 2>&1; do until curl -sf "http://git.${var.domain}/api/v1/version" > /dev/null 2>&1; do
sleep 5 sleep 5
done done
@ -121,7 +121,7 @@ resource "terraform_data" "gitea_runner_registration" {
TOKEN=$(curl -sf \ TOKEN=$(curl -sf \
-u "admin:$PASSWORD" \ -u "admin:$PASSWORD" \
"http://git.homelab.local/api/v1/admin/runners/registration-token" \ "http://git.${var.domain}/api/v1/admin/runners/registration-token" \
| grep -o '"token":"[^"]*"' | cut -d'"' -f4) | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
kubectl patch secret gitea-runner-token -n gitea \ kubectl patch secret gitea-runner-token -n gitea \
@ -147,7 +147,7 @@ resource "kubernetes_secret" "gitea_registry" {
data = { data = {
".dockerconfigjson" = jsonencode({ ".dockerconfigjson" = jsonencode({
auths = { auths = {
"git.homelab.local" = { "git.${var.domain}" = {
auth = base64encode("admin:${random_password.gitea_admin[0].result}") auth = base64encode("admin:${random_password.gitea_admin[0].result}")
} }
} }

View File

@ -21,7 +21,7 @@ resource "helm_release" "kube_prometheus_stack" {
adminPassword = random_password.grafana[0].result adminPassword = random_password.grafana[0].result
ingress = { ingress = {
enabled = true enabled = true
hosts = ["grafana.homelab.local"] hosts = ["grafana.${var.domain}"]
ingressClassName = "traefik" ingressClassName = "traefik"
annotations = { annotations = {
"traefik.ingress.kubernetes.io/router.middlewares" = "auth-forward-auth@kubernetescrd" "traefik.ingress.kubernetes.io/router.middlewares" = "auth-forward-auth@kubernetescrd"
@ -32,7 +32,7 @@ resource "helm_release" "kube_prometheus_stack" {
name = "Jaeger" name = "Jaeger"
type = "jaeger" type = "jaeger"
uid = "jaeger" uid = "jaeger"
url = "http://jaeger.monitoring.svc:16686" url = "http://jaeger.monitoring.svc.cluster.local:16686"
access = "proxy" access = "proxy"
isDefault = false isDefault = false
}, },
@ -92,7 +92,7 @@ resource "helm_release" "jaeger" {
jaeger = { jaeger = {
ingress = { ingress = {
enabled = true enabled = true
hosts = ["jaeger.homelab.local"] hosts = ["jaeger.${var.domain}"]
annotations = { annotations = {
"traefik.ingress.kubernetes.io/router.middlewares" = "auth-forward-auth@kubernetescrd" "traefik.ingress.kubernetes.io/router.middlewares" = "auth-forward-auth@kubernetescrd"
} }

View File

@ -0,0 +1,41 @@
resource "helm_release" "traefik" {
name = "traefik"
namespace = "kube-system"
repository = "https://traefik.github.io/charts"
chart = "traefik"
version = "~> 33.0"
atomic = true
values = [yamlencode({
ports = {
web = {
redirectTo = { port = "websecure" }
}
}
ingressRoute = {
dashboard = { enabled = false }
}
additionalArguments = [
"--certificatesresolvers.letsencrypt.acme.email=goncalo.gr@proton.me",
"--certificatesresolvers.letsencrypt.acme.storage=/data/acme.json",
"--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web",
]
persistence = {
enabled = true
size = "128Mi"
storageClass = "local-path"
}
service = {
type = "LoadBalancer"
}
resources = {
requests = { cpu = "50m", memory = "64Mi" }
limits = { cpu = "200m", memory = "128Mi" }
}
})]
}

View File

@ -9,3 +9,9 @@ variable "enable_monitoring" {
type = bool type = bool
default = true default = true
} }
variable "domain" {
description = "Base domain for all ingress hostnames (e.g. gugagr.xyz). Subdomains are created per service."
type = string
default = "homelab.local"
}