Files
ward/handler.go
2026-03-02 15:19:32 +01:00

234 lines
8.1 KiB
Go

package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"strings"
"time"
)
// ExecCredential is the JSON structure kubectl expects from an exec credential plugin.
// Spec: https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins
type ExecCredential struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Status *ExecCredentialStatus `json:"status,omitempty"`
}
type ExecCredentialStatus struct {
// Raw PEM — not base64. kubectl handles the encoding.
ClientCertificateData string `json:"clientCertificateData,omitempty"`
ClientKeyData string `json:"clientKeyData,omitempty"`
ExpirationTimestamp string `json:"expirationTimestamp,omitempty"` // RFC3339
}
// Handler wires together authentication providers and kubeconfig generation.
// At least one provider must be non-nil.
type Handler struct {
ldap *LDAPAuth
krb *KerberosAuth
htpasswd *HtpasswdAuth
gen *KubeconfigGenerator
}
// NewHandler validates that at least one auth provider is configured, then
// loads the k3s CA files and returns a ready Handler.
func NewHandler(ldap *LDAPAuth, krb *KerberosAuth, htpasswd *HtpasswdAuth, cfg *CertConfig) (*Handler, error) {
if ldap == nil && krb == nil && htpasswd == nil {
return nil, fmt.Errorf("no authentication providers configured: enable at least one of LDAP, --kerberos, or --htpasswd")
}
gen, err := NewKubeconfigGenerator(cfg)
if err != nil {
return nil, err
}
return &Handler{ldap: ldap, krb: krb, htpasswd: htpasswd, gen: gen}, nil
}
// ServeHTTP handles GET /kubeconfig — returns a kubeconfig YAML on success.
//
// curl -u alice https://ward.example.com:8443/kubeconfig > ~/.kube/config
// curl --negotiate -u : https://ward.example.com:8443/kubeconfig > ~/.kube/config
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username, groups, ok := h.authenticate(w, r)
if !ok {
return
}
log.Printf("issuing kubeconfig for %q groups=%v", username, groups)
kubeconfig, err := h.gen.Generate(username, groups)
if err != nil {
log.Printf("kubeconfig generation failed for %q: %v", username, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/yaml")
w.Header().Set("Content-Disposition", `attachment; filename="kubeconfig"`)
_, _ = w.Write(kubeconfig)
}
// ServeCredential handles GET /credential — returns an ExecCredential JSON on success.
// This is the endpoint called by the ward exec credential plugin.
func (h *Handler) ServeCredential(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username, groups, ok := h.authenticate(w, r)
if !ok {
return
}
dbg.Printf("issuing exec credential for %q groups=%v", username, groups)
log.Printf("issuing exec credential for %q groups=%v", username, groups)
cred, err := h.gen.GenerateCredential(username, groups)
if err != nil {
log.Printf("credential generation failed for %q: %v", username, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
ec := ExecCredential{
APIVersion: "client.authentication.k8s.io/v1",
Kind: "ExecCredential",
Status: &ExecCredentialStatus{
ClientCertificateData: string(cred.CertPEM),
ClientKeyData: string(cred.KeyPEM),
ExpirationTimestamp: cred.Expiry.UTC().Format(time.RFC3339),
},
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(ec); err != nil {
log.Printf("encoding credential response for %q: %v", username, err)
}
}
// ServeBootstrap handles GET /bootstrap — returns a kubeconfig with no embedded
// credentials, just the exec plugin stanza pointing back at this server.
// No authentication is required; the file is safe to distribute publicly.
//
// The username embedded in the kubeconfig is resolved in priority order:
// 1. ?user=<name> query parameter
// 2. Credentials present in the request (opportunistic auth — no 401 on failure)
// 3. The placeholder "YOUR_USERNAME_HERE"
func (h *Handler) ServeBootstrap(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := r.URL.Query().Get("user")
if username == "" {
username = h.tryUsername(r)
}
if username == "" {
username = "YOUR_USERNAME_HERE"
}
wardURL := "https://" + r.Host
kubeconfig, err := h.gen.GenerateBootstrap(wardURL, username)
if err != nil {
log.Printf("bootstrap kubeconfig generation failed: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/yaml")
w.Header().Set("Content-Disposition", `attachment; filename="kubeconfig"`)
_, _ = w.Write(kubeconfig)
}
// tryUsername attempts authentication against the request without sending any
// challenge or error response — auth failure simply returns "". Used by
// ServeBootstrap to opportunistically personalise the returned kubeconfig.
func (h *Handler) tryUsername(r *http.Request) string {
username, _, ok := h.authenticate(httptest.NewRecorder(), r)
if !ok {
return ""
}
return username
}
// authenticate dispatches to the correct auth provider based on the Authorization
// header and returns (username, groups, true) on success, or writes a 401 and
// returns ("", nil, false) on failure.
func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) (string, []string, bool) {
authHeader := r.Header.Get("Authorization")
switch {
case h.krb != nil && strings.HasPrefix(authHeader, "Negotiate "):
dbg.Printf("auth: trying Kerberos SPNEGO")
username, err := h.krb.Authenticate(w, r)
if err != nil {
log.Printf("Kerberos auth failed: %v", err)
h.sendChallenge(w, true, h.ldap != nil || h.htpasswd != nil)
return "", nil, false
}
dbg.Printf("auth: Kerberos OK, user=%q", username)
var groups []string
if h.ldap != nil {
groups = h.ldap.LookupGroups(username)
dbg.Printf("auth: LDAP group lookup for %q → %v", username, groups)
}
return username, groups, true
case strings.HasPrefix(authHeader, "Basic "):
user, password, ok := r.BasicAuth()
if !ok || user == "" {
h.sendChallenge(w, h.krb != nil, true)
return "", nil, false
}
dbg.Printf("auth: trying Basic for user=%q", user)
groups, err := h.authenticateBasic(user, password)
if err != nil {
log.Printf("Basic auth failed for %q: %v", user, err)
h.sendChallenge(w, h.krb != nil, true)
return "", nil, false
}
dbg.Printf("auth: Basic OK, user=%q groups=%v", user, groups)
return user, groups, true
default:
dbg.Printf("auth: no Authorization header, sending challenges")
h.sendChallenge(w, h.krb != nil, h.ldap != nil || h.htpasswd != nil)
return "", nil, false
}
}
// authenticateBasic tries LDAP first, then htpasswd as a fallback.
// This lets htpasswd serve as local/break-glass accounts when LDAP is unavailable.
func (h *Handler) authenticateBasic(username, password string) ([]string, error) {
if h.ldap != nil {
groups, err := h.ldap.Authenticate(username, password)
if err == nil {
return groups, nil
}
dbg.Printf("LDAP auth failed for %q: %v", username, err)
if h.htpasswd == nil {
return nil, err
}
}
if h.htpasswd != nil {
if err := h.htpasswd.Authenticate(username, password); err != nil {
return nil, fmt.Errorf("invalid credentials")
}
return nil, nil // htpasswd carries no group information
}
return nil, fmt.Errorf("invalid credentials")
}
// sendChallenge writes a 401 with WWW-Authenticate headers for each active provider.
// RFC 7235 permits multiple challenges in the same response.
func (h *Handler) sendChallenge(w http.ResponseWriter, negotiate, basic bool) {
if negotiate {
w.Header().Add("WWW-Authenticate", "Negotiate")
}
if basic {
w.Header().Add("WWW-Authenticate", `Basic realm="ward"`)
}
http.Error(w, "authentication required", http.StatusUnauthorized)
}