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:
parent
8436295bbc
commit
d4ccff518e
@ -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
|
||||||
|
|||||||
@ -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: /
|
||||||
|
|||||||
@ -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/
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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: /
|
||||||
|
|||||||
@ -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: /
|
||||||
|
|||||||
@ -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}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
41
infrastructure/terraform/traefik.tf
Normal file
41
infrastructure/terraform/traefik.tf
Normal 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" }
|
||||||
|
}
|
||||||
|
})]
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user