Import
This commit is contained in:
233
handler.go
Normal file
233
handler.go
Normal file
@@ -0,0 +1,233 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user