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= 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) }