package main import ( "log" "net/http" "net/http/httputil" "net/url" "os" "strings" goidentity "github.com/jcmturner/goidentity/v6" "github.com/jcmturner/gokrb5/v8/keytab" "github.com/jcmturner/gokrb5/v8/spnego" ) const ( upstreamURL = "http://gitea-http.gitea.svc.cluster.local:3000" listenAddr = ":4000" ) // statusCapture wraps http.ResponseWriter to capture the HTTP status code // written by a downstream handler so callers can inspect it after ServeHTTP. type statusCapture struct { http.ResponseWriter status int } func (s *statusCapture) WriteHeader(code int) { s.status = code s.ResponseWriter.WriteHeader(code) } func main() { keytabPath := os.Getenv("KRB5_KTNAME") if keytabPath == "" { keytabPath = "/etc/krb5/http.keytab" } kt, err := keytab.Load(keytabPath) if err != nil { log.Fatalf("failed to load keytab %s: %v", keytabPath, err) } upstream, _ := url.Parse(upstreamURL) proxy := httputil.NewSingleHostReverseProxy(upstream) proxy.Director = func(req *http.Request) { req.URL.Scheme = upstream.Scheme req.URL.Host = upstream.Host if _, ok := req.Header["User-Agent"]; !ok { req.Header.Set("User-Agent", "") } } // authedInner runs only when SPNEGO negotiation succeeds. // It extracts the client username from the request context and injects // X-WEBAUTH-USER for Gitea reverse-proxy auto-login. authedInner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // goidentity.AddToHTTPRequestContext is called by SPNEGOKRB5Authenticate on success. // FromHTTPRequestContext retrieves the identity set there. if id := goidentity.FromHTTPRequestContext(r); id != nil { username := strings.SplitN(id.UserName(), "@", 2)[0] r.Header.Set("X-WEBAUTH-USER", username) log.Printf("SPNEGO auth OK: %s -> %s", id.UserName(), username) } proxy.ServeHTTP(w, r) }) spnegoHandler := spnego.SPNEGOKRB5Authenticate(authedInner, kt) mux := http.NewServeMux() mux.HandleFunc("/_healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Negotiate ") { // Client is presenting a Kerberos ticket — run full SPNEGO negotiation. // On success: X-WEBAUTH-USER is set by authedInner, Gitea auto-logs in. // On failure: gokrb5 returns 401 (invalid ticket — correct to reject). rw := &statusCapture{ResponseWriter: w} spnegoHandler.ServeHTTP(rw, r) if rw.status == http.StatusUnauthorized { log.Printf("SPNEGO auth FAILED: from=%s host=%s path=%s token-prefix=%.20s", r.RemoteAddr, r.Host, r.URL.Path, auth[len("Negotiate "):]) } return } if auth != "" { // Other auth scheme (Basic, token) — pass through to Gitea. // This allows git CLI users with personal access tokens or passwords to // authenticate directly via Gitea's own credential validation. log.Printf("pass-through auth: from=%s scheme=%.6s...", r.RemoteAddr, auth) proxy.ServeHTTP(w, r) return } // No auth header — issue a SPNEGO challenge. // Browsers with Chrome SPNEGO policy (AuthServerAllowlist=*.prole.org) will // automatically respond with Authorization: Negotiate . // git CLI users with PATs will retry with Basic auth (handled above). w.Header().Set("WWW-Authenticate", "Negotiate") http.Error(w, "Authentication required (SPNEGO/Kerberos)", http.StatusUnauthorized) log.Printf("SPNEGO challenge issued: from=%s host=%s path=%s", r.RemoteAddr, r.Host, r.URL.Path) }) log.Printf("spnego-proxy listening on %s, upstream %s", listenAddr, upstreamURL) log.Fatal(http.ListenAndServe(listenAddr, mux)) }