234 lines
8.1 KiB
Go
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)
|
|
}
|