Gonçalo Rodrigues d4ccff518e 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>
2026-06-26 21:45:19 +01:00

668 lines
19 KiB
Go

package main
import (
"bytes"
"context"
"embed"
"encoding/json"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"time"
"homelab/pkg/auth"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
//go:embed templates/*.html
var templateFS embed.FS
func parseTmpl(files ...string) *template.Template {
return template.Must(template.New("").Funcs(template.FuncMap{
"join": strings.Join,
"has": hasString,
}).ParseFS(templateFS, files...))
}
var loginTmpl = parseTmpl("templates/base.html", "templates/login.html")
var registerTmpl = parseTmpl("templates/base.html", "templates/register.html")
var dashboardTmpl = parseTmpl("templates/base.html", "templates/dashboard.html")
var homeTmpl = parseTmpl("templates/base.html", "templates/home.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")
type Handler struct{}
// --- Template data types ---
type LoginData struct {
Error string
}
type RegisterData struct {
Error string
Success string
}
type DashboardData struct {
UserID string
Email string
Roles []string
Permissions []string
IsAdmin bool
Users []UserView
RoleDefs []RoleView
}
type HomeData struct {
Services []ServiceCard
}
type UserView struct {
ID string
Email string
Roles []string
}
type RoleView struct {
ID string
Name string
Description string
Permissions []string
}
type ServiceCard struct {
Name string
Description string
URL string
Icon string
Delay float64
}
// --- Helpers ---
func usersSvc() string {
if v := os.Getenv("USERS_SERVICE"); v != "" {
return v
}
return "http://users"
}
func spanAttrs(r *http.Request) []attribute.KeyValue {
return []attribute.KeyValue{
attribute.String("http.method", r.Method),
attribute.String("http.target", r.URL.String()),
}
}
func hasString(slice []string, target string) bool {
for _, s := range slice {
if s == target {
return true
}
}
return false
}
func originalHost(r *http.Request) string {
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
return h
}
return r.Host
}
// --- Page handlers ---
func (h *Handler) Root(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
host := originalHost(r)
if strings.Contains(host, "auth.") {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
h.Home(w, r)
}
func (h *Handler) Home(w http.ResponseWriter, r *http.Request) {
_, span := tracer.Start(r.Context(), "Home", trace.WithAttributes(spanAttrs(r)...))
defer span.End()
s, d := scheme(), domain()
services := []ServiceCard{
{Name: "Auth", Description: "Login and account management", URL: s + "://auth." + d + "/dashboard", Icon: "🔑", Delay: 0},
{Name: "Finance", Description: "Track your finances", URL: s + "://finance." + d, Icon: "💰", Delay: 0.2},
{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: s + "://grafana." + d, Icon: "📊", Delay: 0.6},
{Name: "Jaeger", Description: "Trace service requests", URL: s + "://jaeger." + d, Icon: "🔍", Delay: 0.8},
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
homeTmpl.ExecuteTemplate(w, "home.html", HomeData{Services: services})
}
func (h *Handler) LoginPage(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "LoginPage", trace.WithAttributes(spanAttrs(r)...))
defer span.End()
errMsg := r.URL.Query().Get("error")
if errMsg != "" {
span.SetAttributes(attribute.String("login.error", errMsg))
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
loginTmpl.ExecuteTemplate(w, "login.html", LoginData{Error: errMsg})
slog.DebugContext(ctx, "login page served")
}
func (h *Handler) RegisterPage(w http.ResponseWriter, r *http.Request) {
_, span := tracer.Start(r.Context(), "RegisterPage", trace.WithAttributes(spanAttrs(r)...))
defer span.End()
success := r.URL.Query().Get("success")
errMsg := r.URL.Query().Get("error")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
registerTmpl.ExecuteTemplate(w, "register.html", RegisterData{Error: errMsg, Success: success})
}
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "Register", trace.WithAttributes(spanAttrs(r)...))
defer span.End()
r.ParseForm()
email := r.FormValue("email")
password := r.FormValue("password")
code := r.FormValue("code")
body, _ := json.Marshal(map[string]string{"email": email, "password": password, "code": code})
resp, err := httpClient.Post(fmt.Sprintf("%s/register", usersSvc()), "application/json", bytes.NewReader(body))
if err != nil {
span.RecordError(err)
slog.ErrorContext(ctx, "register: users service error", "err", err)
http.Redirect(w, r, "/register?error=upstream+error", http.StatusSeeOther)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
span.SetAttributes(attribute.Int("register.status", resp.StatusCode))
slog.WarnContext(ctx, "register failed", "email", email, "status", resp.StatusCode)
http.Redirect(w, r, "/register?error=registration+failed", http.StatusSeeOther)
return
}
span.SetAttributes(attribute.String("register.email", email))
slog.InfoContext(ctx, "user registered", "email", email)
http.Redirect(w, r, "/login?success=registered", http.StatusSeeOther)
}
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "Dashboard", trace.WithAttributes(spanAttrs(r)...))
defer span.End()
cookie, err := r.Cookie("auth_token")
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
claims, err := auth.ValidateToken(cookie.Value)
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
data := DashboardData{
UserID: claims.UserID,
Email: claims.Email,
Roles: claims.Roles,
Permissions: claims.Permissions,
IsAdmin: hasString(claims.Roles, "admin"),
}
// Fetch users and roles for admin view
if data.IsAdmin {
users, err := fetchAdminUsers(ctx)
if err != nil {
slog.ErrorContext(ctx, "dashboard: fetch users", "err", err)
} else {
data.Users = users
}
roles, err := fetchAdminRoles(ctx)
if err != nil {
slog.ErrorContext(ctx, "dashboard: fetch roles", "err", err)
} else {
data.RoleDefs = roles
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
dashboardTmpl.ExecuteTemplate(w, "dashboard.html", data)
}
// --- API handlers ---
func (h *Handler) LoginAPI(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "LoginAPI", trace.WithAttributes(spanAttrs(r)...))
defer span.End()
var req struct {
Email string `json:"email"`
Password string `json:"password"`
Redirect string `json:"redirect"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
span.RecordError(err)
slog.WarnContext(ctx, "invalid login api body", "err", err)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
span.SetAttributes(attribute.String("login.email", req.Email))
user, err := verifyCredentials(ctx, req.Email, req.Password)
if err != nil {
span.SetAttributes(attribute.String("auth.result", "failure"))
slog.InfoContext(ctx, "login failed", "email", req.Email)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
return
}
span.SetAttributes(
attribute.String("auth.result", "success"),
attribute.String("user.id", user.ID),
attribute.String("user.email", user.Email),
attribute.StringSlice("user.roles", user.Roles),
)
token, err := auth.GenerateToken(user.ID, user.Email, user.Roles, user.Permissions)
if err != nil {
span.RecordError(err)
slog.ErrorContext(ctx, "token generation failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
http.SetCookie(w, &http.Cookie{
Name: "auth_token",
Value: token,
Path: "/",
Domain: "." + domain(),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": user})
slog.InfoContext(ctx, "login success", "email", user.Email, "roles", user.Roles)
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "LoginForm", trace.WithAttributes(spanAttrs(r)...))
defer span.End()
r.ParseForm()
email := r.FormValue("email")
password := r.FormValue("password")
span.SetAttributes(attribute.String("login.email", email))
user, err := verifyCredentials(ctx, email, password)
if err != nil {
span.SetAttributes(attribute.String("auth.result", "failure"))
slog.InfoContext(ctx, "form login failed", "email", email)
http.Redirect(w, r, "/login?error=invalid+credentials", http.StatusSeeOther)
return
}
span.SetAttributes(
attribute.String("auth.result", "success"),
attribute.String("user.id", user.ID),
attribute.String("user.email", user.Email),
)
token, err := auth.GenerateToken(user.ID, user.Email, user.Roles, user.Permissions)
if err != nil {
span.RecordError(err)
slog.ErrorContext(ctx, "token generation failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "auth_token",
Value: token,
Path: "/",
Domain: "." + domain(),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
redirect := r.URL.Query().Get("redirect")
if redirect == "" {
redirect = "/dashboard"
}
http.Redirect(w, r, redirect, http.StatusSeeOther)
slog.InfoContext(ctx, "form login success", "email", user.Email, "redirect", redirect)
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "Logout", trace.WithAttributes(spanAttrs(r)...))
defer span.End()
http.SetCookie(w, &http.Cookie{
Name: "auth_token",
Value: "",
Path: "/",
Domain: "." + domain(),
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, scheme()+"://"+domain()+"/", http.StatusSeeOther)
slog.InfoContext(ctx, "user logged out")
}
func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "Verify", trace.WithAttributes(spanAttrs(r)...))
defer span.End()
cookie, err := r.Cookie("auth_token")
if err != nil {
host := originalHost(r)
redirect := r.URL.Query().Get("redirect")
if redirect == "" {
redirect = fmt.Sprintf("http://%s/", host)
}
loginURL := fmt.Sprintf("%s://auth.%s/login?redirect=%s", scheme(), domain(), url.QueryEscape(redirect))
span.SetAttributes(attribute.String("verify.result", "no_cookie"))
slog.DebugContext(ctx, "verify: no cookie, redirecting", "to", loginURL)
http.Redirect(w, r, loginURL, http.StatusFound)
return
}
claims, err := auth.ValidateToken(cookie.Value)
if err != nil {
span.SetAttributes(attribute.String("verify.result", "invalid_token"))
slog.WarnContext(ctx, "verify: invalid token")
http.Redirect(w, r, scheme()+"://auth."+domain()+"/login", http.StatusFound)
return
}
span.SetAttributes(
attribute.String("verify.result", "valid"),
attribute.String("user.id", claims.UserID),
attribute.String("user.email", claims.Email),
attribute.StringSlice("user.roles", claims.Roles),
)
// Check service-specific permission
host := originalHost(r)
if reqPerm, ok := servicePermissions()[host]; ok {
span.SetAttributes(attribute.String("verify.target_host", host), attribute.String("verify.required_perm", reqPerm))
if !hasPermission(claims.Permissions, reqPerm) {
span.SetAttributes(attribute.String("verify.result", "forbidden"))
slog.WarnContext(ctx, "verify: access denied", "email", claims.Email, "host", host, "required", reqPerm)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
forbiddenTmpl.ExecuteTemplate(w, "forbidden.html", nil)
return
}
}
w.Header().Set("X-Auth-User-Id", claims.UserID)
w.Header().Set("X-Auth-Email", claims.Email)
w.Header().Set("X-Auth-Roles", strings.Join(claims.Roles, ","))
w.Header().Set("X-Auth-Permissions", strings.Join(claims.Permissions, ","))
w.WriteHeader(http.StatusOK)
slog.DebugContext(ctx, "verify: allowed", "email", claims.Email, "host", host)
}
// --- Admin proxy ---
var usersURL *url.URL
func init() {
u, _ := url.Parse(usersSvc())
usersURL = u
}
func (h *Handler) AdminProxy(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "AdminProxy", trace.WithAttributes(spanAttrs(r)...))
defer span.End()
cookie, err := r.Cookie("auth_token")
if err != nil {
span.SetAttributes(attribute.String("proxy.result", "no_cookie"))
slog.WarnContext(ctx, "admin proxy: no cookie")
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
return
}
claims, err := auth.ValidateToken(cookie.Value)
if err != nil {
span.SetAttributes(attribute.String("proxy.result", "invalid_token"))
slog.WarnContext(ctx, "admin proxy: invalid token")
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"})
return
}
span.SetAttributes(
attribute.String("user.id", claims.UserID),
attribute.String("user.email", claims.Email),
attribute.StringSlice("user.roles", claims.Roles),
)
if !hasRole(claims.Roles, "admin") {
span.SetAttributes(attribute.String("proxy.result", "forbidden"))
slog.WarnContext(ctx, "admin proxy: not admin", "email", claims.Email, "roles", claims.Roles)
writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin access required"})
return
}
span.SetAttributes(attribute.String("proxy.result", "proxied"))
r2 := r.Clone(ctx)
r2.URL.Path = strings.TrimPrefix(r.URL.Path, "/api")
r2.URL.RawPath = strings.TrimPrefix(r.URL.RawPath, "/api")
r2.RequestURI = r2.URL.RequestURI()
proxy := httputil.NewSingleHostReverseProxy(usersURL)
proxy.ServeHTTP(w, r2)
slog.InfoContext(ctx, "admin proxy", "path", r.URL.Path, "email", claims.Email)
}
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "Me", trace.WithAttributes(spanAttrs(r)...))
defer span.End()
cookie, err := r.Cookie("auth_token")
if err != nil {
span.SetAttributes(attribute.String("me.result", "no_cookie"))
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
return
}
claims, err := auth.ValidateToken(cookie.Value)
if err != nil {
span.SetAttributes(attribute.String("me.result", "invalid_token"))
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"})
return
}
span.SetAttributes(
attribute.String("user.id", claims.UserID),
attribute.String("user.email", claims.Email),
attribute.StringSlice("user.roles", claims.Roles),
attribute.StringSlice("user.permissions", claims.Permissions),
)
writeJSON(w, http.StatusOK, map[string]any{
"id": claims.UserID,
"email": claims.Email,
"roles": claims.Roles,
"permissions": claims.Permissions,
})
slog.DebugContext(ctx, "me: user info returned", "email", claims.Email)
}
func (h *Handler) RegisterProxy(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "RegisterProxy", trace.WithAttributes(spanAttrs(r)...))
defer span.End()
body, err := io.ReadAll(r.Body)
if err != nil {
span.RecordError(err)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot read body"})
return
}
resp, err := httpClient.Post(fmt.Sprintf("%s/register", usersSvc()), "application/json", bytes.NewReader(body))
if err != nil {
span.RecordError(err)
slog.ErrorContext(ctx, "register proxy: users service error", "err", err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "upstream error"})
return
}
defer resp.Body.Close()
span.SetAttributes(attribute.Int("upstream_status", resp.StatusCode))
respBody, _ := io.ReadAll(resp.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(respBody)
}
func servicePermissions() map[string]string {
d := domain()
return map[string]string{
"grafana." + d: "service:grafana:access",
"jaeger." + d: "service:jaeger:access",
d: "service:home:access",
}
}
func hasPermission(perms []string, target string) bool {
for _, p := range perms {
if p == target || p == "service:*" {
return true
}
}
return false
}
func hasRole(roles []string, target string) bool {
for _, r := range roles {
if r == target {
return true
}
}
return false
}
// --- Shared types ---
type userResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Roles []string `json:"roles"`
Permissions []string `json:"permissions"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
var httpClient = &http.Client{Timeout: 5 * time.Second}
func verifyCredentials(ctx context.Context, email, password string) (*userResponse, error) {
ctx, span := tracer.Start(ctx, "verifyCredentials",
trace.WithAttributes(attribute.String("login.email", email)),
)
defer span.End()
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
resp, err := httpClient.Post(fmt.Sprintf("%s/verify-password", usersSvc()), "application/json", bytes.NewReader(body))
if err != nil {
span.RecordError(err)
return nil, fmt.Errorf("users service: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
span.SetAttributes(attribute.String("verify.result", "invalid_credentials"))
return nil, fmt.Errorf("invalid credentials")
}
var user userResponse
json.NewDecoder(resp.Body).Decode(&user)
span.SetAttributes(
attribute.String("verify.result", "valid"),
attribute.String("user.id", user.ID),
attribute.String("user.email", user.Email),
attribute.StringSlice("user.roles", user.Roles),
)
return &user, nil
}
// --- Admin data fetching ---
func fetchAdminUsers(ctx context.Context) ([]UserView, error) {
ctx, span := tracer.Start(ctx, "fetchAdminUsers")
defer span.End()
resp, err := httpClient.Get(fmt.Sprintf("%s/admin/users", usersSvc()))
if err != nil {
return nil, fmt.Errorf("fetch users: %w", err)
}
defer resp.Body.Close()
var views []UserView
if err := json.NewDecoder(resp.Body).Decode(&views); err != nil {
return nil, fmt.Errorf("decode users: %w", err)
}
return views, nil
}
func fetchAdminRoles(ctx context.Context) ([]RoleView, error) {
ctx, span := tracer.Start(ctx, "fetchAdminRoles")
defer span.End()
resp, err := httpClient.Get(fmt.Sprintf("%s/admin/roles", usersSvc()))
if err != nil {
return nil, fmt.Errorf("fetch roles: %w", err)
}
defer resp.Body.Close()
var roles []RoleView
if err := json.NewDecoder(resp.Body).Decode(&roles); err != nil {
return nil, fmt.Errorf("decode roles: %w", err)
}
return roles, nil
}